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

114 lines
3.8 KiB
JavaScript

// lib/apiState.js
// GET /api/state payload builder (ARCHITECTURE.md 8.5).
//
// StateEnvelope = {
// themes: ThemeListItem[], // metadata only (NOT full token payloads)
// activeThemeId: string|null,
// activeByRole?: { [roleId:number]: string },
// manifest: TokenManifest,
// caps: { layoutMode, canImport, engine, formatVersion, sizeCap },
// ... plus active{}, layoutMode, csrfToken convenience fields for the shell.
// }
// ThemeListItem = { id, name, builtin, active, engine, updatedAt, hasLayoutTree }
//
// buildState merges builtins (builtin:true) + library rows (builtin:false) via
// themeStore.list(), annotates `active` from the LIVE pointer (read via
// getState().plugin_cfgs, no DB hit), and includes the token manifest. caps.layoutMode
// toggles the SPA canvas panel.
const { getState } = require("@saltcorn/data/db/state");
const themeStore = require("./themeStore");
const { TOKEN_SCHEMA, MAX_CSS_BYTES } = require("./tokenSchema");
const { DEFAULT_TOKENS } = require("./themeSchema");
const { PRESETS } = require("./layoutTree");
const {
PLUGIN_NAME, API_VERSION, ENGINE, FORMAT_VERSION, MAX_IMPORT_BYTES, CFG,
} = require("./constants");
// Live merged plugin config for this tenant (no DB hit). Mirrors
// activePointer.getActivePointer / cfgReaders resolution.
function livePointer() {
const cfg = getState().plugin_cfgs[PLUGIN_NAME] || {};
return {
activeThemeId: cfg[CFG.ACTIVE] || null,
activeByRole: cfg[CFG.BY_ROLE] || {},
layoutMode: !!cfg[CFG.LAYOUT_MODE],
};
}
// The server-side TokenManifest: HOW each token maps to CSS, paired with its
// default value. Structurally parallel to the SPA manifest; derived from the
// single TOKEN_SCHEMA source of truth.
function buildManifest() {
const flatDefaults = {};
for (const [sec, obj] of Object.entries(DEFAULT_TOKENS)) {
if (!obj || typeof obj !== "object") {
continue;
}
for (const [k, v] of Object.entries(obj)) {
// camelCase leaf -> kebab-case manifest key (matches compile.flattenTokens)
const kebab = k.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
flatDefaults[kebab] = v;
}
}
const tokens = {};
for (const [key, spec] of Object.entries(TOKEN_SCHEMA)) {
tokens[key] = {
kind: spec.kind,
cssVar: spec.cssVar || null,
selector: spec.selector || null,
prop: spec.prop || null,
derive: spec.derive || null,
default: flatDefaults[key] != null ? flatDefaults[key] : null,
};
}
return { engine: ENGINE, apiVersion: API_VERSION, tokens };
}
// stored row / builtin ThemeDTO -> compact ThemeListItem (metadata only).
function toListItem(t, activeThemeId, activeByRole) {
const isActive = t.id === activeThemeId || Object.values(activeByRole).includes(t.id);
return {
id: t.id,
name: t.name,
builtin: !!t.builtin,
active: isActive,
engine: t.engine,
updatedAt: t.updated_at || null,
hasLayoutTree: !!t.layoutTree,
};
}
async function buildState(req) {
const { activeThemeId, activeByRole, layoutMode } = livePointer();
const all = await themeStore.list();
const themes = all.map((t) => toListItem(t, activeThemeId, activeByRole));
const csrfToken = (req && typeof req.csrfToken === "function") ? req.csrfToken() : "";
return {
themes,
activeThemeId,
activeByRole,
active: { activeThemeId, activeByRole },
layoutMode,
csrfToken,
manifest: buildManifest(),
layoutPresets: PRESETS.map((p) => ({ id: p.id, label: p.label, tree: structuredClone(p.tree) })),
caps: {
layoutMode,
canImport: true,
engine: ENGINE,
formatVersion: FORMAT_VERSION,
sizeCap: MAX_IMPORT_BYTES,
maxCssBytes: MAX_CSS_BYTES,
},
};
}
module.exports = { buildState, buildManifest };