You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1416 lines
37 KiB
1416 lines
37 KiB
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 ThemeDefinition
|
|
} 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',
|
|
'shadow'
|
|
] 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)
|
|
}
|
|
|
|
validateColorSemanticCompleteness(options, issues)
|
|
}
|
|
|
|
// The per-set validation above enforces completeness *when a group is present*,
|
|
// but each group is optional — so a theme that omits `surface`/`content`/`border`/
|
|
// `focus` entirely (and isn't covered by the global `semantics.color`) silently
|
|
// emits hundreds of undefined `var(--color-…)` references. Validate the EFFECTIVE
|
|
// merged set per theme: every group must resolve from the global semantics or the
|
|
// theme itself. (audit P1-3)
|
|
function validateColorSemanticCompleteness(
|
|
options: EidosConfig,
|
|
issues: EidosValidationIssue[]
|
|
): void {
|
|
const groups = ['surface', 'content', 'border', 'focus'] as const
|
|
const globalColor = options.semantics.color
|
|
|
|
// Only themed configs render the `--color-{surface,content,border,focus}-*`
|
|
// vars; a themeless config (e.g. role-only validation fixtures) renders
|
|
// nothing, so it isn't flagged.
|
|
for (const [themeId, theme] of Object.entries(options.themes ?? {})) {
|
|
for (const group of groups) {
|
|
if (globalColor[group] !== undefined) continue
|
|
if (theme.color?.[group] !== undefined) continue
|
|
issues.push({
|
|
path: `themes.${themeId}.color.${group}`,
|
|
message: `effective "${group}" colors are undefined for theme "${themeId}" — define them in semantics.color or this theme`
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if (!isPlainRecord(scale)) {
|
|
issues.push({
|
|
path,
|
|
message: 'color scale must be a plain object'
|
|
})
|
|
return
|
|
}
|
|
|
|
for (const step of COLOR_SCALE_STEPS) {
|
|
if (!hasOwn(scale, step)) {
|
|
issues.push({
|
|
path: `${path}.${step}`,
|
|
message: 'color scales must define every step from 1 to 12'
|
|
})
|
|
continue
|
|
}
|
|
validateNonEmptyCssValue(`${path}.${step}`, scale[step], issues)
|
|
}
|
|
}
|
|
|
|
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
|
|
const sizes = readRequiredPlainRecord(
|
|
'primitives.typography.sizes',
|
|
typography.sizes,
|
|
issues
|
|
)
|
|
const families = readRequiredPlainRecord(
|
|
'primitives.typography.families',
|
|
typography.families,
|
|
issues
|
|
)
|
|
const weights = readRequiredPlainRecord(
|
|
'primitives.typography.weights',
|
|
typography.weights,
|
|
issues
|
|
)
|
|
const styles = readRequiredPlainRecord(
|
|
'primitives.typography.styles',
|
|
typography.styles,
|
|
issues
|
|
)
|
|
|
|
if (sizes) {
|
|
for (const size of TEXT_SIZES) {
|
|
const metric = 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(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'
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if (families) {
|
|
for (const [name, family] of Object.entries(families)) {
|
|
validateCssTokenSuffix(
|
|
`primitives.typography.families.${name}`,
|
|
name,
|
|
'typography family',
|
|
issues
|
|
)
|
|
if (!isPlainRecord(family)) {
|
|
issues.push({
|
|
path: `primitives.typography.families.${name}`,
|
|
message: 'typography family must be a plain object'
|
|
})
|
|
continue
|
|
}
|
|
validateNonEmptyCssValue(
|
|
`primitives.typography.families.${name}.family`,
|
|
family.family,
|
|
issues
|
|
)
|
|
}
|
|
}
|
|
|
|
if (weights) {
|
|
for (const name of Object.keys(weights)) {
|
|
validateCssTokenSuffix(
|
|
`primitives.typography.weights.${name}`,
|
|
name,
|
|
'typography weight',
|
|
issues
|
|
)
|
|
validateNonEmptyCssValue(`primitives.typography.weights.${name}`, weights[name], issues)
|
|
}
|
|
}
|
|
|
|
if (styles) {
|
|
for (const [name, style] of Object.entries(styles)) {
|
|
const path = `primitives.typography.styles.${name}`
|
|
validateCssTokenSuffix(path, name, 'typography style', issues)
|
|
|
|
if (!isPlainRecord(style)) {
|
|
issues.push({
|
|
path,
|
|
message: 'typography style must be a plain object'
|
|
})
|
|
continue
|
|
}
|
|
|
|
const familyRef = style.family
|
|
if (
|
|
familyRef !== undefined &&
|
|
(typeof familyRef !== 'string' || !families || !hasOwn(families, familyRef))
|
|
) {
|
|
issues.push({
|
|
path: `${path}.family`,
|
|
message: `typography style points to missing family "${String(familyRef)}"`
|
|
})
|
|
}
|
|
|
|
const weightRef = style.weight
|
|
if (
|
|
typeof weightRef === 'string' &&
|
|
weightRef.trim() !== '' &&
|
|
(!weights || !hasOwn(weights, weightRef))
|
|
) {
|
|
issues.push({
|
|
path: `${path}.weight`,
|
|
message: `typography style points to missing weight "${weightRef}"`
|
|
})
|
|
}
|
|
|
|
validateTypographySizeValue(`${path}.size`, style.size, sizes, issues)
|
|
}
|
|
}
|
|
}
|
|
|
|
function readRequiredPlainRecord(
|
|
path: string,
|
|
value: unknown,
|
|
issues: EidosValidationIssue[]
|
|
): Record<string, unknown> | undefined {
|
|
if (isPlainRecord(value)) return value
|
|
issues.push({
|
|
path,
|
|
message: 'Eidos config entries must be plain objects'
|
|
})
|
|
return undefined
|
|
}
|
|
|
|
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: unknown,
|
|
options: EidosConfig,
|
|
issues: EidosValidationIssue[]
|
|
): void {
|
|
if (!isPlainRecord(size)) {
|
|
issues.push({
|
|
path,
|
|
message: 'size primitive must be a plain object'
|
|
})
|
|
return
|
|
}
|
|
|
|
const controlHeight = size.controlHeight
|
|
if (
|
|
typeof controlHeight !== 'string' ||
|
|
controlHeight.trim() === '' ||
|
|
!hasOwn(options.primitives.controlHeight ?? {}, controlHeight)
|
|
) {
|
|
issues.push({
|
|
path: `${path}.controlHeight`,
|
|
message: `size primitive points to missing control height "${String(controlHeight)}"`
|
|
})
|
|
}
|
|
|
|
const typography = options.primitives.typography
|
|
const fontSize = size.fontSize
|
|
if (
|
|
typeof fontSize !== 'string' ||
|
|
!TEXT_SIZE_SET.has(fontSize) ||
|
|
!hasOwn(typography?.sizes ?? {}, fontSize)
|
|
) {
|
|
issues.push({
|
|
path: `${path}.fontSize`,
|
|
message: `size primitive points to missing font size "${String(fontSize)}"`
|
|
})
|
|
}
|
|
|
|
const iconSize = size.iconSize
|
|
if (
|
|
typeof iconSize !== 'string' ||
|
|
iconSize.trim() === '' ||
|
|
!hasOwn(options.primitives.icon?.size ?? {}, iconSize)
|
|
) {
|
|
issues.push({
|
|
path: `${path}.iconSize`,
|
|
message: `size primitive points to missing icon size "${String(iconSize)}"`
|
|
})
|
|
}
|
|
|
|
const radius = size.radius
|
|
if (
|
|
typeof radius !== 'string' ||
|
|
radius.trim() === '' ||
|
|
!hasOwn(options.primitives.radius ?? {}, radius)
|
|
) {
|
|
issues.push({
|
|
path: `${path}.radius`,
|
|
message: `size primitive points to missing radius "${String(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)) {
|
|
// `composition` is a reserved sibling key (TSC v2.2 cross-recipe
|
|
// overrides), not a token. Validate its shape separately and
|
|
// move on.
|
|
if (name === 'composition') {
|
|
validateRecipeComposition(`recipes.${component}.composition`, value, issues)
|
|
continue
|
|
}
|
|
|
|
// Recipe token keys are kebab-case CSS token segments. A
|
|
// leading underscore (`_palette-solid`) marks a private
|
|
// token — emitted by the generator as `--_{c}-{rest}`.
|
|
// See `lib/render-css.ts > appendRecipeDeclarations`.
|
|
const nameWithoutPrivatePrefix = name.startsWith('_') ? name.slice(1) : name
|
|
if (!isCssTokenSegment(nameWithoutPrivatePrefix)) {
|
|
issues.push({
|
|
path: `recipes.${component}.${name}`,
|
|
message: 'recipe token keys must be kebab-case CSS token segments (optionally with a leading "_" for private tokens)'
|
|
})
|
|
continue
|
|
}
|
|
|
|
// TSC v2 supports three value forms for a recipe entry:
|
|
// - bare string/number → scope:'root' shorthand
|
|
// - { value, scope?, depends? } → single declaration with TSC metadata
|
|
// - { declarations: [...] } → multi-scope declarations
|
|
// Validate each shape independently; the renderer's TSC pass
|
|
// runs scope-algebra checks separately when emitting CSS.
|
|
validateRecipeTokenValue(`recipes.${component}.${name}`, value, issues)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a recipe's `composition` block (TSC v2.2).
|
|
*
|
|
* Shape:
|
|
* ```
|
|
* composition: {
|
|
* [foreignComponent]: {
|
|
* targetSelector: string,
|
|
* tokens: { [tokenName]: { declarations: [{ value, scope?, depends? }] } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*
|
|
* Each composition token MUST use the multi-declaration form (the
|
|
* recipe is declaring overrides scoped to specific conditions — the
|
|
* single-declaration short form is rejected to keep the intent
|
|
* explicit).
|
|
*/
|
|
function validateRecipeComposition(
|
|
path: string,
|
|
value: unknown,
|
|
issues: EidosValidationIssue[]
|
|
): void {
|
|
if (typeof value !== 'object' || value === null) {
|
|
issues.push({ path, message: 'composition must be an object' })
|
|
return
|
|
}
|
|
for (const [foreignComponent, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
const entryPath = `${path}.${foreignComponent}`
|
|
if (!isCssTokenSegment(foreignComponent)) {
|
|
issues.push({
|
|
path: entryPath,
|
|
message: 'composition foreign-component keys must be kebab-case CSS token segments'
|
|
})
|
|
continue
|
|
}
|
|
if (typeof entry !== 'object' || entry === null) {
|
|
issues.push({ path: entryPath, message: 'composition entry must be { targetSelector, tokens }' })
|
|
continue
|
|
}
|
|
const obj = entry as Record<string, unknown>
|
|
if (typeof obj.targetSelector !== 'string' || obj.targetSelector.trim() === '') {
|
|
issues.push({
|
|
path: `${entryPath}.targetSelector`,
|
|
message: 'targetSelector must be a non-empty CSS selector string'
|
|
})
|
|
}
|
|
if (typeof obj.tokens !== 'object' || obj.tokens === null) {
|
|
issues.push({ path: `${entryPath}.tokens`, message: 'tokens must be an object' })
|
|
continue
|
|
}
|
|
for (const [tokenName, tokenValue] of Object.entries(obj.tokens as Record<string, unknown>)) {
|
|
const tokenPath = `${entryPath}.tokens.${tokenName}`
|
|
const nameWithoutPrivatePrefix = tokenName.startsWith('_') ? tokenName.slice(1) : tokenName
|
|
if (!isCssTokenSegment(nameWithoutPrivatePrefix)) {
|
|
issues.push({
|
|
path: tokenPath,
|
|
message: 'composition token keys must be kebab-case CSS token segments (optionally with leading "_")'
|
|
})
|
|
continue
|
|
}
|
|
if (typeof tokenValue !== 'object' || tokenValue === null || !('declarations' in tokenValue)) {
|
|
issues.push({
|
|
path: tokenPath,
|
|
message: 'composition tokens must use the multi-declaration form { declarations: [...] }'
|
|
})
|
|
continue
|
|
}
|
|
validateRecipeTokenValue(tokenPath, tokenValue, issues)
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateRecipeTokenValue(
|
|
path: string,
|
|
value: unknown,
|
|
issues: EidosValidationIssue[]
|
|
): void {
|
|
if (typeof value === 'string' || typeof value === 'number') {
|
|
validateNonEmptyCssValue(path, value, issues)
|
|
return
|
|
}
|
|
if (typeof value === 'object' && value !== null) {
|
|
const obj = value as Record<string, unknown>
|
|
if ('declarations' in obj) {
|
|
const decls = obj.declarations
|
|
if (!Array.isArray(decls) || decls.length === 0) {
|
|
issues.push({ path, message: 'declarations[] must be a non-empty array' })
|
|
return
|
|
}
|
|
for (let i = 0; i < decls.length; i++) {
|
|
const d = decls[i] as Record<string, unknown>
|
|
if (d == null || typeof d !== 'object') {
|
|
issues.push({
|
|
path: `${path}.declarations[${i}]`,
|
|
message: 'declaration must be { value, scope?, depends? }'
|
|
})
|
|
continue
|
|
}
|
|
validateNonEmptyCssValue(`${path}.declarations[${i}].value`, d.value, issues)
|
|
}
|
|
return
|
|
}
|
|
if ('value' in obj) {
|
|
validateNonEmptyCssValue(`${path}.value`, obj.value, issues)
|
|
return
|
|
}
|
|
}
|
|
issues.push({
|
|
path,
|
|
message: 'recipe value must be a CSS string/number or a TSC entry ({value} or {declarations[]})'
|
|
})
|
|
}
|
|
|
|
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}`, 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}`, value, issues)
|
|
}
|
|
}
|
|
|
|
function validateTypographySizeValue(
|
|
path: string,
|
|
value: unknown,
|
|
sizes: Record<string, unknown> | undefined,
|
|
issues: EidosValidationIssue[]
|
|
): void {
|
|
if (value === undefined) return
|
|
|
|
if (typeof value === 'string') {
|
|
if (!TEXT_SIZE_SET.has(value) || !sizes || !hasOwn(sizes, value)) {
|
|
issues.push({
|
|
path,
|
|
message: `typography style points to missing text size "${value}"`
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if (isPlainRecord(value) && isTextMetricLike(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, sizes, issues)
|
|
}
|
|
return
|
|
}
|
|
|
|
issues.push({
|
|
path,
|
|
message: 'typography size must be a canonical size, metric, or responsive map'
|
|
})
|
|
}
|
|
|
|
function validateNonEmptyCssValue(
|
|
path: string,
|
|
value: unknown,
|
|
issues: EidosValidationIssue[]
|
|
): void {
|
|
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
issues.push({
|
|
path,
|
|
message: 'CSS value must be a string or number'
|
|
})
|
|
return
|
|
}
|
|
|
|
const text = String(value)
|
|
const trimmed = text.trim()
|
|
if (trimmed === '') {
|
|
issues.push({
|
|
path,
|
|
message: 'CSS value cannot be empty'
|
|
})
|
|
return
|
|
}
|
|
|
|
if (/[;{}]/.test(text)) {
|
|
issues.push({
|
|
path,
|
|
message: 'CSS value cannot contain declaration or block delimiters'
|
|
})
|
|
return
|
|
}
|
|
|
|
// A `#`-prefixed value is a hex color; reject malformed hex (e.g. `#ggg`,
|
|
// `#12`) that would otherwise pass through and be silently dropped by the
|
|
// browser, leaving the role/scale step unstyled. Functions (`var()`,
|
|
// `rgb()`, `color-mix()`) and bare tokens don't start with `#`, so they're
|
|
// unaffected. (audit P1-4)
|
|
if (trimmed.startsWith('#') && !/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(trimmed)) {
|
|
issues.push({
|
|
path,
|
|
message: `invalid hex color "${trimmed}" (expected #rgb, #rgba, #rrggbb or #rrggbbaa)`
|
|
})
|
|
}
|
|
}
|
|
|
|
function validateTextMetric(
|
|
path: string,
|
|
metric: unknown,
|
|
issues: EidosValidationIssue[]
|
|
): void {
|
|
if (!isPlainRecord(metric)) {
|
|
issues.push({
|
|
path,
|
|
message: 'text metric must be a plain object'
|
|
})
|
|
return
|
|
}
|
|
|
|
validateNonEmptyCssValue(`${path}.size`, metric.size, issues)
|
|
validateNonEmptyCssValue(`${path}.lineHeight`, metric.lineHeight, issues)
|
|
validateNonEmptyCssValue(`${path}.letterSpacing`, metric.letterSpacing, issues)
|
|
}
|
|
|
|
function isTextMetricLike(value: Record<string, unknown>): boolean {
|
|
return hasOwn(value, 'size') || hasOwn(value, 'lineHeight') || hasOwn(value, 'letterSpacing')
|
|
}
|
|
|
|
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)
|
|
}
|