Initial commit

This commit is contained in:
Scott Duensing 2026-07-01 20:07:28 -05:00
commit cb1cbcc80b
48 changed files with 6270 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
.test-state/
*.log

1425
ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load diff

158
README.md Normal file
View file

@ -0,0 +1,158 @@
# theme-builder
A Saltcorn plugin: a visual theme builder. By default it ships a Bootstrap-5 CSS
custom-property **skin overlay** (no Sass, not a layout) that composes with the
active theme (e.g. sbadmin2). Turning on **layout mode** (the `layoutMode`
setting) flips the plugin into a selectable **layout** whose `wrap()` is
assembled from a layout tree, with vendored Bootstrap and an optional dart-sass
recompile.
The authoritative design is in [`ARCHITECTURE.md`](./ARCHITECTURE.md). This
README covers what is built, how it is stored, security, and how to run it.
## Status (layout mode built + validated)
**Layout mode is built and validated live.** The plugin registers as a real
Saltcorn layout when `layoutMode` is on, and its `wrap()`/`authWrap()` render the
page from the active theme's **layoutTree** (`layoutTree.js` presets
`topnav`/`sidebar` + `renderTree.js` server-side emitter), linking vendored stock
Bootstrap (`public/themeBootstrap.*`) + the theme overlay. The active tree is
persisted in plugin config on activate (so `wrap()` stays synchronous and it
rides backup). The overlay header is suppressed per-request for
theme-builder-served requests; `configWorkflow` hard-blocks enabling layout mode
until `layout_by_role` covers every live role. The editor gains a **Layout
panel** (preset picker) when layout mode is on. Sass remains the documented
opt-in (default engine is the overlay on vendored Bootstrap); the full Craft.js
drag-drop canvas (ARCHITECTURE 8.7) is the remaining authoring enhancement.
**Layout mode validated** (in-process harness + real HTTP, all green): enable ->
registered in `getState().layouts`; `wrap()` renders `tb-root` + navbar/sidebar +
body + theme CSS; `authWrap()` renders the login form (real HTTP login works);
`GET /` served by our layout; overlay suppressed exactly for theme-builder
requests.
### Deep overlay (buttons recolor without Sass)
A plain `:root{--bs-primary}` override does **not** recolor `.btn-primary`
Bootstrap 5.3 bakes button/component colors at Sass compile time. The **deep
overlay** (`lib/bootstrapColor.js` + `lib/deepOverlay.js`) faithfully ports
Bootstrap's `shade`/`tint`/`color-contrast` math to JS and re-emits the per-
component `--bs-btn-*` variables (solid + outline, all 8 variants, incl. the
`.btn-light`/`.btn-dark` force-overrides), so buttons/badges follow the theme.
Unit tests assert **byte parity** with Bootstrap's compiled output for every
variant; validated visually with headless Chromium (primary->pink recolors the
button). Opt out with `tokens.deepOverlay === false`.
The editor's **live preview** uses `POST /api/preview-css` (the *same* server
compiler -> single source of truth), so the preview is WYSIWYG including
recolored buttons (debounced, stale-response-guarded, no save/activate).
### Installed in the dev TEST instance
theme-builder is installed in the `:3001` TEST instance (registered by location,
loaded on boot via `startServerTest.sh`). Validated in a real browser (headless
Chromium): the editor manager, the manifest-driven token panel, and the live
preview (navbar/buttons/links recolor) all work against TEST. The install is
non-disruptive: with no active theme and layout mode off, `theme.css` is a no-op and
sbadmin2 remains the active layout, so the TEST site looks unchanged until an
admin activates a theme.
### Rendering engine: overlay + deep overlay (Sass deferred)
The shipping engine is the **CSS-variable overlay + deep overlay** (no build, no
runtime deps). With the deep overlay recoloring buttons/components, this covers
the practical theming surface. The **dart-sass full recompile** remains an
**opt-in** (`lib/sassCompile.js` degrades to the overlay when `sass` is absent);
the full Bootstrap-SCSS vendoring is intentionally NOT shipped, since the deep
overlay made it unnecessary for the common case. Enable it later by adding `sass`
+ vendoring `scss/bootstrap` if a theme needs build-time-only Sass features.
### Plugin vs Theme classification
theme-builder exports a `layout` function, so Saltcorn classifies it as a
**Theme** (`local_has_theme`): it appears under the Plugins page's **All / Themes
/ Installed** tabs (next to sbadmin2), not the "Modules" tab. This is intentional
and correct (in layout mode it *is* a layout). The Configure gear renders in every
tab it appears in.
## Status (Phase 1)
Built and unit-tested (server-independent):
- **Pure core** (`node --test`, no DB/server): `compile.js` (overlay compiler),
`sanitize.js`, `tokenSchema.js`, `themeSchema.js`, `portability.js`
(export/import + upcasting), `builtins/` (Flatly/Darkly/Cosmo starters).
- **Storage, wiring, HTTP, lifecycle, editor**: `themeStore.js` (the only module
that touches the Table), `activePointer.js`, `cfgReaders.js`, `cssCache.js`,
`activate.js`, `headers.js`, `layout.js`, `routes.js`, `apiHandlers.js`,
`apiState.js`, `httpUtils.js`, `onLoad.js`, `configWorkflow.js`, `index.js`,
plus the buildless editor (`lib/page.js` + `public/builderApp.js`).
All `*.js` pass `node --check`; the pure suite is green (`npm test`, 31 tests).
**Validated end-to-end against a live SQLite instance** (in-process harness +
real HTTP), all green:
- `onLoad` creates the `_themebuilder_themes` Table (registered in `_sc_tables`).
- create -> save (version-CAS; stale save -> 409) -> activate (pointer +
composite `activeHash` persisted) -> `GET /theme.css` serves the overlay.
- export -> import mints a fresh id with a de-duped name.
- Activating a **built-in** serves its overlay (the `getById` seam fix).
- Real HTTP: public `theme.css` (200, immutable+ETag), admin guard (redirect for
HTML, JSON 401 for the API), authed `/api/state`, the editor shell, and the
overlay `<link>?v=<hash>` injected into rendered pages.
Reproduce with `npm run test:integration` (self-contained throwaway DB).
> **Dependency convention:** like `dev-deploy`/`idp`, this plugin declares **no**
> `@saltcorn/*` dependencies -- the host provides them at runtime. Declaring them
> makes the plugin installer try to fetch/copy them. Phase 1 has no third-party
> runtime deps at all.
The editor under `public/builderApp.js` is a **buildless, dependency-free**
vanilla-JS app — the Phase-1 shipping UI. The React + Craft.js source tree
(`builder/src`, webpack-built into `public/builderApp.js`) is the **Phase-2**
upgrade path and is not yet implemented.
## Storage & backup-safety
The theme **library** is a real Saltcorn `Table` (`_themebuilder_themes`)
created via the Table model, so it is registered in `_sc_tables` and rides
backup / restore / snapshots for free, per-tenant. The **active pointer**
(`activeThemeId`, `activeByRole`, `activeHash`, `layoutMode`) lives in
`plugin.configuration`, which is also captured by `plugin_pack`. Compiled CSS is
a **derived cache**, never stored. (This deliberately avoids the raw-DDL
backup-invisibility pitfall documented for the other plugins.)
## Running
This plugin is loaded by a Saltcorn instance, not run standalone. Install it as a
local plugin pointing at this directory; `onLoad` creates the table per tenant
and rehydrates the active-theme CSS cache. The admin editor is served at
`/theme-builder/editor`; the public stylesheet at `/theme-builder/theme.css`.
```sh
npm test # pure unit tests (no server)
npm run test:integration # in-process e2e against a throwaway SQLite DB
npm run build:ui # layout mode: build the React/Craft.js editor (not yet present)
```
## Security: import model
**Theme import performs NO security sanitization** of the uploaded file. An admin
can already inject arbitrary site-wide CSS/JS via `page_custom_css`,
`page_custom_html`, or by installing plugins, so an admin-only theme import
crosses no boundary the admin role does not already own. Import runs only four
**robustness** checks (size cap, format/version upcast, shape validation, and a
"compiles to balanced CSS" probe) — these exist so a malformed file fails with a
clear error and cannot white-screen the site, not as a defense against a trusted
admin.
**Contingency:** if a non-admin role is ever permitted to import or manage
themes, real sanitization (CSS/selector/`@import`/`url()`/expression filtering and
layoutTree resolver allow-listing) **MUST** be reintroduced before that ships.
See `lib/portability.js`.
## License
MIT

77
builder/README.md Normal file
View file

@ -0,0 +1,77 @@
# Theme Builder -- editor UI
This directory documents the two-stage plan for the in-browser theme editor.
The editor is mounted (admin-only) at `GET /theme-builder/editor`, whose shell
page (`lib/page.js`, ARCHITECTURE.md 8.2) loads a single static bundle served by
Saltcorn's built-in plugin static route at
`/plugins/public/theme-builder/builderApp.js` (no extra route -- plugins.js
1246-1276).
## Phase 1 -- the shipping editor (`public/builderApp.js`)
`public/builderApp.js` is the **buildless, dependency-free Phase-1 editor**. It
is hand-written **vanilla ES2020** -- no React, no Craft.js, no build step, no
imports. It IS the committed, shipped artifact: an installed plugin works
without ever running a bundler.
What it does (talking only to the REST API under `window.__TB__.apiBase`):
- **Manager panel** -- `GET /api/state` lists every theme with `active` and
`built-in` badges (ARCHITECTURE.md 8.5/8.8). Per-row buttons: Activate, Edit,
Duplicate, Rename, Delete, Export (plain `<a download>` to
`/api/themes/:id/export`). Top toolbar: New, Import (file input -> multipart
`POST /api/import`).
- **Token panel** -- color / font / spacing inputs rendered **from the manifest**
returned by `/api/state` (`manifest.tokens[<kebabKey>] = { kind, cssVar,
selector, prop, derive, default }`, from `lib/apiState.buildManifest`).
- **Live preview** -- a sandboxed `<iframe>` (`sandbox="allow-same-origin"`, no
scripts) loads the live `theme.css` plus a representative sample page with an
empty `<style id="tb-overlay">`. On every edit the editor rewrites that style
with `--bs-*` custom properties (and the rule-based `selector{prop:value}`
tokens) -- the **exact same overlay mechanism production uses on activate**
(`lib/compile.emitOverlayCss`). WYSIWYG with zero compile and zero network.
### The cardinal rule: load != activate
Loading and editing a theme **never** touches the live site. **Save** (`POST
/api/themes/:id/save` with `{ tokens, layoutTree, baseVersion }`) and
**Activate** (`POST /api/themes/:id/activate`) are wired as **separate
actions**; only Activate publishes (ARCHITECTURE.md 1.4 / 8.3). Save uses
optimistic concurrency on `baseVersion`; a 409 `version_conflict` offers
reload-or-overwrite. Editing a built-in is allowed: the first Save transparently
duplicates it to an editable row, leaving the original untouched.
### CSRF
The shell (`lib/page.js`) bootstraps
`window.__TB__ = { apiBase, cssRoute, csrfToken, base, openThemeId }`, reading
the token via `req.csrfToken()` (core's pattern, e.g. server/wrapper.js:259).
Every `POST` from the editor sends it back as the **`CSRF-Token`** header
(plus `X-Requested-With: XMLHttpRequest` and `Accept: application/json` so the
server treats every request -- including multipart import -- as JSON).
## Phase 2 -- the upgrade path (`builder/src`, NOT yet implemented)
The Phase-2 editor is a **React + Craft.js** application whose source tree will
live under **`builder/src`** (ARCHITECTURE.md 8.3, 8.7, 10). It adds a visual
**canvas panel** (`panel:"canvas"`, enabled only when `caps.phase === 2`) that
reuses saltcorn-builder's `<Editor>/<Frame>` patterns and the
`craftToSaltcorn` / `layoutToNodes` round-trip, with `@craftjs/core` and the
vendored saltcorn-builder element/storage files **bundled directly into the same
output path** (`public/builderApp.js`) by webpack (`build:ui`).
Phase 2 is purely additive and does not change the REST surface or the
"load != activate" rule:
- The token panel, manager panel, and `--bs-*` live-preview overlay carry over
unchanged; the canvas is a third panel feeding the same single `POST /save`
(`{ tokens, layoutTree }`).
- Activate is still just `POST /api/themes/:id/activate`; the server-side engine
swaps the overlay compiler for a full dart-sass recompile behind the
`phase2Enabled` config flag (ARCHITECTURE.md 5.7 / 7.5).
Until that React tree is built, **`public/builderApp.js` (the buildless vanilla
file) is the live editor.** When Phase 2 lands, the webpack build overwrites
`public/builderApp.js` with the compiled React bundle (exposing the same
`window.themeBuilder.mount` entry point), and this `builder/` directory holds its
source.

20
index.js Normal file
View file

@ -0,0 +1,20 @@
// 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
};

74
lib/activate.js Normal file
View file

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

53
lib/activePointer.js Normal file
View file

@ -0,0 +1,53 @@
// lib/activePointer.js
// Active-pointer write: persist -> re-register -> propagate -> refresh assets (ARCHITECTURE.md 5.5)
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" };
}
// Remove every activeByRole entry whose value === id, then persist the scrubbed map.
async function dropRoleRefs(id) {
const { activeByRole } = getActivePointer();
const scrubbed = {};
for (const role of Object.keys(activeByRole)) {
if (activeByRole[role] !== id) scrubbed[role] = activeByRole[role];
}
return setActivePointer({ activeByRole: scrubbed });
}
module.exports = { setActivePointer, getActivePointer, findPluginRow, dropRoleRefs };

260
lib/apiHandlers.js Normal file
View file

