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