212 lines
7.1 KiB
JavaScript
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,
|
|
};
|