@ -0,0 +1,260 @@
// lib/apiHandlers.js
// The HTTP handlers referenced by routes(cfg) (ARCHITECTURE.md 6.5, 6.6, 9.5).
// Each handler speaks the canonical ThemeDTO and the uniform error envelope
// (httpUtils.jsonError). The public theme.css route NEVER 5xx's (degrades to
// fallback CSS); every other route is admin-guarded and answers JSON.
//
// express req/res are the per-tenant router's standard objects (server/app.js
// mounts plugin routes via plugin_routes_handler.js / error_catcher). Multipart
// import uploads arrive on req.files (express-fileupload, useTempFiles) -- see
// importTheme for the empty-truthy-Buffer fix.
const fs = require("fs");
const themeStore = require("./themeStore");
const activePointer = require("./activePointer");
const cssCache = require("./cssCache");
const { activeThemeIdForRole } = require("./cfgReaders");
const activateEngine = require("./activate"); // activateEngine.activateTheme
const { compileTheme } = require("./compile");
const { normalizeTokens } = require("./themeSchema");
const portability = require("./portability");
const builtins = require("./builtins");
const httpUtils = require("./httpUtils");
const apiState = require("./apiState");
const page = require("./page"); // renderEditorShell
const { ROLE_PUBLIC, MAX_IMPORT_BYTES } = require("./constants");
const { guardAdmin, jsonError } = httpUtils;
// --- PUBLIC: GET /theme.css (ARCHITECTURE.md 6.5, folded cache-poisoning fix) ---
async function getThemeCss(req, res, cfg) {
const role = req.query.role ? Number(req.query.role) : (req.user?.role_id ?? ROLE_PUBLIC);
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);
}
// --- GET /editor: SPA shell ---
async function getEditor(req, res) {
if (!guardAdmin(req, res)) return; // GET -> redirect to login on failure
// page.renderEditorShell builds the SPA shell HTML (embeds the /state boot blob +
// csrf) and sends it via res.sendWrap (ARCHITECTURE.md 8.2).
return page.renderEditorShell(req, res);
}
// --- GET /api/state ---
async function getState_(req, res) {
if (!guardAdmin(req, res)) return;
res.json(await apiState.buildState(req));
}
// --- GET /api/themes/:id ---
async function loadTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
const theme = await themeStore.getById(id); // resolves rows AND builtins (SEAM FIX)
if (!theme) return jsonError(res, 404, "not_found", "Unknown theme");
// builtins are read-only: surface the flag so the SPA edits via duplicate-to-edit.
res.json({ theme, readOnly: !!theme.builtin });
}
// --- POST /api/themes: create blank, or clone a template (builtin/theme) ---
async function createTheme(req, res) {
if (!guardAdmin(req, res)) return;
const { name, engine, fromTemplate } = req.body || {};
let theme;
if (fromTemplate) {
// fromTemplate names a builtin/theme id to seed from -> duplicate to a fresh row.
theme = await themeStore.duplicate(fromTemplate, name);
if (!theme) return jsonError(res, 404, "not_found", "Unknown template");
} else {
theme = await themeStore.create({ name, engine, tokens: {}, layoutTree: null });
}
res.json({ theme });
}
// --- POST /api/themes/:id/duplicate ---
async function duplicateTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
const name = req.body?.name;
const theme = await themeStore.duplicate(id, name); // resolves rows AND builtins
if (!theme) return jsonError(res, 404, "not_found", "Unknown theme");
res.json({ theme });
}
// --- POST /api/themes/:id/save: optimistic CAS on version (ARCHITECTURE.md 6.6) ---
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
}
// --- POST /api/themes/:id/rename: name only, builtins blocked, de-dups (no 409) ---
async function renameTheme(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 name = req.body?.name;
if (name == null || String(name).trim() === "") return jsonError(res, 400, "bad_request", "Missing name");
const exists = await themeStore.getById(id);
if (!exists) return jsonError(res, 404, "not_found", "Unknown theme");
// store.rename de-dups ("Name (2)") rather than rejecting, so rename always succeeds.
const theme = await themeStore.rename(id, name);
res.json({ theme });
}
// --- POST /api/themes/:id/delete (ARCHITECTURE.md 6.6) ---
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))?.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 });
}
// --- POST /api/themes/:id/activate: the only live-mutating op (ARCHITECTURE.md 6.6) ---
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
}
}
// --- GET /api/themes/:id/export: JSON envelope as a download (works for builtins) ---
async function exportTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
const theme = await themeStore.getById(id); // resolves rows AND builtins
if (!theme) return jsonError(res, 404, "not_found", "Unknown theme");
const envelope = portability.buildEnvelope(theme);
const slug = String(theme.name || "theme").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64) || "theme";
res.set("Content-Type", "application/json; charset=utf-8");
res.set("Content-Disposition", `attachment; filename="${slug}.theme.json"`);
res.send(JSON.stringify(envelope, null, 2));
}
// --- POST /api/import (ARCHITECTURE.md 9.5, multipart source + fresh-id 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") : 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 });
}
// --- POST /api/preview-css: compile DRAFT tokens for the editor's live preview ---
// Uses the SAME server compiler as production (single source of truth -> the
// preview's buttons/badges match exactly, including the deep overlay). Does NOT
// save or activate; returns text/css with no-store.
async function previewCss(req, res) {
if (!guardAdmin(req, res)) return;
const tokens = normalizeTokens((req.body && req.body.tokens) || {});
const engine = (req.body && req.body.engine) || undefined;
const compiled = compileTheme(tokens, { engine });
res.set("Content-Type", "text/css; charset=utf-8");
res.set("Cache-Control", "no-store");
res.send(compiled.css);
}
module.exports = {
getThemeCss,
getEditor,
getState_,
loadTheme,
createTheme,
duplicateTheme,
saveTheme,
renameTheme,
deleteTheme,
activateTheme,
exportTheme,
importTheme,
previewCss,
};

114
lib/apiState.js Normal file
View file

@ -0,0 +1,114 @@
// 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 };

107
lib/bootstrapColor.js vendored Normal file
View file

@ -0,0 +1,107 @@
// Faithful JS port of the Bootstrap 5.3 Sass color functions the button-variant
// mixin uses, so the "deep overlay" can recompute the per-component
// --bs-btn-* variables Bootstrap bakes at compile time. Pure module.
//
// shade-color(c, w%) = mix(black, c, w) (darken)
// tint-color(c, w%) = mix(white, c, w) (lighten)
// color-contrast(bg) -> #ffffff or #000000 (WCAG, min ratio 4.5, white first)
const WHITE = { r: 255, g: 255, b: 255 };
const BLACK = { r: 0, g: 0, b: 0 };
const MIN_CONTRAST_RATIO = 4.5;
// "#rgb" / "#rrggbb" -> {r,g,b}; null for anything else (named/rgb()/var()).
function parseHexColor(s) {
if (typeof s !== "string") {
return null;
}
const v = s.trim();
const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(v);
const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(v);
if (m6) {
return { r: parseInt(m6[1], 16), g: parseInt(m6[2], 16), b: parseInt(m6[3], 16) };
}
if (m3) {
return { r: parseInt(m3[1] + m3[1], 16), g: parseInt(m3[2] + m3[2], 16), b: parseInt(m3[3] + m3[3], 16) };
}
return null;
}
function clampByte(n) {
return Math.min(255, Math.max(0, Math.round(n)));
}
function toHex(c) {
return "#" + [c.r, c.g, c.b].map((n) => clampByte(n).toString(16).padStart(2, "0")).join("");
}
function toRgbString(c) {
return `${clampByte(c.r)}, ${clampByte(c.g)}, ${clampByte(c.b)}`;
}
// Sass mix($c1, $c2, $weight): each channel = c1*w + c2*(1-w). weight is a fraction.
function mix(c1, c2, weight) {
return {
r: c1.r * weight + c2.r * (1 - weight),
g: c1.g * weight + c2.g * (1 - weight),
b: c1.b * weight + c2.b * (1 - weight),
};
}
function shade(c, percent) {
return mix(BLACK, c, percent / 100);
}
function tint(c, percent) {
return mix(WHITE, c, percent / 100);
}
function channelLuminance(c255) {
const c = c255 / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function relLuminance(c) {
return 0.2126 * channelLuminance(c.r) + 0.7152 * channelLuminance(c.g) + 0.0722 * channelLuminance(c.b);
}
function contrastRatio(a, b) {
const la = relLuminance(a) + 0.05;
const lb = relLuminance(b) + 0.05;
return la > lb ? la / lb : lb / la;
}
// color-contrast($bg): Bootstrap checks white first then black, returning the
// first foreground whose contrast exceeds the min ratio (4.5), else the higher.
function colorContrast(bg) {
const c = typeof bg === "string" ? parseHexColor(bg) : bg;
if (!c) {
return "#000000";
}
const rw = contrastRatio(c, WHITE);
const rb = contrastRatio(c, BLACK);
if (rw > MIN_CONTRAST_RATIO) {
return "#ffffff";
}
if (rb > MIN_CONTRAST_RATIO) {
return "#000000";
}
return rw >= rb ? "#ffffff" : "#000000";
}
module.exports = {
WHITE, BLACK, parseHexColor, toHex, toRgbString, mix, shade, tint,
relLuminance, contrastRatio, colorContrast,
};

17
lib/builtins/cosmo.js Normal file
View file

@ -0,0 +1,17 @@
// Bootswatch-derived bright starter (frozen). See flatly.js for the built-in model.
module.exports = Object.freeze({
id: "builtin:cosmo",
name: "Cosmo",
engine: "bootstrap5",
tokens: {
$tokensVersion: 1,
colors: {
primary: "#2780e3", secondary: "#373a3c", success: "#3fb618",
info: "#9954bb", warning: "#ff7518", danger: "#ff0039",
light: "#f8f9fa", dark: "#373a3c",
bodyBg: "#ffffff", bodyColor: "#373a3c",
},
},
layoutTree: null,
});

18
lib/builtins/darkly.js Normal file
View file

@ -0,0 +1,18 @@
// Bootswatch-derived dark starter (frozen). See flatly.js for the built-in model.
module.exports = Object.freeze({
id: "builtin:darkly",
name: "Darkly",
engine: "bootstrap5",
tokens: {
$tokensVersion: 1,
colors: {
primary: "#375a7f", secondary: "#444444", success: "#00bc8c",
info: "#3498db", warning: "#f39c12", danger: "#e74c3c",
light: "#adb5bd", dark: "#303030",
bodyBg: "#222222", bodyColor: "#ffffff",
},
components: { linkColor: "#00bc8c" },
},
layoutTree: null,
});

21
lib/builtins/flatly.js Normal file
View file

@ -0,0 +1,21 @@
// Bootswatch-derived starter (frozen). Built-ins live in code, never in the DB:
// upgrades ship new/updated starters with no migration, and a user can never
// delete or rename them. The "builtin:" id namespace cannot collide with the
// uuid v4 theme_ids of stored rows.
module.exports = Object.freeze({
id: "builtin:flatly",
name: "Flatly",
engine: "bootstrap5",
tokens: {
$tokensVersion: 1,
colors: {
primary: "#2c3e50", secondary: "#95a5a6", success: "#18bc9c",
info: "#3498db", warning: "#f39c12", danger: "#e74c3c",
light: "#ecf0f1", dark: "#7b8a8b",
bodyBg: "#ffffff", bodyColor: "#212529",
},
typography: { headingsFontFamily: "'Lato', sans-serif" },
},
layoutTree: null,
});

42
lib/builtins/index.js Normal file
View file

@ -0,0 +1,42 @@
// Built-in theme registry. Starters ship as frozen code, are merged into list()
// at read time, and are never inserted into the Table. They are not deletable,
// renamable, or save-able; "loading" a built-in for edit duplicates it to a
// fresh uuid row (handled in the store/handlers).
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;
}
function isBuiltinId(id) {
return typeof id === "string" && id.startsWith("builtin:");
}
// The starter used as a last-resort successor (e.g. when the active theme is
// deleted and no other stored theme exists).
function defaultId() {
return "builtin:flatly";
}
// Valid, empty CSS served on a transient miss (table not yet created, theme
// missing mid-activate). Never a white-screen; the active theme renders
// unchanged. Always sent with Cache-Control: no-store by the route.
function fallbackCss() {
return "/* theme-builder: fallback (no compiled theme) */\n";
}
module.exports = {
listBuiltins, getBuiltin, get: getBuiltin, isBuiltinId, defaultId, fallbackCss,
};

50
lib/cfgReaders.js Normal file
View file

@ -0,0 +1,50 @@
// lib/cfgReaders.js
// Config readers -- one resolution rule (ARCHITECTURE.md 5.4)
const { getState } = require("@saltcorn/data/db/state");
const { PLUGIN_NAME, ROLE_PUBLIC } = require("./constants");
const { DEFAULT_LAYOUT_TREE } = require("./layoutTree");
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 isLayoutMode(cfg) { return !!cfg?.layoutMode; }
function activeHashHint(cfg, roleId){ return (cfg && cfg.activeHash) || "0"; }
// SYNC resolution of the active layout tree (so wrap() stays synchronous): the
// per-role tree, else the default tree, else the built-in default preset.
// activate.js persists these into cfg alongside the pointer when layout mode is on.
function activeLayoutTree(cfg, roleId) {
const byRole = (cfg && cfg.activeLayoutTreeByRole) || {};
const roleTree = roleId != null ? (byRole[roleId] ?? byRole[String(roleId)]) : null;
return roleTree || (cfg && cfg.activeLayoutTree) || DEFAULT_LAYOUT_TREE;
}
// 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;
}
module.exports = {
getActiveThemeId,
getActiveByRole,
activeThemeIdForRole,
isLayoutMode,
activeHashHint,
activeLayoutTree,
requestRendersViaThemeBuilder,
};

212
lib/compile.js Normal file
View file

