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