100 KiB
Theme Builder — Architecture
Saltcorn plugin
theme-builder— a visual theme designer. Phase 1 ships a Bootstrap-5 CSS custom-property skin overlay (no Sass); Phase 2 flips on a selectable layout assembled from a Craft.js tree with an optional full dart-sass recompile. Themes live in a real, backup-safe, per-tenant Saltcorn Table; the active pointer lives in plugin config. Compiled CSS is a derived cache, never stored.
This document is the authoritative, implementation-ready specification. It supersedes the per-subsystem designs it was assembled from and folds in every adversarial-review fix.
Table of contents
- Overview & goals
- Design principles
- Saltcorn integration points
- Data model & storage
- Plugin module contract & lifecycle
- HTTP API surface
- Compile & render pipeline
- Builder SPA
- Export / import
- Package / file layout
- Phase plan & milestones
- Open questions
1. Overview & goals
1.1 What it is
theme-builder is a Saltcorn plugin (sibling to dev-deploy, idp, postiz under /home/scott/claude/saltcorn/) that lets an admin design site themes visually and publish them to the live site. A Saltcorn theme is fundamentally a plugin that exports layout.wrap() (the HTML skeleton) plus linked CSS; theme-builder realizes both halves through config-driven facilities so that one stored theme drives the live appearance.
It maintains a library of themes (built-in starters shipped in code + user themes in a real Table), a token editor with live preview, an activate operation that publishes exactly one theme to the live site, and export/import for cross-instance portability.
1.2 The phased approach
The plugin ships in two phases that share the same data model, store, compile entry point, and SPA shell. The phase is a single config flag, phase2Enabled.
| Aspect | Phase 1 — the "skin" | Phase 2 — the "bones" |
|---|---|---|
| Mechanism | A config-driven CSS custom-property overlay injected via headers(cfg) as a { css: ".../theme.css?v=<hash>" } link, loaded after Bootstrap so it wins by cascade. |
The plugin registers a selectable layout(cfg) so it becomes choosable via layout_by_role / user._attributes.layout. wrap() is assembled from a Craft.js serialized tree. |
| Composition | Composes with the active theme (e.g. sbadmin2); it is not itself a layout. |
The plugin owns the stylesheet, so an optional full dart-sass recompile of Bootstrap from source becomes worthwhile (no double-Bootstrap problem). |
| Compiler | Pure-JS --bs-* overlay emitter. No Sass dependency. |
Optional sass.compileString against vendored Bootstrap source; falls back to the overlay on failure. |
| Per-role | headers only_if(req) predicate. |
Native layout_by_role. |
| Blast radius | Low. Ships first. | Higher; gated behind phase2Enabled. |
1.3 The hybrid bootstrap.build + Craft.js model
Two complementary tooling models are adopted:
bootstrap.build-style token editor — we adopt its model (a panel of color/font/spacing pickers that drive Bootstrap variables), implemented as our own React becausebootstrap.buildis not an embeddable npm library. Tokens map 1:1 onto Bootstrap 5--bs-*custom properties, so the common case needs no Sass compiler.- Craft.js canvas —
@craftjs/coreis already vendored insaltcorn-builder. Phase 2 grafts a structural canvas that reuses saltcorn-builder'sEditor/Frame/resolver patterns and thecraftToSaltcorn/layoutToNodesserialization round-trip, persisting alayoutTreeon the theme row.
1.4 The cardinal rule: load ≠ activate
The single most load-bearing semantic in this design:
load(open in editor) is strictly separated fromactivate(publish). Every editor/library operation (list/load/create/duplicate/save/rename/delete/export/import) only reads or writes the library Table.activateis the ONLY operation that mutates the live site — it recompiles tokens → CSS into the active-CSS cache and flips the active pointer in plugin config. This is enforced structurally: editor handler modules neverrequirethe activate engine.
2. Design principles
2.1 Single source of truth
- A theme's
tokensobject is authoritative. Compiled CSS is a derived cache, recomputed on activate/save, never the source and never stored per-theme. - Craft.js layout nodes (Phase 2) reference
var(--bs-*)/ Bootstrap classes, never literal hex. Token values live only intokens. The serialization bridge never inlines token values into the layout tree, so the token panel and the canvas panel both derive from the onetokensobject. - The token→CSS mapping has one chokepoint each direction: the server
TOKEN_SCHEMAtable (compiler) and the SPATokenManifest(editor) are kept structurally parallel so the live preview and production CSS can never drift (§7, §8). - The active pointer has one home:
plugin.configuration(activeThemeId, optionalactiveByRole). It is read at request time from the live merged cfg; it is written only byactivate. - Identity has one form: the stable
theme_id(uuid). Thenameis display-only.
2.2 Backup-safety via a real Table (the audit lesson)
The earlier backup audit (plugin_db_backup_compat) found that raw-DDL _dd_*/_idp_* tables created with a bare CREATE TABLE are invisible to Saltcorn backup/restore/snapshots (because backup enumerates _sc_tables), and that a raw _dd_row_uuid column could ROLLBACK a user-table restore — a data-loss bug.
This design never issues raw DDL. The theme library is created via the Table model (Table.create writes _sc_tables; Field.create writes _sc_fields), so:
- The table and all its rows ride backup/restore/snapshots automatically.
- It is per-tenant for free (schema-qualified by the Table model from async-local storage).
- The active pointer in plugin config rides
plugin_pack/install_packautomatically.
Export/import is the per-theme cross-instance complement to backup (a portable file for one theme between unrelated instances), deliberately not a workaround for an invisibility gap.
2.3 Admin-trust import model
Per the final, locked import-security decision: there is NO security sanitization of imported themes. An admin can already inject arbitrary site-wide CSS/JS via page_custom_css, page_custom_html, or by installing plugins, so a theme import (admin-only) crosses no boundary the admin role does not already own.
We keep robustness-only validation, justified by correctness/UX/operational hygiene, never by security:
(a) format + version check with upcasting of old exports; (b) mint a fresh id on import + de-dup name, so an import can never overwrite an existing theme; (c) a light valid-CSS / scope-a-bad-token check so a malformed token cannot white-screen every page; (d) a generous size cap (operational hygiene).
Documented contingency: if a non-admin role is ever permitted to import or manage themes, real sanitization (CSS/selector/@import/url()/expression filtering, layoutTree resolver allow-listing) MUST be reintroduced before that capability ships. This is recorded as an inline comment in lib/portability.js and in the plugin README security section.
3. Saltcorn integration points
The single switch that makes the plugin config-driven is the presence of configuration_workflow. Because it is present, State.registerPlugin's withCfg resolver treats every facility (layout, headers, routes, …) as a factory (cfg) => value and calls it with the merged config (state.ts:1019-1024). Therefore our headers, routes, and (Phase 2) layout must be functions; the stored config drives all three.
3.1 The hooks we use (with file:line)
| Hook | How we use it | Grounding |
|---|---|---|
configuration_workflow() |
Returns a Workflow mounting the phase toggle + a link/iframe to the builder SPA + the active-pointer step. Its presence is the config-driven switch. |
plugins.js:843-854,920-957; workflow.ts:28-54,212-322 |
routes(cfg) |
Returns the full route array (public theme.css + admin editor/API). Mounted at app root on the per-tenant router; routesChangedCb fires only when routes.length>0. Callbacks run positionally (req,res,next) via error_catcher. |
state.ts:1136-1138; plugin_routes_handler.js:26-53; utils.js:298-306 |
headers(cfg) |
Phase 1: returns { css: ".../theme.css?v=<hash>", only_if } entries. Emitted after the theme's Bootstrap CSS so the overlay wins. The Header type key is css/script/headerTag — there is no style key in the type (though headersInHead emits h.style at runtime, we never rely on it). |
state.ts:1123-1135; base_types.ts:69-76; sbadmin2/index.js:400-427; layout_utils.ts:646-720; wrapper.js:132-190 |
layout(cfg) |
Phase 2 only: returns { wrap, authWrap?, renderBody?, pluginName }. Registered into state.layouts["theme-builder"] only when truthy (if (layout) this.layouts[name] = layout). Phase 1 returns undefined so we are never a layout. |
state.ts:1113-1117; base_types.ts:159-175 |
onLoad(cfg) |
Idempotent per-tenant bootstrap (table ensure, builtins are code-only so nothing to seed into DB, active-CSS cache rehydration). Runs outside withCfg with the raw stored config; errors are swallowed+logged, so every step must be idempotent and self-guarded. |
plugin.ts:499-505 |
static public/ |
Serves the compiled SPA bundle at /plugins/public/theme-builder/<file> with zero extra route. |
plugins.js:1246-1276 |
exposed_configs |
Omitted (see §5.3) — the factories read the full merged cfg directly, and declaring it triggers a misleading "users must relogin" flash on every config save. | state.ts:1007-1012; plugins.js:962-963 |
3.2 How configuration_workflow makes facilities config-driven
Plugin.loadPlugin(plugin)
-> registerPlugin(plugin_name||name, module, cfg, location, modname) // state.ts:993
withCfg(key, def) = plugin.configuration_workflow
? (plugin[key] ? plugin[key](cfg||{}) : def)
: (plugin[key] || def) // state.ts:1019
this.layouts[name] = withCfg("layout") // only set if truthy state.ts:1113
this.headers[name].push( ...withCfg("headers", []) ) state.ts:1129
this.plugin_routes[name] = withCfg("routes", []) state.ts:1136
-> module.onLoad(plugin.configuration) // RAW cfg, not merged plugin.ts:499
cfg passed to factories is merged: { ...savedConfig, ...fixed_plugin_configuration[name] } (state.ts:1002-1006). onLoad receives the raw plugin.configuration. We keep activeThemeId/activeByRole/phase2Enabled/activeHash as plugin-internal keys that an admin would not set in fixed_plugin_configuration (or they would be un-editable AND stripped from packs).
3.3 Layout selection (Phase 2)
getLayout(user) precedence is role > per-user > last-installed (state.ts:378-426), with the twist that when layout_by_role names a plugin and the user has a per-user layout for that same plugin, the per-user entry wins (state.ts:388-392). Critically, only the layout_by_role branch and the per-user userLayouts branch stamp pluginName onto the returned layout; the last-installed fallback branch (state.ts:419-425) returns the layout object WITHOUT pluginName — a fact §7.6 and §5.7 must never rely on (see the suppression fix there). activeByRole is intentionally shaped like core's layout_by_role ({ [role_id:number]: value }) so Phase 2 can project it mechanically. The canonical key everywhere (state.layouts, plugin_cfgs, layout_by_role values) is plugin_name = "theme-builder", not the npm package name (plugin.ts:474).
4. Data model & storage
This is owned by lib/themeStore.js (the only module that touches the Table or plugin.configuration), with pure helpers in lib/themeSchema.js, lib/portability.js, and lib/builtins/.
4.1 The theme library — a real Saltcorn Table
Table name: _themebuilder_themes (leading underscore + unique prefix marks it plugin-internal; there is no flag to hide a registered table from the admin Tables UI, so naming + min_role are the only levers — table.ts:770 gotcha). min_role_read = 1, min_role_write = 1, versioned = false, has_sync_info = false.
Identity column resolution (folded review fix): Table.create always creates an integer serial pk literally named id (table.ts:750-797). The stable theme identity must NOT live in a column named id or it collides with that pk and the locked IDENTITY decision breaks (route :id would resolve to the integer pk). Therefore the stable uuid lives in a separate theme_id String column; the integer id is internal housekeeping only and is never a theme reference.
| Column | Saltcorn type | SQL | required | unique | Purpose |
|---|---|---|---|---|---|
id |
(auto pk) | serial | yes | yes | Row housekeeping only. NOT a theme reference. |
theme_id |
String | text | yes | yes (is_unique) |
Stable theme identity (uuid v4). All pointers/role-map refs use this. |
name |
String | text | yes | no (app-enforced) | Display name, freely renamable. Uniqueness is UX-only (no DB constraint, so import can de-dup freely). |
engine |
String | text | yes | no | Token dialect: "bootstrap5". Forward-compat. |
tokens |
String | text | yes | no | JSON.stringify(tokensObject) — the SINGLE SOURCE OF TRUTH. |
layout_tree |
String | text | no | no | JSON.stringify(craftTree) — Phase 2 only; null in Phase 1. |
version |
Integer | int | yes | no | Optimistic-lock counter, starts at 1. Bumped only by save. |
created_at |
Date | timestamptz | yes | no | Row creation. |
updated_at |
Date | timestamptz | yes | no | Last save. |
Why String for JSON: the base plugin registers only String, Integer, Bool, Date, Float, Color (base-plugin/index.ts:68); there is no core JSON type. String maps to unbounded SQL text (types.ts:782-785), so tokens/layout_tree are String and the store does JSON.stringify/JSON.parse itself. This also avoids the SQLite-only jsonb auto-parse asymmetry.
name uniqueness is NOT a DB constraint. It is enforced in app code at create/rename/import via dedupName (so import can de-dup freely and never 409 on a clash). Identity (theme_id) carries the only real unique constraint.
4.2 Built-in / starter themes (in code, merged at read time)
Built-ins ship as frozen JS/JSON files under lib/builtins/ (Bootswatch-derived token maps). They are never inserted into the Table — they live in code so upgrades ship new/updated starters with no DB migration and a user can never delete or rename them.
// lib/builtins/flatly.js
module.exports = Object.freeze({
id: "builtin:flatly", // "builtin:" namespace prevents collision with row uuids
name: "Flatly",
engine: "bootstrap5",
tokens: { $tokensVersion: 1, colors: { primary: "#2c3e50", success: "#18bc9c" /* ... */ } },
layoutTree: null, // Phase-2 builtins may ship a tree
});
// lib/builtins/index.js
const themes = [require("./flatly"), require("./darkly"), require("./cosmo") /* ... */];
const byId = Object.freeze(Object.fromEntries(themes.map((t) => [t.id, t])));
function listBuiltins() { return themes; }
function getBuiltin(id) { return byId[id] || null; }
module.exports = { listBuiltins, getBuiltin };
Merge semantics:
- Row
theme_ids are uuid v4; builtin ids arebuiltin:<slug>— collision is impossible by namespacing.ensureTableasserts no storedtheme_idstarts withbuiltin:. - Built-ins are not deletable, not renamable, not save-able: any mutating op whose id
startsWith("builtin:")is rejected before touching the Table. - "Load" of a builtin = duplicate-to-edit: opening a builtin returns its definition read-only; the first save duplicates it to a fresh uuid row.
- Activating a builtin directly is allowed (a builtin id is a valid active-pointer value); duplicating is only required to edit.
- Built-ins appear in
list()even on a fresh, empty install.
4.3 Token schema, shape & defaults
tokens mirrors the bootstrap.build model and maps 1:1 onto Bootstrap 5 --bs-* custom properties.
type Tokens = {
$tokensVersion: 1; // token-shape version (for future upcasting)
colors: { primary?, secondary?, success?, info?, warning?, danger?, light?, dark?,
bodyBg?, bodyColor? }; // all string CSS values
typography: { fontSansSerif?, fontMonospace?, rootFontSize?, bodyFontSize?,
bodyFontWeight?, bodyLineHeight?, headingsFontFamily? };
borders: { borderRadius?, borderWidth?, borderColor? };
components: { navbarBg?, cardBg?, linkColor?, linkHoverColor? };
custom?: { [cssVarName: string]: string }; // raw "--my-var": "value" escape hatch
sass?: string; // Phase-2 only: extra admin-authored Sass before recompile
};
Every leaf is a string (CSS value text). All keys are optional except $tokensVersion. Missing keys fall back to DEFAULT_TOKENS via deep-merge-on-read, so a sparse theme never white-screens (an empty {} compiles to a Bootstrap no-op overlay).
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: {},
});
Token-shape upcasting (folded review fix). The token axis is currently a no-op placeholder at v1:
normalizeTokensvalidates and deep-merges overDEFAULT_TOKENS; it does not transform. The$tokensVersiongate exists so a realTOKEN_UPCASTERSchain can be added later mirroring the envelope upcaster. We do not claim a transform that is not built.
// themeSchema.js
function normalizeTokens(raw) {
// v1: validate + deep-merge over DEFAULT_TOKENS (no transform yet).
// Future: while ($tokensVersion < CURRENT) raw = TOKEN_UPCASTERS[v](raw)
const merged = deepMerge(DEFAULT_TOKENS, raw || {});
merged.$tokensVersion = 1;
return merged;
}
function emptyTokens() { return structuredClone(DEFAULT_TOKENS); }
function tokensAreValid(tokens) { /* object; no non-string leaf; key-count cap -> {ok, errors[]} */ }
normalizeTokens is the single chokepoint every read passes through (rowToTheme and portability.parseEnvelope both call it), so the compiler always receives a fully-populated, current-shape object.
4.4 The active pointer (plugin config)
// in plugin.configuration:
{
activeThemeId: "ab12-...uuid...", // global active theme id (never name)
activeByRole: { "4": "cd34-...", "8": "ef56-..." }, // optional {role_id: themeId}
activeHash: "ab12cd34", // COMPOSITE content hash over the default CSS
// AND every activeByRole entry's CSS (cache-buster)
phase2Enabled: false,
}
The pointer lives in config (not the Table) so it rides plugin_pack (pack.ts:118-139) and install_pack (pack.ts:558-578). It is read at request time from the live merged cfg (getState().plugin_cfgs["theme-builder"], no DB hit); it is written only by activate/delete-auto-switch, via the persist → re-register → propagate sequence (§5.5).
activeHashis a first-class, COMPOSITE config key (folded review fix resolving an inter-subsystem contradiction). The compiler subsystem relies on a stable cross-worker cache-buster inheaders(cfg).activaterecomputesactiveHashas a composite over every pointer-implied CSS (the default theme's hash AND eachactiveByRoleentry's hash) and writes it intoplugin.configurationalongside the pointer, beforeupsert, on every activation (default and role). Because it folds in the role-override hashes, re-activating a theme for a specific role changesactiveHash, so every?v=link the headers facet emits (default and per-role) changes too. This resolves the earlier "activeHash referenced but never written" contradiction AND the "role-override?vnever changes → stale immutable role CSS" seam (§7.6, §7.8).
4.5 Backup interaction
| Concern | Mechanism | Rides backup? |
|---|---|---|
| Whole library (all DB themes) | real Table _themebuilder_themes in _sc_tables/_sc_fields |
Yes — catalog-registered (table.ts:786, field.ts:1390). Per-tenant for free. |
Active pointer + role map + activeHash + phase2Enabled |
plugin.configuration |
Yes — plugin_pack copies config minus fixed keys (pack.ts:118-139); install_pack round-trips it (pack.ts:558-578). |
| Built-in starters | plugin code | N/A — travel with the plugin install, never in DB/backup. |
| Compiled CSS | in-memory derived cache | No — never stored, regenerated on activate / lazily on miss. |
| Single-theme cross-instance portability | export/import envelope | The additive complement (one theme as a file between unrelated instances). |
Restore-over-existing caveat (documented, not a bug): install_pack skips a plugin whose name already exists (pack.ts:569), so restoring a pack onto an instance that already has theme-builder installed will not overwrite the live activeThemeId. To migrate the pointer between live instances, use export/import + an explicit activate.
onLoad-vs-pack double-definition is safe: on restore, install_pack runs the plugin (→ onLoad → ensureTable creates the empty table) before its table-pack loop, and that loop finds the table existing and updates rather than re-creating; Field.create is gated on field existence (pack.ts:600-630). Both ensureTable and the pack loop are existence-checked, so there is no duplicate-create on restore.
4.6 Multi-tenant behavior
- The Table is created once per tenant in
ensureTable, iterated via the canonicaleachTenanthelper (which establishes per-tenant async-local schema). All CRUD is tenant-implicit (schema-qualified by the Table model from async-local storage — table.ts:761). - Plugin config (
_sc_plugins/plugin_cfgs) is per-State/per-tenant, soactiveThemeId/activeByRolediffer independently per tenant. - Built-ins are global code, identical across tenants, merged at read time — no per-tenant storage cost.
5. Plugin module contract & lifecycle
5.1 The export object (index.js)
// index.js
const { API_VERSION, PLUGIN_NAME } = require("./lib/constants");
const configuration_workflow = require("./lib/configWorkflow");
const { headers } = require("./lib/headers"); // (cfg)=>Header[]
const { routes } = require("./lib/routes"); // (cfg)=>PluginRoute[]
const { layout } = require("./lib/layout"); // (cfg)=>PluginLayout|undefined
const { onLoad } = require("./lib/onLoad");
module.exports = {
sc_plugin_api_version: API_VERSION, // 1, gates compat (sbadmin2/index.js:575)
plugin_name: PLUGIN_NAME, // "theme-builder" — canonical key everywhere (plugin.ts:474)
configuration_workflow, // PRESENCE => every facility is a (cfg)=>value factory
// NOTE: exposed_configs intentionally OMITTED (see 5.3)
headers, // Phase-1 overlay links; per-request only_if gated (see 5.4)
routes, // public theme.css + admin editor/API
layout, // (cfg)=> undefined in Phase 1; {wrap,...} in Phase 2
onLoad, // raw cfg; idempotent; per-tenant; rehydrates active CSS
};
Why layout returns undefined in Phase 1 (load-bearing): registerPlugin does const layout = withCfg("layout"); if (layout) this.layouts[name] = layout (state.ts:1113-1117). A falsy return means we are never inserted into state.layouts, never appear in getLayout's last-installed fallback, and are never a layout_by_role choice — exactly the Phase-1 "skin overlay, not a layout" contract. Returning {} would wrongly register us; we return undefined.
5.2 Constants (lib/constants.js)
const PLUGIN_NAME = "theme-builder"; // canonical plugin_name
const API_VERSION = 1;
const THEME_TABLE = "_themebuilder_themes";
const URL_PREFIX = "/theme-builder"; // top-level, NOT under core /admin (see 6.1)
const API_BASE = `${URL_PREFIX}/api`;
const CSS_ROUTE = `${URL_PREFIX}/theme.css`;
const FORMAT_VERSION = 1; // export envelope formatVersion
const SCHEMA_ID = "saltcorn-theme";
const ENGINE = "bootstrap5";
const MAX_IMPORT_BYTES= 2 * 1024 * 1024; // 2 MiB
const MAX_TOKENS = 2000;
const ROLE_ADMIN = 1;
const ROLE_PUBLIC = 100;
// config keys:
const CFG = { ACTIVE: "activeThemeId", BY_ROLE: "activeByRole",
HASH: "activeHash", PHASE2: "phase2Enabled", SCHEMA_VER: "schemaVersion" };
module.exports = { PLUGIN_NAME, API_VERSION, THEME_TABLE, URL_PREFIX, API_BASE, CSS_ROUTE,
FORMAT_VERSION, SCHEMA_ID, ENGINE, MAX_IMPORT_BYTES, MAX_TOKENS,
ROLE_ADMIN, ROLE_PUBLIC, CFG };
5.3 exposed_configs is omitted (folded review fix)
The factories read the full merged cfg directly; plugins_cfg_context (the only thing exposed_configs populates — state.ts:1007-1012) is never read. Worse, declaring exposed_configs.length>0 makes the native configure route flash a misleading "users need to relogin" warning on every config save (plugins.js:962-963). We therefore drop exposed_configs entirely.
5.4 Config readers — one resolution rule (lib/cfgReaders.js)
All facilities and the CSS route resolve the active theme through these readers, so there is exactly one rule.
function getActiveThemeId(cfg) { return cfg?.activeThemeId || null; }
function getActiveByRole(cfg) { return cfg?.activeByRole || {}; }
function activeThemeIdForRole(cfg, roleId) { // role override then global
const byRole = getActiveByRole(cfg);
return byRole[roleId] != null ? byRole[roleId] : getActiveThemeId(cfg);
}
function isPhase2(cfg) { return !!cfg?.phase2Enabled; }
function activeHashHint(cfg, roleId){ return (cfg && cfg.activeHash) || "0"; }
// Replicate getLayout's fallback precedence WITHOUT reading the pluginName field the
// last-installed fallback omits (state.ts:419-425). A request renders via theme-builder when
// layout_by_role[role] names us OR (no layout_by_role entry for that role AND theme-builder is
// the last-installed/only registered layout). (folded review fix — see 5.7/7.6)
function requestRendersViaThemeBuilder(req) {
const state = getState();
if (!state.layouts[PLUGIN_NAME]) return false; // not registered as a layout (Phase 1)
const role = req.user?.role_id ?? ROLE_PUBLIC;
const byRole = state.getConfig("layout_by_role", {}) || {};
const mapped = byRole[role] ?? byRole[String(role)];
if (mapped != null) return mapped === PLUGIN_NAME; // role branch is authoritative
// no layout_by_role entry for this role -> falls through to per-user / last-installed.
// theme-builder is selected by the fallback iff it is the last-installed layout key.
const keys = Object.keys(state.layouts);
return keys.length > 0 && keys[keys.length - 1] === PLUGIN_NAME;
}
role-key coercion:
activeByRole(andlayout_by_role) keys are JSON strings butreq.user.role_idis numeric. Readers and writers coerce consistently (lookups use the numeric value as a property key, which JS stringifies; all comparisons normalize toNumber/Stringat the boundary).
5.5 Active-pointer write: persist → re-register → propagate → refresh assets (folded review fixes)
The pointer is written by exactly one routine. The earlier subsystem designs were correct that the sequence must be upsert → loadPlugin → processSend (mirroring the core configure route, plugins.js:956-967), but two review fixes are mandatory:
Fix 1 — assets_by_role must be recomputed. get_headers reads the precomputed state.assets_by_role[role] bucket FIRST and only falls back to raw state.headers when that bucket is empty (wrapper.js:133-145). assets_by_role is rebuilt only by computeAssetsByRole(), which is called only from refresh_views() (state.ts:718). Neither registerPlugin nor the refresh_plugin_cfg cluster handler triggers it. Without an explicit refresh, an activated theme's new ?v=<hash> header is never served and the Phase-1 "activate is the only live-changer" contract silently fails. So the writer must call getState().refresh_views() locally after re-register, and the cluster receiver must do the same.
Fix 2 — loadPlugin is heavy; document and accept. Plugin.loadPlugin constructs a PluginInstaller and runs loader.install() (a full reinstall) before registerPlugin (plugin.ts:460-505), and re-runs onLoad. This is correct (parity with the configure route) but not cheap; it runs synchronously inside the activate request and on every peer worker. We accept it for correctness and keep onLoad fast and idempotent (its hot path short-circuits — §5.6).
// lib/activePointer.js
const Plugin = require("@saltcorn/data/models/plugin");
const { getState } = require("@saltcorn/data/db/state");
const db = require("@saltcorn/data/db");
const { PLUGIN_NAME } = require("./constants");
// Resolve the install-name row defensively: _sc_plugins.name is the INSTALL name, which
// is NOT guaranteed to equal plugin_name. Try the canonical name, then fall back to scanning
// for the row whose module resolves to plugin_name. (folded review fix: read/write name-space split)
async function findPluginRow() {
let p = await Plugin.findOne({ name: PLUGIN_NAME });
if (p) return p;
const all = await Plugin.find({});
return all.find((r) => {
const mod = getState().plugins[PLUGIN_NAME];
return mod && (getState().plugin_module_names[r.name] === PLUGIN_NAME || r.name === PLUGIN_NAME);
}) || null;
}
async function setActivePointer(patch /* {activeThemeId?|activeByRole?|activeHash?} */) {
const plugin = await findPluginRow();
if (!plugin) throw new Error("theme-builder plugin row not found");
plugin.configuration = { ...(plugin.configuration || {}), ...patch };
await plugin.upsert(); // _sc_plugins (plugin.ts:104-119)
await Plugin.loadPlugin(plugin); // re-register -> re-run headers(cfg)
await getState().refresh_views(); // REBUILD assets_by_role (Fix 1)
getState().processSend({ refresh_plugin_cfg: PLUGIN_NAME, tenant: db.getTenantSchema() });
return plugin.configuration;
}
// READ: live merged cfg, no DB hit.
function getActivePointer() {
const cfg = getState().plugin_cfgs[PLUGIN_NAME] || {};
return { activeThemeId: cfg.activeThemeId || null, activeByRole: cfg.activeByRole || {},
activeHash: cfg.activeHash || "0" };
}
module.exports = { setActivePointer, getActivePointer, findPluginRow };
Cluster receiver requirement (cross-seam contract): the worker that handles refresh_plugin_cfg for theme-builder must, after re-loading the plugin, also computeAssetsByRole() (via refresh_views()) and bustAll() its local CSS cache, then lazily recompile on first request. This is a required step the lifecycle owner wires into the refresh handler; registerPlugin alone is insufficient.
5.6 onLoad — idempotent per-tenant bootstrap + active-CSS rehydration (folded review fixes)
// lib/onLoad.js
const db = require("@saltcorn/data/db");
const { getState } = require("@saltcorn/data/db/state");
const { eachTenant } = require("@saltcorn/admin-models/models/tenant"); // canonical (folded fix)
const themeStore = require("./themeStore");
const cssCache = require("./cssCache");
const { getActivePointer } = require("./activePointer");
const { PLUGIN_NAME } = require("./constants");
async function bootstrapTenant() {
// 1) HOT-PATH SHORT-CIRCUIT: if the table already exists for THIS tenant, skip all DDL
// work cheaply (loadPlugin re-runs onLoad on every activate). (folded review fix)
await themeStore.ensureTableForCurrentTenant(); // Table.findOne first; Table.create only if null
// 2) Rehydrate the active-CSS cache from the persisted pointer so the live site is themed
// immediately after a restart/new worker (folded review fix: rehydration was unowned).
try {
const { activeThemeId, activeByRole } = getActivePointer();
const keys = new Set();
if (activeThemeId) keys.add(null); // default key
for (const r of Object.keys(activeByRole)) keys.add(Number(r));
for (const roleKey of keys) {
const id = roleKey == null ? activeThemeId : activeByRole[roleKey];
if (id) await cssCache.warm(id, roleKey); // recompile -> cache
}
} catch (e) { getState().log(3, `theme-builder rehydrate failed: ${e.message}`); }
}
// eachTenant self-selects single vs multi: it runs default_schema FIRST then iterates
// DB-backed tenants only in MT mode. No hand-rolled is_it_multi_tenant branch. (folded fix)
async function onLoad(/* rawCfg */) {
await eachTenant(bootstrapTenant);
}
module.exports = { onLoad, bootstrapTenant };
Folded review fixes embodied above:
- Canonical tenant iteration. Use
eachTenantfrom@saltcorn/admin-models/models/tenant, which runsdefault_schemafirst and then iterates the DB-backed tenant list. This fixes three bugs from the subsystem drafts: (a)for...ofover the plain-objectgetAllTenants()(which throwsTypeError), (b) the in-memorydb/state.getAllTenantsbeing best-effort (only loaded tenants), and (c) the admingetAllTenants()excluding the default schema (which would leave the root site with no table). - Errors are swallowed (plugin.ts:502-503), so every step is wrapped and idempotent; a throw won't fail load but would leave the table/cache unbuilt — hence the explicit try/catch on rehydration.
- Hot-path cheapness.
ensureTableForCurrentTenantdoesTable.findOne(THEME_TABLE)first and returns immediately if present, so re-running on every activate is cheap.
5.7 Phase 1 → Phase 2 toggle
| Aspect | Phase 1 | Phase 2 |
|---|---|---|
| config flag | phase2Enabled: false |
phase2Enabled: true |
layout(cfg) returns |
undefined → not in state.layouts |
{ wrap, … } → registered (state.ts:1116) |
| token CSS delivery | headers overlay link, after Bootstrap |
owned by wrap(); overlay suppressed per-request (§7.6) |
| per-role | only_if overlay headers |
native layout_by_role |
| Sass | not needed | optional full dart-sass recompile |
The toggle is a pure config change. The config workflow persists phase2Enabled; the writer's upsert → loadPlugin → refresh_views → processSend re-runs the factories cluster-wide.
Phase-2 suppression must be per-request, must NOT read
getLayout().pluginName, and is gated on full coverage (folded review fix). A blanketheaders(cfg) → []whenisPhase2un-themes any role whose layout does not resolve totheme-builder(e.g. a role still on sbadmin2 becauselayout_by_rolemaps only some roles). So Phase-2 suppression is gated by anonly_if(req)that suppresses the overlay only for requests that actually render via theme-builder. Crucially, that decision must not be made by readinggetState().getLayout(req.user)?.pluginName === PLUGIN_NAME: the groundedgetLayout(state.ts:378-426) stampspluginNameonly in thelayout_by_rolebranch and the per-useruserLayoutsbranch — the last-installed fallback branch (state.ts:419-425) returns the layout WITHOUTpluginName. In Phase 2 theme-builder is typically the only registered layout, so a role with nolayout_by_roleentry resolves to theme-builder via exactly that fallback; therepluginNameisundefined,undefined === "theme-builder"is false, the overlay is NOT suppressed, and that role's pages get tokens applied TWICE (overlay header +wrap()) — the precise "uncovered role" case the check was meant to handle, double-themed instead. We therefore (a) detect theme-builder-served requests withcfgReaders.requestRendersViaThemeBuilder(req), which replicatesgetLayout's fallback precedence (role branch, else last-installed/only-layout) rather than reading the omitted field; AND (b) the config workflow hard-blocks savingphase2Enabled: trueuntillayout_by_rolecovers every live role, so the last-installed fallback is never the selector for a live role. (Details and code in §7.6; coverage hard-block in §12.3 resolved to a hard block.)
5.8 package.json
Phase 1 needs no Sass/Bootstrap runtime deps.
{
"name": "theme-builder",
"version": "0.0.1",
"description": "Saltcorn plugin: visual theme builder (Phase 1 Bootstrap-5 skin overlay; Phase 2 Craft.js layout + optional dart-sass).",
"main": "index.js",
"scripts": {
"test": "node --test test/",
"build:ui": "webpack --config builder/webpack.config.js --mode production",
"watch:ui": "webpack --config builder/webpack.config.js --watch --mode development"
},
"engines": { "node": ">=20" },
"dependencies": {
"@saltcorn/data": "*",
"@saltcorn/markup": "*"
// Phase 2 adds: "sass": "^1.x", "bootstrap": "^5.x" (vendored SCSS under scss/)
},
"devDependencies": {
// webpack, webpack-cli, babel-*, @craftjs/core (bundled into builderApp.js for Phase 2)
},
"author": "Scott Duensing",
"license": "MIT"
}
6. HTTP API surface
6.1 Mounting model & namespace (folded review fix)
Routes are returned from the routes(cfg) factory and stored in state.plugin_routes["theme-builder"]; routesChangedCb fires only when routes.length>0 (state.ts:1138). We always return a non-empty array (the public CSS route is always present), so the tenant router always mounts. Routes mount at the app root (no /plugins prefix) on the per-tenant router after mountRoutes (app.js:483-502). Callbacks run positionally (req,res,next) via error_catcher (utils.js:298-306).
Top-level namespace /theme-builder (NOT /admin/theme-builder). An earlier draft namespaced under the core-owned /admin prefix (mounted at app.js:478 before the plugin router), which works today only because core's admin sub-router has no matching route and falls through — fragile if core ever adds /admin/theme-builder* or an /admin catch-all. We use a unique top-level prefix per the grounded guidance.
6.2 Route table
A = admin-guarded; P = public. Plugin routes support only get/post (plugin_routes_handler.js:26-53) — there is no PUT/DELETE verb, so all mutations are POST sub-paths.
| # | method | url | guard | body / query | success | errors |
|---|---|---|---|---|---|---|
| 1 | get | /theme-builder/theme.css |
P | ?v=<hash>, ?role= |
200 text/css |
never 5xx → fallback CSS |
| 2 | get | /theme-builder/editor |
A | ?id=<theme_id> |
200 text/html SPA shell |
302/401 |
| 3 | get | /theme-builder/api/state |
A | — | 200 StateEnvelope |
401/403 |
| 4 | get | /theme-builder/api/themes/:id |
A | — | 200 {theme} |
404 |
| 5 | post | /theme-builder/api/themes |
A | {name?, engine?, fromTemplate?} |
200 {theme} |
422 bad engine |
| 6 | post | /theme-builder/api/themes/:id/duplicate |
A | {name?} |
200 {theme} |
404 |
| 7 | post | /theme-builder/api/themes/:id/save |
A | {tokens, layoutTree?, baseVersion, force?} |
200 {theme} |
400/403/404/409 |
| 8 | post | /theme-builder/api/themes/:id/rename |
A | {name} |
200 {theme} |
403/404/409 |
| 9 | post | /theme-builder/api/themes/:id/delete |
A | {autoSwitch?} |
200 {ok, switchedActiveTo?} |
403/404/409 |
| 10 | post | /theme-builder/api/themes/:id/activate |
A | {role?} |
200 {ok, activeThemeId, activeByRole, cssHash} |
404/500 |
| 11 | get | /theme-builder/api/themes/:id/export |
A | — | 200 JSON attachment | 404 |
| 12 | post | /theme-builder/api/import |
A | multipart file OR {envelope} |
200 {theme} |
400/413/422 |
CSRF: GET routes are inherently exempt. theme.css is fully public. All POSTs are admin-only and the SPA carries the CSRF token (read from the shell bootstrap, sent as CSRF-Token header — mirrors core builder Library.js:102-129). We add no route to noCsrf/disable_csrf_routes.
6.3 routes(cfg) factory
// lib/routes.js
const { URL_PREFIX, API_BASE, CSS_ROUTE } = require("./constants");
const h = require("./apiHandlers");
function routes(cfg) {
return [
{ url: CSS_ROUTE, method: "get", callback: (req,res)=>h.getThemeCss(req,res,cfg) }, // PUBLIC
{ url: `${URL_PREFIX}/editor`, method: "get", callback: h.getEditor },
{ url: `${API_BASE}/state`, method: "get", callback: h.getState_ },
{ url: `${API_BASE}/themes/:id`, method: "get", callback: h.loadTheme },
{ url: `${API_BASE}/themes`, method: "post", callback: h.createTheme },
{ url: `${API_BASE}/themes/:id/duplicate`, method: "post", callback: h.duplicateTheme },
{ url: `${API_BASE}/themes/:id/save`, method: "post", callback: h.saveTheme },
{ url: `${API_BASE}/themes/:id/rename`, method: "post", callback: h.renameTheme },
{ url: `${API_BASE}/themes/:id/delete`, method: "post", callback: h.deleteTheme },
{ url: `${API_BASE}/themes/:id/activate`, method: "post", callback: h.activateTheme },
{ url: `${API_BASE}/themes/:id/export`, method: "get", callback: h.exportTheme },
{ url: `${API_BASE}/import`, method: "post", callback: h.importTheme },
];
}
module.exports = { routes };
:id carries the theme_id (slug/uuid), not the integer pk. error_catcher escapes req.params/req.query values (utils.js:299-304) — fine for slug ids; bodies are read raw.
6.4 Admin guard (lib/httpUtils.js) — JSON-aware (folded review fix)
const db = require("@saltcorn/data/db");
const { ROLE_ADMIN, API_BASE } = require("./constants");
function isAdminReq(req) { // mirror core isAdmin (utils.js:85-100)
return !!(req.user && req.user.role_id === ROLE_ADMIN && req.user.tenant === db.getTenantSchema());
}
function wantsJson(req) {
return req.xhr
|| (req.path || "").startsWith(API_BASE) // API routes ALWAYS answer JSON (fixes multipart-import misclassify)
|| (req.get("accept") || "").includes("application/json")
|| (req.get("content-type") || "").includes("application/json");
}
function guardAdmin(req, res) {
if (isAdminReq(req)) return true;
if (wantsJson(req)) res.status(req.user ? 403 : 401).json({ error: { code: "forbidden", message: "Must be admin" } });
else res.redirect(req.user ? "/" : `/auth/login?dest=${encodeURIComponent(req.originalUrl)}`);
return false;
}
function jsonError(res, status, code, message, extra) { res.status(status).json({ error: { code, message, ...(extra||{}) } }); }
module.exports = { isAdminReq, wantsJson, guardAdmin, jsonError };
Uniform error envelope: { error: { code, message, ...context } }, where code ∈ { forbidden, not_found, version_conflict, name_taken, active_theme, builtin_immutable, bad_format, too_large, uncompilable, bad_request, compile_failed, activation_failed, initializing }.
The wantsJson API_BASE check (folded fix) ensures a non-admin multipart import POST (which has neither JSON content-type nor req.xhr) still receives the JSON {error:{…}} envelope rather than an opaque redirect.
6.5 The public route — GET /theme.css (folded cache-poisoning fix)
async function getThemeCss(req, res, cfg) {
const role = req.query.role ? Number(req.query.role) : (req.user?.role_id ?? 100);
const themeId = activeThemeIdForRole(cfg, role);
res.set("Content-Type", "text/css; charset=utf-8");
if (!themeId) { res.set("Cache-Control","no-store"); res.send("/* theme-builder: no active theme */"); return; }
const roleKey = (cfg.activeByRole && cfg.activeByRole[role]) ? role : null;
let entry = cssCache.getCached(themeId, roleKey);
let isReal = !!entry;
if (!entry) { // lazy compile-on-miss
const theme = await themeStore.getById(themeId);
if (theme) {
const compiled = compileTheme(theme.tokens, { engine: theme.engine });
entry = { ...compiled, themeId, role: roleKey, builtAt: Date.now() };
cssCache.setCached(entry);
isReal = true;
}
}
if (!entry) { // theme missing mid-activate, transient
res.set("Cache-Control", "no-store"); // FOLDED FIX: never memoize fallback under live hash
res.send(builtins.fallbackCss());
return;
}
// ETag always reflects the per-entry CONTENT hash, so the If-None-Match/304 path can correct
// stale CSS even when a ?v cache-buster failed to change. We keep `immutable` ONLY when the
// emitted link's ?v is guaranteed to change on re-activation. With the COMPOSITE activeHash
// (4.4/7.8) that guarantee now holds for the per-role link too, so `immutable` is safe here;
// were that guarantee ever weakened, drop `immutable` (keep ETag + must-revalidate) so the
// 304/If-None-Match path can still correct stale role CSS. (folded review fix)
res.set("ETag", `"${entry.hash}"`);
res.set("Cache-Control", isReal ? "public, max-age=31536000, immutable" : "no-store");
if (req.headers["if-none-match"] === `"${entry.hash}"`) { res.status(304).end(); return; }
res.send(entry.css);
}
Folded fixes:
- No 5xx on a public page. A missing table, missing theme, or compile miss degrades to valid (often empty) CSS — the active theme renders unchanged, never a white-screen. (This also subsumes the "503 initializing" status: a not-yet-created table on a freshly-spawned tenant degrades to fallback CSS on the public path rather than throwing; the 503 status is reserved for admin mutation paths where a hard error is appropriate.)
- No cache poisoning. The
immutable, max-age=1yheader is set only when serving real compiled CSS under its true content hash. Fallback / transient-miss responses useCache-Control: no-store, so a later successful compile is picked up. - Role CSS staleness is correctable. The
ETagis the per-entry content hash, so the 304/If-None-Matchpath corrects stale CSS independently of the?vquery; and becauseactiveHashis now composite (§4.4, §7.8), every role link's?vactually changes on re-activation, so theimmutablelong-cache is safe rather than pinning old role CSS forever.
6.6 Selected handler semantics
save — optimistic CAS on version (row-level concurrency):
async function saveTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Built-in themes are read-only");
const { tokens, layoutTree = null, baseVersion, force } = req.body || {};
if (tokens == null) return jsonError(res, 400, "bad_request", "Missing tokens");
if (baseVersion == null && !force) return jsonError(res, 400, "bad_request", "Missing baseVersion");
const updated = force
? await themeStore.forceUpdate(id, { tokens, layoutTree })
: await themeStore.casUpdate(id, baseVersion, { tokens, layoutTree }); // UPDATE ... WHERE theme_id=$ AND version=$base, version=version+1
if (!updated) {
const cur = await themeStore.getById(id);
if (!cur) return jsonError(res, 404, "not_found", "Unknown theme");
return jsonError(res, 409, "version_conflict", "Theme was modified by another session", { currentVersion: cur.version });
}
res.json({ theme: updated }); // SAVE does NOT touch the live site
}
Two concurrent saves: first wins (version → base+1); second matches 0 rows → 409 with currentVersion so the SPA offers reload-or-overwrite (force:true). The pointer (config) is written only by activate (single-writer per tenant), so pointer writes never race row writes.
delete — block if active unless autoSwitch; builtins never deletable:
async function deleteTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Cannot delete built-in");
const r = await themeStore.getById(id);
if (!r) return jsonError(res, 404, "not_found", "Unknown theme");
const { activeThemeId, activeByRole } = activePointer.getActivePointer();
const isActive = activeThemeId === id || Object.values(activeByRole).includes(id);
if (isActive) {
if (!req.body?.autoSwitch) return jsonError(res, 409, "active_theme", "Theme is active; pass autoSwitch to reassign first");
const successor = (await themeStore.firstOther(id))?.theme_id || builtins.defaultId();
await activateEngine.activateTheme(successor); // recompile + flip to successor FIRST
await activePointer.dropRoleRefs(id); // scrub activeByRole entries == id
}
await themeStore.remove(id);
res.json({ ok: true, switchedActiveTo: isActive ? undefined : null });
}
Delete-of-active policy is unified to auto-switch (the API and SPA layers offer the explicit
autoSwitchconfirmation; the engine recompiles to the successor before the row is deleted, so the live site is never pointed at a deleted theme). The SPA blocks delete of a role-assigned theme with a clear message and offersautoSwitch.
activate — the only live-mutating op; staged compile vs pointer failures (folded review fix):
async function activateTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
const exists = builtins.isBuiltinId(id) ? !!builtins.get(id) : !!(await themeStore.getById(id));
if (!exists) return jsonError(res, 404, "not_found", "Unknown theme");
try {
const out = await activateEngine.activateTheme(id, req.body?.role);
res.json({ ok: true, ...out });
} catch (e) {
if (e.stage === "compile") return jsonError(res, 500, "compile_failed", e.message); // pointer untouched
return jsonError(res, 500, "activation_failed", e.message); // pointer write/propagate failed
}
}
6.7 Concurrency model summary
- Library rows: row-level, with optimistic CAS on
versionforsave.renametouchesnameonly and does not bumpversion. - Active pointer: single-writer per tenant via
activePointer.setActivePointer(upsert+loadPlugin+refresh_views+processSend). - CSS cache: per-tenant in-memory map; busted on activate, lazily refilled on miss.
7. Compile & render pipeline
Owns tokens → CSS, the theme.css route, the compile/cache/cache-bust lifecycle, the robustness (no-white-screen) guard, and the load-order proof. It consumes tokens/layoutTree and produces the bytes the browser loads.
7.1 Module layout & shared helpers (folded export fix)
lib/
compile.js # compileTheme(), emitOverlayCss(), robustnessGuard(), contentHash(), deriveRgb()
sanitize.js # sanitizeValue(), sanitizeSelector() <-- shared by compile.js AND sassCompile.js
sassCompile.js # compileSass() (Phase 2, lazy require('sass'))
cssCache.js # per-tenant cache + hashing + busting
activate.js # activateTheme(): recompile + cache + persist pointer+composite-hash + re-register + refresh
tokenSchema.js # TOKEN_SCHEMA mapping table
scss/ # Phase 2: vendored Bootstrap SCSS + _themeVars template
Folded fix: the value/selector guards (
sanitizeValue,sanitizeSelector) live in their ownsanitize.js, imported by bothcompile.jsandsassCompile.js. They are not internal-only helpers ofcompile.js(an earlier draftrequire('./compile').sanitizeValue'd a non-exported symbol, silently degrading every Sass theme to the overlay fallback).
7.2 The token mapping table (tokenSchema.js)
The single server-side source of truth for how a token maps to CSS, kept structurally parallel to the SPA TokenManifest (§8.6).
const TOKEN_SCHEMA = {
primary: { kind: "bsvar", cssVar: "primary", derive: ["primary-rgb"] },
secondary: { kind: "bsvar", cssVar: "secondary", derive: ["secondary-rgb"] },
success: { kind: "bsvar", cssVar: "success", derive: ["success-rgb"] },
"body-bg": { kind: "bsvar", cssVar: "body-bg", derive: ["body-bg-rgb"] },
"body-color": { kind: "bsvar", cssVar: "body-color",derive: ["body-color-rgb"] },
"font-sans-serif": { kind: "bsvar", cssVar: "font-sans-serif" },
"border-radius":{ kind: "bsvar", cssVar: "border-radius" },
"link-color": { kind: "bsvar", cssVar: "link-color", derive: ["link-color-rgb"] },
"navbar-bg": { kind: "rule", selector: ".navbar", prop: "--bs-navbar-bg" },
"sidebar-bg": { kind: "rule", selector: ".sidebar, #accordionSidebar", prop: "background-color" },
// kind:"ignore" -> known-but-not-emitted (forward-compat)
};
const TOKEN_KEY_RE = /^[-a-zA-Z0-9_]{1,64}$/;
const MAX_VALUE_LEN = 2048, MAX_CSS_BYTES = 512 * 1024;
kind:"bsvar" → :root { --bs-<cssVar>: <value>; } (+ auto-derived --bs-<x>-rgb companion for rgba() mixes). kind:"rule" → a targeted selector{prop:value;} for things --bs-* alone can't reach.
The compiler walks a flattened view of the
tokensobject (colors/typography/borders/components/custom merged intokey→valuepairs by a small adapter), sonormalizeTokens's nested shape andTOKEN_SCHEMA's flat keys stay reconciled in one place.
7.3 Phase 1 overlay compiler (compile.js)
function compileTheme(tokens, opts = {}) { // pure; NEVER throws
const engine = opts.engine === "sass" ? "sass" : "overlay";
const out = engine === "sass"
? require("./sassCompile").compileSass(tokens, opts) // Phase 2
: emitOverlayCss(tokens);
const guarded = robustnessGuard(out.css, out.warnings || []);
return { css: guarded.css, hash: contentHash(guarded.css), warnings: guarded.warnings, engine };
}
function contentHash(css) { return crypto.createHash("sha1").update(css).digest("hex").slice(0, 8); }
emitOverlayCss iterates the flattened tokens, drops keys failing TOKEN_KEY_RE, passes each value through sanitize.sanitizeValue (dropping it from output on failure), and emits :root{ --bs-*: v; … } plus per-token selector{prop:v;} rule blocks and validated custom/_rules entries. Unknown-but-valid tokens are emitted as harmless --tb-<key> passthrough custom properties.
7.4 Robustness guard — "scope a bad token so it can't white-screen" (folded truncation fix)
Three layered, non-security defenses:
- Per-value scoping (
sanitize.sanitizeValue) — the primary mechanism. A value containing{,},;,</,/*,*/, or exceedingMAX_VALUE_LEN, is dropped from output (not escaped). Because a CSS declaration value containing}could close:root{}early and leak following declarations globally, dropping it confines every token to its own declaration. A dropped token degrades to "that one variable falls back to Bootstrap's default," not "the whole block is lost." - Block-isolation emit shape — each
--bs-x:v;is its own declaration and each targeted rule is its own{}block, so the CSS parser's standard error recovery drops a single bad declaration/rule without discarding siblings. - Final structural validate (
robustnessGuard) — checks balanced braces andMAX_CSS_BYTES; on failure, degrade safely:
function robustnessGuard(css, warnings) {
if (Buffer.byteLength(css, "utf8") > MAX_CSS_BYTES) {
warnings.push("css exceeds cap");
const rootLine = css.split("\n").find((l) => l.startsWith(":root{")) || "";
// FOLDED FIX: re-check the selected :root line; if STILL over cap, fall through to empty.
css = (rootLine && Buffer.byteLength(rootLine, "utf8") <= MAX_CSS_BYTES) ? rootLine : "/* theme-builder: empty (over cap) */";
}
let depth = 0, ok = true;
for (const ch of css) { if (ch === "{") depth++; else if (ch === "}") { if (--depth < 0) { ok = false; break; } } }
if (!ok || depth !== 0) { warnings.push("unbalanced braces; emitting empty"); css = "/* theme-builder: empty (invalid) */"; }
return { css, warnings };
}
Empty-but-valid CSS = active theme renders unchanged = no white-screen. (Folded fix: the truncation path re-checks the selected :root line and falls through to the empty sentinel if it is itself over cap, so size is actually bounded; even better, emitOverlayCss caps rootDecls length during emit.)
compileTheme doubles as the import robustness check (§9): the importer calls it, and a hard fallback flags the import.
7.5 Phase 2 dart-sass recompile (sassCompile.js) (folded path/@use fix)
const path = require("path");
const { sanitizeValue } = require("./sanitize"); // shared guard (folded fix)
function compileSass(tokens, opts = {}) {
const sass = require("sass"); // lazy
const warnings = [];
const flat = flattenTokens(tokens);
const varBlock = Object.entries(flat)
.filter(([k]) => /^[-a-zA-Z0-9_]{1,64}$/.test(k))
.map(([k, v]) => { const sv = sanitizeValue(v, warnings); return sv == null ? "" : `$${k}: ${sv};`; })
.filter(Boolean).join("\n");
const scssDir = path.join(__dirname, "..", "scss");
// FOLDED FIX: use a relative module id resolved via loadPaths; no absolute @import string.
const entry = `@use "bootstrap/bootstrap" with (\n${varBlock}\n);\n`;
try {
const result = sass.compileString(entry, { loadPaths: [scssDir], style: "compressed",
logger: { warn: (m) => warnings.push(m), debug: () => {} } });
return { css: result.css, warnings };
} catch (e) {
warnings.push("sass compile failed: " + e.message);
return require("./compile").emitOverlayCss(tokens); // fall back to overlay; never white-screen
}
}
Notes:
- The plugin ships Bootstrap SCSS under
theme-builder/scss/bootstrap/(not vendored in saltcorn). Pin a Bootstrap 5 version matching the Phase-1--bs-*names. - Uses
@use … with (…)(modern dart-sass) and a relative module id resolved byloadPaths(folded fix — an absolute interpolated@importpath breaks on Windows backslashes and is deprecated in modern dart-sass). - Runs synchronously, only on activate/save — amortized by the cache.
7.6 headers(cfg) injection + per-role / Phase-2 suppression (folded fixes)
const { requestRendersViaThemeBuilder } = require("./cfgReaders");
function headers(cfg) {
const def = getActiveThemeId(cfg);
const byRole = getActiveByRole(cfg);
const list = [];
if (!def) return list;
// ONE header; only_if decides per-request whether this role gets the overlay.
list.push({
css: `${CSS_ROUTE}?v=${activeHashHint(cfg)}`,
only_if: (req) => {
const role = req.user?.role_id ?? 100;
// PHASE 2: suppress ONLY for requests that actually render via theme-builder.
// Detected by replicating getLayout's fallback precedence (cfgReaders), NOT by reading
// getLayout().pluginName -- the last-installed fallback omits pluginName, so an uncovered
// role would otherwise be double-themed (overlay + wrap()). (folded review fix)
if (isPhase2(cfg) && requestRendersViaThemeBuilder(req)) return false;
// role with an explicit override gets its own header below, not this one
return byRole[role] === undefined || byRole[role] === def;
},
});
// per-role overlays for explicit overrides (Phase 1)
for (const [roleStr, tid] of Object.entries(byRole)) {
if (tid === def) continue;
const role = Number(roleStr);
list.push({
css: `${CSS_ROUTE}?v=${activeHashHint(cfg)}&theme=${encodeURIComponent(tid)}&role=${role}`,
only_if: (req) => {
if (isPhase2(cfg) && requestRendersViaThemeBuilder(req)) return false;
return (req.user?.role_id ?? 100) === role; // MUST strictly return true (wrapper.js:137)
},
});
}
return list;
}
Folded fixes embodied:
only_ifmust strictly returntrue(wrapper.js:137) — every predicate returns an explicit boolean.- Phase-2 suppression is per-request and does NOT read
getLayout().pluginName. The overlay is suppressed only for requestsrequestRendersViaThemeBuilder(req)deems theme-builder-served (those get tokens viawrap()), and is still emitted for roles left on a Bootstrap theme.requestRendersViaThemeBuilder(§5.4) replicatesgetLayout's fallback precedence — role branch, else last-installed/only-layout — rather than reading the field the fallback branch omits (state.ts:419-425); this fixes the priorundefined === "theme-builder"miss that double-themed every uncovered role. The config workflow additionally hard-blocks savingphase2Enabledunlesslayout_by_rolecovers all live roles (§5.7, §12.3), so the last-installed fallback is never the live selector and suppression is deterministic. - Cross-worker cache-buster comes from the composite
cfg.activeHash(written byactivatebeforeupsert, folding in the default AND every role override's CSS hash — §4.4/§7.8), so all workers emit a consistent?v=even on a cold worker, AND every per-role link's?vactually changes when that role is re-activated. The route lazily compiles to match. cssheaders are not de-duped (onlyscriptURLs are — state.ts:1123-1135), so role-specific links coexist; distinct?v/rolequeries keep them distinct.
7.7 Cache + cache-busting (cssCache.js)
const cache = new Map(); // tenantSchema -> Map<`${themeId}:${role??"_"}`, CompiledEntry>
function tkey() { return db.getTenantSchema(); }
function ck(themeId, role) { return `${themeId}:${role ?? "_"}`; }
function getCached(themeId, role) { return cache.get(tkey())?.get(ck(themeId, role)); }
function setCached(e) { let t = cache.get(tkey()); if (!t) cache.set(tkey(), (t = new Map())); t.set(ck(e.themeId, e.role), e); }
function bustTheme(themeId) { const t = cache.get(tkey()); if (t) for (const k of [...t.keys()]) if (k.startsWith(themeId + ":")) t.delete(k); }
function bustAll() { cache.delete(tkey()); }
async function warm(themeId, role) { // used by onLoad rehydration + activate
const theme = await themeStore.getById(themeId);
if (!theme) return null;
const compiled = compileTheme(theme.tokens, { engine: theme.engine });
const entry = { ...compiled, themeId, role: role ?? null, builtAt: Date.now() };
setCached(entry);
return entry;
}
Per-tenant for free (outer key = getTenantSchema()). Derived; never persisted; rebuilt lazily on restart (plus eager rehydration in onLoad, §5.6). The cache key space is pinned: numeric role (or null/"_" for default). activate writes all keys implied by the current pointer (default + every activeByRole role), so the headers facet never reads a key activate didn't populate (folded fix).
7.8 activateTheme (activate.js) — staged, persists COMPOSITE hash, refreshes assets (folded fixes)
const crypto = require("crypto");
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) {
const theme = builtins.isBuiltinId(id) ? builtins.get(id) : 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);
try {
const patch = role == null
? { activeThemeId: id, activeByRole: nextByRole, activeHash: nextHash }
: { activeByRole: nextByRole, activeHash: nextHash }; // 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 };
}
Folded fixes: staged compile-vs-pointer failures (compile_failed vs activation_failed) with orphan-cache cleanup on pointer-write failure; setActivePointer performs the mandatory refresh_views() so the new ?v=hash header actually reaches pages; and activeHash is now a composite persisted into config on every activation (default AND role), folding in the default plus every activeByRole entry's content hash. This closes the prior seam where the role-override path wrote { activeByRole } only — leaving activeHash unchanged — so a re-activated role's per-role ?v link was byte-identical and, under the immutable, max-age=1y header (§6.5), browsers served stale role CSS forever. Now any role re-activation changes activeHash, hence changes every emitted ?v, busting the immutable cache.
saveof the currently-active theme routes throughactivateTheme(sameId)to recompile-in-place (locked: recompile on activate/save). Non-active saves never touch the live cache or pointer.
7.9 Load-order proof (Phase 1)
sbadmin2.wrapItemits, in order in<head>: fontawesome, nunito,sb-admin-2.min.css(its Bootstrap-derived stylesheet), optionalbootstrap.rtl, and only thenheadersInHead(headers)(sbadmin2/index.js:408-414).headersInHeademits allcsslinks first (layout_utils.ts:660-666), so our overlaycssis in that first group.get_headersplacesstate_headers(where our plugin headers land) afterstdHeaders(which includes coresaltcorn.css) (wrapper.js:183-190).
Result: sb-admin-2.min.css → … → [overlay theme.css]. The later :root{--bs-*} wins by cascade order, overriding the theme on equal specificity. The overlay composes with the active theme (it is a css header, not a layout) — the Phase-1 "skin not bones" requirement.
For Phase 2 the plugin is the layout; its wrap() controls head order, emits the single (Sass-compiled) Bootstrap link and then headersInHead(headers) — no second Bootstrap, no overlay needed.
7.10 Phase 2 layout(cfg).wrap()
function layout(cfg) {
if (!isPhase2(cfg)) return undefined; // falsy => not registered as a layout (Phase 1)
return {
pluginName: PLUGIN_NAME,
wrap: ({ title, body, brand, menu, alerts, headers, bodyClass, role, req }) => {
const themeLink = `<link rel="stylesheet" href="${CSS_ROUTE}?v=${activeHashHint(cfg, role)}">`;
const skeleton = renderTreeToHtml(activeLayoutTree(cfg, role), { title, body, brand, menu, alerts });
return `<!doctype html><html><head>${themeLink}${headersInHead(headers)}<title>${title}</title></head>`
+ `<body class="${bodyClass||""}">${skeleton}${headersInBody(headers)}</body></html>`;
},
authWrap: /* analogous */,
renderBody: /* passthrough */,
};
}
Registered into state.layouts["theme-builder"]; selectable via layout_by_role[role]="theme-builder" or user._attributes.layout. renderTreeToHtml is the Phase-2 builder facet's tree→HTML emitter; the tree references var(--bs-*)/Bootstrap classes only. Because wrap() emits the token <link> itself (with the composite-hash ?v), Phase-2 token delivery never needs the Phase-1 overlay header — which is exactly why the §7.6 only_if suppresses it for theme-builder-served requests.
7.11 Data-flow diagram
┌──────────────────────────── theme-builder plugin ───────────────────────────┐
EDIT (no compile) │ │
┌─────────────┐ PUT/POST │ ┌────────────┐ Table model ┌───────────────────────────┐ │
│ Builder SPA │──────────────▶│ themeStore │───────────────▶│ _themebuilder_themes Table│ (in _sc_tables│
│ token panel │ /save │ │ (CRUD) │ JSON.stringify│ theme_id,name,tokens,... │ => backup-safe│
│ +preview │◀────────── │ └────────────┘ getById └───────────────────────────┘ │
└─────┬───────┘ GET load │ ▲ │
│ applyTokens(--bs-*)│ │ getById(activeId).tokens │
│ (live, in iframe) │ ┌──────┴─────┐ compileTheme(tokens) ┌──────────────┐ │
│ │ │ activate │───────────────────────▶│ compile.js │ tokens->CSS+hash │
ACTIVATE (only live op) │ │ engine │ │ (robustness │ (DERIVED cache only)│
┌─────────────┐ POST │ │ │ setCached(all keys) │ guard) │ │
│ Activate btn│───────────────▶│ 1 compile │───────────────────────▶│ │ │
└─────────────┘ /activate │ │ 2 cache │ ┌──────────┐ └──────────────┘ │
│ │ 3 persist │──▶│ cssCache │ Map<tenant, Map<themeId:role, {css,hash}>> │
│ │ pointer │ └────┬─────┘ │
│ │ +compos │ │ getCached / warm-on-miss │
│ │ -ite │ │ │
│ │ hash │ │ │
│ │ 4 reload │ │ │
│ │ 5 refresh │ │ │
│ │ _views │ ┌─────▼──────┐ {css:".../theme.css?v=hash", only_if(req)} │
│ │ 6 procSend │ │ headers(cfg)│──────────────────┐ │
│ └────────────┘ └────────────┘ │ │
└─────────────────────────────────────────────────── │ ─────────────────────────┘
▼
BROWSER page render: sbadmin2 wrap emits Bootstrap CSS ─────▶ then headersInHead(headers) emits overlay link
GET /theme-builder/theme.css?v=hash ─────▶ cssCache hit -> 200 text/css (304 if If-None-Match)
:root{--bs-*} applies AFTER Bootstrap ─────▶ theme visible, no compile at request time
8. Builder SPA
The in-browser React editor, mounted on an admin-only route and built as one static bundle served from the plugin's public/ dir.
8.1 Serving the bundle
The SPA compiles to public/builderApp.js + public/builderApp.css, served by Saltcorn's built-in static route at /plugins/public/theme-builder/builderApp.js with zero extra route (plugins.js:1246-1276). The webpack output.library global is themeBuilder. The compiled artifacts are committed (so an installed plugin works without a build step) and rebuilt via build:ui. We do not reuse core's /static_assets/<tag>/builder_bundle.js (core-owned, version_tag-gated — builder.ts:67-69).
For Phase 2 we bundle our own @craftjs/core into builderApp.js (and vendor/copy the saltcorn-builder element/storage source files), because @saltcorn/builder exposes no importable craftToSaltcorn/layoutToNodes (its functions are ESM source bundled only into builder_bundle.js). This keeps the SPA self-contained and decoupled from a specific core builder version.
8.2 The shell page (lib/page.js)
async function getEditor(req, res) {
if (!guardAdmin(req, res)) return; // GET -> redirect to login on failure
const boot = await apiState.buildState(req); // same payload as GET /api/state
const csrf = req.csrfToken ? req.csrfToken() : "";
const html = `
<div id="theme-builder-root" data-base="${URL_PREFIX}"></div>
<link rel="stylesheet" href="/plugins/public/theme-builder/builderApp.css">
<script>
window.__THEME_BUILDER__ = { boot:${JSON.stringify(boot)}, csrf:${JSON.stringify(csrf)},
apiBase:${JSON.stringify(API_BASE)}, base:${JSON.stringify(URL_PREFIX)},
openThemeId:${JSON.stringify(req.query.id || null)} };
</script>
<script src="/plugins/public/theme-builder/builderApp.js"></script>
<script>window.themeBuilder.mount("theme-builder-root", window.__THEME_BUILDER__);</script>`;
res.sendWrap({ title: "Theme Builder", no_menu: false }, html);
}
The SPA reads CSRF from the bootstrap blob and sends it as CSRF-Token on every POST. The shell embeds the initial /state payload to avoid a flash GET.
8.3 Top-level state machine (app.jsx)
AppState = {
view: "manager" | "editor",
panel: "tokens" | "canvas", // canvas only when caps.phase === 2
themes: ThemeListItem[],
activeThemeId: string|null,
manifest: TokenManifest, caps: {...},
open: { id, name, builtin, baseTokens, draftTokens, layoutTree, version, dirty } | null
}
Transitions:
openTheme(id)→ GET/api/themes/:id; ifbuiltin, setopen.builtin=true(read-only); switchview:"editor".editToken(path,value)→ mutatedraftTokens, setdirty, push to preview iframe (no network).save()→ ifopen.builtin, first POST/dupand swapopen.id; then POST/savewith{tokens, layoutTree, baseVersion:open.version}; on 409 offer reload-or-overwrite; on successbaseTokens=draftTokens, version++ , dirty=false.activate()→ POST/activate; on success updateactiveThemeId+ re-fetch/state. The only transition that publishes.rename/delete/create/duplicate/import/export→ library mutations + re-fetch/state.
save() and activate() are distinct buttons; editing/saving never implies publishing. A "Save & Apply" affordance = save then activate.
8.4 REST adapter (api.js)
function makeApi(base, csrf) {
const j = async (method, path, body) => {
const r = await fetch(base + path, {
method, headers: { "Content-Type":"application/json", "X-Requested-With":"XMLHttpRequest",
"Accept":"application/json", "CSRF-Token": csrf },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error((await r.json().catch(()=>({}))).error?.message || r.statusText);
return (r.headers.get("content-type")||"").includes("json") ? r.json() : r.text();
};
return {
state: () => j("GET", "/api/state"),
load: (id) => j("GET", `/api/themes/${id}`),
create: (b) => j("POST", "/api/themes", b),
duplicate: (id,b)=> j("POST", `/api/themes/${id}/duplicate`, b),
save: (id,b)=> j("POST", `/api/themes/${id}/save`, b),
rename: (id,n)=> j("POST", `/api/themes/${id}/rename`, { name:n }),
remove: (id,autoSwitch)=> j("POST", `/api/themes/${id}/delete`, { autoSwitch }),
activate: (id,role)=> j("POST", `/api/themes/${id}/activate`, { role }),
exportUrl: (id)=> `${base}/api/themes/${id}/export`, // plain <a download>
};
}
The X-Requested-With/Accept headers ensure wantsJson is true even for multipart import (folded fix). CSRF comes from the shell blob (core-builder pattern, Library.js:102-129).
8.5 GET /api/state payload
StateEnvelope = {
themes: ThemeListItem[], // metadata only (NOT full token payloads)
activeThemeId: string|null,
activeByRole?: { [roleId:number]: string },
manifest: TokenManifest,
caps: { phase: 1|2, canImport: true, engine: "bootstrap5", formatVersion, sizeCap },
}
ThemeListItem = { id, name, builtin, active, engine, updatedAt, hasLayoutTree }
buildState merges builtins (mapped builtin:true) + library rows (builtin:false), annotates active from the live pointer (read via getState().plugin_cfgs, no DB hit), and includes the manifest. caps.phase toggles the canvas panel.
8.6 Phase 1 — token panel + live preview
The token panel renders from the manifest (parallel to the server TOKEN_SCHEMA), grouping descriptors into sections (colors/fonts/spacing) and naming the picker + --bs-* var(s) each token maps to:
TokenManifest = { sections: [
{ id:"colors", label:"Colors", tokens:[
{ key:"primary", label:"Primary", picker:"color", cssVars:["--bs-primary","--bs-primary-rgb"], default:"#0d6efd" } ]},
{ id:"fonts", label:"Typography", tokens:[
{ key:"fontBase", label:"Base font", picker:"font", cssVars:["--bs-body-font-family"], default:"system-ui" } ]},
{ id:"spacing", label:"Spacing", tokens:[
{ key:"spacer", label:"Spacer", picker:"spacing", cssVars:["--bs-body-line-height"], default:"1rem", unit:"rem" } ]},
]}
Live preview (no compile while editing). The preview is a sandboxed <iframe> whose document loads the same Bootstrap/active-theme stylesheet the live site uses plus a representative sample page, with an empty <style id="tb-overlay"> we write into. On every token change we set --bs-* custom properties — the exact same overlay mechanism production uses on activate — so WYSIWYG with zero compile.
function PreviewFrame({ tokens, manifest }) {
const ref = useRef();
useEffect(() => { ref.current.srcdoc = buildSrcdoc(); }, []);
// FOLDED FIX: apply tokens on iframe load (srcdoc parses async) AND on tokens change.
const apply = () => { const d = ref.current?.contentDocument; if (d) applyTokens(d, tokens, manifest); };
useEffect(apply, [tokens, manifest]);
return <iframe ref={ref} onLoad={apply} sandbox="allow-same-origin" title="Theme preview" className="tb-preview"/>;
}
function applyTokens(doc, tokens, manifest) {
const decls = [];
for (const s of manifest.sections) for (const tk of s.tokens) {
const v = tokens[tk.key]; if (v == null) continue;
for (const cssVar of tk.cssVars) decls.push(`${cssVar}: ${formatVar(cssVar, v, tk)};`);
}
const style = doc.getElementById("tb-overlay");
if (style) style.textContent = `:root, [data-bs-theme]{${decls.join("")}}`;
}
The folded fix applies tokens on the iframe load event (the srcdoc document parses asynchronously, so the first [tokens] effect would otherwise no-op on a not-yet-parsed doc and show defaults). sandbox="allow-same-origin" only (no allow-scripts) — the preview cannot run arbitrary code or navigate.
Parity guarantee: editing → preview = tokens → --bs-* overlay (client, live); activate → production = tokens → --bs-* overlay served by headers(cfg) as cached CSS via the route (server). Same mapping; no drift. Production emits the overlay as a css link to the hash-busted theme.css (a real Header key) — never the untyped style Header field (folded fix).
8.7 Phase 2 — Craft.js canvas
A third panel (panel:"canvas") reuses saltcorn-builder's patterns: <Editor resolver onRender handlers> + <Frame><Element canvas is={Container}/></Frame> (Builder.js:834-873,967-973) and the craftToSaltcorn/layoutToNodes round-trip (storage.js:97-679).
function CanvasPanel({ open, onChange, options }) {
return (
<Editor onRender={RenderNode} resolver={RESOLVER} handlers={s=>new DefaultEventHandlers({store:s})}>
<Frame><Element canvas is={Container}/></Frame>
{/* on mount: layoutToNodes(open.layoutTree, query, actions.history.ignore(), "ROOT", options) */}
{/* on change (debounced): craftToSaltcorn(JSON.parse(query.serialize()),"ROOT",options).layout -> onChange(layout) */}
</Editor>
);
}
- Load:
layoutToNodes(..., actions.history.ignore(), ...)so the seed render is not undoable (Builder.js:762-764). - Edit/serialize:
craftToSaltcorn(...).layout→ store aslayoutTree; mark dirty. No network, no compile. - Save: the one
/savePOST carries{ tokens, layoutTree }together (both panels feed it). - Activate (Phase 2): the engine does a full Sass recompile (viable because the plugin owns the stylesheet). The SPA is unchanged — still just POST
/activate. - Resolver completeness: every node type appearing in a serialized tree MUST be in the Editor resolver or
deserializethrows (Builder.js:845-873). The canvas resolver mirrors saltcorn-builder's structural subset; new node types must also be registered in the storageallElementslist. - Craft nodes reference
var(--bs-*)/Bootstrap classes only — never literal hex — keepingtokensthe single source of truth across both panels.
8.8 Manager panel buttons
| Button | Enabled when |
|---|---|
| Activate | always (builtin or not) |
| Load/Edit | always; builtin opens read-only, first save duplicates |
| Duplicate | always |
| Rename | not builtin |
| Delete | not builtin AND (not active OR autoSwitch confirmed) AND not role-assigned-without-confirm |
| Export | always (<a href={exportUrl} download>) |
Top toolbar: New, Import (file input → reads text → POST /import).
9. Export / import
9.1 Envelope
ThemeEnvelope = {
$schema: "saltcorn-theme", // SCHEMA_ID discriminator
formatVersion: number, // FORMAT_VERSION (1) at export; import upcasts older
name: string, // import de-dups; never an id
engine: "bootstrap5",
tokens: object, // authoritative
layoutTree?: object, // present only if the theme has one
}
The envelope carries no id, no version, no timestamps, no compiled CSS — identity is instance-local (import mints fresh), so import is structurally non-destructive.
9.2 Build (export)
function buildEnvelope(theme) {
const env = { $schema: SCHEMA_ID, formatVersion: FORMAT_VERSION,
name: theme.name, engine: theme.engine || ENGINE, tokens: theme.tokens };
if (theme.layoutTree) env.layoutTree = theme.layoutTree;
return env;
}
Export works for builtins too. The route sets Content-Disposition: attachment; filename="<slug>.theme.json" (ASCII slug, round-trips across OSes).
9.3 Upcaster chain (two axes)
const FORMAT_UPCASTERS = {
0: (e) => ({ $schema: SCHEMA_ID, formatVersion: 1, name: e.name || "Imported",
engine: e.engine || ENGINE, tokens: e.vars || e.tokens || {}, layoutTree: e.layoutTree || null }),
// future: 1 -> 2 here
};
function upcastEnvelope(e) {
let cur = e, v = typeof e.formatVersion === "number" ? e.formatVersion : 0;
while (v < FORMAT_VERSION) {
if (!FORMAT_UPCASTERS[v]) throw new ImportError(`no upcaster for formatVersion ${v}`);
cur = FORMAT_UPCASTERS[v](cur); v = cur.formatVersion;
}
if (v > FORMAT_VERSION) throw new ImportError(`formatVersion ${v} newer than supported ${FORMAT_VERSION}`);
return cur;
}
The envelope axis (formatVersion) is handled here; the token-shape axis (tokens.$tokensVersion) is handled by normalizeTokens (§4.3), currently a v1 no-op.
9.4 Parse + robustness validation — NO security sanitization
async function parseEnvelope(rawText) {
// (d) size cap — operational hygiene, BEFORE parse
if (Buffer.byteLength(rawText, "utf8") > MAX_IMPORT_BYTES) return { ok: false, error: "Theme file exceeds size limit" };
let parsed; try { parsed = JSON.parse(rawText); } catch { return { ok:false, error:"Not valid JSON" }; }
// (a) format + discriminator + upcast
if (!parsed || parsed.$schema !== SCHEMA_ID) return { ok:false, error:"Not a saltcorn-theme file" };
let env; try { env = upcastEnvelope(parsed); } catch (e) { return { ok:false, error:e.message }; }
if (env.engine && env.engine !== ENGINE) return { ok:false, error:`Unsupported engine: ${env.engine}` };
const tokens = normalizeTokens(env.tokens);
if (Object.keys(tokens).length > MAX_TOKENS) return { ok:false, error:"Too many tokens" };
const tv = tokensAreValid(tokens); if (!tv.ok) return { ok:false, error:tv.errors.join("; ") };
// (c) light "won't white-screen" check: actually compile + verify balanced braces.
const compiled = compileTheme(tokens, { engine: env.engine || ENGINE });
if (compiled.warnings.some((w) => /unbalanced|empty \(invalid\)/.test(w)))
return { ok:false, error:"Theme tokens do not compile to valid CSS" };
return { ok:true, draft: { name: (env.name||"Imported Theme").trim(), engine: env.engine||ENGINE,
tokens, layoutTree: env.layoutTree ?? null } };
}
9.5 Import handler — multipart source fix + fresh-id (folded fixes)
async function importTheme(req, res) {
if (!guardAdmin(req, res)) return;
let raw;
if (req.files && req.files.file) {
const f = req.files.file;
if (f.size > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme file too large");
// FOLDED FIX: with useTempFiles, f.data is an EMPTY (truthy) Buffer -> branch on LENGTH, read tempFilePath.
raw = (f.data && f.data.length) ? f.data.toString("utf8") : require("fs").readFileSync(f.tempFilePath, "utf8");
} else if (req.body && (req.body.$schema || req.body.envelope)) {
raw = typeof req.body.envelope === "string" ? req.body.envelope : JSON.stringify(req.body.envelope || req.body);
if (Buffer.byteLength(raw) > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme too large");
} else return jsonError(res, 400, "bad_request", "No theme file provided");
const parsed = await portability.parseEnvelope(raw);
if (!parsed.ok) return jsonError(res, parsed.error.includes("compile") ? 422 : 400,
parsed.error.includes("compile") ? "uncompilable" : "bad_format", parsed.error);
// (b) FRESH id + de-dup name via create() -> import NEVER overwrites
const theme = await themeStore.create({ name: parsed.draft.name, engine: parsed.draft.engine,
tokens: parsed.draft.tokens, layoutTree: parsed.draft.layoutTree });
res.json({ theme });
}
Folded fixes: branch on f.data.length (with useTempFiles, f.data is an empty truthy Buffer, so a truthiness branch always took the empty path → JSON.parse("") → every multipart import 400'd); size-check before reading the temp file.
Import is admin-only; no sanitization (admin already owns site CSS/JS); only the four robustness checks run. Re-importing the same file yields a second copy (fresh uuid + de-dup name), never a clobber. Contingency (re-add sanitization if non-admin import is ever enabled) is documented inline.
10. Package / file layout
/home/scott/claude/saltcorn/theme-builder/
index.js # export object: api_version, plugin_name, configuration_workflow,
# headers, routes, layout, onLoad
package.json
README.md # incl. SECURITY section (no-sanitization rationale + contingency)
lib/
constants.js # PLUGIN_NAME, THEME_TABLE, URL_PREFIX, CSS_ROUTE, CFG keys, caps
configWorkflow.js # Workflow: phase2 toggle (+coverage HARD-BLOCK), builder link, active step
themeSchema.js # DEFAULT_TOKENS, normalizeTokens, tokensAreValid, emptyTokens
themeStore.js # ONLY module touching the Table: ensureTable, CRUD, casUpdate, dedupName
builtins/
index.js # listBuiltins(), getBuiltin(id), isBuiltinId(id), defaultId(), fallbackCss()
flatly.js darkly.js cosmo.js ... # Bootswatch-derived starters (frozen)
activePointer.js # getActivePointer (read live cfg), setActivePointer (persist+reload+refresh)
cfgReaders.js # getActiveThemeId, activeThemeIdForRole, isPhase2, activeHashHint, requestRendersViaThemeBuilder
portability.js # buildEnvelope, parseEnvelope, upcastEnvelope (robustness-only)
sanitize.js # sanitizeValue, sanitizeSelector (shared by compile + sassCompile)
compile.js # compileTheme, emitOverlayCss, robustnessGuard, contentHash, deriveRgb
sassCompile.js # compileSass (Phase 2, lazy require('sass'))
cssCache.js # getCached/setCached/bustTheme/bustAll/warm (per-tenant)
activate.js # activateTheme (staged compile->cache->persist composite hash->reload->refresh)
tokenSchema.js # TOKEN_SCHEMA mapping table
tokenManifest.js # getTokenManifest() (parallel to TOKEN_SCHEMA), validateCompiles()
headers.js # headers(cfg) factory
layout.js # layout(cfg) factory (Phase 2)
routes.js # routes(cfg) factory
onLoad.js # eachTenant bootstrap + active-CSS rehydration
httpUtils.js # isAdminReq, wantsJson, guardAdmin, jsonError
apiHandlers.js # getThemeCss, getEditor, getState_, loadTheme, createTheme, ...
page.js # SPA shell HTML
apiState.js # buildState
scss/ # Phase 2 only: vendored Bootstrap SCSS source
bootstrap/ ...
builder/ # React SPA source (compiled -> public/)
webpack.config.js babel.config.js
src/
index.jsx # window.themeBuilder.mount(elId, boot)
app.jsx api.js store.js
panels/ managerPanel.jsx tokenPanel.jsx canvasPanel.jsx
preview/ previewFrame.jsx previewSrcdoc.js
pickers/ colorPicker.jsx fontPicker.jsx spacingPicker.jsx
craft/ resolver.js craftBridge.js # Phase 2 (vendored saltcorn-builder source)
public/ # served at /plugins/public/theme-builder/<file>
builderApp.js builderApp.css # committed compiled artifacts
test/
contract.test.js compile.test.js portability.test.js themeStore.test.js e2e.js
Filenames are camelCase throughout, per the repo convention.
11. Phase plan & milestones
11.1 Phase 1 ships
themeStore+ real Table_themebuilder_themes(backup-safe, per-tenant) + built-ins in code.- Plugin export with
configuration_workflow,headers(cfg),routes(cfg),onLoad(table ensure + active-CSS rehydration).layout(cfg)present but returnsundefined. - Full HTTP API (list/load/create/duplicate/save[CAS]/rename/delete[auto-switch]/activate/export/import) + public
theme.css. - Pure-JS overlay compiler + robustness guard + per-tenant cache + composite
?v=hashbusting +refresh_viewson activate. - Builder SPA: manager panel + token panel + live-preview iframe (no compile while editing).
- Export/import envelope + upcaster + robustness-only validation.
11.2 Phase 2 ships (behind phase2Enabled)
layout(cfg)returns a real{wrap,...}registered intostate.layouts; selectable vialayout_by_role/per-user attr.- Per-request Phase-2 overlay suppression (only for requests that render via
theme-builder, detected by replicatinggetLayout's fallback precedence — never by reading the fallback-omittedpluginName), with a config-workflow hard-block requiring fulllayout_by_rolecoverage beforephase2Enabledcan be saved. - Craft.js canvas panel (vendored saltcorn-builder source) persisting
layoutTree;wrap()renders the tree. - Optional full dart-sass recompile (
@use … with), overlay fallback on failure. - Vendored Bootstrap SCSS under
scss/;sass/bootstrapadded topackage.json.
11.3 Testing strategy
- Pure-unit:
compile.test.js(overlay emit, robustness guard, brace-balance, size-cap truncation, bad-token scoping),portability.test.js(upcast chain, fresh-id, de-dup, multipart vs JSON source, size cap),themeSchema(normalize/merge/version). - Store/contract:
themeStore.test.jsagainst both SQLite and Postgres (per the Postgres-preferred directive; SQLite as the simple/mixed-topology peer) —ensureTableidempotency, CAS save,dropRoleRefs, dedupName bound. - e2e (
test/e2e.js): drive both instances via the existing sqlite + curl +sc-exec.jsshim harness pattern. Cover: load≠activate (save of active theme does not change live CSS unless re-activated), activate flips?v=hashAND the served header actually changes (assertassets_by_rolerefresh), per-role overlayonly_if, re-activating a single role override changesactiveHashso its per-role?vlink changes (no stale immutable role CSS), delete-of-active auto-switch, import never overwrites, restore-over-existing pointer caveat, multi-tenant isolation, cold-worker rehydration, publictheme.cssnever 5xx and never poisons cache on transient miss. - Phase-2 gates: Craft round-trip equality (
craftToSaltcorn(layoutToNodes(x)) === x), resolver completeness, Sass-failure → overlay fallback, per-request suppression coverage — including the uncovered-role-via-last-installed-fallback case (a role with nolayout_by_roleentry that resolves to theme-builder via the fallback is suppressed correctly and NOT double-themed), and the coverage hard-block rejectingphase2Enabledwhen any live role is uncovered. - Security-first per-phase gate (Scott's directive): enumerate abuse cases per module and write the adversarial tests before coding (e.g. token value with
}cannot escape:root{}; oversized import → 413; non-admin → 401/403 JSON on every API route; the import contingency is asserted to be a no-op only while import is admin-only).
11.4 Risks & mitigations
| Risk | Mitigation |
|---|---|
Activate doesn't visibly update browsers (stale assets_by_role) |
setActivePointer calls refresh_views() locally; cluster receiver recomputes assets + busts cache. e2e asserts the served ?v changes. |
| Stale immutable role CSS after re-activating a role override | activeHash is a COMPOSITE over default + every role-override hash, updated on EVERY activation, so each role link's ?v changes; ETag (per-entry content hash) + the 304 path provide a second correction lever. |
| Cache poisoning under the live hash on transient miss | Fallback/miss responses use Cache-Control: no-store; immutable only for real hashed CSS. |
| Uncovered role double-themed under Phase 2 (overlay + wrap) | Suppression detects theme-builder-served requests by replicating getLayout's fallback precedence (not the fallback-omitted pluginName); config workflow hard-blocks phase2Enabled until layout_by_role covers every live role. |
loadPlugin reinstall cost on every activate |
Accepted for parity; onLoad hot-path short-circuits; document the cost; activate is admin-triggered, not per-request. |
| Theme lost after restart | onLoad eager rehydration of all pointer-implied cache keys. |
| Cross-tenant table miss for late-created tenant | eachTenant (default-first, DB-backed); public route degrades to fallback CSS, never 5xx, if a tenant's table is missing. |
Bootstrap version drift between Phase-1 --bs-* names and Phase-2 SCSS |
Pin a single Bootstrap 5 version for both; CI check that vendored SCSS var names match the manifest/schema. |
install name ≠ plugin_name breaks pointer writes |
findPluginRow resolves the _sc_plugins row defensively (canonical name, then scan), never assumes name === plugin_name. |
12. Open questions
- Per-role compile granularity at scale. Phase 1 ships a single global active theme plus an optional
activeByRolemap; activate recompiles all pointer-implied keys eagerly. IfactiveByRolegrows large (many roles × distinct themes), eager recompile-on-activate may warrant a lazy-only strategy for non-default roles. The cache keying and lazy-compile-on-miss already support either; the policy choice (eager vs lazy for role overrides) is deferred until real role counts are known. - Tenant created after the last plugin load. A tenant added to
_sc_tenantsafter the most recent plugin load gets its table only on the next plugin load / tenant-create hook. The public path degrades gracefully (fallback CSS, no 5xx) and the first admin action triggersensureTable, but whether to subscribe to a tenant-creation event for eager table creation is an open operational choice. - Phase-2
layout_by_rolecoverage UX — RESOLVED to a hard block. Enablingphase2Enabledwithoutlayout_by_rolecovering all live roles would leave some roles selected by the last-installed fallback (whichgetLayoutreturns withoutpluginName), making per-request overlay suppression undecidable fromgetLayout().pluginNameand double-theming those roles. To keep suppression deterministic, the config workflow hard-blocks savingphase2Enabled: trueuntillayout_by_rolecovers every live role (rather than merely warning). Remaining open question: the UX of presenting and auto-filling that coverage requirement (e.g. offering to map all uncovered roles to theme-builder in one click). @use … withvs runtime--bs-*fidelity (Phase 2). Full Sass recompile gives higher fidelity for Bootstrap's build-time-computed values (button shades, etc.) than--bs-*overrides. Whether Phase-2 themes default to Sass or keep the overlay engine unless the admin opts in is deferred to Phase-2 implementation, when compile-time benchmarks are available.