@ -0,0 +1,212 @@
// Phase 1 overlay compiler: tokens -> CSS custom-property overlay that loads
// AFTER Bootstrap and wins by cascade order. Pure and NEVER throws (a bad theme
// degrades to empty-but-valid CSS = active theme renders unchanged = no
// white-screen). The same compileTheme doubles as the import robustness check.
const crypto = require("crypto");
const { sanitizeValue, sanitizeSelector } = require("./sanitize");
const { TOKEN_SCHEMA, TOKEN_KEY_RE, MAX_CSS_BYTES } = require("./tokenSchema");
const deepOverlay = require("./deepOverlay");
// Bound the emitted overlay so a pathological theme cannot balloon the output.
const MAX_ROOT_DECLS = 2000;
const MAX_RULES = 500;
// Nested token sections flattened into the kebab-case keys TOKEN_SCHEMA uses.
const FLAT_SECTIONS = ["colors", "typography", "borders", "components"];
function camelToKebab(key) {
return String(key).replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
}
// Merge colors/typography/borders/components into one { kebabKey: value } map.
// `custom` (raw "--var": value) is handled separately by emitOverlayCss.
function flattenTokens(tokens) {
const flat = {};
if (!tokens || typeof tokens !== "object") {
return flat;
}
for (const sec of FLAT_SECTIONS) {
const obj = tokens[sec];
if (!obj || typeof obj !== "object") {
continue;
}
for (const [k, v] of Object.entries(obj)) {
if (v != null) {
flat[camelToKebab(k)] = v;
}
}
}
return flat;
}
// "#ff0000" / "#f00" -> "255, 0, 0" for the --bs-<x>-rgb companion. Returns null
// for non-hex values (the companion is simply not emitted).
function deriveRgb(value) {
if (typeof value !== "string") {
return null;
}
const s = value.trim();
const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(s);
const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(s);
let r;
let g;
let b;
if (m6) {
r = parseInt(m6[1], 16);
g = parseInt(m6[2], 16);
b = parseInt(m6[3], 16);
} else if (m3) {
r = parseInt(m3[1] + m3[1], 16);
g = parseInt(m3[2] + m3[2], 16);
b = parseInt(m3[3] + m3[3], 16);
} else {
return null;
}
return `${r}, ${g}, ${b}`;
}
// Build the overlay. 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.
function emitOverlayCss(tokens) {
const warnings = [];
const rootDecls = [];
const rules = [];
const flat = flattenTokens(tokens);
for (const [key, rawVal] of Object.entries(flat)) {
if (rootDecls.length >= MAX_ROOT_DECLS || rules.length >= MAX_RULES) {
warnings.push("token cap reached; remaining tokens dropped");
break;
}
if (!TOKEN_KEY_RE.test(key)) {
continue;
}
const sv = sanitizeValue(rawVal, warnings);
if (sv == null) {
continue;
}
const schema = TOKEN_SCHEMA[key];
if (schema && schema.kind === "bsvar") {
rootDecls.push(`--bs-${schema.cssVar}: ${sv};`);
if (Array.isArray(schema.derive)) {
const rgb = deriveRgb(sv);
if (rgb != null) {
rootDecls.push(`--bs-${schema.cssVar}-rgb: ${rgb};`);
}
}
} else if (schema && schema.kind === "rule") {
const sel = sanitizeSelector(schema.selector);
if (sel != null) {
rules.push(`${sel}{${schema.prop}: ${sv};}`);
}
} else if (schema && schema.kind === "ignore") {
// known-but-not-emitted (forward-compat)
} else {
// unknown-but-valid token -> harmless passthrough custom property
rootDecls.push(`--tb-${key}: ${sv};`);
}
}
// raw custom CSS variables ("--my-var": "value")
const custom = tokens && tokens.custom;
if (custom && typeof custom === "object") {
for (const [k, v] of Object.entries(custom)) {
if (rootDecls.length >= MAX_ROOT_DECLS) {
break;
}
if (!/^--[-a-zA-Z0-9_]{1,64}$/.test(k)) {
continue;
}
const sv = sanitizeValue(v, warnings);
if (sv != null) {
rootDecls.push(`${k}: ${sv};`);
}
}
}
let css = "";
if (rootDecls.length > 0) {
css += `:root{${rootDecls.join("")}}\n`;
}
if (rules.length > 0) {
css += rules.join("\n") + "\n";
}
// DEEP OVERLAY: recompute the per-component --bs-btn-* variables Bootstrap
// bakes at compile time, so buttons actually recolor under the overlay. A
// plain :root{--bs-primary} override does NOT reach .btn-primary. Opt out
// with tokens.deepOverlay === false. Skipped silently for non-hex colors.
if (!tokens || tokens.deepOverlay !== false) {
const btnCss = deepOverlay.emitButtonRules((tokens && tokens.colors) || {});
if (btnCss) {
css += btnCss + "\n";
}
}
return { css, warnings };
}
// Final structural backstop. Bounds size and verifies balanced braces; on
// failure degrades to an empty-but-valid sentinel rather than emitting CSS that
// could break the whole page.
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{")) || "";
css = (rootLine && Buffer.byteLength(rootLine, "utf8") <= MAX_CSS_BYTES)
? rootLine
: "/* theme-builder: empty (over cap) */";
}
let depth = 0;
let ok = true;
for (const ch of css) {
if (ch === "{") {
depth += 1;
} else if (ch === "}") {
depth -= 1;
if (depth < 0) {
ok = false;
break;
}
}
}
if (!ok || depth !== 0) {
warnings.push("unbalanced braces; emitting empty");
css = "/* theme-builder: empty (invalid) */";
}
return { css, warnings };
}
function contentHash(css) {
return crypto.createHash("sha1").update(css).digest("hex").slice(0, 8);
}
// Pure; never throws. opts.engine "sass" routes to the Phase-2 recompiler
// (lazy-required), anything else to the overlay.
function compileTheme(tokens, opts = {}) {
const engine = opts.engine === "sass" ? "sass" : "overlay";
let out;
try {
out = engine === "sass"
? require("./sassCompile").compileSass(tokens, opts)
: emitOverlayCss(tokens);
} catch (e) {
out = { css: "/* theme-builder: empty (compile error) */", warnings: ["compile threw: " + e.message] };
}
const guarded = robustnessGuard(out.css, out.warnings || []);
return { css: guarded.css, hash: contentHash(guarded.css), warnings: guarded.warnings, engine };
}
module.exports = {
compileTheme, emitOverlayCss, robustnessGuard, contentHash, deriveRgb,
flattenTokens, camelToKebab,
};

112
lib/configWorkflow.js Normal file
View file

@ -0,0 +1,112 @@
// lib/configWorkflow.js
// The presence of configuration_workflow is the single switch that makes every
// facility (headers/routes/layout) a (cfg)=>value factory (ARCHITECTURE.md 3.1/5.1).
// This workflow mounts:
// (1) an informational step linking to the builder SPA (URL_PREFIX + "/editor"), and
// (2) the layoutMode toggle, HARD-BLOCKED unless layout_by_role covers every live
// role so the last-installed fallback is never the live selector and per-request
// overlay suppression stays deterministic (ARCHITECTURE.md 5.7 / 12.3).
const Workflow = require("@saltcorn/data/models/workflow");
const Form = require("@saltcorn/data/models/form");
const Role = require("@saltcorn/data/models/role");
const { getState } = require("@saltcorn/data/db/state");
const { PLUGIN_NAME, URL_PREFIX, CFG } = require("./constants");
const EDITOR_URL = URL_PREFIX + "/editor";
// Every role row in _sc_roles is a "live" role. A role is covered when
// layout_by_role names theme-builder for it (keys are JSON strings, role.id is
// numeric -> normalize to String on both sides). Returns the list of uncovered
// role names; empty means full coverage (ARCHITECTURE.md 5.7 / 12.3).
async function uncoveredRoles() {
const byRole = getState().getConfig("layout_by_role", {}) || {};
const roles = await Role.find({}, { orderBy: "id" });
const out = [];
for (const role of roles) {
const mapped = byRole[role.id] != null ? byRole[role.id] : byRole[String(role.id)];
if (mapped !== PLUGIN_NAME) {
out.push(role.role);
}
}
return out;
}
function configuration_workflow() {
return new Workflow({
steps: [
{
name: "Theme builder",
form: async () =>
new Form({
blurb:
"Design and manage themes in the visual builder, then activate one " +
"from the theme list. The builder opens in a full-page editor.",
fields: [
{
name: "_builder_link",
label: "Open the builder",
input_type: "custom_html",
attributes: {
html:
'<a class="btn btn-primary" target="_blank" rel="noopener" href="' +
EDITOR_URL +
'">Open theme builder &raquo;</a>',
},
},
],
}),
},
{
name: "Layout mode",
form: async () => {
// Resolve coverage up front so the (synchronous) form validator can
// hard-block enabling layoutMode when any live role is uncovered.
const uncovered = await uncoveredRoles();
return new Form({
blurb:
"By default theme-builder only RECOLORS your current theme (a CSS " +
"overlay on top of your active layout). Turn this on to let " +
"theme-builder provide the PAGE LAYOUT itself (navbar / sidebar / " +
"content), making it a selectable site layout. It stays off until " +
"you opt in so that installing the plugin never changes your site's " +
"layout (Saltcorn uses the last-installed layout as the default). " +
"When on, assign theme-builder to roles under 'layout by role'; it " +
"must cover every role before this can be enabled." +
(uncovered.length
? '<div class="alert alert-warning mt-2">Roles not yet mapped to ' +
"theme-builder: " +
uncovered.join(", ") +
". Map them under Settings -> Users and security -> Roles before " +
"enabling layout mode.</div>"
: ""),
fields: [
{
name: CFG.LAYOUT_MODE,
label: "Use theme-builder as the page layout",
type: "Bool",
default: false,
sublabel:
"Hard-blocked unless layout_by_role covers every live role.",
},
],
validator(values) {
if (values[CFG.LAYOUT_MODE] && uncovered.length) {
return (
"Cannot use theme-builder as the page layout yet: assign it to " +
"every role under 'layout by role' first. Uncovered roles: " +
uncovered.join(", ") +
". (This prevents some roles from silently falling back to a " +
"different layout.)"
);
}
},
});
},
},
],
});
}
module.exports = configuration_workflow;

32
lib/constants.js Normal file
View file

@ -0,0 +1,32 @@
// One source of truth for plugin metadata, route paths, table name, caps, and
// config keys. Pure module: imports nothing (so it is safe to require from the
// unit-testable core as well as from the Saltcorn-dependent wiring).
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
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 (live in plugin.configuration):
const CFG = {
ACTIVE: "activeThemeId",
BY_ROLE: "activeByRole",
HASH: "activeHash",
LAYOUT_MODE: "layoutMode",
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,
};

23
lib/cssCache.js Normal file
View file

@ -0,0 +1,23 @@
// lib/cssCache.js
// Per-tenant compiled-CSS cache + cache-busting (ARCHITECTURE.md 7.7)
const db = require("@saltcorn/data/db");
const { compileTheme } = require("./compile");
const themeStore = require("./themeStore");
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); // resolves rows AND builtins (SEAM FIX)
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;
}
module.exports = { getCached, setCached, bustTheme, bustAll, warm };

89
lib/deepOverlay.js Normal file
View file

@ -0,0 +1,89 @@
// The "deep overlay": emit the per-component --bs-btn-* variables that Bootstrap
// 5.3 bakes at Sass compile time, recomputed from the theme's colors -- so solid
// and outline BUTTONS actually recolor under the CSS-variable overlay (a plain
// :root{--bs-primary} override does NOT recolor .btn-primary, whose colors are
// build-time literals). Mirrors scss/_buttons.scss + mixins/_buttons.scss.
const {
parseHexColor, toHex, toRgbString, mix, shade, tint, colorContrast,
} = require("./bootstrapColor");
// The themeable button variants, in Bootstrap order.
const BTN_VARIANTS = ["primary", "secondary", "success", "info", "warning", "danger", "light", "dark"];
// button-variant hover/active shade & tint amounts (scss/_variables.scss).
const A = {
hoverBgShade: 15, hoverBgTint: 15,
hoverBorderShade: 20, hoverBorderTint: 10,
activeBgShade: 20, activeBgTint: 20,
activeBorderShade: 25, activeBorderTint: 10,
};
// One solid .btn-<name> rule. `name` selects the light/dark force-overrides:
// .btn-light -> always SHADE; .btn-dark -> always TINT; else by text color.
function solidButtonRule(name, color) {
const c = parseHexColor(color);
if (!c) {
return "";
}
const textColor = colorContrast(c); // "#ffffff" | "#000000"
const shadeMode = name === "light" ? true : name === "dark" ? false : textColor === "#ffffff";
const hoverBg = shadeMode ? shade(c, A.hoverBgShade) : tint(c, A.hoverBgTint);
const hoverBorder = shadeMode ? shade(c, A.hoverBorderShade) : tint(c, A.hoverBorderTint);
const activeBg = shadeMode ? shade(c, A.activeBgShade) : tint(c, A.activeBgTint);
const activeBorder = shadeMode ? shade(c, A.activeBorderShade) : tint(c, A.activeBorderTint);
const focusRgb = toRgbString(mix(parseHexColor(textColor), c, 0.15));
const bg = toHex(c);
return `.btn-${name}{`
+ `--bs-btn-color:${textColor};--bs-btn-bg:${bg};--bs-btn-border-color:${bg};`
+ `--bs-btn-hover-color:${colorContrast(hoverBg)};--bs-btn-hover-bg:${toHex(hoverBg)};--bs-btn-hover-border-color:${toHex(hoverBorder)};`
+ `--bs-btn-focus-shadow-rgb:${focusRgb};`
+ `--bs-btn-active-color:${colorContrast(activeBg)};--bs-btn-active-bg:${toHex(activeBg)};--bs-btn-active-border-color:${toHex(activeBorder)};`
+ `--bs-btn-disabled-color:${textColor};--bs-btn-disabled-bg:${bg};--bs-btn-disabled-border-color:${bg};`
+ `}`;
}
// One .btn-outline-<name> rule (button-outline-variant): transparent fill that
// inverts to the variant color on hover/active.
function outlineButtonRule(name, color) {
const c = parseHexColor(color);
if (!c) {
return "";
}
const hex = toHex(c);
const onColor = colorContrast(c);
return `.btn-outline-${name}{`
+ `--bs-btn-color:${hex};--bs-btn-border-color:${hex};`
+ `--bs-btn-hover-color:${onColor};--bs-btn-hover-bg:${hex};--bs-btn-hover-border-color:${hex};`
+ `--bs-btn-focus-shadow-rgb:${toRgbString(c)};`
+ `--bs-btn-active-color:${onColor};--bs-btn-active-bg:${hex};--bs-btn-active-border-color:${hex};`
+ `--bs-btn-disabled-color:${hex};--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:${hex};`
+ `}`;
}
// Emit the full deep-overlay CSS for the given color map { primary, secondary,
// ... }. Variants whose value is not a hex color are skipped (the :root overlay
// still sets --bs-<name> for them).
function emitButtonRules(colors) {
if (!colors || typeof colors !== "object") {
return "";
}
const out = [];
for (const name of BTN_VARIANTS) {
const color = colors[name];
if (!color || !parseHexColor(color)) {
continue;
}
out.push(solidButtonRule(name, color));
out.push(outlineButtonRule(name, color));
}
return out.filter(Boolean).join("\n");
}
module.exports = { emitButtonRules, solidButtonRule, outlineButtonRule, BTN_VARIANTS };

54
lib/headers.js Normal file
View file

@ -0,0 +1,54 @@
// lib/headers.js
// headers(cfg) injection + per-role / layout-mode suppression (ARCHITECTURE.md 7.6)
//
// Grounding (real Saltcorn source under packages/):
// - server/wrapper.js:137 -- a header is emitted only when `h.only_if(req) === true`
// (strict equality), so every predicate here returns an explicit boolean.
// - saltcorn-data/db/state.ts:1122-1135 -- only `script` URLs are de-duped; `css`
// headers are not, so distinct per-role ?v/&theme/&role links coexist.
const { CSS_ROUTE } = require("./constants");
const {
getActiveThemeId,
getActiveByRole,
activeHashHint,
isLayoutMode,
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;
// LAYOUT MODE: 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 (isLayoutMode(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 (isLayoutMode(cfg) && requestRendersViaThemeBuilder(req)) return false;
return (req.user?.role_id ?? 100) === role; // MUST strictly return true (wrapper.js:137)
},
});
}
return list;
}
module.exports = { headers };

