|
|
|
|
import {
|
|
|
|
|
ASPECT_RATIO_KEYS,
|
|
|
|
|
BORDER_COLOR_KEYS,
|
|
|
|
|
BORDER_STYLE_KEYS,
|
|
|
|
|
BORDER_WIDTH_KEYS,
|
|
|
|
|
COLOR_ROLES,
|
|
|
|
|
COLOR_ROLE_SLOTS,
|
|
|
|
|
COLOR_SCALE_STEPS,
|
|
|
|
|
CONTAINER_WIDTH_KEYS,
|
|
|
|
|
CONTENT_COLOR_KEYS,
|
|
|
|
|
CONTENT_WIDTH_KEYS,
|
|
|
|
|
DENSITY_KEYS,
|
|
|
|
|
DURATION_KEYS,
|
|
|
|
|
EASE_KEYS,
|
|
|
|
|
FOCUS_COLOR_KEYS,
|
|
|
|
|
ICON_STROKE_WIDTH_KEYS,
|
|
|
|
|
MOTION_DISTANCE_KEYS,
|
|
|
|
|
MOTION_SCALE_KEYS,
|
|
|
|
|
OPACITY_KEYS,
|
|
|
|
|
RADIUS_KEYS,
|
|
|
|
|
SHADOW_KEYS,
|
|
|
|
|
SIZE_PRIMITIVE_KEYS,
|
|
|
|
|
SURFACE_COLOR_KEYS,
|
|
|
|
|
TEXT_SIZES,
|
|
|
|
|
Z_INDEX_KEYS,
|
|
|
|
|
type BorderPrimitiveSet,
|
|
|
|
|
type ColorSemanticSet,
|
|
|
|
|
type ColorRole,
|
|
|
|
|
type ColorRoleDefinition,
|
|
|
|
|
type ColorScale,
|
|
|
|
|
type EidosCssContract,
|
|
|
|
|
type EidosValidationIssue,
|
|
|
|
|
type EidosValidationReport,
|
|
|
|
|
type EidosConfig,
|
|
|
|
|
type EidosConfigSnapshot,
|
|
|
|
|
type RecipeTokenMap,
|
|
|
|
|
type SizePrimitive,
|
|
|
|
|
type TextMetric,
|
|
|
|
|
type ThemeDefinition,
|
|
|
|
|
type TypographyPrimitiveSet
|
|
|
|
|
} from './config-types'
|
|
|
|
|
import { createEidosCssContract } from './contract'
|
|
|
|
|
import {
|
|
|
|
|
createEidosConfigDocument as createEidosConfigDocumentEnvelope,
|
|
|
|
|
parseEidosConfigDocument as parseEidosConfigDocumentEnvelope,
|
|
|
|
|
readEidosConfigDocument as readEidosConfigDocumentEnvelope,
|
|
|
|
|
serializeEidosConfigDocument as serializeEidosConfigDocumentEnvelope,
|
|
|
|
|
type EidosConfigDocument
|
|
|
|
|
} from './persistence'
|
|
|
|
|
import { clonePlain, hasOwn, isPlainRecord } from './utils'
|
|
|
|
|
import { EidosConfigValidationError } from '../errors'
|
|
|
|
|
|
|
|
|
|
const COLOR_ROLE_SET = new Set<string>(COLOR_ROLES)
|
|
|
|
|
const ASPECT_RATIO_KEY_SET = new Set<string>(ASPECT_RATIO_KEYS)
|
|
|
|
|
const BORDER_WIDTH_KEY_SET = new Set<string>(BORDER_WIDTH_KEYS)
|
|
|
|
|
const BORDER_STYLE_KEY_SET = new Set<string>(BORDER_STYLE_KEYS)
|
|
|
|
|
const BORDER_COLOR_KEY_SET = new Set<string>(BORDER_COLOR_KEYS)
|
|
|
|
|
const CONTENT_COLOR_KEY_SET = new Set<string>(CONTENT_COLOR_KEYS)
|
|
|
|
|
const COLOR_ROLE_SLOT_SET = new Set<string>(COLOR_ROLE_SLOTS)
|
|
|
|
|
const COLOR_SCALE_STEP_SET = new Set<string>(COLOR_SCALE_STEPS)
|
|
|
|
|
const CONTAINER_WIDTH_KEY_SET = new Set<string>(CONTAINER_WIDTH_KEYS)
|
|
|
|
|
const CONTENT_WIDTH_KEY_SET = new Set<string>(CONTENT_WIDTH_KEYS)
|
|
|
|
|
const DENSITY_KEY_SET = new Set<string>(DENSITY_KEYS)
|
|
|
|
|
const DURATION_KEY_SET = new Set<string>(DURATION_KEYS)
|
|
|
|
|
const EASE_KEY_SET = new Set<string>(EASE_KEYS)
|
|
|
|
|
const FOCUS_COLOR_KEY_SET = new Set<string>(FOCUS_COLOR_KEYS)
|
|
|
|
|
const ICON_STROKE_WIDTH_KEY_SET = new Set<string>(ICON_STROKE_WIDTH_KEYS)
|
|
|
|
|
const MOTION_DISTANCE_KEY_SET = new Set<string>(MOTION_DISTANCE_KEYS)
|
|
|
|
|
const MOTION_SCALE_KEY_SET = new Set<string>(MOTION_SCALE_KEYS)
|
|
|
|
|
const OPACITY_KEY_SET = new Set<string>(OPACITY_KEYS)
|
|
|
|
|
const RADIUS_KEY_SET = new Set<string>(RADIUS_KEYS)
|
|
|
|
|
const SHADOW_KEY_SET = new Set<string>(SHADOW_KEYS)
|
|
|
|
|
const SIZE_PRIMITIVE_KEY_SET = new Set<string>(SIZE_PRIMITIVE_KEYS)
|
|
|
|
|
const SURFACE_COLOR_KEY_SET = new Set<string>(SURFACE_COLOR_KEYS)
|
|
|
|
|
const TEXT_SIZE_SET = new Set<string>(TEXT_SIZES)
|
|
|
|
|
const Z_INDEX_KEY_SET = new Set<string>(Z_INDEX_KEYS)
|
|
|
|
|
const RESPONSIVE_KEY_SET = new Set(['base', 'sm', 'md', 'lg', 'xl', 'xxl'])
|
|
|
|
|
|
|
|
|
|
export function snapshotEidosConfig(options: EidosConfig): EidosConfigSnapshot {
|
|
|
|
|
return clonePlain(options)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function readEidosConfigFromDocument(document: unknown): EidosConfig {
|
|
|
|
|
const config = snapshotEidosConfig(readEidosConfigDocumentEnvelope(document).options)
|
|
|
|
|
assertValidEidosConfig(config)
|
|
|
|
|
return config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function parseEidosConfigFromJson(json: string): EidosConfig {
|
|
|
|
|
const config = snapshotEidosConfig(parseEidosConfigDocumentEnvelope(json).options)
|
|
|
|
|
assertValidEidosConfig(config)
|
|
|
|
|
return config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createEidosConfigDocumentFromConfig(options: EidosConfig): EidosConfigDocument {
|
|
|
|
|
assertValidEidosConfig(options)
|
|
|
|
|
|
|
|
|
|
return createEidosConfigDocumentEnvelope(options)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function serializeEidosConfig(options: EidosConfig): string {
|
|
|
|
|
return serializeEidosConfigDocumentEnvelope(createEidosConfigDocumentFromConfig(options))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function validateEidosConfig(options: EidosConfig): EidosValidationReport {
|
|
|
|
|
const issues: EidosValidationIssue[] = []
|
|
|
|
|
|
|
|
|
|
if (!validateEidosConfigShape(options, issues)) {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
issues
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validateThemeKeys(options, issues)
|
|
|
|
|
validateColorScales(options, issues)
|
|
|
|
|
validateColorRoles(options, issues)
|
|
|
|
|
validateColorSemanticGroups(options, issues)
|
|
|
|
|
validateTypography(options, issues)
|
|
|
|
|
validateSizePrimitives(options, issues)
|
|
|
|
|
validateStaticPrimitives(options, issues)
|
|
|
|
|
validateBorderPrimitives(options, issues)
|
|
|
|
|
validateLayoutPrimitives(options, issues)
|
|
|
|
|
validateDensityPrimitives(options, issues)
|
|
|
|
|
validateMotionPrimitives(options, issues)
|
|
|
|
|
validateIconPrimitives(options, issues)
|
|
|
|
|
validateRecipeTokens(options, issues)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.opacity',
|
|
|
|
|
options.primitives.opacity,
|
|
|
|
|
OPACITY_KEYS,
|
|
|
|
|
OPACITY_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.zIndex',
|
|
|
|
|
options.primitives.zIndex,
|
|
|
|
|
Z_INDEX_KEYS,
|
|
|
|
|
Z_INDEX_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateThemeShadows(options, issues)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
ok: issues.length === 0,
|
|
|
|
|
issues
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function assertValidEidosConfig(options: EidosConfig): void {
|
|
|
|
|
const report = validateEidosConfig(options)
|
|
|
|
|
if (report.ok) return
|
|
|
|
|
throw new EidosConfigValidationError(report.issues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateEidosConfigShape(
|
|
|
|
|
options: EidosConfig,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): options is EidosConfig {
|
|
|
|
|
const input = options as unknown
|
|
|
|
|
|
|
|
|
|
if (!isPlainRecord(input)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: '',
|
|
|
|
|
message: 'Eidos config must be a plain object'
|
|
|
|
|
})
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let ok = true
|
|
|
|
|
|
|
|
|
|
if (!isPlainRecord(input.primitives)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: 'primitives',
|
|
|
|
|
message: 'Eidos config must define a primitives object'
|
|
|
|
|
})
|
|
|
|
|
ok = false
|
|
|
|
|
} else {
|
|
|
|
|
const primitiveKeys = [
|
|
|
|
|
'color',
|
|
|
|
|
'size',
|
|
|
|
|
'space',
|
|
|
|
|
'controlHeight',
|
|
|
|
|
'radius',
|
|
|
|
|
'border',
|
|
|
|
|
'focusRing',
|
|
|
|
|
'layout',
|
|
|
|
|
'density',
|
|
|
|
|
'typography',
|
|
|
|
|
'motion',
|
|
|
|
|
'icon',
|
|
|
|
|
'opacity',
|
|
|
|
|
'zIndex'
|
|
|
|
|
] as const
|
|
|
|
|
|
|
|
|
|
for (const key of primitiveKeys) {
|
|
|
|
|
ok = validateOptionalPlainRecord(input.primitives[key], `primitives.${key}`, issues) && ok
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const semantics = input.semantics
|
|
|
|
|
const color = isPlainRecord(semantics) ? semantics.color : undefined
|
|
|
|
|
const roles = isPlainRecord(color) ? color.roles : undefined
|
|
|
|
|
|
|
|
|
|
if (!isPlainRecord(roles)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: 'semantics.color.roles',
|
|
|
|
|
message: 'Eidos config must define semantic color roles'
|
|
|
|
|
})
|
|
|
|
|
ok = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.themes !== undefined) {
|
|
|
|
|
if (!isPlainRecord(input.themes)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: 'themes',
|
|
|
|
|
message: 'Eidos config themes must be a plain object'
|
|
|
|
|
})
|
|
|
|
|
ok = false
|
|
|
|
|
} else {
|
|
|
|
|
for (const [themeId, theme] of Object.entries(input.themes)) {
|
|
|
|
|
if (!isPlainRecord(theme)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `themes.${themeId}`,
|
|
|
|
|
message: 'Eidos config theme entries must be plain objects'
|
|
|
|
|
})
|
|
|
|
|
ok = false
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
ok = validateOptionalPlainRecord(theme.color, `themes.${themeId}.color`, issues) && ok
|
|
|
|
|
ok = validateOptionalPlainRecord(theme.shadow, `themes.${themeId}.shadow`, issues) && ok
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.recipes !== undefined) {
|
|
|
|
|
if (!isPlainRecord(input.recipes)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: 'recipes',
|
|
|
|
|
message: 'Eidos config recipes must be a plain object'
|
|
|
|
|
})
|
|
|
|
|
ok = false
|
|
|
|
|
} else {
|
|
|
|
|
for (const [component, tokens] of Object.entries(input.recipes)) {
|
|
|
|
|
ok = validateOptionalPlainRecord(tokens, `recipes.${component}`, issues) && ok
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateOptionalPlainRecord(
|
|
|
|
|
value: unknown,
|
|
|
|
|
path: string,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): boolean {
|
|
|
|
|
if (value === undefined) return true
|
|
|
|
|
if (isPlainRecord(value)) return true
|
|
|
|
|
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'Eidos config entries must be plain objects'
|
|
|
|
|
})
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listEidosColorScales(options: EidosConfig): readonly string[] {
|
|
|
|
|
return Object.keys(options.primitives.color?.scales ?? {})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listEidosThemes(options: EidosConfig): readonly string[] {
|
|
|
|
|
return Object.keys(options.themes ?? {})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listEidosRecipes(options: EidosConfig): readonly string[] {
|
|
|
|
|
return Object.keys(options.recipes ?? {})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getEidosTheme(options: EidosConfig, id: string): ThemeDefinition | undefined {
|
|
|
|
|
const theme = options.themes?.[id]
|
|
|
|
|
return theme ? clonePlain(theme) : undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getEidosRecipeTokens(
|
|
|
|
|
options: EidosConfig,
|
|
|
|
|
component: string
|
|
|
|
|
): RecipeTokenMap | undefined {
|
|
|
|
|
const tokens = options.recipes?.[component]
|
|
|
|
|
return tokens ? clonePlain(tokens) : undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listEidosColorRoles(): readonly ColorRole[] {
|
|
|
|
|
return [...COLOR_ROLES]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getEidosColorScale(options: EidosConfig, name: string): ColorScale | undefined {
|
|
|
|
|
const scale = options.primitives.color?.scales?.[name]
|
|
|
|
|
return scale ? clonePlain(scale) : undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getEidosColorRoleScale(
|
|
|
|
|
options: EidosConfig,
|
|
|
|
|
role: ColorRole,
|
|
|
|
|
themeId?: string
|
|
|
|
|
): ColorScale | undefined {
|
|
|
|
|
const theme = themeId ? options.themes?.[themeId] : undefined
|
|
|
|
|
const roleDef = theme?.color?.roles?.[role] ?? options.semantics.color.roles[role]
|
|
|
|
|
const scaleName = resolveRoleScaleName(roleDef)
|
|
|
|
|
if (!scaleName) return undefined
|
|
|
|
|
|
|
|
|
|
const themeScale = theme?.color?.scales?.[scaleName]
|
|
|
|
|
if (themeScale) return clonePlain(themeScale)
|
|
|
|
|
|
|
|
|
|
return getEidosColorScale(options, scaleName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getEidosCssContract(options: EidosConfig): EidosCssContract {
|
|
|
|
|
assertValidEidosConfig(options)
|
|
|
|
|
|
|
|
|
|
return createEidosCssContract(options)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateColorScales(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
const scales = options.primitives.color?.scales ?? {}
|
|
|
|
|
|
|
|
|
|
for (const [name, scale] of Object.entries(scales)) {
|
|
|
|
|
validateCssTokenKey(`primitives.color.scales.${name}`, name, 'color scale', issues)
|
|
|
|
|
validateColorScale(`primitives.color.scales.${name}`, name, scale, issues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [themeId, theme] of Object.entries(options.themes ?? {})) {
|
|
|
|
|
for (const [name, scale] of Object.entries(theme.color?.scales ?? {})) {
|
|
|
|
|
validateCssTokenKey(`themes.${themeId}.color.scales.${name}`, name, 'color scale', issues)
|
|
|
|
|
validateColorScale(`themes.${themeId}.color.scales.${name}`, name, scale, issues)
|
|
|
|
|
}
|
|
|
|
|
for (const [name, scale] of Object.entries(theme.color?.alphaScales ?? {})) {
|
|
|
|
|
validateCssTokenKey(
|
|
|
|
|
`themes.${themeId}.color.alphaScales.${name}`,
|
|
|
|
|
name,
|
|
|
|
|
'alpha color scale',
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateColorScale(`themes.${themeId}.color.alphaScales.${name}`, name, scale, issues)
|
|
|
|
|
if (
|
|
|
|
|
!hasOwn(options.primitives.color?.scales ?? {}, name) &&
|
|
|
|
|
!hasOwn(theme.color?.scales ?? {}, name)
|
|
|
|
|
) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `themes.${themeId}.color.alphaScales.${name}`,
|
|
|
|
|
message: `alpha scale "${name}" must match a configured color scale`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateThemeKeys(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
for (const themeId of Object.keys(options.themes ?? {})) {
|
|
|
|
|
validateCssTokenKey(`themes.${themeId}`, themeId, 'theme id', issues)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateColorRoles(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
const roles = options.semantics.color.roles
|
|
|
|
|
const globalScales = options.primitives.color?.scales ?? {}
|
|
|
|
|
const themes = options.themes ?? {}
|
|
|
|
|
|
|
|
|
|
for (const role of COLOR_ROLES) {
|
|
|
|
|
if (!hasOwn(roles, role)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `semantics.color.roles.${role}`,
|
|
|
|
|
message: 'every canonical color role must be mapped to a primitive scale'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [role, roleDef] of Object.entries(roles)) {
|
|
|
|
|
validateColorRole(`semantics.color.roles.${role}`, role, roleDef, globalScales, themes, issues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [themeId, theme] of Object.entries(themes)) {
|
|
|
|
|
for (const [role, roleDef] of Object.entries(theme.color?.roles ?? {})) {
|
|
|
|
|
validateColorRole(
|
|
|
|
|
`themes.${themeId}.color.roles.${role}`,
|
|
|
|
|
role,
|
|
|
|
|
roleDef,
|
|
|
|
|
globalScales,
|
|
|
|
|
{ [themeId]: theme },
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateColorSemanticGroups(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
validateColorSemanticSet('semantics.color', options.semantics.color, issues)
|
|
|
|
|
|
|
|
|
|
for (const [themeId, theme] of Object.entries(options.themes ?? {})) {
|
|
|
|
|
if (!theme.color) continue
|
|
|
|
|
validateColorSemanticSet(`themes.${themeId}.color`, theme.color, issues)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateColorSemanticSet(
|
|
|
|
|
path: string,
|
|
|
|
|
color: Partial<
|
|
|
|
|
Pick<ColorSemanticSet, 'surface' | 'content' | 'border' | 'focus'>
|
|
|
|
|
>,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
validateColorSemanticGroup(
|
|
|
|
|
`${path}.surface`,
|
|
|
|
|
color.surface,
|
|
|
|
|
SURFACE_COLOR_KEYS,
|
|
|
|
|
SURFACE_COLOR_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateColorSemanticGroup(
|
|
|
|
|
`${path}.content`,
|
|
|
|
|
color.content,
|
|
|
|
|
CONTENT_COLOR_KEYS,
|
|
|
|
|
CONTENT_COLOR_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateColorSemanticGroup(
|
|
|
|
|
`${path}.border`,
|
|
|
|
|
color.border,
|
|
|
|
|
BORDER_COLOR_KEYS,
|
|
|
|
|
BORDER_COLOR_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateColorSemanticGroup(
|
|
|
|
|
`${path}.focus`,
|
|
|
|
|
color.focus,
|
|
|
|
|
FOCUS_COLOR_KEYS,
|
|
|
|
|
FOCUS_COLOR_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateColorSemanticGroup(
|
|
|
|
|
path: string,
|
|
|
|
|
group: unknown,
|
|
|
|
|
keys: readonly string[],
|
|
|
|
|
keySet: Set<string>,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (group === undefined) return
|
|
|
|
|
if (!isPlainRecord(group)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'color semantic group must be a plain object'
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
if (!hasOwn(group, key)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.${key}`,
|
|
|
|
|
message: 'color semantic groups must define every canonical key'
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const value = group[key]
|
|
|
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.${key}`,
|
|
|
|
|
message: 'color semantic CSS value cannot be empty'
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
validateNonEmptyCssValue(`${path}.${key}`, value, issues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const key of Object.keys(group)) {
|
|
|
|
|
if (keySet.has(key)) continue
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.${key}`,
|
|
|
|
|
message: 'unknown color semantic key'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateColorScale(
|
|
|
|
|
path: string,
|
|
|
|
|
name: string,
|
|
|
|
|
scale: ColorScale,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
for (const step of COLOR_SCALE_STEPS) {
|
|
|
|
|
if (!hasOwn(scale, step) || scale[step] === '') {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.${step}`,
|
|
|
|
|
message: 'color scales must define every step from 1 to 12'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateColorRole(
|
|
|
|
|
path: string,
|
|
|
|
|
role: string,
|
|
|
|
|
roleDef: string | ColorRoleDefinition | undefined,
|
|
|
|
|
globalScales: Record<string, ColorScale>,
|
|
|
|
|
themes: Record<string, ThemeDefinition>,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (!COLOR_ROLE_SET.has(role)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'unknown color role'
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (roleDef !== undefined && typeof roleDef !== 'string' && !isPlainRecord(roleDef)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'color role definition must be a scale name or plain object'
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const scaleName = resolveRoleScaleName(roleDef)
|
|
|
|
|
if (!scaleName) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'color role must point to a primitive scale'
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof roleDef !== 'string' && roleDef?.slots !== undefined) {
|
|
|
|
|
validateColorRoleSlots(`${path}.slots`, roleDef.slots, issues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const themeEntries = Object.entries(themes)
|
|
|
|
|
if (themeEntries.length === 0) {
|
|
|
|
|
if (!hasOwn(globalScales, scaleName)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: `color role points to missing primitive scale "${scaleName}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [themeId, theme] of themeEntries) {
|
|
|
|
|
if (!hasOwn(globalScales, scaleName) && !hasOwn(theme.color?.scales ?? {}, scaleName)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `themes.${themeId}.color.scales.${scaleName}`,
|
|
|
|
|
message: `color role "${role}" points to a scale missing in this theme`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateColorRoleSlots(
|
|
|
|
|
path: string,
|
|
|
|
|
slots: unknown,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (!isPlainRecord(slots)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'color role slots must be a plain object'
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [slot, step] of Object.entries(slots)) {
|
|
|
|
|
if (!COLOR_ROLE_SLOT_SET.has(slot)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.${slot}`,
|
|
|
|
|
message: 'unknown color role slot'
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if (typeof step !== 'string' || !COLOR_SCALE_STEP_SET.has(step)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.${slot}`,
|
|
|
|
|
message: 'color role slot must point to a scale step from 1 to 12'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateTypography(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
const typography = options.primitives.typography
|
|
|
|
|
if (!typography) return
|
|
|
|
|
|
|
|
|
|
for (const size of TEXT_SIZES) {
|
|
|
|
|
const metric = typography.sizes[size]
|
|
|
|
|
if (!metric) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `primitives.typography.sizes.${size}`,
|
|
|
|
|
message: 'every canonical text size must be defined'
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
validateTextMetric(`primitives.typography.sizes.${size}`, metric, issues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const name of Object.keys(typography.sizes)) {
|
|
|
|
|
validateCssTokenSuffix(`primitives.typography.sizes.${name}`, name, 'typography size', issues)
|
|
|
|
|
if (!TEXT_SIZE_SET.has(name)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `primitives.typography.sizes.${name}`,
|
|
|
|
|
message: 'unknown typography size key'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [name, family] of Object.entries(typography.families)) {
|
|
|
|
|
validateCssTokenSuffix(
|
|
|
|
|
`primitives.typography.families.${name}`,
|
|
|
|
|
name,
|
|
|
|
|
'typography family',
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
if (family.family.trim() === '') {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `primitives.typography.families.${name}.family`,
|
|
|
|
|
message: 'font family name cannot be empty'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const name of Object.keys(typography.weights)) {
|
|
|
|
|
validateCssTokenSuffix(
|
|
|
|
|
`primitives.typography.weights.${name}`,
|
|
|
|
|
name,
|
|
|
|
|
'typography weight',
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [name, style] of Object.entries(typography.styles)) {
|
|
|
|
|
const path = `primitives.typography.styles.${name}`
|
|
|
|
|
validateCssTokenSuffix(path, name, 'typography style', issues)
|
|
|
|
|
|
|
|
|
|
if (style.family !== undefined && !hasOwn(typography.families, style.family)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.family`,
|
|
|
|
|
message: `typography style points to missing family "${style.family}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
typeof style.weight === 'string' &&
|
|
|
|
|
style.weight.trim() !== '' &&
|
|
|
|
|
!hasOwn(typography.weights, style.weight)
|
|
|
|
|
) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.weight`,
|
|
|
|
|
message: `typography style points to missing weight "${style.weight}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validateTypographySizeValue(`${path}.size`, style.size, typography, issues)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateSizePrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
const sizes = options.primitives.size
|
|
|
|
|
if (!sizes) return
|
|
|
|
|
|
|
|
|
|
for (const size of SIZE_PRIMITIVE_KEYS) {
|
|
|
|
|
if (!hasOwn(sizes, size)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `primitives.size.${size}`,
|
|
|
|
|
message: 'every canonical physical size must be defined'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [name, size] of Object.entries(sizes)) {
|
|
|
|
|
const path = `primitives.size.${name}`
|
|
|
|
|
|
|
|
|
|
if (!SIZE_PRIMITIVE_KEY_SET.has(name)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'unknown size primitive key'
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validateSizePrimitive(path, size, options, issues)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateSizePrimitive(
|
|
|
|
|
path: string,
|
|
|
|
|
size: SizePrimitive,
|
|
|
|
|
options: EidosConfig,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (!hasOwn(options.primitives.controlHeight ?? {}, size.controlHeight)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.controlHeight`,
|
|
|
|
|
message: `size primitive points to missing control height "${size.controlHeight}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const typography = options.primitives.typography
|
|
|
|
|
if (!TEXT_SIZE_SET.has(size.fontSize) || !hasOwn(typography?.sizes ?? {}, size.fontSize)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.fontSize`,
|
|
|
|
|
message: `size primitive points to missing font size "${size.fontSize}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasOwn(options.primitives.icon?.size ?? {}, size.iconSize)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.iconSize`,
|
|
|
|
|
message: `size primitive points to missing icon size "${size.iconSize}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasOwn(options.primitives.radius ?? {}, size.radius)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.radius`,
|
|
|
|
|
message: `size primitive points to missing radius "${size.radius}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validateNonEmptyCssValue(`${path}.paddingInline`, size.paddingInline, issues)
|
|
|
|
|
validateNonEmptyCssValue(`${path}.paddingBlock`, size.paddingBlock, issues)
|
|
|
|
|
validateNonEmptyCssValue(`${path}.gap`, size.gap, issues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateStaticPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
validateOpenScalePrimitive('primitives.space', options.primitives.space, 'space', issues)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.controlHeight',
|
|
|
|
|
options.primitives.controlHeight,
|
|
|
|
|
SIZE_PRIMITIVE_KEYS,
|
|
|
|
|
SIZE_PRIMITIVE_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.radius',
|
|
|
|
|
options.primitives.radius,
|
|
|
|
|
RADIUS_KEYS,
|
|
|
|
|
RADIUS_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const focusRing = options.primitives.focusRing
|
|
|
|
|
if (!focusRing) return
|
|
|
|
|
|
|
|
|
|
validateNonEmptyCssValue('primitives.focusRing.offset', focusRing.offset, issues)
|
|
|
|
|
validateNonEmptyCssValue('primitives.focusRing.width', focusRing.width, issues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateBorderPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
const border = options.primitives.border
|
|
|
|
|
if (!border) return
|
|
|
|
|
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.border.width',
|
|
|
|
|
border.width,
|
|
|
|
|
BORDER_WIDTH_KEYS,
|
|
|
|
|
BORDER_WIDTH_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.border.style',
|
|
|
|
|
border.style,
|
|
|
|
|
BORDER_STYLE_KEYS,
|
|
|
|
|
BORDER_STYLE_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
validateBorderDefault(
|
|
|
|
|
'primitives.border.defaultWidth',
|
|
|
|
|
border.defaultWidth,
|
|
|
|
|
border,
|
|
|
|
|
'width',
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateBorderDefault(
|
|
|
|
|
'primitives.border.defaultStyle',
|
|
|
|
|
border.defaultStyle,
|
|
|
|
|
border,
|
|
|
|
|
'style',
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateBorderDefault(
|
|
|
|
|
path: string,
|
|
|
|
|
value: string,
|
|
|
|
|
border: BorderPrimitiveSet,
|
|
|
|
|
field: 'width' | 'style',
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (hasOwn(border[field], value)) return
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: `border default points to missing ${field} "${value}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateLayoutPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
const layout = options.primitives.layout
|
|
|
|
|
if (!layout) return
|
|
|
|
|
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.layout.containerWidth',
|
|
|
|
|
layout.containerWidth,
|
|
|
|
|
CONTAINER_WIDTH_KEYS,
|
|
|
|
|
CONTAINER_WIDTH_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.layout.contentWidth',
|
|
|
|
|
layout.contentWidth,
|
|
|
|
|
CONTENT_WIDTH_KEYS,
|
|
|
|
|
CONTENT_WIDTH_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.layout.aspectRatio',
|
|
|
|
|
layout.aspectRatio,
|
|
|
|
|
ASPECT_RATIO_KEYS,
|
|
|
|
|
ASPECT_RATIO_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateNonEmptyCssValue(
|
|
|
|
|
'primitives.layout.containerPaddingInline',
|
|
|
|
|
layout.containerPaddingInline,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateDensityPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
const density = options.primitives.density
|
|
|
|
|
if (!density) return
|
|
|
|
|
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.density.scale',
|
|
|
|
|
density.scale,
|
|
|
|
|
DENSITY_KEYS,
|
|
|
|
|
DENSITY_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.density.spaceScale',
|
|
|
|
|
density.spaceScale,
|
|
|
|
|
DENSITY_KEYS,
|
|
|
|
|
DENSITY_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.density.controlScale',
|
|
|
|
|
density.controlScale,
|
|
|
|
|
DENSITY_KEYS,
|
|
|
|
|
DENSITY_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.density.contentScale',
|
|
|
|
|
density.contentScale,
|
|
|
|
|
DENSITY_KEYS,
|
|
|
|
|
DENSITY_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateMotionPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
const motion = options.primitives.motion
|
|
|
|
|
if (!motion) return
|
|
|
|
|
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.motion.duration',
|
|
|
|
|
motion.duration,
|
|
|
|
|
DURATION_KEYS,
|
|
|
|
|
DURATION_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive('primitives.motion.ease', motion.ease, EASE_KEYS, EASE_KEY_SET, issues)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.motion.distance',
|
|
|
|
|
motion.distance,
|
|
|
|
|
MOTION_DISTANCE_KEYS,
|
|
|
|
|
MOTION_DISTANCE_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.motion.scale',
|
|
|
|
|
motion.scale,
|
|
|
|
|
MOTION_SCALE_KEYS,
|
|
|
|
|
MOTION_SCALE_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateNonEmptyCssValue('primitives.motion.stagger', motion.stagger, issues)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateIconPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
const icon = options.primitives.icon
|
|
|
|
|
if (!icon) return
|
|
|
|
|
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.icon.size',
|
|
|
|
|
icon.size,
|
|
|
|
|
SIZE_PRIMITIVE_KEYS,
|
|
|
|
|
SIZE_PRIMITIVE_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
'primitives.icon.strokeWidth',
|
|
|
|
|
icon.strokeWidth,
|
|
|
|
|
ICON_STROKE_WIDTH_KEYS,
|
|
|
|
|
ICON_STROKE_WIDTH_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateRecipeTokens(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
for (const [component, tokens] of Object.entries(options.recipes ?? {})) {
|
|
|
|
|
if (!isCssTokenSegment(component)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `recipes.${component}`,
|
|
|
|
|
message: 'recipe component keys must be kebab-case CSS token segments'
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [name, value] of Object.entries(tokens)) {
|
|
|
|
|
if (!isCssTokenSegment(name)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `recipes.${component}.${name}`,
|
|
|
|
|
message: 'recipe token keys must be kebab-case CSS token segments'
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
validateNonEmptyCssValue(`recipes.${component}.${name}`, String(value), issues)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateThemeShadows(options: EidosConfig, issues: EidosValidationIssue[]): void {
|
|
|
|
|
for (const [themeId, theme] of Object.entries(options.themes ?? {})) {
|
|
|
|
|
validateRecordPrimitive(
|
|
|
|
|
`themes.${themeId}.shadow`,
|
|
|
|
|
theme.shadow,
|
|
|
|
|
SHADOW_KEYS,
|
|
|
|
|
SHADOW_KEY_SET,
|
|
|
|
|
issues
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateRecordPrimitive(
|
|
|
|
|
path: string,
|
|
|
|
|
record: Record<string, string | number> | undefined,
|
|
|
|
|
keys: readonly string[],
|
|
|
|
|
keySet: Set<string>,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (!record) return
|
|
|
|
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
if (!hasOwn(record, key)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.${key}`,
|
|
|
|
|
message: 'every canonical token key must be defined'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(record)) {
|
|
|
|
|
if (!keySet.has(key)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.${key}`,
|
|
|
|
|
message: 'unknown token key'
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
validateNonEmptyCssValue(`${path}.${key}`, String(value), issues)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateOpenScalePrimitive(
|
|
|
|
|
path: string,
|
|
|
|
|
record: Record<string, string | number> | undefined,
|
|
|
|
|
label: string,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (!record) return
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(record)) {
|
|
|
|
|
validateCssTokenSuffix(`${path}.${key}`, key, label, issues)
|
|
|
|
|
validateNonEmptyCssValue(`${path}.${key}`, String(value), issues)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateTypographySizeValue(
|
|
|
|
|
path: string,
|
|
|
|
|
value: unknown,
|
|
|
|
|
typography: TypographyPrimitiveSet,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (value === undefined) return
|
|
|
|
|
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
if (!TEXT_SIZE_SET.has(value) || !hasOwn(typography.sizes, value)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: `typography style points to missing text size "${value}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isTextMetric(value)) {
|
|
|
|
|
validateTextMetric(path, value, issues)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isPlainRecord(value)) {
|
|
|
|
|
for (const [key, item] of Object.entries(value)) {
|
|
|
|
|
if (!RESPONSIVE_KEY_SET.has(key)) {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.${key}`,
|
|
|
|
|
message: `unknown responsive breakpoint "${key}"`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
validateTypographySizeValue(`${path}.${key}`, item, typography, issues)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'typography size must be a canonical size, metric, or responsive map'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateNonEmptyCssValue(
|
|
|
|
|
path: string,
|
|
|
|
|
value: string,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (value.trim() === '') {
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'CSS value cannot be empty'
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!/[;{}]/.test(value)) return
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: 'CSS value cannot contain declaration or block delimiters'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateTextMetric(
|
|
|
|
|
path: string,
|
|
|
|
|
metric: TextMetric,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (metric.size.trim() === '') {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.size`,
|
|
|
|
|
message: 'text metric size cannot be empty'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (metric.lineHeight.trim() === '') {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.lineHeight`,
|
|
|
|
|
message: 'text metric lineHeight cannot be empty'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (metric.letterSpacing.trim() === '') {
|
|
|
|
|
issues.push({
|
|
|
|
|
path: `${path}.letterSpacing`,
|
|
|
|
|
message: 'text metric letterSpacing cannot be empty'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTextMetric(value: unknown): value is TextMetric {
|
|
|
|
|
return (
|
|
|
|
|
isPlainRecord(value) &&
|
|
|
|
|
typeof value.size === 'string' &&
|
|
|
|
|
typeof value.lineHeight === 'string' &&
|
|
|
|
|
typeof value.letterSpacing === 'string'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateCssTokenKey(
|
|
|
|
|
path: string,
|
|
|
|
|
value: string,
|
|
|
|
|
label: string,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (isCssTokenSegment(value)) return
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: `${label} must be a kebab-case CSS token segment`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateCssTokenSuffix(
|
|
|
|
|
path: string,
|
|
|
|
|
value: string,
|
|
|
|
|
label: string,
|
|
|
|
|
issues: EidosValidationIssue[]
|
|
|
|
|
): void {
|
|
|
|
|
if (isCssTokenSuffix(value)) return
|
|
|
|
|
issues.push({
|
|
|
|
|
path,
|
|
|
|
|
message: `${label} must be a kebab-case CSS token suffix`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveRoleScaleName(
|
|
|
|
|
roleDef: string | ColorRoleDefinition | undefined
|
|
|
|
|
): string | undefined {
|
|
|
|
|
if (typeof roleDef === 'string') return roleDef
|
|
|
|
|
return roleDef?.scale
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isCssTokenSegment(value: string): boolean {
|
|
|
|
|
return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isCssTokenSuffix(value: string): boolean {
|
|
|
|
|
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)
|
|
|
|
|
}
|