101 lines
3.3 KiB
JavaScript
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 };
|