27
lib/httpUtils.js Normal file
View file

@ -0,0 +1,27 @@
// lib/httpUtils.js
// Admin guard -- JSON-aware (ARCHITECTURE.md 6.4, folded review fix)
//
// Uniform error envelope: { error: { code, message, ...context } }, where
// code in { forbidden, not_found, version_conflict, name_taken, active_theme,
// builtin_immutable, bad_format, too_large, uncompilable,
// bad_request, compile_failed, activation_failed, initializing }.
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 };

81
lib/layout.js Normal file
View file

@ -0,0 +1,81 @@
// lib/layout.js
// layout(cfg) -- Phase-1 returns undefined; Phase-2 returns a real PluginLayout
// (ARCHITECTURE.md 5.1, 7.10). When Phase 2 is enabled the plugin registers into
// state.layouts["theme-builder"] (state.ts:1113-1117) and is selectable via
// layout_by_role[role] === "theme-builder" (or user._attributes.layout).
//
// wrap() OWNS the document: it links the vendored stock Bootstrap CSS + the
// theme overlay (var(--bs-*)) CSS, renders the page chrome from the active
// layoutTree (renderTree.js), and threads Saltcorn's headersInHead/Body. Because
// wrap() emits the token <link> itself, headers(cfg)'s only_if suppresses the
// Phase-1 overlay header for theme-builder-served requests (7.6). Sass is the
// documented opt-in; the default engine is the overlay on stock Bootstrap.
const { PLUGIN_NAME, CSS_ROUTE } = require("./constants");
const { isLayoutMode, activeHashHint, activeLayoutTree } = require("./cfgReaders");
const { renderTreeToHtml, renderToasts, esc } = require("./renderTree");
const { headersInHead, headersInBody } = require("@saltcorn/markup/layout_utils");
const { renderForm } = require("@saltcorn/markup");
const ASSET_BASE = `/plugins/public/${PLUGIN_NAME}`;
const BOOTSTRAP_CSS = `${ASSET_BASE}/themeBootstrap.min.css`;
const BOOTSTRAP_JS = `${ASSET_BASE}/themeBootstrap.bundle.min.js`;
function headLinks(cfg, role, headers) {
const v = activeHashHint(cfg, role);
return `<meta charset="utf-8">`
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`
+ `<link rel="stylesheet" href="${BOOTSTRAP_CSS}">`
+ `<link rel="stylesheet" href="${CSS_ROUTE}?v=${v}">`
+ headersInHead(headers || []);
}
function layout(cfg) {
if (!isLayoutMode(cfg)) {
return undefined; // falsy => not registered as a layout (layout mode off)
}
return {
pluginName: PLUGIN_NAME,
wrap: ({ title, body, brand, menu, alerts, headers, bodyClass, role, req, currentUrl } = {}) => {
const chrome = renderTreeToHtml(activeLayoutTree(cfg, role), {
title, body, brand, menu, alerts: alerts || [], role, req, currentUrl,
});
return `<!doctype html><html lang="en"><head>${headLinks(cfg, role, headers)}`
+ `<title>${esc(title || "")}</title></head>`
+ `<body class="${esc(bodyClass || "")}">${chrome}`
+ `<script src="${BOOTSTRAP_JS}"></script>${headersInBody(headers || [])}`
+ `</body></html>`;
},
// Auth pages (login/signup): a centered card. The login/signup FORM arrives
// as a Saltcorn Form OBJECT under `form` and MUST be rendered via
// renderForm(form, csrfToken) -- it carries the _csrf field. authLinks are
// the login/forgot/signup cross-links; afterForm is extra HTML.
authWrap: ({ title, form, afterForm, authLinks, alerts, headers, csrfToken, role, req } = {}) => {
const links = [];
if (authLinks) {
if (authLinks.login) links.push(`<a href="${esc(authLinks.login)}">Login</a>`);
if (authLinks.forgot) links.push(`<a href="${esc(authLinks.forgot)}">Forgot password?</a>`);
if (authLinks.signup) links.push(`<a href="${esc(authLinks.signup)}">Create an account</a>`);
}
const formHtml = form ? renderForm(form, csrfToken || "") : "";
return `<!doctype html><html lang="en"><head>${headLinks(cfg, role, headers)}`
+ `<title>${esc(title || "")}</title></head>`
+ `<body class="tb-auth"><div class="container" style="max-width:28rem"><div class="py-5">`
+ `<div class="card shadow-sm"><div class="card-body p-4">`
+ `<h1 class="h4 mb-3 text-center">${esc(title || "")}</h1>`
+ `${formHtml}<div class="mt-3 text-center small">${links.join(" | ")}</div>${afterForm || ""}`
+ `</div></div></div></div>${renderToasts(alerts || [])}`
+ `<script src="${BOOTSTRAP_JS}"></script>${headersInBody(headers || [])}`
+ `</body></html>`;
},
// renderBody: passthrough -- the body content is already rendered HTML.
renderBody: ({ body } = {}) => body || "",
};
}
module.exports = { layout };

127
lib/layoutTree.js Normal file
View file

@ -0,0 +1,127 @@
// Phase-2 layout "bones": the layoutTree schema, the structural node-type set
// (the server-side resolver mirror), built-in PRESETS, and validation. A theme's
// layout_tree (stored JSON) is one of these trees; renderTree.js emits HTML from
// it. Pure module -- no @saltcorn, no DB.
//
// A node is { type, props?, children? }. Slots are filled at render time from the
// wrap() context (brand/menu -> Navbar/Sidebar, body -> Content, alerts -> toasts).
// The structural subset every serialized tree may use (resolver completeness:
// renderTree must handle each of these or it falls back to a passthrough).
const NODE_TYPES = ["Root", "Navbar", "Sidebar", "Content", "Footer", "Row", "Col", "Html", "AlertsSlot"];
const MAX_NODES = 200;
const MAX_DEPTH = 12;
// ---- built-in presets ------------------------------------------------------
const TOPNAV = Object.freeze({
type: "Root",
props: { className: "min-vh-100 d-flex flex-column" },
children: [
{ type: "Navbar", props: { variant: "dark", bg: "primary", expand: "lg", brand: true, menu: true, fluid: false } },
{ type: "Content", props: { container: "container" } },
{ type: "Footer", props: { text: "" } },
],
});
const SIDEBAR = Object.freeze({
type: "Root",
props: { className: "min-vh-100 d-flex" },
children: [
{ type: "Sidebar", props: { variant: "dark", bg: "dark", brand: true, menu: true, width: "240px" } },
{ type: "Content", props: { container: "fluid", grow: true } },
],
});
const PRESETS = Object.freeze([
Object.freeze({ id: "topnav", label: "Top navbar", tree: TOPNAV }),
Object.freeze({ id: "sidebar", label: "Left sidebar", tree: SIDEBAR }),
]);
const DEFAULT_LAYOUT_TREE = TOPNAV;
function listPresets() {
return PRESETS.map((p) => ({ id: p.id, label: p.label }));
}
function getPreset(id) {
const p = PRESETS.find((x) => x.id === id);
return p ? structuredClone(p.tree) : null;
}
// ---- validation ------------------------------------------------------------
function isPlainObject(x) {
return !!x && typeof x === "object" && !Array.isArray(x);
}
// Walk the tree counting nodes / Content slots and checking node types + depth.
function walk(node, depth, acc) {
if (!isPlainObject(node)) {
acc.errors.push("node is not an object");
return;
}
acc.count += 1;
if (acc.count > MAX_NODES) {
acc.errors.push("too many nodes");
return;
}
if (depth > MAX_DEPTH) {
acc.errors.push("tree too deep");
return;
}
if (!NODE_TYPES.includes(node.type)) {
acc.errors.push(`unknown node type: ${node.type}`);
}
if (node.type === "Content") {
acc.contentSlots += 1;
}
if (node.children != null) {
if (!Array.isArray(node.children)) {
acc.errors.push(`${node.type}.children must be an array`);
} else {
for (const c of node.children) {
walk(c, depth + 1, acc);
}
}
}
}
// Returns { ok, errors[] }. A valid tree has a Root, exactly one Content slot,
// only known node types, and is within the size/depth caps.
function validateLayoutTree(tree) {
const acc = { errors: [], count: 0, contentSlots: 0 };
if (!isPlainObject(tree)) {
return { ok: false, errors: ["layoutTree must be an object"] };
}
if (tree.type !== "Root") {
acc.errors.push("layoutTree root must be a Root node");
}
walk(tree, 0, acc);
if (acc.contentSlots !== 1) {
acc.errors.push(`exactly one Content slot required (found ${acc.contentSlots})`);
}
return { ok: acc.errors.length === 0, errors: acc.errors };
}
// Return the tree if it is valid, else the default preset (never white-screen).
function normalizeLayoutTree(tree) {
if (tree == null) {
return structuredClone(DEFAULT_LAYOUT_TREE);
}
return validateLayoutTree(tree).ok ? tree : structuredClone(DEFAULT_LAYOUT_TREE);
}
module.exports = {
NODE_TYPES, MAX_NODES, MAX_DEPTH, PRESETS, DEFAULT_LAYOUT_TREE,
listPresets, getPreset, validateLayoutTree, normalizeLayoutTree,
};

35
lib/onLoad.js Normal file
View file

@ -0,0 +1,35 @@
// lib/onLoad.js
// Idempotent per-tenant bootstrap + active-CSS rehydration (ARCHITECTURE.md 5.6).
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 };

112
lib/page.js Normal file
View file

@ -0,0 +1,112 @@
// lib/page.js
// The Builder SPA shell page (ARCHITECTURE.md 8.2).
//
// renderEditorShell(req, res) builds a COMPLETE, dependency-free HTML document
// (no React in the served shell) that:
// (a) loads the compiled Phase-1 editor from the plugin's static public dir,
// /plugins/public/theme-builder/builderApp.js (plugins.js:1246-1276),
// (b) bootstraps window.__TB__ = { apiBase, cssRoute, csrfToken } -- the CSRF
// token is read from req exactly the way core does it (req.csrfToken(),
// e.g. server/wrapper.js:259, server/app.js:74 where it defaults to ""),
// (c) exposes a single mount point <div id="theme-builder-root"> the editor
// attaches to.
//
// The function RETURNS the complete document string; when a res is supplied
// (the apiHandlers.getEditor path) it also res.send()s that document. We send a
// standalone document rather than res.sendWrap()'ing a body fragment so the SPA
// owns the whole page and stays free of the core React chrome -- per the task's
// "COMPLETE HTML document / dependency-free shell" contract.
const { API_BASE, CSS_ROUTE, URL_PREFIX, PLUGIN_NAME } = require("./constants");
// The plugin's committed editor bundle, served by core's static plugin route.
const BUNDLE_URL = `/plugins/public/${PLUGIN_NAME}/builderApp.js`;
// Mirror core's CSRF read: req.csrfToken is a function (csurf adds it; in some
// modes app.js stubs it to return ""). Guard so a missing token never throws.
function readCsrfToken(req) {
if (req && typeof req.csrfToken === "function") {
try {
return req.csrfToken() || "";
} catch (e) {
return "";
}
}
return "";
}
// Minimal HTML-escaper for the <title> and any attribute text. The JSON blob is
// emitted via JSON.stringify with "<" escaped so it cannot break out of the
// <script> element.
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// JSON for inline <script>: prevent "</script>" / "<!--" from terminating the
// element, and keep it ASCII-only.
function safeJson(value) {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/[\u2028]/g, "\\u2028")
.replace(/[\u2029]/g, "\\u2029");
}
// Build the complete HTML document string. openThemeId (?id=) lets a deep-link
// open straight into a theme; the editor reads it from the boot blob.
function buildShellHtml(req) {
const csrfToken = readCsrfToken(req);
const openThemeId = (req && req.query && req.query.id) ? String(req.query.id) : null;
const boot = {
apiBase: API_BASE,
cssRoute: CSS_ROUTE,
base: URL_PREFIX,
csrfToken,
openThemeId,
};
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>${escapeHtml("Theme Builder")}</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
#theme-builder-root { height: 100%; }
#theme-builder-root .tb-loading { padding: 2rem; color: #555; }
</style>
</head>
<body>
<div id="theme-builder-root"><div class="tb-loading">Loading Theme Builder...</div></div>
<script>window.__TB__ = ${safeJson(boot)};</script>
<script src="${BUNDLE_URL}" defer></script>
</body>
</html>
`;
}
// Public entry point. Returns the document string; if res is provided, sends it.
function renderEditorShell(req, res) {
const html = buildShellHtml(req);
if (res && typeof res.send === "function") {
res.set("Content-Type", "text/html; charset=utf-8");
res.send(html);
}
return html;
}
module.exports = { renderEditorShell, buildShellHtml, BUNDLE_URL };

119
lib/portability.js Normal file
View file

@ -0,0 +1,119 @@
// Export/import as a versioned, self-describing JSON envelope. Import is
// admin-only and performs NO security sanitization (an admin already owns site
// CSS/JS via page_custom_css / custom headers / plugin install -- see README
// SECURITY). Only four ROBUSTNESS checks run: size cap, format+version upcast,
// shape validation, and a "won't white-screen" compile check.
//
// CONTINGENCY: if a non-admin role is ever allowed to import/manage themes,
// real sanitization (CSS/selector/@import/url()/expression filtering, layoutTree
// resolver allow-listing) MUST be reintroduced before that capability ships.
const {
SCHEMA_ID, FORMAT_VERSION, ENGINE, MAX_IMPORT_BYTES, MAX_TOKENS,
} = require("./constants");
const { normalizeTokens, tokensAreValid } = require("./themeSchema");
const { compileTheme } = require("./compile");
class ImportError extends Error {}
// The export envelope carries NO id, version, timestamps, or compiled CSS --
// identity is instance-local (import mints fresh), so import is structurally
// non-destructive.
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;
}
// Envelope-axis upcasters (formatVersion). The token-shape axis
// (tokens.$tokensVersion) is handled separately by normalizeTokens.
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;
let 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;
}
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,
},
};
}
module.exports = { ImportError, buildEnvelope, upcastEnvelope, FORMAT_UPCASTERS, parseEnvelope };

154
lib/renderTree.js Normal file
View file

