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

101 lines
3.3 KiB
JavaScript

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