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

74 lines
3.7 KiB
JavaScript

// lib/activate.js
// activateTheme -- staged, persists COMPOSITE hash, refreshes assets (ARCHITECTURE.md 7.8)
const crypto = require("crypto");
const { getState } = require("@saltcorn/data/db/state");
const { compileTheme } = require("./compile");
const themeStore = require("./themeStore");
const cssCache = require("./cssCache");
const activePointer = require("./activePointer");
const { normalizeLayoutTree } = require("./layoutTree");
const { PLUGIN_NAME } = require("./constants");
function compositeHash(entries /* CompiledEntry[] */) {
// Fold the default + every role-override entry's content hash into ONE cache-buster so that
// re-activating ANY single role changes activeHash (and thus every emitted ?v). (folded fix)
const h = crypto.createHash("sha1");
for (const e of entries.filter(Boolean)) { h.update(`${e.role ?? "_"}:${e.hash};`); }
return h.digest("hex").slice(0, 8);
}
async function activateTheme(id, role) {
// Resolve uniformly through the store so the SEAM FIX applies (getById returns a
// normalized ThemeDTO for builtin ids too) -- STAGE-1's compile probe then sees the
// same defaulted token shape STAGE-2 caches and serves.
const theme = await themeStore.getById(id);
if (!theme) { const e = new Error("theme not found"); e.stage = "compile"; throw e; }
// STAGE 1: compile (distinct failure stage -> compile_failed). Pointer untouched on throw.
let compiled;
try { compiled = compileTheme(theme.tokens, { engine: theme.engine }); }
catch (e) { e.stage = "compile"; throw e; }
// STAGE 2: prime cache for ALL pointer-implied keys (default + every activeByRole role).
const { activeThemeId, activeByRole } = activePointer.getActivePointer();
const nextByRole = role == null ? activeByRole : { ...activeByRole, [role]: id };
const nextDefault = role == null ? id : activeThemeId;
cssCache.bustAll();
const entries = [];
if (nextDefault) entries.push(await cssCache.warm(nextDefault, null));
for (const r of Object.keys(nextByRole)) entries.push(await cssCache.warm(nextByRole[r], Number(r)));
// STAGE 3: persist pointer + COMPOSITE hash, re-register, refresh assets, propagate
// (distinct failure -> activation_failed).
// activeHash folds in the default AND every role-override entry's content hash, so re-activating
// a single role CHANGES activeHash -> the per-role ?v link changes -> immutable role CSS is busted.
// (folded review fix: role-override cache-buster never changed -> stale immutable CSS)
const nextHash = compositeHash(entries);
// LAYOUT MODE: persist the activated theme's layout tree into cfg so wrap() can read
// it synchronously (and it rides backup with the pointer). No-op when layout mode is off.
const liveCfg = getState().plugin_cfgs[PLUGIN_NAME] || {};
let layoutPatch = {};
if (liveCfg.layoutMode) {
const tree = normalizeLayoutTree(theme.layoutTree);
layoutPatch = role == null
? { activeLayoutTree: tree }
: { activeLayoutTreeByRole: { ...(liveCfg.activeLayoutTreeByRole || {}), [role]: tree } };
}
try {
const patch = role == null
? { activeThemeId: id, activeByRole: nextByRole, activeHash: nextHash, ...layoutPatch }
: { activeByRole: nextByRole, activeHash: nextHash, ...layoutPatch }; // ALWAYS update activeHash, incl. role path
await activePointer.setActivePointer(patch); // upsert + loadPlugin + refresh_views + processSend
} catch (e) {
cssCache.bustTheme(id); // drop orphan cache entry (folded fix)
e.stage = "activation"; throw e;
}
return { activeThemeId: nextDefault, activeByRole: nextByRole, cssHash: nextHash };
}
module.exports = { activateTheme, compositeHash };