@ -0,0 +1,154 @@
// Phase-2 server-side tree -> HTML emitter. Walks a layoutTree (see layoutTree.js)
// and produces the page chrome, filling slots from the wrap() context:
// brand/menu -> Navbar/Sidebar, body -> Content, alerts -> toasts.
//
// body/menu/brand are server-generated HTML from Saltcorn (trusted) and are NOT
// escaped; only admin/user TEXT (brand name, footer text) is escaped. Pure module.
const { normalizeLayoutTree } = require("./layoutTree");
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function bsAlertType(t) {
const map = { error: "danger", danger: "danger", success: "success", warning: "warning", info: "info", primary: "primary" };
return map[t] || "info";
}
function renderBrand(brand) {
if (!brand) {
return "";
}
if (typeof brand === "string") {
return brand; // already HTML
}
const logo = brand.logo ? `<img src="${esc(brand.logo)}" height="30" class="me-2" alt="">` : "";
return `<a class="navbar-brand" href="/">${logo}${esc(brand.name || "")}</a>`;
}
// Render Saltcorn's menu array into Bootstrap nav markup. Defensive about shape:
// menu is an array of sections, each with an `items` array (or an item itself).
function renderMenu(menu, variant, ctx) {
if (!Array.isArray(menu) || menu.length === 0) {
return "";
}
const ulClass = variant === "sidebar" ? "nav flex-column" : "navbar-nav me-auto mb-2 mb-lg-0";
const items = [];
for (const section of menu) {
const list = Array.isArray(section?.items) ? section.items : [section];
for (const it of list) {
if (!it || (it.label == null && it.link == null)) {
continue;
}
const icon = it.icon ? `<i class="${esc(it.icon)}"></i> ` : "";
const active = it.link && it.link === ctx.currentUrl ? " active" : "";
if (Array.isArray(it.subitems) && it.subitems.length) {
const sub = it.subitems
.filter((s) => s && (s.label != null || s.link != null))
.map((s) => `<li><a class="dropdown-item" href="${esc(s.link || "#")}">${esc(s.label || "")}</a></li>`)
.join("");
items.push(
`<li class="nav-item dropdown"><a class="nav-link dropdown-toggle${active}" href="#" role="button" data-bs-toggle="dropdown">${icon}${esc(it.label || "")}</a><ul class="dropdown-menu">${sub}</ul></li>`
);
} else {
items.push(`<li class="nav-item"><a class="nav-link${active}" href="${esc(it.link || "#")}">${icon}${esc(it.label || "")}</a></li>`);
}
}
}
return `<ul class="${ulClass}">${items.join("")}</ul>`;
}
function renderToasts(alerts) {
if (!Array.isArray(alerts) || alerts.length === 0) {
return "";
}
const items = alerts
.map((a) => `<div class="alert alert-${bsAlertType(a.type)} shadow-sm" role="alert">${a.msg || ""}</div>`)
.join("");
return `<div class="tb-alerts position-fixed top-0 end-0 p-3" style="z-index:1080;max-width:90vw">${items}</div>`;
}
function renderChildren(node, ctx) {
if (!Array.isArray(node.children)) {
return "";
}
return node.children.map((c) => renderNode(c, ctx)).join("");
}
function renderNode(node, ctx) {
const p = node.props || {};
switch (node.type) {
case "Root":
return `<div id="tb-root" class="${esc(p.className || "")}">${renderChildren(node, ctx)}</div>`;
case "Navbar": {
const variantClass = p.variant === "light" ? "navbar-light" : "navbar-dark";
const expand = `navbar-expand-${p.expand || "lg"}`;
const bg = p.bg ? `bg-${esc(p.bg)}` : "";
const brand = p.brand ? renderBrand(ctx.brand) : "";
const menu = p.menu
? `<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#tbNav" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>`
+ `<div class="collapse navbar-collapse" id="tbNav">${renderMenu(ctx.menu, "navbar", ctx)}</div>`
: "";
return `<nav class="navbar ${expand} ${variantClass} ${bg}"><div class="container${p.fluid ? "-fluid" : ""}">${brand}${menu}</div></nav>`;
}
case "Sidebar": {
const variantClass = p.variant === "light" ? "navbar-light" : "navbar-dark";
const bg = p.bg ? `bg-${esc(p.bg)}` : "bg-dark";
const brand = p.brand ? `<div class="mb-3">${renderBrand(ctx.brand)}</div>` : "";
const menu = p.menu ? renderMenu(ctx.menu, "sidebar", ctx) : "";
return `<aside class="tb-sidebar ${variantClass} ${bg} p-3" style="width:${esc(p.width || "240px")};min-height:100vh">${brand}${menu}</aside>`;
}
case "Content": {
ctx._bodyEmitted = true;
const container = p.container === "fluid" ? "container-fluid" : "container";
const grow = p.grow ? " flex-grow-1" : "";
return `<main class="tb-content${grow}" style="min-width:0"><div class="${container} py-3">${ctx.body || ""}</div></main>`;
}
case "Footer": {
const inner = p.html != null ? String(p.html) : esc(p.text || "");
return `<footer class="tb-footer border-top py-3 mt-auto text-center text-muted small"><div class="container">${inner}</div></footer>`;
}
case "Row":
return `<div class="row ${esc(p.className || "")}">${renderChildren(node, ctx)}</div>`;
case "Col":
return `<div class="col ${esc(p.className || "")}">${renderChildren(node, ctx)}</div>`;
case "Html":
return p.html != null ? String(p.html) : "";
case "AlertsSlot":
ctx._alertsEmitted = true;
return renderToasts(ctx.alerts);
default:
// resolver fallback: render children so an unknown wrapper never drops the body
return renderChildren(node, ctx);
}
}
// renderTreeToHtml(tree, ctx) -> HTML string. ctx = {title, body, brand, menu,
// alerts, role, req, currentUrl}. Guarantees the body and alerts are emitted even
// if the tree omits a Content/AlertsSlot node (never silently drop page content).
function renderTreeToHtml(tree, ctx) {
const t = normalizeLayoutTree(tree);
const c = { ...ctx, _bodyEmitted: false, _alertsEmitted: false };
let html = renderNode(t, c);
if (!c._bodyEmitted) {
html += `<div class="container py-3">${ctx.body || ""}</div>`;
}
if (!c._alertsEmitted) {
html += renderToasts(ctx.alerts);
}
return html;
}
module.exports = { renderTreeToHtml, renderMenu, renderBrand, renderToasts, esc };

23
lib/routes.js Normal file
View file

@ -0,0 +1,23 @@
// lib/routes.js
// routes(cfg) factory (ARCHITECTURE.md 6.3)
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 },
{ url: `${API_BASE}/preview-css`, method: "post", callback: h.previewCss }, // editor WYSIWYG (no save/activate)
];
}
module.exports = { routes };

57
lib/sanitize.js Normal file
View file

@ -0,0 +1,57 @@
// Robustness guards shared by compile.js (overlay) AND sassCompile.js (Phase 2).
// NOT a security boundary (see README SECURITY section): an admin can already
// inject site CSS/JS by other means. These exist solely so a single malformed
// token value cannot white-screen every page -- a value that could close a CSS
// block early ("}"), terminate a declaration (";"), or open/close a comment is
// DROPPED from output (not escaped), confining each token to its own declaration.
const { MAX_VALUE_LEN } = require("./tokenSchema");
// Substrings that would let a value escape its own declaration/block.
const BAD_SUBSTRINGS = ["{", "}", ";", "</", "/*", "*/"];
// Returns the value unchanged if safe to emit, or null if it must be dropped.
// When dropped, pushes a human-readable note onto the optional warnings array.
function sanitizeValue(value, warnings) {
if (value == null) {
return null;
}
const v = String(value);
if (v.length > MAX_VALUE_LEN) {
if (warnings) {
warnings.push(`value dropped (too long): ${v.slice(0, 24)}...`);
}
return null;
}
for (const bad of BAD_SUBSTRINGS) {
if (v.includes(bad)) {
if (warnings) {
warnings.push(`value dropped (illegal "${bad}")`);
}
return null;
}
}
return v;
}
// Returns a conservative selector unchanged, or null if it could break out of a
// rule block. Selectors come only from TOKEN_SCHEMA (trusted), but we guard
// anyway so a future data-driven selector cannot escape.
function sanitizeSelector(selector) {
if (selector == null) {
return null;
}
const s = String(selector);
if (s.length > 256) {
return null;
}
if (/[{};<>]/.test(s) || s.includes("/*") || s.includes("*/")) {
return null;
}
return s;
}
module.exports = { sanitizeValue, sanitizeSelector };

41
lib/sassCompile.js Normal file
View file

@ -0,0 +1,41 @@
// Phase 2 dart-sass recompile (ARCHITECTURE.md 7.5). Lazy and defensive: the
// `sass` dependency and the vendored Bootstrap SCSS under ../scss are Phase-2
// only, so a missing dep or compile error degrades to the Phase-1 overlay
// rather than white-screening. Only ever reached for engine:"sass" themes.
const path = require("path");
const { sanitizeValue } = require("./sanitize"); // shared guard (folded fix)
function compileSass(tokens, opts = {}) {
const warnings = [];
try {
const sass = require("sass"); // lazy; Phase-2 dep
const flat = require("./compile").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");
// relative module id resolved via loadPaths; no absolute @import string.
const entry = `@use "bootstrap/bootstrap" with (\n${varBlock}\n);\n`;
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 unavailable/failed: " + e.message);
// fall back to the overlay; never white-screen.
const overlay = require("./compile").emitOverlayCss(tokens);
return { css: overlay.css, warnings: warnings.concat(overlay.warnings || []) };
}
}
module.exports = { compileSass };

101
lib/themeSchema.js Normal file
View file

@ -0,0 +1,101 @@
// Token shape: defaults, deep-merge-on-read normalization, and validation.
// Pure module (only depends on constants for the token-count cap). `tokens` is
// the SINGLE SOURCE OF TRUTH for a theme; every read passes through
// normalizeTokens so the compiler always receives a fully-populated, current
// shape (a sparse/empty theme can never white-screen).
const { MAX_TOKENS } = require("./constants");
const DEFAULT_TOKENS = Object.freeze({
$tokensVersion: 1,
colors: {
primary: "#0d6efd", secondary: "#6c757d", success: "#198754",
info: "#0dcaf0", warning: "#ffc107", danger: "#dc3545",
light: "#f8f9fa", dark: "#212529",
bodyBg: "#ffffff", bodyColor: "#212529",
},
typography: {
rootFontSize: "16px", bodyFontSize: "1rem",
bodyFontWeight: "400", bodyLineHeight: "1.5",
},
borders: { borderRadius: "0.375rem", borderWidth: "1px" },
components: {},
custom: {},
});
function isPlainObject(x) {
return !!x && typeof x === "object" && !Array.isArray(x);
}
// Recursive merge; `over` wins. Non-object values are replaced wholesale.
function deepMerge(base, over) {
const out = isPlainObject(base) ? { ...base } : {};
if (!isPlainObject(over)) {
return out;
}
for (const [k, v] of Object.entries(over)) {
if (isPlainObject(v) && isPlainObject(out[k])) {
out[k] = deepMerge(out[k], v);
} else {
out[k] = v;
}
}
return out;
}
// The single chokepoint every token read passes through. v1: validate + deep-
// merge over DEFAULT_TOKENS (no transform yet). Future: a TOKEN_UPCASTERS chain
// keyed on $tokensVersion can be added here, mirroring the envelope upcaster.
function normalizeTokens(raw) {
const merged = deepMerge(structuredClone(DEFAULT_TOKENS), isPlainObject(raw) ? raw : {});
merged.$tokensVersion = 1;
return merged;
}
function emptyTokens() {
return structuredClone(DEFAULT_TOKENS);
}
// Shape check used by import. Returns { ok, errors[] }. Every leaf under a known
// section must be a string; the token count is capped.
function tokensAreValid(tokens) {
const errors = [];
if (!isPlainObject(tokens)) {
return { ok: false, errors: ["tokens must be an object"] };
}
if (tokens.$tokensVersion != null && typeof tokens.$tokensVersion !== "number") {
errors.push("$tokensVersion must be a number");
}
if (tokens.sass != null && typeof tokens.sass !== "string") {
errors.push("sass must be a string");
}
let leafCount = 0;
for (const sec of ["colors", "typography", "borders", "components", "custom"]) {
const obj = tokens[sec];
if (obj == null) {
continue;
}
if (!isPlainObject(obj)) {
errors.push(`${sec} must be an object`);
continue;
}
for (const [k, v] of Object.entries(obj)) {
leafCount += 1;
if (typeof v !== "string") {
errors.push(`${sec}.${k} must be a string`);
}
}
}
if (leafCount > MAX_TOKENS) {
errors.push("too many tokens");
}
return { ok: errors.length === 0, errors };
}
module.exports = { DEFAULT_TOKENS, deepMerge, normalizeTokens, emptyTokens, tokensAreValid };

308
lib/themeStore.js Normal file
View file

