import type { DesignDiagnostic, DesignSystem, DesignToken, TokenType } from "./types.ts"; import { validateRecipes } from "./recipes.ts"; export const DESIGN_SYSTEM_SCHEMA_VERSION = 1; export const TOKEN_TYPES: readonly TokenType[] = [ "color", "dimension", "number", "fontFamily", "duration", "shadow", "fontWeight", "colorScheme", ]; const forbidden = new Set(["__proto__", "prototype", "constructor"]); export const validName = (value: string): boolean => /^[a-zA-Z][\w.-]*$/.test(value) && !value.split(/[.-]/).some((part) => forbidden.has(part)); export const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v)); const length = /^-?(?:\d*\.)?\d+(?:px|rem|em|%|vw|vh|dvh|svh|ch|ex|vmin|vmax)$/; /** Deliberately bounded CSS subset. References use { ref }, never arbitrary var()/url(). */ export function validTokenValue(type: TokenType, value: unknown): boolean { if (isRecord(value)) return Object.keys(value).length === 1 && typeof value.ref === "string" && validName(value.ref); if (typeof value === "number") return ( Number.isFinite(value) && (type === "number" || (type === "fontWeight" && value >= 1 && value <= 1000)) ); if ( typeof value !== "string" || !value.trim() || value.length > 2048 || /[;{}<>\\]|\/\*|\*\//.test(value) || [...value].some((character) => character.charCodeAt(0) < 32) ) return false; if (type === "colorScheme") return value === "light" || value === "dark"; if (type === "dimension") return value === "0" || length.test(value); if (type === "duration") return /^(?:\d*\.)?\d+(ms|s)$/.test(value); if (type === "fontWeight") return /^(normal|bold|[1-9]\d{0,2}|1000)$/.test(value); if (type === "number") return /^-?(?:\d*\.)?\d+$/.test(value) && Number.isFinite(Number(value)); if (type === "fontFamily") return /^(?:"[\w -]+"|'[\w -]+'|[a-zA-Z][\w -]*)(?:\s*,\s*(?:"[\w -]+"|'[\w -]+'|[a-zA-Z][\w -]*))*$/.test( value, ); if (type === "color") { if ( /^(?:#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8})$/i.test(value) || /^(?:transparent|currentColor|black|white|red|green|blue|gray|grey|orange|purple|yellow|pink|rebeccapurple)$/i.test( value, ) ) return true; const call = /^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\(([^()]+)\)$/i.exec(value); if (!call) return false; const args = call[2]!.trim(); const kind = call[1]!.toLowerCase(); let channels: string[]; if (args.includes(",")) { if (args.includes("/") || !["rgb", "rgba", "hsl", "hsla"].includes(kind)) return false; channels = args.split(/\s*,\s*/); } else { const sections = args.split(/\s*\/\s*/); if (sections.length > 2) return false; channels = sections[0]!.trim().split(/\s+/); if (channels.length !== 3) return false; if (sections.length === 2) channels.push(sections[1]!); } return ( (channels.length === 3 || channels.length === 4) && channels.every((channel, index) => { const hue = (["hsl", "hsla"].includes(kind) && index === 0) || (["lch", "oklch"].includes(kind) && index === 2); const pattern = hue ? /^[+-]?(?:\d*\.)?\d+(?:deg)?$/ : /^[+-]?(?:\d*\.)?\d+%?$/; if (!pattern.test(channel) || !Number.isFinite(parseFloat(channel))) return false; return ( !(["hsl", "hsla"].includes(kind) && (index === 1 || index === 2)) || channel.endsWith("%") ); }) ); } if (type === "shadow") { if (value === "none") return true; const shadows = value.split(/,(?![^()]*\))/); return shadows.every((shadow) => { let colors = 0; const dimensions = shadow .replace( /(?:rgba?|hsla?|oklch|oklab|lab|lch)\([^()]*\)|#[\da-f]+|\b(?:black|white|transparent|currentColor)\b/gi, (color) => { if (!validTokenValue("color", color)) return "INVALID"; colors++; return ""; }, ) .trim() .split(/\s+/); const inset = dimensions.indexOf("inset"); if (inset !== -1) dimensions.splice(inset, 1); return ( colors <= 1 && dimensions.length >= 2 && dimensions.length <= 4 && dimensions.every((part) => part === "0" || (length.test(part) && !part.endsWith("%"))) && (dimensions.length < 3 || parseFloat(dimensions[2]!) >= 0) ); }); } return false; } export function validateDesignSystem(input: unknown): DesignDiagnostic[] { const issues: DesignDiagnostic[] = []; const error = (code: string, path: string, message: string) => issues.push({ code, path, message }); if (!isRecord(input)) return [{ code: "WRN-DS-SCHEMA", path: "$", message: "Expected a data-only object." }]; const fields = new Set([ "schemaVersion", "id", "name", "version", "description", "googleFonts", "extends", "tokens", "modes", "densities", "defaultMode", "recipes", ]); for (const key of Object.keys(input).sort()) if (!fields.has(key)) error("WRN-DS-FIELD", key, "Unknown or unsupported field."); if (input.schemaVersion !== 1) error("WRN-DS-VERSION", "schemaVersion", "Only schemaVersion 1 is supported."); for (const key of ["id", "name", "version"] as const) if (typeof input[key] !== "string" || !input[key].trim()) error("WRN-DS-SCHEMA", key, "Expected a nonempty string."); if (typeof input.id === "string" && !validName(input.id)) error("WRN-DS-NAME", "id", "Invalid system identifier."); if (input.description !== undefined && typeof input.description !== "string") error("WRN-DS-SCHEMA", "description", "Expected a string."); if ( input.extends !== undefined && (!Array.isArray(input.extends) || input.extends.some((v) => typeof v !== "string" || !validName(v))) ) error("WRN-DS-SCHEMA", "extends", "Expected system identifier array."); if ( input.defaultMode !== undefined && (typeof input.defaultMode !== "string" || !validName(input.defaultMode)) ) error("WRN-DS-NAME", "defaultMode", "Invalid mode name."); if ( input.googleFonts !== undefined && (!Array.isArray(input.googleFonts) || input.googleFonts.length > 10 || input.googleFonts.some( (name: unknown) => typeof name !== "string" || !/^[A-Za-z][A-Za-z0-9 ]{0,79}$/.test(name) || name.trim() !== name, )) ) error( "WRN-DS-SCHEMA", "googleFonts", "Choose up to ten Google Font family names using letters, numbers and spaces.", ); const map = (value: unknown, path: string) => { if (!isRecord(value)) { error("WRN-DS-SCHEMA", path, "Expected a token map."); return; } for (const [key, token] of Object.entries(value).sort()) { const at = `${path}.${key}`; if (!validName(key)) error("WRN-DS-NAME", at, "Invalid token name."); if (!isRecord(token)) { error("WRN-DS-SCHEMA", at, "Expected a token object."); continue; } for (const field of Object.keys(token)) if (!["type", "value", "legacy", "description"].includes(field)) error("WRN-DS-FIELD", `${at}.${field}`, "Unknown token field."); if (!TOKEN_TYPES.includes(token.type as TokenType)) error("WRN-DS-TYPE", `${at}.type`, "Unsupported token type."); else if (!validTokenValue(token.type as TokenType, token.value)) error("WRN-DS-VALUE", `${at}.value`, "Invalid or unsupported token value."); if ( token.legacy !== undefined && (typeof token.legacy !== "string" || !/^[a-z][a-z0-9-]*$/.test(token.legacy) || !validName(token.legacy)) ) error("WRN-DS-NAME", `${at}.legacy`, "Invalid legacy token suffix."); if (token.legacy === "color-scheme" && token.type !== "colorScheme") error("WRN-DS-TYPE", `${at}.legacy`, "color-scheme requires colorScheme type."); if (token.description !== undefined && typeof token.description !== "string") error("WRN-DS-SCHEMA", `${at}.description`, "Expected a string."); } }; map(input.tokens, "tokens"); for (const axis of ["modes", "densities"]) if (input[axis] !== undefined) { if (!isRecord(input[axis])) error("WRN-DS-SCHEMA", axis, "Expected a map."); else for (const [key, tokens] of Object.entries(input[axis]).sort()) { if (!validName(key)) error("WRN-DS-NAME", `${axis}.${key}`, "Invalid axis name."); map(tokens, `${axis}.${key}`); } } issues.push(...validateRecipes(input.recipes)); return issues; } export class DesignSystemError extends Error { constructor(public readonly diagnostics: DesignDiagnostic[]) { super(diagnostics.map((d) => `${d.code} ${d.path}: ${d.message}`).join("\n")); this.name = "DesignSystemError"; } } export function assertDesignSystem(input: unknown): asserts input is DesignSystem { const issues = validateDesignSystem(input); if (issues.length) throw new DesignSystemError(issues); } export function defineDesignSystem(input: T): T { assertDesignSystem(input); return input; } export function isReference(value: DesignToken["value"]): value is { ref: string } { return isRecord(value); }