sc-theme-builder/lib/compile.js
2026-07-01 20:07:28 -05:00

212 lines
7.1 KiB
JavaScript

// 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-<x>-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,
};