@ -0,0 +1,308 @@
// The ONLY module that touches the themes Table or treats builtins as themes.
// All theme persistence (per-tenant Table CRUD) and the builtins-as-themes merge
// live here; everything above this layer speaks the canonical ThemeDTO and never
// sees a raw row. See ARCHITECTURE.md 4.1 (schema), 4.2 (builtins merge),
// 4.6 (multi-tenant), 6.6 (save CAS + delete semantics).
//
// ThemeDTO:
// { id, name, engine, tokens(object, normalized), layoutTree(object|null),
// version(>=1 rows, 0 builtins), builtin(bool), created_at?, updated_at? }
const crypto = require("crypto");
const db = require("@saltcorn/data/db");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const { THEME_TABLE, ENGINE } = require("./constants");
const { normalizeTokens } = require("./themeSchema");
const builtins = require("./builtins");
// --- column model (ARCHITECTURE.md 4.1) ------------------------------------
// id (serial pk) is created automatically by Table.create; the stable theme
// identity is the separate theme_id String column. JSON columns are String
// (text) because the base plugin registers no JSON type; the store does the
// stringify/parse itself.
const FIELD_DEFS = [
{ name: "theme_id", type: "String", required: true, is_unique: true },
{ name: "name", type: "String", required: true },
{ name: "engine", type: "String", required: true },
{ name: "tokens", type: "String", required: true },
{ name: "layout_tree", type: "String", required: false },
{ name: "version", type: "Integer", required: true },
{ name: "created_at", type: "Date", required: true },
{ name: "updated_at", type: "Date", required: true },
];
function nowIso() {
return new Date().toISOString();
}
// Coerce a stored timestamp (Date on PG, text on SQLite) to an ISO string.
function toIso(v) {
if (v == null) {
return undefined;
}
if (v instanceof Date) {
return v.toISOString();
}
return new Date(v).toISOString();
}
// Normalize a builtin's frozen definition into the canonical ThemeDTO. Builtins
// carry version 0, builtin:true, and no timestamps.
function builtinToTheme(b) {
return {
id: b.id,
name: b.name,
engine: b.engine || ENGINE,
tokens: normalizeTokens(b.tokens),
layoutTree: b.layoutTree || null,
version: 0,
builtin: true,
};
}
// Create the per-tenant Table once. Cheap fast-path when it already exists.
// All CRUD is tenant-implicit (the Table model schema-qualifies from
// async-local storage); ensureTable is iterated per tenant by onLoad.
async function ensureTableForCurrentTenant() {
const existing = Table.findOne({ name: THEME_TABLE });
if (existing) {
return existing;
}
const table = await Table.create(THEME_TABLE, { min_role_read: 1, min_role_write: 1 });
for (const f of FIELD_DEFS) {
await Field.create({
table: table,
name: f.name,
label: f.name,
type: f.type,
required: !!f.required,
is_unique: !!f.is_unique,
});
}
// Namespacing makes a collision impossible (row ids are uuid v4, builtin
// ids are "builtin:<slug>"); assert it so a stored "builtin:" id can never
// shadow a code-shipped builtin in list()/getById().
const rows = await db.select(THEME_TABLE, {});
const clash = rows.find((r) => typeof r.theme_id === "string" && r.theme_id.startsWith("builtin:"));
if (clash) {
throw new Error(`themeStore: stored theme_id may not start with "builtin:" (${clash.theme_id})`);
}
return table;
}
// Raw row (stored theme) -> canonical ThemeDTO. JSON columns are parsed here;
// tokens always pass through normalizeTokens so the compiler sees a current,
// fully-populated shape. builtin:false for every stored row.
function rowToTheme(row) {
let tokens = {};
try {
tokens = row.tokens ? JSON.parse(row.tokens) : {};
} catch (e) {
tokens = {};
}
let layoutTree = null;
if (row.layout_tree) {
try {
layoutTree = JSON.parse(row.layout_tree);
} catch (e) {
layoutTree = null;
}
}
return {
id: row.theme_id,
name: row.name,
engine: row.engine || ENGINE,
tokens: normalizeTokens(tokens),
layoutTree: layoutTree,
version: row.version,
builtin: false,
created_at: toIso(row.created_at),
updated_at: toIso(row.updated_at),
};
}
// All themes visible to the admin: stored rows PLUS the code-shipped builtins.
// Builtins are merged at read time so they appear even on a fresh, empty install.
async function list() {
const rows = await db.select(THEME_TABLE, {}, { orderBy: "name" });
const stored = rows.map(rowToTheme);
const builtinThemes = builtins.listBuiltins().map(builtinToTheme);
return [...stored, ...builtinThemes];
}
// Resolve a theme by its public id. SEAM FIX: builtin ids resolve too, so
// getThemeCss / cssCache.warm / activate handle ANY active theme uniformly
// (activating a builtin is allowed; ARCHITECTURE.md 4.2).
async function getById(id) {
if (builtins.isBuiltinId(id)) {
const b = builtins.get(id);
return b ? builtinToTheme(b) : null;
}
const row = await db.selectMaybeOne(THEME_TABLE, { theme_id: id });
return row ? rowToTheme(row) : null;
}
// App-enforced name uniqueness (no DB constraint). Returns the input name if
// free, else the first available "Name (2)", "Name (3)", ... suffix.
async function dedupName(name) {
const base = (name == null ? "" : String(name)).trim() || "Untitled";
const rows = await db.select(THEME_TABLE, {});
const taken = new Set(rows.map((r) => r.name));
if (!taken.has(base)) {
return base;
}
let n = 2;
while (taken.has(`${base} (${n})`)) {
n += 1;
}
return `${base} (${n})`;
}
// Insert a fresh stored theme (uuid theme_id, version 1, timestamps).
async function create({ name, engine, tokens, layoutTree }) {
const themeId = crypto.randomUUID();
const finalName = await dedupName(name);
const ts = nowIso();
await db.insert(THEME_TABLE, {
theme_id: themeId,
name: finalName,
engine: engine || ENGINE,
tokens: JSON.stringify(normalizeTokens(tokens)),
layout_tree: layoutTree == null ? null : JSON.stringify(layoutTree),
version: 1,
created_at: ts,
updated_at: ts,
});
return getById(themeId);
}
// Copy an existing theme (stored OR builtin) into a fresh stored row. This is
// the "load a builtin for edit = duplicate-to-edit" path.
async function duplicate(id, name) {
const src = await getById(id);
if (!src) {
return null;
}
const dupName = name != null ? name : `${src.name} copy`;
return create({
name: dupName,
engine: src.engine,
tokens: src.tokens,
layoutTree: src.layoutTree,
});
}
// Positional placeholder for raw parameterized SQL ("$1" on PG, "?" on SQLite).
// mkWhere is dialect-aware but cannot express version=version+1, so the two
// version-mutating updates issue raw SQL and pick the placeholder here.
function ph(n) {
return db.isSQLite ? "?" : `$${n}`;
}
// Optimistic compare-and-swap on version: UPDATE ... WHERE theme_id=$ AND
// version=$base, SET version=version+1. Returns the updated ThemeDTO, or null
// when no row matched (stale baseVersion / unknown id) so the handler can 409.
// RETURNING + rows.length is the matched-row count (portable; rowCount is
// PG-only and absent from the SQLite driver's {rows} result).
async function casUpdate(id, baseVersion, { tokens, layoutTree }) {
const schema = db.getTenantSchemaPrefix();
const ts = nowIso();
const sql = `update ${schema}"${THEME_TABLE}" `
+ `set tokens=${ph(1)}, layout_tree=${ph(2)}, version=version+1, updated_at=${ph(3)} `
+ `where theme_id=${ph(4)} and version=${ph(5)} returning theme_id`;
const params = [
JSON.stringify(normalizeTokens(tokens)),
layoutTree == null ? null : JSON.stringify(layoutTree),
ts,
id,
baseVersion,
];
const result = await db.query(sql, params);
if (!result || !result.rows || result.rows.length === 0) {
return null;
}
return getById(id);
}
// Unconditional save (force overwrite): bumps version, ignores baseVersion.
async function forceUpdate(id, { tokens, layoutTree }) {
const schema = db.getTenantSchemaPrefix();
const ts = nowIso();
const sql = `update ${schema}"${THEME_TABLE}" `
+ `set tokens=${ph(1)}, layout_tree=${ph(2)}, version=version+1, updated_at=${ph(3)} `
+ `where theme_id=${ph(4)}`;
await db.query(sql, [
JSON.stringify(normalizeTokens(tokens)),
layoutTree == null ? null : JSON.stringify(layoutTree),
ts,
id,
]);
return getById(id);
}
// Rename: name only, app-enforced uniqueness, NO version bump (rename does not
// touch the optimistic-lock counter; ARCHITECTURE.md 6.7).
async function rename(id, name) {
const finalName = await dedupName(name);
const ts = nowIso();
await db.updateWhere(THEME_TABLE, { name: finalName, updated_at: ts }, { theme_id: id });
return getById(id);
}
// Delete a stored theme by theme_id. Returns whether a row was removed.
async function remove(id) {
const before = await db.selectMaybeOne(THEME_TABLE, { theme_id: id });
if (!before) {
return false;
}
await db.deleteWhere(THEME_TABLE, { theme_id: id });
return true;
}
// First stored theme that is NOT id, used as a delete-of-active successor.
// Returns a ThemeDTO or null (caller falls back to builtins.defaultId()).
async function firstOther(id) {
const rows = await db.select(THEME_TABLE, {}, { orderBy: "name" });
for (const r of rows) {
if (r.theme_id !== id) {
return rowToTheme(r);
}
}
return null;
}
module.exports = {
ensureTableForCurrentTenant,
rowToTheme,
list,
getById,
create,
duplicate,
casUpdate,
forceUpdate,
rename,
remove,
firstOther,
dedupName,
};

43
lib/tokenSchema.js Normal file
View file

@ -0,0 +1,43 @@
// The single server-side source of truth for HOW a token maps to CSS. Kept
// structurally parallel to the SPA TokenManifest. Pure data + caps; no imports.
//
// kind:"bsvar" -> :root { --bs-<cssVar>: <value>; } (+ optional --bs-<x>-rgb companion)
// kind:"rule" -> a targeted selector { prop: value; } for things --bs-* can't reach
// kind:"ignore" -> known-but-not-emitted (forward-compat)
//
// Keys are the FLATTENED, kebab-case token names (see compile.flattenTokens,
// which camel->kebab maps the nested tokens object onto these keys).
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"] },
info: { kind: "bsvar", cssVar: "info", derive: ["info-rgb"] },
warning: { kind: "bsvar", cssVar: "warning", derive: ["warning-rgb"] },
danger: { kind: "bsvar", cssVar: "danger", derive: ["danger-rgb"] },
light: { kind: "bsvar", cssVar: "light", derive: ["light-rgb"] },
dark: { kind: "bsvar", cssVar: "dark", derive: ["dark-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" },
"font-monospace": { kind: "bsvar", cssVar: "font-monospace" },
"root-font-size": { kind: "bsvar", cssVar: "root-font-size" },
"body-font-size": { kind: "bsvar", cssVar: "body-font-size" },
"body-font-weight":{ kind: "bsvar", cssVar: "body-font-weight" },
"body-line-height":{ kind: "bsvar", cssVar: "body-line-height" },
"headings-font-family": { kind: "bsvar", cssVar: "headings-font-family" },
"border-radius": { kind: "bsvar", cssVar: "border-radius" },
"border-width": { kind: "bsvar", cssVar: "border-width" },
"border-color": { kind: "bsvar", cssVar: "border-color" },
"link-color": { kind: "bsvar", cssVar: "link-color", derive: ["link-color-rgb"] },
"link-hover-color":{ kind: "bsvar", cssVar: "link-hover-color" },
"navbar-bg": { kind: "rule", selector: ".navbar", prop: "--bs-navbar-bg" },
"card-bg": { kind: "rule", selector: ".card", prop: "--bs-card-bg" },
"sidebar-bg": { kind: "rule", selector: ".sidebar, #accordionSidebar", prop: "background-color" },
};
const TOKEN_KEY_RE = /^[-a-zA-Z0-9_]{1,64}$/;
const MAX_VALUE_LEN = 2048;
const MAX_CSS_BYTES = 512 * 1024;
module.exports = { TOKEN_SCHEMA, TOKEN_KEY_RE, MAX_VALUE_LEN, MAX_CSS_BYTES };

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"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/*.test.js",
"test:integration": "./test/runIntegration.sh",
"build:ui": "webpack --config builder/webpack.config.js --mode production",
"watch:ui": "webpack --config builder/webpack.config.js --watch --mode development"
},
"engines": { "node": ">=20" },
"comment": "@saltcorn/* are provided by the host at runtime (see dev-deploy/idp convention) and are intentionally NOT declared as dependencies. Phase 1 has no third-party runtime deps; Phase 2 adds sass + bootstrap.",
"dependencies": {},
"devDependencies": {},
"author": "Scott Duensing",
"license": "MIT"
}

1346
public/builderApp.js Normal file

File diff suppressed because it is too large Load diff

7
public/themeBootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
public/themeBootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

46
test/builtins.test.js Normal file
View file

@ -0,0 +1,46 @@
const { test } = require("node:test");
const assert = require("node:assert");
const builtins = require("../lib/builtins");
const { compileTheme } = require("../lib/compile");
const { normalizeTokens } = require("../lib/themeSchema");
test("listBuiltins returns frozen starters with namespaced ids", () => {
const list = builtins.listBuiltins();
assert.ok(list.length >= 3);
for (const t of list) {
assert.ok(builtins.isBuiltinId(t.id), `${t.id} is namespaced`);
assert.ok(Object.isFrozen(t));
assert.equal(t.engine, "bootstrap5");
}
});
test("getBuiltin / get resolve by id, default is a real builtin", () => {
assert.equal(builtins.getBuiltin("builtin:flatly").name, "Flatly");
assert.equal(builtins.get("builtin:darkly").name, "Darkly");
assert.equal(builtins.getBuiltin("builtin:nope"), null);
assert.ok(builtins.getBuiltin(builtins.defaultId()));
});
test("isBuiltinId distinguishes builtins from uuid rows", () => {
assert.equal(builtins.isBuiltinId("builtin:flatly"), true);
assert.equal(builtins.isBuiltinId("ab12cd34-..."), false);
assert.equal(builtins.isBuiltinId(undefined), false);
});
test("fallbackCss is valid, brace-free CSS", () => {
const css = builtins.fallbackCss();
assert.equal(typeof css, "string");
assert.doesNotMatch(css, /[{}]/);
});
test("every builtin compiles to balanced CSS", () => {
for (const t of builtins.listBuiltins()) {
const out = compileTheme(normalizeTokens(t.tokens));
assert.ok(!out.warnings.some((w) => /unbalanced|invalid/.test(w)), `${t.id} compiles cleanly`);
}
});

74
test/compile.test.js Normal file
View file

