// Phase 1 overlay compiler: tokens -> CSS custom-property overlay that loads // AFTER Bootstrap and wins by cascade order. Pure and NEVER throws (a bad theme // degrades to empty-but-valid CSS = active theme renders unchanged = no // white-screen). The same compileTheme doubles as the import robustness check. const crypto = require("crypto"); const { sanitizeValue, sanitizeSelector } = require("./sanitize"); const { TOKEN_SCHEMA, TOKEN_KEY_RE, MAX_CSS_BYTES } = require("./tokenSchema"); const deepOverlay = require("./deepOverlay"); // Bound the emitted overlay so a pathological theme cannot balloon the output. const MAX_ROOT_DECLS = 2000; const MAX_RULES = 500; // Nested token sections flattened into the kebab-case keys TOKEN_SCHEMA uses. const FLAT_SECTIONS = ["colors", "typography", "borders", "components"]; function camelToKebab(key) { return String(key).replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()); } // Merge colors/typography/borders/components into one { kebabKey: value } map. // `custom` (raw "--var": value) is handled separately by emitOverlayCss. function flattenTokens(tokens) { const flat = {}; if (!tokens || typeof tokens !== "object") { return flat; } for (const sec of FLAT_SECTIONS) { const obj = tokens[sec]; if (!obj || typeof obj !== "object") { continue; } for (const [k, v] of Object.entries(obj)) { if (v != null) { flat[camelToKebab(k)] = v; } } } return flat; } // "#ff0000" / "#f00" -> "255, 0, 0" for the --bs--rgb companion. Returns null // for non-hex values (the companion is simply not emitted). function deriveRgb(value) { if (typeof value !== "string") { return null; } const s = value.trim(); const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(s); const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(s); let r; let g; let b; if (m6) { r = parseInt(m6[1], 16); g = parseInt(m6[2], 16); b = parseInt(m6[3], 16); } else if (m3) { r = parseInt(m3[1] + m3[1], 16); g = parseInt(m3[2] + m3[2], 16); b = parseInt(m3[3] + m3[3], 16); } else { return null; } return `${r}, ${g}, ${b}`; } // Build the overlay. Each --bs-x:v; is its own declaration and each targeted // rule is its own {} block, so the CSS parser's standard error recovery drops a // single bad declaration/rule without discarding siblings. function emitOverlayCss(tokens) { const warnings = []; const rootDecls = []; const rules = []; const flat = flattenTokens(tokens); for (const [key, rawVal] of Object.entries(flat)) { if (rootDecls.length >= MAX_ROOT_DECLS || rules.length >= MAX_RULES) { warnings.push("token cap reached; remaining tokens dropped"); break; } if (!TOKEN_KEY_RE.test(key)) { continue; } const sv = sanitizeValue(rawVal, warnings); if (sv == null) { continue; } const schema = TOKEN_SCHEMA[key]; if (schema && schema.kind === "bsvar") { rootDecls.push(`--bs-${schema.cssVar}: ${sv};`); if (Array.isArray(schema.derive)) { const rgb = deriveRgb(sv); if (rgb != null) { rootDecls.push(`--bs-${schema.cssVar}-rgb: ${rgb};`); } } } else if (schema && schema.kind === "rule") { const sel = sanitizeSelector(schema.selector); if (sel != null) { rules.push(`${sel}{${schema.prop}: ${sv};}`); } } else if (schema && schema.kind === "ignore") { // known-but-not-emitted (forward-compat) } else { // unknown-but-valid token -> harmless passthrough custom property rootDecls.push(`--tb-${key}: ${sv};`); } } // raw custom CSS variables ("--my-var": "value") const custom = tokens && tokens.custom; if (custom && typeof custom === "object") { for (const [k, v] of Object.entries(custom)) { if (rootDecls.length >= MAX_ROOT_DECLS) { break; } if (!/^--[-a-zA-Z0-9_]{1,64}$/.test(k)) { continue; } const sv = sanitizeValue(v, warnings); if (sv != null) { rootDecls.push(`${k}: ${sv};`); } } } let css = ""; if (rootDecls.length > 0) { css += `:root{${rootDecls.join("")}}\n`; } if (rules.length > 0) { css += rules.join("\n") + "\n"; } // DEEP OVERLAY: recompute the per-component --bs-btn-* variables Bootstrap // bakes at compile time, so buttons actually recolor under the overlay. A // plain :root{--bs-primary} override does NOT reach .btn-primary. Opt out // with tokens.deepOverlay === false. Skipped silently for non-hex colors. if (!tokens || tokens.deepOverlay !== false) { const btnCss = deepOverlay.emitButtonRules((tokens && tokens.colors) || {}); if (btnCss) { css += btnCss + "\n"; } } return { css, warnings }; } // Final structural backstop. Bounds size and verifies balanced braces; on // failure degrades to an empty-but-valid sentinel rather than emitting CSS that // could break the whole page. function robustnessGuard(css, warnings) { if (Buffer.byteLength(css, "utf8") > MAX_CSS_BYTES) { warnings.push("css exceeds cap"); const rootLine = css.split("\n").find((l) => l.startsWith(":root{")) || ""; css = (rootLine && Buffer.byteLength(rootLine, "utf8") <= MAX_CSS_BYTES) ? rootLine : "/* theme-builder: empty (over cap) */"; } let depth = 0; let ok = true; for (const ch of css) { if (ch === "{") { depth += 1; } else if (ch === "}") { depth -= 1; if (depth < 0) { ok = false; break; } } } if (!ok || depth !== 0) { warnings.push("unbalanced braces; emitting empty"); css = "/* theme-builder: empty (invalid) */"; } return { css, warnings }; } function contentHash(css) { return crypto.createHash("sha1").update(css).digest("hex").slice(0, 8); } // Pure; never throws. opts.engine "sass" routes to the Phase-2 recompiler // (lazy-required), anything else to the overlay. function compileTheme(tokens, opts = {}) { const engine = opts.engine === "sass" ? "sass" : "overlay"; let out; try { out = engine === "sass" ? require("./sassCompile").compileSass(tokens, opts) : emitOverlayCss(tokens); } catch (e) { out = { css: "/* theme-builder: empty (compile error) */", warnings: ["compile threw: " + e.message] }; } const guarded = robustnessGuard(out.css, out.warnings || []); return { css: guarded.css, hash: contentHash(guarded.css), warnings: guarded.warnings, engine }; } module.exports = { compileTheme, emitOverlayCss, robustnessGuard, contentHash, deriveRgb, flattenTokens, camelToKebab, };