import { ASPECT_RATIO_KEYS, BORDER_STYLE_KEYS, BORDER_WIDTH_KEYS, COLOR_ROLES, COLOR_SCALE_STEPS, CONTAINER_WIDTH_KEYS, CONTENT_WIDTH_KEYS, OPACITY_KEYS, SHADOW_KEYS, SIZE_PRIMITIVE_KEYS, TEXT_SIZES, Z_INDEX_KEYS, type BorderPrimitiveSet, type ColorRole, type ColorRoleDefinition, type ColorScale, type EidosCssContract, type EidosValidationIssue, type EidosValidationReport, type EidosConfig, type EidosConfigSnapshot, 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 { EidosConfigValidationError } from '../errors' const COLOR_ROLE_SET = new Set(COLOR_ROLES) const ASPECT_RATIO_KEY_SET = new Set(ASPECT_RATIO_KEYS) const BORDER_WIDTH_KEY_SET = new Set(BORDER_WIDTH_KEYS) const BORDER_STYLE_KEY_SET = new Set(BORDER_STYLE_KEYS) const CONTAINER_WIDTH_KEY_SET = new Set(CONTAINER_WIDTH_KEYS) const CONTENT_WIDTH_KEY_SET = new Set(CONTENT_WIDTH_KEYS) const OPACITY_KEY_SET = new Set(OPACITY_KEYS) const SHADOW_KEY_SET = new Set(SHADOW_KEYS) const SIZE_PRIMITIVE_KEY_SET = new Set(SIZE_PRIMITIVE_KEYS) const TEXT_SIZE_SET = new Set(TEXT_SIZES) const Z_INDEX_KEY_SET = new Set(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 { return snapshotEidosConfig(readEidosConfigDocumentEnvelope(document).options) } export function parseEidosConfigFromJson(json: string): EidosConfig { return snapshotEidosConfig(parseEidosConfigDocumentEnvelope(json).options) } 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[] = [] validateColorScales(options, issues) validateColorRoles(options, issues) validateTypography(options, issues) validateSizePrimitives(options, issues) validateBorderPrimitives(options, issues) validateLayoutPrimitives(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) } 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 getEidosTheme(options: EidosConfig, id: string): ThemeDefinition | undefined { const theme = options.themes?.[id] return theme ? clonePlain(theme) : 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)) { 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 ?? {})) { validateColorScale(`themes.${themeId}.color.scales.${name}`, name, scale, issues) } for (const [name, scale] of Object.entries(theme.color?.alphaScales ?? {})) { 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 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 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, themes: Record, issues: EidosValidationIssue[] ): void { if (!COLOR_ROLE_SET.has(role)) { issues.push({ path, message: 'unknown color role' }) return } const scaleName = resolveRoleScaleName(roleDef) if (!scaleName) { issues.push({ path, message: 'color role must point to a primitive scale' }) return } 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 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, family] of Object.entries(typography.families)) { if (family.family.trim() === '') { issues.push({ path: `primitives.typography.families.${name}.family`, message: 'font family name cannot be empty' }) } } for (const [name, style] of Object.entries(typography.styles)) { const path = `primitives.typography.styles.${name}` 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 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 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 | undefined, keys: readonly string[], keySet: Set, 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 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() !== '') return issues.push({ path, message: 'CSS value cannot be empty' }) } 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 isPlainRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function resolveRoleScaleName( roleDef: string | ColorRoleDefinition | undefined ): string | undefined { if (typeof roleDef === 'string') return roleDef return roleDef?.scale } function hasOwn(value: T, key: PropertyKey): boolean { return Object.prototype.hasOwnProperty.call(value, key) } function clonePlain(value: T): T { if (typeof structuredClone === 'function') return structuredClone(value) return JSON.parse(JSON.stringify(value)) as T }