@ -0,0 +1,74 @@
const { test } = require("node:test");
const assert = require("node:assert");
const { compileTheme, emitOverlayCss, robustnessGuard, contentHash, deriveRgb } = require("../lib/compile");
const { normalizeTokens } = require("../lib/themeSchema");
function bracesBalanced(css) {
let depth = 0;
for (const ch of css) {
if (ch === "{") depth += 1;
else if (ch === "}") { depth -= 1; if (depth < 0) return false; }
}
return depth === 0;
}
test("compileTheme emits --bs-* overlay with derived rgb companion", () => {
const out = compileTheme(normalizeTokens({ colors: { primary: "#ff0000" } }));
assert.match(out.css, /--bs-primary: #ff0000;/);
assert.match(out.css, /--bs-primary-rgb: 255, 0, 0;/);
assert.equal(out.engine, "overlay");
assert.ok(bracesBalanced(out.css));
});
test("compileTheme NEVER throws and is balanced even for a brace-injecting token", () => {
// A value containing "}" would close :root{} early -> it must be DROPPED.
const out = compileTheme(normalizeTokens({ colors: { primary: "#fff} body{display:none" } }));
assert.ok(bracesBalanced(out.css), "braces stay balanced");
assert.doesNotMatch(out.css, /display:none/);
assert.ok(out.warnings.length >= 1);
});
test("targeted rule tokens emit their own block", () => {
const out = emitOverlayCss(normalizeTokens({ components: { navbarBg: "#101010" } }));
assert.match(out.css, /\.navbar\{--bs-navbar-bg: #101010;\}/);
assert.ok(bracesBalanced(out.css));
});
test("empty/default theme compiles to valid non-white-screen CSS", () => {
const out = compileTheme(normalizeTokens({}));
assert.ok(bracesBalanced(out.css));
assert.match(out.css, /:root\{/);
});
test("custom raw vars pass through, malformed keys are skipped", () => {
const out = emitOverlayCss(normalizeTokens({ custom: { "--my-var": "10px", "bad key": "x" } }));
assert.match(out.css, /--my-var: 10px;/);
assert.doesNotMatch(out.css, /bad key/);
});
test("deriveRgb handles #rgb, #rrggbb, and rejects non-hex", () => {
assert.equal(deriveRgb("#fff"), "255, 255, 255");
assert.equal(deriveRgb("#0d6efd"), "13, 110, 253");
assert.equal(deriveRgb("rebeccapurple"), null);
});
test("robustnessGuard collapses unbalanced CSS to an empty sentinel", () => {
const g = robustnessGuard(":root{--x: 1;", []);
assert.match(g.css, /empty \(invalid\)/);
});
test("contentHash is stable and 8 hex chars", () => {
const a = contentHash(":root{--bs-primary: #000;}");
const b = contentHash(":root{--bs-primary: #000;}");
assert.equal(a, b);
assert.match(a, /^[0-9a-f]{8}$/);
});

72
test/deepOverlay.test.js Normal file
View file

@ -0,0 +1,72 @@
const { test } = require("node:test");
const assert = require("node:assert");
const { colorContrast, shade, tint, toHex, parseHexColor } = require("../lib/bootstrapColor");
const { solidButtonRule, outlineButtonRule, emitButtonRules } = require("../lib/deepOverlay");
// Ground truth extracted from the vendored Bootstrap 5.3.3 compiled CSS:
// each variant -> { bg, color, hoverBg, activeBorder }.
const ORACLES = {
primary: { bg: "#0d6efd", color: "#ffffff", hoverBg: "#0b5ed7", activeBorder: "#0a53be" },
secondary: { bg: "#6c757d", color: "#ffffff", hoverBg: "#5c636a", activeBorder: "#51585e" },
success: { bg: "#198754", color: "#ffffff", hoverBg: "#157347", activeBorder: "#13653f" },
info: { bg: "#0dcaf0", color: "#000000", hoverBg: "#31d2f2", activeBorder: "#25cff2" },
warning: { bg: "#ffc107", color: "#000000", hoverBg: "#ffca2c", activeBorder: "#ffc720" },
danger: { bg: "#dc3545", color: "#ffffff", hoverBg: "#bb2d3b", activeBorder: "#a52834" },
light: { bg: "#f8f9fa", color: "#000000", hoverBg: "#d3d4d5", activeBorder: "#babbbc" },
dark: { bg: "#212529", color: "#ffffff", hoverBg: "#424649", activeBorder: "#373b3e" },
};
test("colorContrast matches Bootstrap's --bs-btn-color for all 8 variants", () => {
for (const [name, o] of Object.entries(ORACLES)) {
assert.equal(colorContrast(o.bg), o.color, `${name} (${o.bg})`);
}
});
test("solid .btn-<variant> rules match Bootstrap's compiled hover/active colors", () => {
for (const [name, o] of Object.entries(ORACLES)) {
const rule = solidButtonRule(name, o.bg);
assert.ok(rule.includes(`--bs-btn-hover-bg:${o.hoverBg};`), `${name} hover-bg -> ${o.hoverBg}\n${rule}`);
assert.ok(rule.includes(`--bs-btn-active-border-color:${o.activeBorder};`), `${name} active-border -> ${o.activeBorder}`);
assert.ok(rule.includes(`--bs-btn-bg:${o.bg};`), `${name} bg`);
}
});
test("primary focus-shadow-rgb matches Bootstrap (49, 132, 253)", () => {
assert.ok(solidButtonRule("primary", "#0d6efd").includes("--bs-btn-focus-shadow-rgb:49, 132, 253;"));
assert.ok(solidButtonRule("warning", "#ffc107").includes("--bs-btn-focus-shadow-rgb:217, 164, 6;"));
});
test("shade/tint match Bootstrap's Sass math", () => {
assert.equal(toHex(shade(parseHexColor("#0d6efd"), 15)), "#0b5ed7");
assert.equal(toHex(tint(parseHexColor("#ffc107"), 15)), "#ffca2c");
assert.equal(toHex(shade(parseHexColor("#f8f9fa"), 25)), "#babbbc"); // light force-shade
assert.equal(toHex(tint(parseHexColor("#212529"), 10)), "#373b3e"); // dark force-tint
});
test("outline button inverts to the variant color", () => {
const r = outlineButtonRule("primary", "#0d6efd");
assert.ok(r.includes("--bs-btn-color:#0d6efd;"));
assert.ok(r.includes("--bs-btn-hover-bg:#0d6efd;"));
assert.ok(r.includes("--bs-btn-disabled-bg:transparent;"));
});
test("a recolored primary produces a recolored button (the whole point)", () => {
const css = emitButtonRules({ primary: "#e83e8c" });
assert.match(css, /\.btn-primary\{[^}]*--bs-btn-bg:#e83e8c;/);
// #e83e8c gets BLACK text via color-contrast -> tint mode -> hover LIGHTENS:
assert.match(css, /--bs-btn-hover-bg:#eb5b9d;/); // tint(#e83e8c,15%)
assert.match(css, /--bs-btn-color:#000000;/); // contrast picks dark text
});
test("non-hex variant colors are skipped (no throw)", () => {
const css = emitButtonRules({ primary: "var(--x)", success: "#198754" });
assert.doesNotMatch(css, /\.btn-primary\{/);
assert.match(css, /\.btn-success\{/);
});

219
test/integration.js Normal file
View file

@ -0,0 +1,219 @@
// In-process integration smoke test against a REAL Saltcorn DB (not the unit
// suite -- needs @saltcorn + an initialized DB, so it is NOT a *.test.js file).
//
// ./test/runIntegration.sh (self-contained: throwaway DB + NODE_PATH)
//
// Registers theme-builder by location (runs the real onLoad -> Table.create),
// then drives the actual apiHandlers with mock req/res through the full Phase-1
// lifecycle. Exits non-zero on the first failure.
const path = require("path");
const db = require("@saltcorn/data/db");
const { getState } = require("@saltcorn/data/db/state");
const Plugin = require("@saltcorn/data/models/plugin");
const Table = require("@saltcorn/data/models/table");
const PLUGIN_DIR = path.resolve(__dirname, "..");
const { PLUGIN_NAME, THEME_TABLE } = require("../lib/constants");
let failures = 0;
function ok(label, cond, detail) {
const tag = cond ? "PASS" : "FAIL";
if (!cond) failures += 1;
console.log(` [${tag}] ${label}${detail ? " -- " + detail : ""}`);
}
function mkRes() {
const r = { statusCode: 200, headers: {}, body: undefined };
r.status = (c) => { r.statusCode = c; return r; };
r.json = (o) => { r.body = o; r._json = o; return r; };
r.send = (s) => { r.body = s; r._sent = s; return r; };
r.set = (k, v) => { if (k && typeof k === "object") Object.assign(r.headers, k); else r.headers[String(k).toLowerCase()] = v; return r; };
r.get = (k) => r.headers[String(k).toLowerCase()];
r.redirect = (u) => { r._redirect = u; return r; };
r.end = () => { r._ended = true; return r; };
return r;
}
function mkReq(over) {
return Object.assign({
user: { role_id: 1, tenant: db.getTenantSchema(), email: "admin@test" },
params: {}, query: {}, body: {}, headers: {}, xhr: true,
path: "/theme-builder/api/x", originalUrl: "/theme-builder/api/x",
get: () => undefined, csrfToken: () => "testcsrf",
}, over);
}
async function step(label, fn) {
try {
await fn();
} catch (e) {
failures += 1;
console.log(` [FAIL] ${label} THREW -- ${e && (e.stack || e.message)}`);
}
}
async function main() {
console.log(`DB = ${process.env.SQLITE_FILEPATH}`);
// --- bootstrap state + register the plugin by location (runs onLoad) ---
await Plugin.loadAllPlugins();
const plugin = new Plugin({ name: PLUGIN_NAME, source: "local", location: PLUGIN_DIR, configuration: {} });
await Plugin.loadAndSaveNewPlugin(plugin, true, false);
console.log("registered theme-builder; cfg =", JSON.stringify(getState().plugin_cfgs[PLUGIN_NAME] || {}));
const h = require("../lib/apiHandlers");
const liveCfg = () => getState().plugin_cfgs[PLUGIN_NAME] || {};
console.log("\n# onLoad / table");
await step("ensureTable created the themes Table", async () => {
const t = Table.findOne({ name: THEME_TABLE });
ok("Table registered in _sc_tables", !!t, t ? `id=${t.id}` : "missing");
});
console.log("\n# GET /api/state");
let createdId;
await step("buildState lists builtins", async () => {
const res = mkRes();
await h.getState_(mkReq(), res);
const themes = res.body && res.body.themes;
ok("state returns themes array", Array.isArray(themes), `count=${themes && themes.length}`);
ok("builtins present (>=3)", themes && themes.filter((x) => x.builtin).length >= 3);
});
console.log("\n# create -> save -> activate");
await step("create a theme", async () => {
const res = mkRes();
await h.createTheme(mkReq({ body: { name: "My Theme" } }), res);
ok("create returns a theme with uuid id + version 1", !!(res.body && res.body.theme && res.body.theme.id && res.body.theme.version === 1), JSON.stringify(res.body && res.body.theme));
createdId = res.body && res.body.theme && res.body.theme.id;
});
await step("save tokens (CAS on version)", async () => {
const res = mkRes();
const tokens = { $tokensVersion: 1, colors: { primary: "#abcdef" } };
await h.saveTheme(mkReq({ params: { id: createdId }, body: { tokens, baseVersion: 1 } }), res);
ok("save bumps version to 2", !!(res.body && res.body.theme && res.body.theme.version === 2), JSON.stringify(res.body && (res.body.theme || res.body.error)));
});
await step("stale save -> 409 version_conflict", async () => {
const res = mkRes();
await h.saveTheme(mkReq({ params: { id: createdId }, body: { tokens: { colors: {} }, baseVersion: 1 } }), res);
ok("stale baseVersion rejected with 409", res.statusCode === 409 && res.body.error && res.body.error.code === "version_conflict", `status=${res.statusCode}`);
});
await step("activate the theme (the only live-mutating op)", async () => {
const res = mkRes();
await h.activateTheme(mkReq({ params: { id: createdId } }), res);
ok("activate ok + pointer set", !!(res.body && res.body.ok && res.body.activeThemeId === createdId), JSON.stringify(res.body));
ok("activeThemeId persisted to cfg", liveCfg().activeThemeId === createdId, `cfg.activeThemeId=${liveCfg().activeThemeId}`);
ok("activeHash written", !!liveCfg().activeHash, `hash=${liveCfg().activeHash}`);
});
console.log("\n# GET /theme.css (public)");
await step("serve active overlay CSS", async () => {
const res = mkRes();
await h.getThemeCss(mkReq({ query: {}, user: undefined }), res, liveCfg());
const css = res.body || "";
ok("returns text/css", (res.get("content-type") || "").includes("text/css"));
ok("css contains the saved --bs-primary", css.includes("--bs-primary: #abcdef;"), css.slice(0, 80));
ok("deep overlay recolors buttons", css.includes(".btn-primary{") && css.includes("--bs-btn-bg:#abcdef;"));
ok("braces balanced", (css.match(/{/g) || []).length === (css.match(/}/g) || []).length);
});
console.log("\n# export -> import");
let exportedJson;
await step("export the theme", async () => {
const res = mkRes();
await h.exportTheme(mkReq({ params: { id: createdId } }), res);
const raw = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
exportedJson = raw;
const env = JSON.parse(raw);
ok("envelope has $schema + tokens", env.$schema === "saltcorn-theme" && !!env.tokens, env.$schema);
ok("Content-Disposition attachment set", (res.get("content-disposition") || "").includes("attachment"));
});
await step("import the exported envelope -> fresh id", async () => {
const res = mkRes();
await h.importTheme(mkReq({ body: JSON.parse(exportedJson) }), res);
ok("import creates a new theme", !!(res.body && res.body.theme && res.body.theme.id && res.body.theme.id !== createdId), JSON.stringify(res.body && (res.body.theme || res.body.error)));
});
console.log("\n# activate a BUILTIN (seam fix)");
await step("activate builtin:darkly", async () => {
const res = mkRes();
await h.activateTheme(mkReq({ params: { id: "builtin:darkly" } }), res);
ok("builtin activate ok", !!(res.body && res.body.ok), JSON.stringify(res.body));
const cssRes = mkRes();
await h.getThemeCss(mkReq({ query: {}, user: undefined }), cssRes, liveCfg());
ok("builtin CSS served (darkly primary #375a7f)", String(cssRes.body || "").includes("--bs-primary: #375a7f;"), String(cssRes.body || "").slice(0, 80));
});
console.log("\n# preview-css (editor WYSIWYG, same compiler as production)");
await step("preview-css compiles draft tokens incl. deep button overlay", async () => {
const res = mkRes();
await h.previewCss(mkReq({ body: { tokens: { colors: { primary: "#e83e8c" } } } }), res);
const css = res.body || "";
ok("returns text/css no-store", (res.get("content-type") || "").includes("text/css") && res.get("cache-control") === "no-store");
ok("preview recolors .btn-primary to draft color", css.includes(".btn-primary{") && css.includes("--bs-btn-bg:#e83e8c;"));
});
console.log("\n# LAYOUT MODE: enable -> register as layout -> wrap() renders the page");
const activePointer = require("../lib/activePointer");
const cfgReaders = require("../lib/cfgReaders");
const { getPreset } = require("../lib/layoutTree");
let p2Id;
await step("enable layout mode registers theme-builder as a layout", async () => {
await activePointer.setActivePointer({ layoutMode: true });
ok("cfg.layoutMode is true", liveCfg().layoutMode === true);
ok("registered in getState().layouts", !!getState().layouts[PLUGIN_NAME]);
});
await step("create + save a theme with a sidebar layout tree, then activate", async () => {
let res = mkRes();
await h.createTheme(mkReq({ body: { name: "P2 Theme" } }), res);
p2Id = res.body.theme.id;
res = mkRes();
await h.saveTheme(mkReq({ params: { id: p2Id }, body: { tokens: { colors: { primary: "#112233" } }, layoutTree: getPreset("sidebar"), baseVersion: 1 } }), res);
ok("save persisted layoutTree", !!(res.body.theme && res.body.theme.layoutTree), JSON.stringify(res.body.theme && res.body.theme.layoutTree ? res.body.theme.layoutTree.type : res.body.error));
res = mkRes();
await h.activateTheme(mkReq({ params: { id: p2Id } }), res);
ok("activate (layout mode) persisted activeLayoutTree", !!liveCfg().activeLayoutTree, liveCfg().activeLayoutTree && liveCfg().activeLayoutTree.type);
});
await step("layout(cfg).wrap() renders the full themed document", async () => {
const L = require("../lib/layout").layout(liveCfg());
ok("layout(cfg) returns a PluginLayout", !!(L && typeof L.wrap === "function"));
const html = L.wrap({
title: "Home", body: "<p id='pg'>page</p>", brand: { name: "Acme" },
menu: [{ items: [{ label: "Home", link: "/" }] }], alerts: [{ type: "success", msg: "ok" }],
headers: [], role: 1, currentUrl: "/",
});
ok("links vendored Bootstrap", html.includes("/themeBootstrap.min.css"));
ok("links the theme overlay css", html.includes("/theme-builder/theme.css?v="));
ok("renders the layout tree (tb-root + sidebar)", html.includes("id=\"tb-root\"") && html.includes("tb-sidebar"));
ok("injects the page body + brand", html.includes("id='pg'") && html.includes("Acme"));
ok("angle brackets balanced", (html.match(/</g) || []).length === (html.match(/>/g) || []).length);
});
await step("Phase-2 overlay header is suppressed for theme-builder-served requests", async () => {
const req = mkReq({ user: { role_id: 1, tenant: db.getTenantSchema() } });
const rendersTB = cfgReaders.requestRendersViaThemeBuilder(req);
const hdrs = require("../lib/headers").headers(liveCfg());
ok("overlay only_if returns boolean", typeof hdrs[0].only_if(req) === "boolean");
ok("overlay suppressed exactly when rendered via theme-builder", hdrs[0].only_if(req) === !rendersTB, `rendersTB=${rendersTB}`);
});
console.log(`\n==== ${failures === 0 ? "ALL GREEN" : failures + " FAILURE(S)"} ====`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((e) => { console.error("HARNESS CRASH:", e && (e.stack || e.message)); process.exit(2); });

48
test/layoutTree.test.js Normal file
View file

@ -0,0 +1,48 @@
const { test } = require("node:test");
const assert = require("node:assert");
const {
PRESETS, DEFAULT_LAYOUT_TREE, listPresets, getPreset,
validateLayoutTree, normalizeLayoutTree,
} = require("../lib/layoutTree");
test("built-in presets are valid layout trees", () => {
assert.ok(PRESETS.length >= 2);
for (const p of PRESETS) {
const v = validateLayoutTree(p.tree);
assert.ok(v.ok, `${p.id} valid: ${v.errors.join("; ")}`);
}
});
test("listPresets / getPreset", () => {
const ids = listPresets().map((p) => p.id);
assert.ok(ids.includes("topnav") && ids.includes("sidebar"));
assert.equal(getPreset("topnav").type, "Root");
assert.equal(getPreset("nope"), null);
});
test("validateLayoutTree requires Root + exactly one Content", () => {
assert.equal(validateLayoutTree({ type: "Navbar" }).ok, false);
const noContent = { type: "Root", children: [{ type: "Navbar" }] };
assert.equal(validateLayoutTree(noContent).ok, false);
const twoContent = { type: "Root", children: [{ type: "Content" }, { type: "Content" }] };
assert.equal(validateLayoutTree(twoContent).ok, false);
assert.equal(validateLayoutTree({ type: "Root", children: [{ type: "Content" }] }).ok, true);
});
test("validateLayoutTree rejects unknown node types", () => {
const v = validateLayoutTree({ type: "Root", children: [{ type: "Content" }, { type: "Evil" }] });
assert.equal(v.ok, false);
assert.ok(v.errors.some((e) => e.includes("Evil")));
});
test("normalizeLayoutTree falls back to default for junk", () => {
assert.deepEqual(normalizeLayoutTree(null), DEFAULT_LAYOUT_TREE);
assert.deepEqual(normalizeLayoutTree({ type: "Bad" }), DEFAULT_LAYOUT_TREE);
const good = getPreset("sidebar");
assert.deepEqual(normalizeLayoutTree(good), good); // valid tree preserved
});

61
test/portability.test.js Normal file
View file

@ -0,0 +1,61 @@
const { test } = require("node:test");
const assert = require("node:assert");
const { buildEnvelope, parseEnvelope, upcastEnvelope } = require("../lib/portability");
const { normalizeTokens } = require("../lib/themeSchema");
const { SCHEMA_ID, FORMAT_VERSION, ENGINE } = require("../lib/constants");
test("buildEnvelope produces a self-describing, id-less envelope", () => {
const env = buildEnvelope({ name: "Solar", engine: ENGINE, tokens: { colors: { primary: "#111" } } });
assert.equal(env.$schema, SCHEMA_ID);
assert.equal(env.formatVersion, FORMAT_VERSION);
assert.equal(env.name, "Solar");
assert.ok(!("id" in env) && !("version" in env));
});
test("export -> import round-trips", async () => {
const env = buildEnvelope({ name: "Solar", engine: ENGINE, tokens: normalizeTokens({ colors: { primary: "#abcdef" } }) });
const res = await parseEnvelope(JSON.stringify(env));
assert.equal(res.ok, true);
assert.equal(res.draft.name, "Solar");
assert.equal(res.draft.tokens.colors.primary, "#abcdef");
});
test("import rejects a non-saltcorn file", async () => {
const res = await parseEnvelope(JSON.stringify({ hello: "world" }));
assert.equal(res.ok, false);
assert.match(res.error, /Not a saltcorn-theme/);
});
test("import rejects invalid JSON and oversized files", async () => {
assert.equal((await parseEnvelope("{not json")).ok, false);
const huge = JSON.stringify({ $schema: SCHEMA_ID, formatVersion: 1, x: "y".repeat(3 * 1024 * 1024) });
const res = await parseEnvelope(huge);
assert.equal(res.ok, false);
assert.match(res.error, /size limit/);
});
test("upcastEnvelope lifts a formatVersion-0 (vars) export to current", () => {
const up = upcastEnvelope({ $schema: SCHEMA_ID, name: "Old", vars: { colors: { primary: "#222" } } });
assert.equal(up.formatVersion, FORMAT_VERSION);
assert.equal(up.tokens.colors.primary, "#222");
});
test("import upcasts a legacy envelope end-to-end", async () => {
const legacy = JSON.stringify({ $schema: SCHEMA_ID, name: "Legacy", vars: { colors: { primary: "#333" } } });
const res = await parseEnvelope(legacy);
assert.equal(res.ok, true);
assert.equal(res.draft.tokens.colors.primary, "#333");
});
test("import rejects an unsupported engine", async () => {
const res = await parseEnvelope(JSON.stringify({ $schema: SCHEMA_ID, formatVersion: 1, name: "X", engine: "tailwind", tokens: {} }));
assert.equal(res.ok, false);
assert.match(res.error, /Unsupported engine/);
});

66
test/renderTree.test.js Normal file
View file

@ -0,0 +1,66 @@
const { test } = require("node:test");
const assert = require("node:assert");
const { renderTreeToHtml, renderMenu, esc } = require("../lib/renderTree");
const { getPreset } = require("../lib/layoutTree");
const CTX = {
title: "T",
body: "<p id='the-body'>BODY</p>",
brand: { name: "Acme", logo: null },
menu: [{ items: [{ label: "Home", link: "/" }, { label: "Views", link: "/viewedit", subitems: [{ label: "New", link: "/viewedit/new" }] }] }],
alerts: [{ type: "success", msg: "saved" }],
currentUrl: "/",
};
function balanced(html) {
// crude tag-name-agnostic sanity: angle brackets balance
return (html.match(/</g) || []).length === (html.match(/>/g) || []).length;
}
test("topnav preset renders navbar + body slot + footer", () => {
const html = renderTreeToHtml(getPreset("topnav"), CTX);
assert.match(html, /<nav class="navbar/);
assert.match(html, /id='the-body'/, "body injected into Content slot");
assert.match(html, /navbar-brand[^>]*>.*Acme/s, "brand rendered");
assert.match(html, /<footer/);
assert.ok(balanced(html));
});
test("sidebar preset renders an aside + body", () => {
const html = renderTreeToHtml(getPreset("sidebar"), CTX);
assert.match(html, /<aside class="tb-sidebar/);
assert.match(html, /id='the-body'/);
assert.ok(balanced(html));
});
test("body + alerts always emitted even if tree omits the slots", () => {
const bare = { type: "Root", children: [{ type: "Content" }] }; // no AlertsSlot
const html = renderTreeToHtml(bare, CTX);
assert.match(html, /id='the-body'/);
assert.match(html, /alert-success/, "alerts appended when no AlertsSlot");
});
test("junk tree falls back to default (still renders body)", () => {
const html = renderTreeToHtml({ type: "Nope" }, CTX);
assert.match(html, /id='the-body'/);
});
test("menu renders links + a dropdown for subitems; marks active", () => {
const html = renderMenu(CTX.menu, "navbar", CTX);
assert.match(html, /href="\/"[^>]*>.*Home/s);
assert.match(html, /dropdown/);
assert.match(html, /nav-link active/, "current url marked active");
});
test("esc escapes text but body HTML is passed through", () => {
assert.equal(esc(`<b>&"'`), "&lt;b&gt;&amp;&quot;&#39;");
const html = renderTreeToHtml(getPreset("topnav"), { ...CTX, body: "<script>x</script>" });
assert.match(html, /<script>x<\/script>/, "trusted server body not escaped");
});

30
test/runIntegration.sh Executable file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Reproducible, SELF-CONTAINED in-process integration test for theme-builder.
# Spins up a throwaway SQLite DB under ./.test-state, runs Saltcorn migrations,
# then drives the real apiHandlers via test/integration.js. Touches no other
# instance and no shared plugins folder.
#
# ./test/runIntegration.sh
#
# @saltcorn/* are host-provided (this plugin declares no deps, per the
# dev-deploy/idp convention), so the harness gets them via NODE_PATH pointing at
# the upstream checkout's node_modules.
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
PLUGIN="$(dirname "$HERE")"
REPO="$(dirname "$PLUGIN")"
SC_BIN="$REPO/saltcorn/packages/saltcorn-cli/bin/saltcorn"
STATE="$PLUGIN/.test-state"
mkdir -p "$STATE/files"
export SQLITE_FILEPATH="$STATE/test.sqlite"
export SALTCORN_FILE_STORE="$STATE/files"
export SALTCORN_SESSION_SECRET="theme-builder-integration-test-secret"
export SALTCORN_NWORKERS=1
export NODE_PATH="$REPO/saltcorn/node_modules"
rm -f "$SQLITE_FILEPATH"
rm -rf "/tmp/$USER/saltcorn-tmp/temp_install/theme-builder" 2>/dev/null || true
node "$SC_BIN" reset-schema --force >/dev/null
exec node "$HERE/integration.js"

34
test/sanitize.test.js Normal file
View file

@ -0,0 +1,34 @@
const { test } = require("node:test");
const assert = require("node:assert");
const { sanitizeValue, sanitizeSelector } = require("../lib/sanitize");
test("sanitizeValue passes ordinary CSS values", () => {
assert.equal(sanitizeValue("#ff0000"), "#ff0000");
assert.equal(sanitizeValue("1.5rem"), "1.5rem");
assert.equal(sanitizeValue("'Lato', sans-serif"), "'Lato', sans-serif");
});
test("sanitizeValue drops values that could escape their declaration", () => {
const w = [];
assert.equal(sanitizeValue("#fff}", w), null); // closes :root{} early
assert.equal(sanitizeValue("red; }body{display:none", w), null);
assert.equal(sanitizeValue("red/* comment", w), null);
assert.equal(sanitizeValue("a</style>", w), null);
assert.ok(w.length >= 4, "each drop pushes a warning");
});
test("sanitizeValue drops over-length values and null", () => {
assert.equal(sanitizeValue(null), null);
assert.equal(sanitizeValue("x".repeat(5000)), null);
});
test("sanitizeSelector allows conservative selectors, drops breakouts", () => {
assert.equal(sanitizeSelector(".navbar"), ".navbar");
assert.equal(sanitizeSelector(".sidebar, #accordionSidebar"), ".sidebar, #accordionSidebar");
assert.equal(sanitizeSelector("a{color:red}"), null);
assert.equal(sanitizeSelector("x;y"), null);
});

54
test/themeSchema.test.js Normal file
View file

@ -0,0 +1,54 @@
const { test } = require("node:test");
const assert = require("node:assert");
const {
DEFAULT_TOKENS, deepMerge, normalizeTokens, emptyTokens, tokensAreValid,
} = require("../lib/themeSchema");
test("normalizeTokens deep-merges a sparse theme over the defaults", () => {
const t = normalizeTokens({ colors: { primary: "#123456" } });
assert.equal(t.colors.primary, "#123456"); // override wins
assert.equal(t.colors.success, DEFAULT_TOKENS.colors.success); // default preserved
assert.equal(t.$tokensVersion, 1);
});
test("normalizeTokens never mutates the frozen DEFAULT_TOKENS", () => {
const before = DEFAULT_TOKENS.colors.primary;
const t = normalizeTokens({ colors: { primary: "#000000" } });
t.colors.primary = "#ffffff";
assert.equal(DEFAULT_TOKENS.colors.primary, before);
});
test("normalizeTokens tolerates junk input", () => {
assert.equal(normalizeTokens(null).$tokensVersion, 1);
assert.equal(normalizeTokens("nope").colors.primary, DEFAULT_TOKENS.colors.primary);
});
test("emptyTokens returns a mutable clone of the defaults", () => {
const e = emptyTokens();
e.colors.primary = "#abcdef";
assert.notEqual(DEFAULT_TOKENS.colors.primary, "#abcdef");
});
test("deepMerge replaces scalars and merges nested objects", () => {
const out = deepMerge({ a: 1, n: { x: 1, y: 2 } }, { a: 2, n: { y: 3 } });
assert.deepEqual(out, { a: 2, n: { x: 1, y: 3 } });
});
test("tokensAreValid accepts strings, rejects non-string leaves", () => {
assert.equal(tokensAreValid(normalizeTokens({})).ok, true);
const bad = tokensAreValid({ colors: { primary: 123 } });
assert.equal(bad.ok, false);
assert.ok(bad.errors.some((e) => e.includes("colors.primary")));
});
test("tokensAreValid rejects non-object input", () => {
assert.equal(tokensAreValid(null).ok, false);
assert.equal(tokensAreValid([]).ok, false);
});