// Token shape: defaults, deep-merge-on-read normalization, and validation. // Pure module (only depends on constants for the token-count cap). `tokens` is // the SINGLE SOURCE OF TRUTH for a theme; every read passes through // normalizeTokens so the compiler always receives a fully-populated, current // shape (a sparse/empty theme can never white-screen). const { MAX_TOKENS } = require("./constants"); const DEFAULT_TOKENS = Object.freeze({ $tokensVersion: 1, colors: { primary: "#0d6efd", secondary: "#6c757d", success: "#198754", info: "#0dcaf0", warning: "#ffc107", danger: "#dc3545", light: "#f8f9fa", dark: "#212529", bodyBg: "#ffffff", bodyColor: "#212529", }, typography: { rootFontSize: "16px", bodyFontSize: "1rem", bodyFontWeight: "400", bodyLineHeight: "1.5", }, borders: { borderRadius: "0.375rem", borderWidth: "1px" }, components: {}, custom: {}, }); function isPlainObject(x) { return !!x && typeof x === "object" && !Array.isArray(x); } // Recursive merge; `over` wins. Non-object values are replaced wholesale. function deepMerge(base, over) { const out = isPlainObject(base) ? { ...base } : {}; if (!isPlainObject(over)) { return out; } for (const [k, v] of Object.entries(over)) { if (isPlainObject(v) && isPlainObject(out[k])) { out[k] = deepMerge(out[k], v); } else { out[k] = v; } } return out; } // The single chokepoint every token read passes through. v1: validate + deep- // merge over DEFAULT_TOKENS (no transform yet). Future: a TOKEN_UPCASTERS chain // keyed on $tokensVersion can be added here, mirroring the envelope upcaster. function normalizeTokens(raw) { const merged = deepMerge(structuredClone(DEFAULT_TOKENS), isPlainObject(raw) ? raw : {}); merged.$tokensVersion = 1; return merged; } function emptyTokens() { return structuredClone(DEFAULT_TOKENS); } // Shape check used by import. Returns { ok, errors[] }. Every leaf under a known // section must be a string; the token count is capped. function tokensAreValid(tokens) { const errors = []; if (!isPlainObject(tokens)) { return { ok: false, errors: ["tokens must be an object"] }; } if (tokens.$tokensVersion != null && typeof tokens.$tokensVersion !== "number") { errors.push("$tokensVersion must be a number"); } if (tokens.sass != null && typeof tokens.sass !== "string") { errors.push("sass must be a string"); } let leafCount = 0; for (const sec of ["colors", "typography", "borders", "components", "custom"]) { const obj = tokens[sec]; if (obj == null) { continue; } if (!isPlainObject(obj)) { errors.push(`${sec} must be an object`); continue; } for (const [k, v] of Object.entries(obj)) { leafCount += 1; if (typeof v !== "string") { errors.push(`${sec}.${k} must be a string`); } } } if (leafCount > MAX_TOKENS) { errors.push("too many tokens"); } return { ok: errors.length === 0, errors }; } module.exports = { DEFAULT_TOKENS, deepMerge, normalizeTokens, emptyTokens, tokensAreValid };