Initial commit
This commit is contained in:
commit
cb1cbcc80b
48 changed files with 6270 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
.test-state/
|
||||||
|
*.log
|
||||||
1425
ARCHITECTURE.md
Normal file
1425
ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load diff
158
README.md
Normal file
158
README.md
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
# theme-builder
|
||||||
|
|
||||||
|
A Saltcorn plugin: a visual theme builder. By default it ships a Bootstrap-5 CSS
|
||||||
|
custom-property **skin overlay** (no Sass, not a layout) that composes with the
|
||||||
|
active theme (e.g. sbadmin2). Turning on **layout mode** (the `layoutMode`
|
||||||
|
setting) flips the plugin into a selectable **layout** whose `wrap()` is
|
||||||
|
assembled from a layout tree, with vendored Bootstrap and an optional dart-sass
|
||||||
|
recompile.
|
||||||
|
|
||||||
|
The authoritative design is in [`ARCHITECTURE.md`](./ARCHITECTURE.md). This
|
||||||
|
README covers what is built, how it is stored, security, and how to run it.
|
||||||
|
|
||||||
|
## Status (layout mode built + validated)
|
||||||
|
|
||||||
|
**Layout mode is built and validated live.** The plugin registers as a real
|
||||||
|
Saltcorn layout when `layoutMode` is on, and its `wrap()`/`authWrap()` render the
|
||||||
|
page from the active theme's **layoutTree** (`layoutTree.js` presets
|
||||||
|
`topnav`/`sidebar` + `renderTree.js` server-side emitter), linking vendored stock
|
||||||
|
Bootstrap (`public/themeBootstrap.*`) + the theme overlay. The active tree is
|
||||||
|
persisted in plugin config on activate (so `wrap()` stays synchronous and it
|
||||||
|
rides backup). The overlay header is suppressed per-request for
|
||||||
|
theme-builder-served requests; `configWorkflow` hard-blocks enabling layout mode
|
||||||
|
until `layout_by_role` covers every live role. The editor gains a **Layout
|
||||||
|
panel** (preset picker) when layout mode is on. Sass remains the documented
|
||||||
|
opt-in (default engine is the overlay on vendored Bootstrap); the full Craft.js
|
||||||
|
drag-drop canvas (ARCHITECTURE 8.7) is the remaining authoring enhancement.
|
||||||
|
|
||||||
|
**Layout mode validated** (in-process harness + real HTTP, all green): enable ->
|
||||||
|
registered in `getState().layouts`; `wrap()` renders `tb-root` + navbar/sidebar +
|
||||||
|
body + theme CSS; `authWrap()` renders the login form (real HTTP login works);
|
||||||
|
`GET /` served by our layout; overlay suppressed exactly for theme-builder
|
||||||
|
requests.
|
||||||
|
|
||||||
|
### Deep overlay (buttons recolor without Sass)
|
||||||
|
|
||||||
|
A plain `:root{--bs-primary}` override does **not** recolor `.btn-primary` —
|
||||||
|
Bootstrap 5.3 bakes button/component colors at Sass compile time. The **deep
|
||||||
|
overlay** (`lib/bootstrapColor.js` + `lib/deepOverlay.js`) faithfully ports
|
||||||
|
Bootstrap's `shade`/`tint`/`color-contrast` math to JS and re-emits the per-
|
||||||
|
component `--bs-btn-*` variables (solid + outline, all 8 variants, incl. the
|
||||||
|
`.btn-light`/`.btn-dark` force-overrides), so buttons/badges follow the theme.
|
||||||
|
Unit tests assert **byte parity** with Bootstrap's compiled output for every
|
||||||
|
variant; validated visually with headless Chromium (primary->pink recolors the
|
||||||
|
button). Opt out with `tokens.deepOverlay === false`.
|
||||||
|
|
||||||
|
The editor's **live preview** uses `POST /api/preview-css` (the *same* server
|
||||||
|
compiler -> single source of truth), so the preview is WYSIWYG including
|
||||||
|
recolored buttons (debounced, stale-response-guarded, no save/activate).
|
||||||
|
|
||||||
|
### Installed in the dev TEST instance
|
||||||
|
|
||||||
|
theme-builder is installed in the `:3001` TEST instance (registered by location,
|
||||||
|
loaded on boot via `startServerTest.sh`). Validated in a real browser (headless
|
||||||
|
Chromium): the editor manager, the manifest-driven token panel, and the live
|
||||||
|
preview (navbar/buttons/links recolor) all work against TEST. The install is
|
||||||
|
non-disruptive: with no active theme and layout mode off, `theme.css` is a no-op and
|
||||||
|
sbadmin2 remains the active layout, so the TEST site looks unchanged until an
|
||||||
|
admin activates a theme.
|
||||||
|
|
||||||
|
### Rendering engine: overlay + deep overlay (Sass deferred)
|
||||||
|
|
||||||
|
The shipping engine is the **CSS-variable overlay + deep overlay** (no build, no
|
||||||
|
runtime deps). With the deep overlay recoloring buttons/components, this covers
|
||||||
|
the practical theming surface. The **dart-sass full recompile** remains an
|
||||||
|
**opt-in** (`lib/sassCompile.js` degrades to the overlay when `sass` is absent);
|
||||||
|
the full Bootstrap-SCSS vendoring is intentionally NOT shipped, since the deep
|
||||||
|
overlay made it unnecessary for the common case. Enable it later by adding `sass`
|
||||||
|
+ vendoring `scss/bootstrap` if a theme needs build-time-only Sass features.
|
||||||
|
|
||||||
|
### Plugin vs Theme classification
|
||||||
|
|
||||||
|
theme-builder exports a `layout` function, so Saltcorn classifies it as a
|
||||||
|
**Theme** (`local_has_theme`): it appears under the Plugins page's **All / Themes
|
||||||
|
/ Installed** tabs (next to sbadmin2), not the "Modules" tab. This is intentional
|
||||||
|
and correct (in layout mode it *is* a layout). The Configure gear renders in every
|
||||||
|
tab it appears in.
|
||||||
|
|
||||||
|
## Status (Phase 1)
|
||||||
|
|
||||||
|
Built and unit-tested (server-independent):
|
||||||
|
|
||||||
|
- **Pure core** (`node --test`, no DB/server): `compile.js` (overlay compiler),
|
||||||
|
`sanitize.js`, `tokenSchema.js`, `themeSchema.js`, `portability.js`
|
||||||
|
(export/import + upcasting), `builtins/` (Flatly/Darkly/Cosmo starters).
|
||||||
|
- **Storage, wiring, HTTP, lifecycle, editor**: `themeStore.js` (the only module
|
||||||
|
that touches the Table), `activePointer.js`, `cfgReaders.js`, `cssCache.js`,
|
||||||
|
`activate.js`, `headers.js`, `layout.js`, `routes.js`, `apiHandlers.js`,
|
||||||
|
`apiState.js`, `httpUtils.js`, `onLoad.js`, `configWorkflow.js`, `index.js`,
|
||||||
|
plus the buildless editor (`lib/page.js` + `public/builderApp.js`).
|
||||||
|
|
||||||
|
All `*.js` pass `node --check`; the pure suite is green (`npm test`, 31 tests).
|
||||||
|
|
||||||
|
**Validated end-to-end against a live SQLite instance** (in-process harness +
|
||||||
|
real HTTP), all green:
|
||||||
|
|
||||||
|
- `onLoad` creates the `_themebuilder_themes` Table (registered in `_sc_tables`).
|
||||||
|
- create -> save (version-CAS; stale save -> 409) -> activate (pointer +
|
||||||
|
composite `activeHash` persisted) -> `GET /theme.css` serves the overlay.
|
||||||
|
- export -> import mints a fresh id with a de-duped name.
|
||||||
|
- Activating a **built-in** serves its overlay (the `getById` seam fix).
|
||||||
|
- Real HTTP: public `theme.css` (200, immutable+ETag), admin guard (redirect for
|
||||||
|
HTML, JSON 401 for the API), authed `/api/state`, the editor shell, and the
|
||||||
|
overlay `<link>?v=<hash>` injected into rendered pages.
|
||||||
|
|
||||||
|
Reproduce with `npm run test:integration` (self-contained throwaway DB).
|
||||||
|
|
||||||
|
> **Dependency convention:** like `dev-deploy`/`idp`, this plugin declares **no**
|
||||||
|
> `@saltcorn/*` dependencies -- the host provides them at runtime. Declaring them
|
||||||
|
> makes the plugin installer try to fetch/copy them. Phase 1 has no third-party
|
||||||
|
> runtime deps at all.
|
||||||
|
|
||||||
|
The editor under `public/builderApp.js` is a **buildless, dependency-free**
|
||||||
|
vanilla-JS app — the Phase-1 shipping UI. The React + Craft.js source tree
|
||||||
|
(`builder/src`, webpack-built into `public/builderApp.js`) is the **Phase-2**
|
||||||
|
upgrade path and is not yet implemented.
|
||||||
|
|
||||||
|
## Storage & backup-safety
|
||||||
|
|
||||||
|
The theme **library** is a real Saltcorn `Table` (`_themebuilder_themes`)
|
||||||
|
created via the Table model, so it is registered in `_sc_tables` and rides
|
||||||
|
backup / restore / snapshots for free, per-tenant. The **active pointer**
|
||||||
|
(`activeThemeId`, `activeByRole`, `activeHash`, `layoutMode`) lives in
|
||||||
|
`plugin.configuration`, which is also captured by `plugin_pack`. Compiled CSS is
|
||||||
|
a **derived cache**, never stored. (This deliberately avoids the raw-DDL
|
||||||
|
backup-invisibility pitfall documented for the other plugins.)
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
This plugin is loaded by a Saltcorn instance, not run standalone. Install it as a
|
||||||
|
local plugin pointing at this directory; `onLoad` creates the table per tenant
|
||||||
|
and rehydrates the active-theme CSS cache. The admin editor is served at
|
||||||
|
`/theme-builder/editor`; the public stylesheet at `/theme-builder/theme.css`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test # pure unit tests (no server)
|
||||||
|
npm run test:integration # in-process e2e against a throwaway SQLite DB
|
||||||
|
npm run build:ui # layout mode: build the React/Craft.js editor (not yet present)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security: import model
|
||||||
|
|
||||||
|
**Theme import performs NO security sanitization** of the uploaded file. An admin
|
||||||
|
can already inject arbitrary site-wide CSS/JS via `page_custom_css`,
|
||||||
|
`page_custom_html`, or by installing plugins, so an admin-only theme import
|
||||||
|
crosses no boundary the admin role does not already own. Import runs only four
|
||||||
|
**robustness** checks (size cap, format/version upcast, shape validation, and a
|
||||||
|
"compiles to balanced CSS" probe) — these exist so a malformed file fails with a
|
||||||
|
clear error and cannot white-screen the site, not as a defense against a trusted
|
||||||
|
admin.
|
||||||
|
|
||||||
|
**Contingency:** if a non-admin role is ever permitted to import or manage
|
||||||
|
themes, real sanitization (CSS/selector/`@import`/`url()`/expression filtering and
|
||||||
|
layoutTree resolver allow-listing) **MUST** be reintroduced before that ships.
|
||||||
|
See `lib/portability.js`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
77
builder/README.md
Normal file
77
builder/README.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Theme Builder -- editor UI
|
||||||
|
|
||||||
|
This directory documents the two-stage plan for the in-browser theme editor.
|
||||||
|
The editor is mounted (admin-only) at `GET /theme-builder/editor`, whose shell
|
||||||
|
page (`lib/page.js`, ARCHITECTURE.md 8.2) loads a single static bundle served by
|
||||||
|
Saltcorn's built-in plugin static route at
|
||||||
|
`/plugins/public/theme-builder/builderApp.js` (no extra route -- plugins.js
|
||||||
|
1246-1276).
|
||||||
|
|
||||||
|
## Phase 1 -- the shipping editor (`public/builderApp.js`)
|
||||||
|
|
||||||
|
`public/builderApp.js` is the **buildless, dependency-free Phase-1 editor**. It
|
||||||
|
is hand-written **vanilla ES2020** -- no React, no Craft.js, no build step, no
|
||||||
|
imports. It IS the committed, shipped artifact: an installed plugin works
|
||||||
|
without ever running a bundler.
|
||||||
|
|
||||||
|
What it does (talking only to the REST API under `window.__TB__.apiBase`):
|
||||||
|
|
||||||
|
- **Manager panel** -- `GET /api/state` lists every theme with `active` and
|
||||||
|
`built-in` badges (ARCHITECTURE.md 8.5/8.8). Per-row buttons: Activate, Edit,
|
||||||
|
Duplicate, Rename, Delete, Export (plain `<a download>` to
|
||||||
|
`/api/themes/:id/export`). Top toolbar: New, Import (file input -> multipart
|
||||||
|
`POST /api/import`).
|
||||||
|
- **Token panel** -- color / font / spacing inputs rendered **from the manifest**
|
||||||
|
returned by `/api/state` (`manifest.tokens[<kebabKey>] = { kind, cssVar,
|
||||||
|
selector, prop, derive, default }`, from `lib/apiState.buildManifest`).
|
||||||
|
- **Live preview** -- a sandboxed `<iframe>` (`sandbox="allow-same-origin"`, no
|
||||||
|
scripts) loads the live `theme.css` plus a representative sample page with an
|
||||||
|
empty `<style id="tb-overlay">`. On every edit the editor rewrites that style
|
||||||
|
with `--bs-*` custom properties (and the rule-based `selector{prop:value}`
|
||||||
|
tokens) -- the **exact same overlay mechanism production uses on activate**
|
||||||
|
(`lib/compile.emitOverlayCss`). WYSIWYG with zero compile and zero network.
|
||||||
|
|
||||||
|
### The cardinal rule: load != activate
|
||||||
|
|
||||||
|
Loading and editing a theme **never** touches the live site. **Save** (`POST
|
||||||
|
/api/themes/:id/save` with `{ tokens, layoutTree, baseVersion }`) and
|
||||||
|
**Activate** (`POST /api/themes/:id/activate`) are wired as **separate
|
||||||
|
actions**; only Activate publishes (ARCHITECTURE.md 1.4 / 8.3). Save uses
|
||||||
|
optimistic concurrency on `baseVersion`; a 409 `version_conflict` offers
|
||||||
|
reload-or-overwrite. Editing a built-in is allowed: the first Save transparently
|
||||||
|
duplicates it to an editable row, leaving the original untouched.
|
||||||
|
|
||||||
|
### CSRF
|
||||||
|
|
||||||
|
The shell (`lib/page.js`) bootstraps
|
||||||
|
`window.__TB__ = { apiBase, cssRoute, csrfToken, base, openThemeId }`, reading
|
||||||
|
the token via `req.csrfToken()` (core's pattern, e.g. server/wrapper.js:259).
|
||||||
|
Every `POST` from the editor sends it back as the **`CSRF-Token`** header
|
||||||
|
(plus `X-Requested-With: XMLHttpRequest` and `Accept: application/json` so the
|
||||||
|
server treats every request -- including multipart import -- as JSON).
|
||||||
|
|
||||||
|
## Phase 2 -- the upgrade path (`builder/src`, NOT yet implemented)
|
||||||
|
|
||||||
|
The Phase-2 editor is a **React + Craft.js** application whose source tree will
|
||||||
|
live under **`builder/src`** (ARCHITECTURE.md 8.3, 8.7, 10). It adds a visual
|
||||||
|
**canvas panel** (`panel:"canvas"`, enabled only when `caps.phase === 2`) that
|
||||||
|
reuses saltcorn-builder's `<Editor>/<Frame>` patterns and the
|
||||||
|
`craftToSaltcorn` / `layoutToNodes` round-trip, with `@craftjs/core` and the
|
||||||
|
vendored saltcorn-builder element/storage files **bundled directly into the same
|
||||||
|
output path** (`public/builderApp.js`) by webpack (`build:ui`).
|
||||||
|
|
||||||
|
Phase 2 is purely additive and does not change the REST surface or the
|
||||||
|
"load != activate" rule:
|
||||||
|
|
||||||
|
- The token panel, manager panel, and `--bs-*` live-preview overlay carry over
|
||||||
|
unchanged; the canvas is a third panel feeding the same single `POST /save`
|
||||||
|
(`{ tokens, layoutTree }`).
|
||||||
|
- Activate is still just `POST /api/themes/:id/activate`; the server-side engine
|
||||||
|
swaps the overlay compiler for a full dart-sass recompile behind the
|
||||||
|
`phase2Enabled` config flag (ARCHITECTURE.md 5.7 / 7.5).
|
||||||
|
|
||||||
|
Until that React tree is built, **`public/builderApp.js` (the buildless vanilla
|
||||||
|
file) is the live editor.** When Phase 2 lands, the webpack build overwrites
|
||||||
|
`public/builderApp.js` with the compiled React bundle (exposing the same
|
||||||
|
`window.themeBuilder.mount` entry point), and this `builder/` directory holds its
|
||||||
|
source.
|
||||||
20
index.js
Normal file
20
index.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// index.js
|
||||||
|
const { API_VERSION, PLUGIN_NAME } = require("./lib/constants");
|
||||||
|
const configuration_workflow = require("./lib/configWorkflow");
|
||||||
|
const { headers } = require("./lib/headers"); // (cfg)=>Header[]
|
||||||
|
const { routes } = require("./lib/routes"); // (cfg)=>PluginRoute[]
|
||||||
|
const { layout } = require("./lib/layout"); // (cfg)=>PluginLayout|undefined
|
||||||
|
const { onLoad } = require("./lib/onLoad");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sc_plugin_api_version: API_VERSION, // 1, gates compat (sbadmin2/index.js:575)
|
||||||
|
plugin_name: PLUGIN_NAME, // "theme-builder" -- canonical key everywhere (plugin.ts:474)
|
||||||
|
|
||||||
|
configuration_workflow, // PRESENCE => every facility is a (cfg)=>value factory
|
||||||
|
// NOTE: exposed_configs intentionally OMITTED (see 5.3)
|
||||||
|
|
||||||
|
headers, // Phase-1 overlay links; per-request only_if gated (see 5.4)
|
||||||
|
routes, // public theme.css + admin editor/API
|
||||||
|
layout, // (cfg)=> undefined in Phase 1; {wrap,...} in Phase 2
|
||||||
|
onLoad, // raw cfg; idempotent; per-tenant; rehydrates active CSS
|
||||||
|
};
|
||||||
74
lib/activate.js
Normal file
74
lib/activate.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// lib/activate.js
|
||||||
|
// activateTheme -- staged, persists COMPOSITE hash, refreshes assets (ARCHITECTURE.md 7.8)
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { getState } = require("@saltcorn/data/db/state");
|
||||||
|
|
||||||
|
const { compileTheme } = require("./compile");
|
||||||
|
const themeStore = require("./themeStore");
|
||||||
|
const cssCache = require("./cssCache");
|
||||||
|
const activePointer = require("./activePointer");
|
||||||
|
const { normalizeLayoutTree } = require("./layoutTree");
|
||||||
|
const { PLUGIN_NAME } = require("./constants");
|
||||||
|
|
||||||
|
function compositeHash(entries /* CompiledEntry[] */) {
|
||||||
|
// Fold the default + every role-override entry's content hash into ONE cache-buster so that
|
||||||
|
// re-activating ANY single role changes activeHash (and thus every emitted ?v). (folded fix)
|
||||||
|
const h = crypto.createHash("sha1");
|
||||||
|
for (const e of entries.filter(Boolean)) { h.update(`${e.role ?? "_"}:${e.hash};`); }
|
||||||
|
return h.digest("hex").slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function activateTheme(id, role) {
|
||||||
|
// Resolve uniformly through the store so the SEAM FIX applies (getById returns a
|
||||||
|
// normalized ThemeDTO for builtin ids too) -- STAGE-1's compile probe then sees the
|
||||||
|
// same defaulted token shape STAGE-2 caches and serves.
|
||||||
|
const theme = await themeStore.getById(id);
|
||||||
|
if (!theme) { const e = new Error("theme not found"); e.stage = "compile"; throw e; }
|
||||||
|
|
||||||
|
// STAGE 1: compile (distinct failure stage -> compile_failed). Pointer untouched on throw.
|
||||||
|
let compiled;
|
||||||
|
try { compiled = compileTheme(theme.tokens, { engine: theme.engine }); }
|
||||||
|
catch (e) { e.stage = "compile"; throw e; }
|
||||||
|
|
||||||
|
// STAGE 2: prime cache for ALL pointer-implied keys (default + every activeByRole role).
|
||||||
|
const { activeThemeId, activeByRole } = activePointer.getActivePointer();
|
||||||
|
const nextByRole = role == null ? activeByRole : { ...activeByRole, [role]: id };
|
||||||
|
const nextDefault = role == null ? id : activeThemeId;
|
||||||
|
cssCache.bustAll();
|
||||||
|
const entries = [];
|
||||||
|
if (nextDefault) entries.push(await cssCache.warm(nextDefault, null));
|
||||||
|
for (const r of Object.keys(nextByRole)) entries.push(await cssCache.warm(nextByRole[r], Number(r)));
|
||||||
|
|
||||||
|
// STAGE 3: persist pointer + COMPOSITE hash, re-register, refresh assets, propagate
|
||||||
|
// (distinct failure -> activation_failed).
|
||||||
|
// activeHash folds in the default AND every role-override entry's content hash, so re-activating
|
||||||
|
// a single role CHANGES activeHash -> the per-role ?v link changes -> immutable role CSS is busted.
|
||||||
|
// (folded review fix: role-override cache-buster never changed -> stale immutable CSS)
|
||||||
|
const nextHash = compositeHash(entries);
|
||||||
|
|
||||||
|
// LAYOUT MODE: persist the activated theme's layout tree into cfg so wrap() can read
|
||||||
|
// it synchronously (and it rides backup with the pointer). No-op when layout mode is off.
|
||||||
|
const liveCfg = getState().plugin_cfgs[PLUGIN_NAME] || {};
|
||||||
|
let layoutPatch = {};
|
||||||
|
if (liveCfg.layoutMode) {
|
||||||
|
const tree = normalizeLayoutTree(theme.layoutTree);
|
||||||
|
layoutPatch = role == null
|
||||||
|
? { activeLayoutTree: tree }
|
||||||
|
: { activeLayoutTreeByRole: { ...(liveCfg.activeLayoutTreeByRole || {}), [role]: tree } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const patch = role == null
|
||||||
|
? { activeThemeId: id, activeByRole: nextByRole, activeHash: nextHash, ...layoutPatch }
|
||||||
|
: { activeByRole: nextByRole, activeHash: nextHash, ...layoutPatch }; // ALWAYS update activeHash, incl. role path
|
||||||
|
await activePointer.setActivePointer(patch); // upsert + loadPlugin + refresh_views + processSend
|
||||||
|
} catch (e) {
|
||||||
|
cssCache.bustTheme(id); // drop orphan cache entry (folded fix)
|
||||||
|
e.stage = "activation"; throw e;
|
||||||
|
}
|
||||||
|
return { activeThemeId: nextDefault, activeByRole: nextByRole, cssHash: nextHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { activateTheme, compositeHash };
|
||||||
53
lib/activePointer.js
Normal file
53
lib/activePointer.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// lib/activePointer.js
|
||||||
|
// Active-pointer write: persist -> re-register -> propagate -> refresh assets (ARCHITECTURE.md 5.5)
|
||||||
|
const Plugin = require("@saltcorn/data/models/plugin");
|
||||||
|
const { getState } = require("@saltcorn/data/db/state");
|
||||||
|
const db = require("@saltcorn/data/db");
|
||||||
|
const { PLUGIN_NAME } = require("./constants");
|
||||||
|
|
||||||
|
// Resolve the install-name row defensively: _sc_plugins.name is the INSTALL name, which
|
||||||
|
// is NOT guaranteed to equal plugin_name. Try the canonical name, then fall back to scanning
|
||||||
|
// for the row whose module resolves to plugin_name. (folded review fix: read/write name-space split)
|
||||||
|
async function findPluginRow() {
|
||||||
|
let p = await Plugin.findOne({ name: PLUGIN_NAME });
|
||||||
|
if (p) return p;
|
||||||
|
const all = await Plugin.find({});
|
||||||
|
return all.find((r) => {
|
||||||
|
const mod = getState().plugins[PLUGIN_NAME];
|
||||||
|
return mod && (getState().plugin_module_names[r.name] === PLUGIN_NAME || r.name === PLUGIN_NAME);
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function setActivePointer(patch /* {activeThemeId?|activeByRole?|activeHash?} */) {
|
||||||
|
const plugin = await findPluginRow();
|
||||||
|
if (!plugin) throw new Error("theme-builder plugin row not found");
|
||||||
|
plugin.configuration = { ...(plugin.configuration || {}), ...patch };
|
||||||
|
await plugin.upsert(); // _sc_plugins (plugin.ts:104-119)
|
||||||
|
await Plugin.loadPlugin(plugin); // re-register -> re-run headers(cfg)
|
||||||
|
await getState().refresh_views(); // REBUILD assets_by_role (Fix 1)
|
||||||
|
getState().processSend({ refresh_plugin_cfg: PLUGIN_NAME, tenant: db.getTenantSchema() });
|
||||||
|
return plugin.configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// READ: live merged cfg, no DB hit.
|
||||||
|
function getActivePointer() {
|
||||||
|
const cfg = getState().plugin_cfgs[PLUGIN_NAME] || {};
|
||||||
|
return { activeThemeId: cfg.activeThemeId || null, activeByRole: cfg.activeByRole || {},
|
||||||
|
activeHash: cfg.activeHash || "0" };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Remove every activeByRole entry whose value === id, then persist the scrubbed map.
|
||||||
|
async function dropRoleRefs(id) {
|
||||||
|
const { activeByRole } = getActivePointer();
|
||||||
|
const scrubbed = {};
|
||||||
|
for (const role of Object.keys(activeByRole)) {
|
||||||
|
if (activeByRole[role] !== id) scrubbed[role] = activeByRole[role];
|
||||||
|
}
|
||||||
|
return setActivePointer({ activeByRole: scrubbed });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { setActivePointer, getActivePointer, findPluginRow, dropRoleRefs };
|
||||||
260
lib/apiHandlers.js
Normal file
260
lib/apiHandlers.js
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
// lib/apiHandlers.js
|
||||||
|
// The HTTP handlers referenced by routes(cfg) (ARCHITECTURE.md 6.5, 6.6, 9.5).
|
||||||
|
// Each handler speaks the canonical ThemeDTO and the uniform error envelope
|
||||||
|
// (httpUtils.jsonError). The public theme.css route NEVER 5xx's (degrades to
|
||||||
|
// fallback CSS); every other route is admin-guarded and answers JSON.
|
||||||
|
//
|
||||||
|
// express req/res are the per-tenant router's standard objects (server/app.js
|
||||||
|
// mounts plugin routes via plugin_routes_handler.js / error_catcher). Multipart
|
||||||
|
// import uploads arrive on req.files (express-fileupload, useTempFiles) -- see
|
||||||
|
// importTheme for the empty-truthy-Buffer fix.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const themeStore = require("./themeStore");
|
||||||
|
const activePointer = require("./activePointer");
|
||||||
|
const cssCache = require("./cssCache");
|
||||||
|
const { activeThemeIdForRole } = require("./cfgReaders");
|
||||||
|
const activateEngine = require("./activate"); // activateEngine.activateTheme
|
||||||
|
const { compileTheme } = require("./compile");
|
||||||
|
const { normalizeTokens } = require("./themeSchema");
|
||||||
|
const portability = require("./portability");
|
||||||
|
const builtins = require("./builtins");
|
||||||
|
const httpUtils = require("./httpUtils");
|
||||||
|
const apiState = require("./apiState");
|
||||||
|
const page = require("./page"); // renderEditorShell
|
||||||
|
const { ROLE_PUBLIC, MAX_IMPORT_BYTES } = require("./constants");
|
||||||
|
|
||||||
|
const { guardAdmin, jsonError } = httpUtils;
|
||||||
|
|
||||||
|
|
||||||
|
// --- PUBLIC: GET /theme.css (ARCHITECTURE.md 6.5, folded cache-poisoning fix) ---
|
||||||
|
async function getThemeCss(req, res, cfg) {
|
||||||
|
const role = req.query.role ? Number(req.query.role) : (req.user?.role_id ?? ROLE_PUBLIC);
|
||||||
|
const themeId = activeThemeIdForRole(cfg, role);
|
||||||
|
res.set("Content-Type", "text/css; charset=utf-8");
|
||||||
|
if (!themeId) { res.set("Cache-Control", "no-store"); res.send("/* theme-builder: no active theme */"); return; }
|
||||||
|
|
||||||
|
const roleKey = (cfg.activeByRole && cfg.activeByRole[role]) ? role : null;
|
||||||
|
let entry = cssCache.getCached(themeId, roleKey);
|
||||||
|
let isReal = !!entry;
|
||||||
|
if (!entry) { // lazy compile-on-miss
|
||||||
|
const theme = await themeStore.getById(themeId);
|
||||||
|
if (theme) {
|
||||||
|
const compiled = compileTheme(theme.tokens, { engine: theme.engine });
|
||||||
|
entry = { ...compiled, themeId, role: roleKey, builtAt: Date.now() };
|
||||||
|
cssCache.setCached(entry);
|
||||||
|
isReal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!entry) { // theme missing mid-activate, transient
|
||||||
|
res.set("Cache-Control", "no-store"); // FOLDED FIX: never memoize fallback under live hash
|
||||||
|
res.send(builtins.fallbackCss());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ETag always reflects the per-entry CONTENT hash, so the If-None-Match/304 path can correct
|
||||||
|
// stale CSS even when a ?v cache-buster failed to change. We keep `immutable` ONLY when the
|
||||||
|
// emitted link's ?v is guaranteed to change on re-activation. With the COMPOSITE activeHash
|
||||||
|
// (4.4/7.8) that guarantee now holds for the per-role link too, so `immutable` is safe here;
|
||||||
|
// were that guarantee ever weakened, drop `immutable` (keep ETag + must-revalidate) so the
|
||||||
|
// 304/If-None-Match path can still correct stale role CSS. (folded review fix)
|
||||||
|
res.set("ETag", `"${entry.hash}"`);
|
||||||
|
res.set("Cache-Control", isReal ? "public, max-age=31536000, immutable" : "no-store");
|
||||||
|
if (req.headers["if-none-match"] === `"${entry.hash}"`) { res.status(304).end(); return; }
|
||||||
|
res.send(entry.css);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- GET /editor: SPA shell ---
|
||||||
|
async function getEditor(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return; // GET -> redirect to login on failure
|
||||||
|
// page.renderEditorShell builds the SPA shell HTML (embeds the /state boot blob +
|
||||||
|
// csrf) and sends it via res.sendWrap (ARCHITECTURE.md 8.2).
|
||||||
|
return page.renderEditorShell(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- GET /api/state ---
|
||||||
|
async function getState_(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
res.json(await apiState.buildState(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- GET /api/themes/:id ---
|
||||||
|
async function loadTheme(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
const id = req.params.id;
|
||||||
|
const theme = await themeStore.getById(id); // resolves rows AND builtins (SEAM FIX)
|
||||||
|
if (!theme) return jsonError(res, 404, "not_found", "Unknown theme");
|
||||||
|
// builtins are read-only: surface the flag so the SPA edits via duplicate-to-edit.
|
||||||
|
res.json({ theme, readOnly: !!theme.builtin });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- POST /api/themes: create blank, or clone a template (builtin/theme) ---
|
||||||
|
async function createTheme(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
const { name, engine, fromTemplate } = req.body || {};
|
||||||
|
let theme;
|
||||||
|
if (fromTemplate) {
|
||||||
|
// fromTemplate names a builtin/theme id to seed from -> duplicate to a fresh row.
|
||||||
|
theme = await themeStore.duplicate(fromTemplate, name);
|
||||||
|
if (!theme) return jsonError(res, 404, "not_found", "Unknown template");
|
||||||
|
} else {
|
||||||
|
theme = await themeStore.create({ name, engine, tokens: {}, layoutTree: null });
|
||||||
|
}
|
||||||
|
res.json({ theme });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- POST /api/themes/:id/duplicate ---
|
||||||
|
async function duplicateTheme(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
const id = req.params.id;
|
||||||
|
const name = req.body?.name;
|
||||||
|
const theme = await themeStore.duplicate(id, name); // resolves rows AND builtins
|
||||||
|
if (!theme) return jsonError(res, 404, "not_found", "Unknown theme");
|
||||||
|
res.json({ theme });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- POST /api/themes/:id/save: optimistic CAS on version (ARCHITECTURE.md 6.6) ---
|
||||||
|
async function saveTheme(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
const id = req.params.id;
|
||||||
|
if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Built-in themes are read-only");
|
||||||
|
const { tokens, layoutTree = null, baseVersion, force } = req.body || {};
|
||||||
|
if (tokens == null) return jsonError(res, 400, "bad_request", "Missing tokens");
|
||||||
|
if (baseVersion == null && !force) return jsonError(res, 400, "bad_request", "Missing baseVersion");
|
||||||
|
const updated = force
|
||||||
|
? await themeStore.forceUpdate(id, { tokens, layoutTree })
|
||||||
|
: await themeStore.casUpdate(id, baseVersion, { tokens, layoutTree }); // UPDATE ... WHERE theme_id=$ AND version=$base, version=version+1
|
||||||
|
if (!updated) {
|
||||||
|
const cur = await themeStore.getById(id);
|
||||||
|
if (!cur) return jsonError(res, 404, "not_found", "Unknown theme");
|
||||||
|
return jsonError(res, 409, "version_conflict", "Theme was modified by another session", { currentVersion: cur.version });
|
||||||
|
}
|
||||||
|
res.json({ theme: updated }); // SAVE does NOT touch the live site
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- POST /api/themes/:id/rename: name only, builtins blocked, de-dups (no 409) ---
|
||||||
|
async function renameTheme(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
const id = req.params.id;
|
||||||
|
if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Built-in themes are read-only");
|
||||||
|
const name = req.body?.name;
|
||||||
|
if (name == null || String(name).trim() === "") return jsonError(res, 400, "bad_request", "Missing name");
|
||||||
|
const exists = await themeStore.getById(id);
|
||||||
|
if (!exists) return jsonError(res, 404, "not_found", "Unknown theme");
|
||||||
|
// store.rename de-dups ("Name (2)") rather than rejecting, so rename always succeeds.
|
||||||
|
const theme = await themeStore.rename(id, name);
|
||||||
|
res.json({ theme });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- POST /api/themes/:id/delete (ARCHITECTURE.md 6.6) ---
|
||||||
|
async function deleteTheme(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
const id = req.params.id;
|
||||||
|
if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Cannot delete built-in");
|
||||||
|
const r = await themeStore.getById(id);
|
||||||
|
if (!r) return jsonError(res, 404, "not_found", "Unknown theme");
|
||||||
|
const { activeThemeId, activeByRole } = activePointer.getActivePointer();
|
||||||
|
const isActive = activeThemeId === id || Object.values(activeByRole).includes(id);
|
||||||
|
if (isActive) {
|
||||||
|
if (!req.body?.autoSwitch) return jsonError(res, 409, "active_theme", "Theme is active; pass autoSwitch to reassign first");
|
||||||
|
const successor = (await themeStore.firstOther(id))?.id || builtins.defaultId();
|
||||||
|
await activateEngine.activateTheme(successor); // recompile + flip to successor FIRST
|
||||||
|
await activePointer.dropRoleRefs(id); // scrub activeByRole entries == id
|
||||||
|
}
|
||||||
|
await themeStore.remove(id);
|
||||||
|
res.json({ ok: true, switchedActiveTo: isActive ? undefined : null });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- POST /api/themes/:id/activate: the only live-mutating op (ARCHITECTURE.md 6.6) ---
|
||||||
|
async function activateTheme(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
const id = req.params.id;
|
||||||
|
const exists = builtins.isBuiltinId(id) ? !!builtins.get(id) : !!(await themeStore.getById(id));
|
||||||
|
if (!exists) return jsonError(res, 404, "not_found", "Unknown theme");
|
||||||
|
try {
|
||||||
|
const out = await activateEngine.activateTheme(id, req.body?.role);
|
||||||
|
res.json({ ok: true, ...out });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.stage === "compile") return jsonError(res, 500, "compile_failed", e.message); // pointer untouched
|
||||||
|
return jsonError(res, 500, "activation_failed", e.message); // pointer write/propagate failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- GET /api/themes/:id/export: JSON envelope as a download (works for builtins) ---
|
||||||
|
async function exportTheme(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
const id = req.params.id;
|
||||||
|
const theme = await themeStore.getById(id); // resolves rows AND builtins
|
||||||
|
if (!theme) return jsonError(res, 404, "not_found", "Unknown theme");
|
||||||
|
const envelope = portability.buildEnvelope(theme);
|
||||||
|
const slug = String(theme.name || "theme").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64) || "theme";
|
||||||
|
res.set("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.set("Content-Disposition", `attachment; filename="${slug}.theme.json"`);
|
||||||
|
res.send(JSON.stringify(envelope, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- POST /api/import (ARCHITECTURE.md 9.5, multipart source + fresh-id fixes) ---
|
||||||
|
async function importTheme(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
let raw;
|
||||||
|
if (req.files && req.files.file) {
|
||||||
|
const f = req.files.file;
|
||||||
|
if (f.size > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme file too large");
|
||||||
|
// FOLDED FIX: with useTempFiles, f.data is an EMPTY (truthy) Buffer -> branch on LENGTH, read tempFilePath.
|
||||||
|
raw = (f.data && f.data.length) ? f.data.toString("utf8") : fs.readFileSync(f.tempFilePath, "utf8");
|
||||||
|
} else if (req.body && (req.body.$schema || req.body.envelope)) {
|
||||||
|
raw = typeof req.body.envelope === "string" ? req.body.envelope : JSON.stringify(req.body.envelope || req.body);
|
||||||
|
if (Buffer.byteLength(raw) > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme too large");
|
||||||
|
} else return jsonError(res, 400, "bad_request", "No theme file provided");
|
||||||
|
|
||||||
|
const parsed = await portability.parseEnvelope(raw);
|
||||||
|
if (!parsed.ok) return jsonError(res, parsed.error.includes("compile") ? 422 : 400,
|
||||||
|
parsed.error.includes("compile") ? "uncompilable" : "bad_format", parsed.error);
|
||||||
|
// (b) FRESH id + de-dup name via create() -> import NEVER overwrites
|
||||||
|
const theme = await themeStore.create({ name: parsed.draft.name, engine: parsed.draft.engine,
|
||||||
|
tokens: parsed.draft.tokens, layoutTree: parsed.draft.layoutTree });
|
||||||
|
res.json({ theme });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- POST /api/preview-css: compile DRAFT tokens for the editor's live preview ---
|
||||||
|
// Uses the SAME server compiler as production (single source of truth -> the
|
||||||
|
// preview's buttons/badges match exactly, including the deep overlay). Does NOT
|
||||||
|
// save or activate; returns text/css with no-store.
|
||||||
|
async function previewCss(req, res) {
|
||||||
|
if (!guardAdmin(req, res)) return;
|
||||||
|
const tokens = normalizeTokens((req.body && req.body.tokens) || {});
|
||||||
|
const engine = (req.body && req.body.engine) || undefined;
|
||||||
|
const compiled = compileTheme(tokens, { engine });
|
||||||
|
res.set("Content-Type", "text/css; charset=utf-8");
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
res.send(compiled.css);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getThemeCss,
|
||||||
|
getEditor,
|
||||||
|
getState_,
|
||||||
|
loadTheme,
|
||||||
|
createTheme,
|
||||||
|
duplicateTheme,
|
||||||
|
saveTheme,
|
||||||
|
renameTheme,
|
||||||
|
deleteTheme,
|
||||||
|
activateTheme,
|
||||||
|
exportTheme,
|
||||||
|
importTheme,
|
||||||
|
previewCss,
|
||||||
|
};
|
||||||
114
lib/apiState.js
Normal file
114
lib/apiState.js
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// lib/apiState.js
|
||||||
|
// GET /api/state payload builder (ARCHITECTURE.md 8.5).
|
||||||
|
//
|
||||||
|
// StateEnvelope = {
|
||||||
|
// themes: ThemeListItem[], // metadata only (NOT full token payloads)
|
||||||
|
// activeThemeId: string|null,
|
||||||
|
// activeByRole?: { [roleId:number]: string },
|
||||||
|
// manifest: TokenManifest,
|
||||||
|
// caps: { layoutMode, canImport, engine, formatVersion, sizeCap },
|
||||||
|
// ... plus active{}, layoutMode, csrfToken convenience fields for the shell.
|
||||||
|
// }
|
||||||
|
// ThemeListItem = { id, name, builtin, active, engine, updatedAt, hasLayoutTree }
|
||||||
|
//
|
||||||
|
// buildState merges builtins (builtin:true) + library rows (builtin:false) via
|
||||||
|
// themeStore.list(), annotates `active` from the LIVE pointer (read via
|
||||||
|
// getState().plugin_cfgs, no DB hit), and includes the token manifest. caps.layoutMode
|
||||||
|
// toggles the SPA canvas panel.
|
||||||
|
|
||||||
|
const { getState } = require("@saltcorn/data/db/state");
|
||||||
|
|
||||||
|
const themeStore = require("./themeStore");
|
||||||
|
const { TOKEN_SCHEMA, MAX_CSS_BYTES } = require("./tokenSchema");
|
||||||
|
const { DEFAULT_TOKENS } = require("./themeSchema");
|
||||||
|
const { PRESETS } = require("./layoutTree");
|
||||||
|
const {
|
||||||
|
PLUGIN_NAME, API_VERSION, ENGINE, FORMAT_VERSION, MAX_IMPORT_BYTES, CFG,
|
||||||
|
} = require("./constants");
|
||||||
|
|
||||||
|
|
||||||
|
// Live merged plugin config for this tenant (no DB hit). Mirrors
|
||||||
|
// activePointer.getActivePointer / cfgReaders resolution.
|
||||||
|
function livePointer() {
|
||||||
|
const cfg = getState().plugin_cfgs[PLUGIN_NAME] || {};
|
||||||
|
return {
|
||||||
|
activeThemeId: cfg[CFG.ACTIVE] || null,
|
||||||
|
activeByRole: cfg[CFG.BY_ROLE] || {},
|
||||||
|
layoutMode: !!cfg[CFG.LAYOUT_MODE],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The server-side TokenManifest: HOW each token maps to CSS, paired with its
|
||||||
|
// default value. Structurally parallel to the SPA manifest; derived from the
|
||||||
|
// single TOKEN_SCHEMA source of truth.
|
||||||
|
function buildManifest() {
|
||||||
|
const flatDefaults = {};
|
||||||
|
for (const [sec, obj] of Object.entries(DEFAULT_TOKENS)) {
|
||||||
|
if (!obj || typeof obj !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
// camelCase leaf -> kebab-case manifest key (matches compile.flattenTokens)
|
||||||
|
const kebab = k.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
||||||
|
flatDefaults[kebab] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tokens = {};
|
||||||
|
for (const [key, spec] of Object.entries(TOKEN_SCHEMA)) {
|
||||||
|
tokens[key] = {
|
||||||
|
kind: spec.kind,
|
||||||
|
cssVar: spec.cssVar || null,
|
||||||
|
selector: spec.selector || null,
|
||||||
|
prop: spec.prop || null,
|
||||||
|
derive: spec.derive || null,
|
||||||
|
default: flatDefaults[key] != null ? flatDefaults[key] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { engine: ENGINE, apiVersion: API_VERSION, tokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// stored row / builtin ThemeDTO -> compact ThemeListItem (metadata only).
|
||||||
|
function toListItem(t, activeThemeId, activeByRole) {
|
||||||
|
const isActive = t.id === activeThemeId || Object.values(activeByRole).includes(t.id);
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
builtin: !!t.builtin,
|
||||||
|
active: isActive,
|
||||||
|
engine: t.engine,
|
||||||
|
updatedAt: t.updated_at || null,
|
||||||
|
hasLayoutTree: !!t.layoutTree,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function buildState(req) {
|
||||||
|
const { activeThemeId, activeByRole, layoutMode } = livePointer();
|
||||||
|
const all = await themeStore.list();
|
||||||
|
const themes = all.map((t) => toListItem(t, activeThemeId, activeByRole));
|
||||||
|
const csrfToken = (req && typeof req.csrfToken === "function") ? req.csrfToken() : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
themes,
|
||||||
|
activeThemeId,
|
||||||
|
activeByRole,
|
||||||
|
active: { activeThemeId, activeByRole },
|
||||||
|
layoutMode,
|
||||||
|
csrfToken,
|
||||||
|
manifest: buildManifest(),
|
||||||
|
layoutPresets: PRESETS.map((p) => ({ id: p.id, label: p.label, tree: structuredClone(p.tree) })),
|
||||||
|
caps: {
|
||||||
|
layoutMode,
|
||||||
|
canImport: true,
|
||||||
|
engine: ENGINE,
|
||||||
|
formatVersion: FORMAT_VERSION,
|
||||||
|
sizeCap: MAX_IMPORT_BYTES,
|
||||||
|
maxCssBytes: MAX_CSS_BYTES,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { buildState, buildManifest };
|
||||||
107
lib/bootstrapColor.js
vendored
Normal file
107
lib/bootstrapColor.js
vendored
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// Faithful JS port of the Bootstrap 5.3 Sass color functions the button-variant
|
||||||
|
// mixin uses, so the "deep overlay" can recompute the per-component
|
||||||
|
// --bs-btn-* variables Bootstrap bakes at compile time. Pure module.
|
||||||
|
//
|
||||||
|
// shade-color(c, w%) = mix(black, c, w) (darken)
|
||||||
|
// tint-color(c, w%) = mix(white, c, w) (lighten)
|
||||||
|
// color-contrast(bg) -> #ffffff or #000000 (WCAG, min ratio 4.5, white first)
|
||||||
|
|
||||||
|
const WHITE = { r: 255, g: 255, b: 255 };
|
||||||
|
const BLACK = { r: 0, g: 0, b: 0 };
|
||||||
|
const MIN_CONTRAST_RATIO = 4.5;
|
||||||
|
|
||||||
|
|
||||||
|
// "#rgb" / "#rrggbb" -> {r,g,b}; null for anything else (named/rgb()/var()).
|
||||||
|
function parseHexColor(s) {
|
||||||
|
if (typeof s !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const v = s.trim();
|
||||||
|
const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(v);
|
||||||
|
const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(v);
|
||||||
|
if (m6) {
|
||||||
|
return { r: parseInt(m6[1], 16), g: parseInt(m6[2], 16), b: parseInt(m6[3], 16) };
|
||||||
|
}
|
||||||
|
if (m3) {
|
||||||
|
return { r: parseInt(m3[1] + m3[1], 16), g: parseInt(m3[2] + m3[2], 16), b: parseInt(m3[3] + m3[3], 16) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function clampByte(n) {
|
||||||
|
return Math.min(255, Math.max(0, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function toHex(c) {
|
||||||
|
return "#" + [c.r, c.g, c.b].map((n) => clampByte(n).toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function toRgbString(c) {
|
||||||
|
return `${clampByte(c.r)}, ${clampByte(c.g)}, ${clampByte(c.b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Sass mix($c1, $c2, $weight): each channel = c1*w + c2*(1-w). weight is a fraction.
|
||||||
|
function mix(c1, c2, weight) {
|
||||||
|
return {
|
||||||
|
r: c1.r * weight + c2.r * (1 - weight),
|
||||||
|
g: c1.g * weight + c2.g * (1 - weight),
|
||||||
|
b: c1.b * weight + c2.b * (1 - weight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function shade(c, percent) {
|
||||||
|
return mix(BLACK, c, percent / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function tint(c, percent) {
|
||||||
|
return mix(WHITE, c, percent / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function channelLuminance(c255) {
|
||||||
|
const c = c255 / 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function relLuminance(c) {
|
||||||
|
return 0.2126 * channelLuminance(c.r) + 0.7152 * channelLuminance(c.g) + 0.0722 * channelLuminance(c.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function contrastRatio(a, b) {
|
||||||
|
const la = relLuminance(a) + 0.05;
|
||||||
|
const lb = relLuminance(b) + 0.05;
|
||||||
|
return la > lb ? la / lb : lb / la;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// color-contrast($bg): Bootstrap checks white first then black, returning the
|
||||||
|
// first foreground whose contrast exceeds the min ratio (4.5), else the higher.
|
||||||
|
function colorContrast(bg) {
|
||||||
|
const c = typeof bg === "string" ? parseHexColor(bg) : bg;
|
||||||
|
if (!c) {
|
||||||
|
return "#000000";
|
||||||
|
}
|
||||||
|
const rw = contrastRatio(c, WHITE);
|
||||||
|
const rb = contrastRatio(c, BLACK);
|
||||||
|
if (rw > MIN_CONTRAST_RATIO) {
|
||||||
|
return "#ffffff";
|
||||||
|
}
|
||||||
|
if (rb > MIN_CONTRAST_RATIO) {
|
||||||
|
return "#000000";
|
||||||
|
}
|
||||||
|
return rw >= rb ? "#ffffff" : "#000000";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
WHITE, BLACK, parseHexColor, toHex, toRgbString, mix, shade, tint,
|
||||||
|
relLuminance, contrastRatio, colorContrast,
|
||||||
|
};
|
||||||
17
lib/builtins/cosmo.js
Normal file
17
lib/builtins/cosmo.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Bootswatch-derived bright starter (frozen). See flatly.js for the built-in model.
|
||||||
|
|
||||||
|
module.exports = Object.freeze({
|
||||||
|
id: "builtin:cosmo",
|
||||||
|
name: "Cosmo",
|
||||||
|
engine: "bootstrap5",
|
||||||
|
tokens: {
|
||||||
|
$tokensVersion: 1,
|
||||||
|
colors: {
|
||||||
|
primary: "#2780e3", secondary: "#373a3c", success: "#3fb618",
|
||||||
|
info: "#9954bb", warning: "#ff7518", danger: "#ff0039",
|
||||||
|
light: "#f8f9fa", dark: "#373a3c",
|
||||||
|
bodyBg: "#ffffff", bodyColor: "#373a3c",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layoutTree: null,
|
||||||
|
});
|
||||||
18
lib/builtins/darkly.js
Normal file
18
lib/builtins/darkly.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// Bootswatch-derived dark starter (frozen). See flatly.js for the built-in model.
|
||||||
|
|
||||||
|
module.exports = Object.freeze({
|
||||||
|
id: "builtin:darkly",
|
||||||
|
name: "Darkly",
|
||||||
|
engine: "bootstrap5",
|
||||||
|
tokens: {
|
||||||
|
$tokensVersion: 1,
|
||||||
|
colors: {
|
||||||
|
primary: "#375a7f", secondary: "#444444", success: "#00bc8c",
|
||||||
|
info: "#3498db", warning: "#f39c12", danger: "#e74c3c",
|
||||||
|
light: "#adb5bd", dark: "#303030",
|
||||||
|
bodyBg: "#222222", bodyColor: "#ffffff",
|
||||||
|
},
|
||||||
|
components: { linkColor: "#00bc8c" },
|
||||||
|
},
|
||||||
|
layoutTree: null,
|
||||||
|
});
|
||||||
21
lib/builtins/flatly.js
Normal file
21
lib/builtins/flatly.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Bootswatch-derived starter (frozen). Built-ins live in code, never in the DB:
|
||||||
|
// upgrades ship new/updated starters with no migration, and a user can never
|
||||||
|
// delete or rename them. The "builtin:" id namespace cannot collide with the
|
||||||
|
// uuid v4 theme_ids of stored rows.
|
||||||
|
|
||||||
|
module.exports = Object.freeze({
|
||||||
|
id: "builtin:flatly",
|
||||||
|
name: "Flatly",
|
||||||
|
engine: "bootstrap5",
|
||||||
|
tokens: {
|
||||||
|
$tokensVersion: 1,
|
||||||
|
colors: {
|
||||||
|
primary: "#2c3e50", secondary: "#95a5a6", success: "#18bc9c",
|
||||||
|
info: "#3498db", warning: "#f39c12", danger: "#e74c3c",
|
||||||
|
light: "#ecf0f1", dark: "#7b8a8b",
|
||||||
|
bodyBg: "#ffffff", bodyColor: "#212529",
|
||||||
|
},
|
||||||
|
typography: { headingsFontFamily: "'Lato', sans-serif" },
|
||||||
|
},
|
||||||
|
layoutTree: null,
|
||||||
|
});
|
||||||
42
lib/builtins/index.js
Normal file
42
lib/builtins/index.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Built-in theme registry. Starters ship as frozen code, are merged into list()
|
||||||
|
// at read time, and are never inserted into the Table. They are not deletable,
|
||||||
|
// renamable, or save-able; "loading" a built-in for edit duplicates it to a
|
||||||
|
// fresh uuid row (handled in the store/handlers).
|
||||||
|
|
||||||
|
const themes = [require("./flatly"), require("./darkly"), require("./cosmo")];
|
||||||
|
const byId = Object.freeze(Object.fromEntries(themes.map((t) => [t.id, t])));
|
||||||
|
|
||||||
|
|
||||||
|
function listBuiltins() {
|
||||||
|
return themes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getBuiltin(id) {
|
||||||
|
return byId[id] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function isBuiltinId(id) {
|
||||||
|
return typeof id === "string" && id.startsWith("builtin:");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The starter used as a last-resort successor (e.g. when the active theme is
|
||||||
|
// deleted and no other stored theme exists).
|
||||||
|
function defaultId() {
|
||||||
|
return "builtin:flatly";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Valid, empty CSS served on a transient miss (table not yet created, theme
|
||||||
|
// missing mid-activate). Never a white-screen; the active theme renders
|
||||||
|
// unchanged. Always sent with Cache-Control: no-store by the route.
|
||||||
|
function fallbackCss() {
|
||||||
|
return "/* theme-builder: fallback (no compiled theme) */\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listBuiltins, getBuiltin, get: getBuiltin, isBuiltinId, defaultId, fallbackCss,
|
||||||
|
};
|
||||||
50
lib/cfgReaders.js
Normal file
50
lib/cfgReaders.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// lib/cfgReaders.js
|
||||||
|
// Config readers -- one resolution rule (ARCHITECTURE.md 5.4)
|
||||||
|
const { getState } = require("@saltcorn/data/db/state");
|
||||||
|
const { PLUGIN_NAME, ROLE_PUBLIC } = require("./constants");
|
||||||
|
const { DEFAULT_LAYOUT_TREE } = require("./layoutTree");
|
||||||
|
|
||||||
|
function getActiveThemeId(cfg) { return cfg?.activeThemeId || null; }
|
||||||
|
function getActiveByRole(cfg) { return cfg?.activeByRole || {}; }
|
||||||
|
function activeThemeIdForRole(cfg, roleId) { // role override then global
|
||||||
|
const byRole = getActiveByRole(cfg);
|
||||||
|
return byRole[roleId] != null ? byRole[roleId] : getActiveThemeId(cfg);
|
||||||
|
}
|
||||||
|
function isLayoutMode(cfg) { return !!cfg?.layoutMode; }
|
||||||
|
function activeHashHint(cfg, roleId){ return (cfg && cfg.activeHash) || "0"; }
|
||||||
|
|
||||||
|
// SYNC resolution of the active layout tree (so wrap() stays synchronous): the
|
||||||
|
// per-role tree, else the default tree, else the built-in default preset.
|
||||||
|
// activate.js persists these into cfg alongside the pointer when layout mode is on.
|
||||||
|
function activeLayoutTree(cfg, roleId) {
|
||||||
|
const byRole = (cfg && cfg.activeLayoutTreeByRole) || {};
|
||||||
|
const roleTree = roleId != null ? (byRole[roleId] ?? byRole[String(roleId)]) : null;
|
||||||
|
return roleTree || (cfg && cfg.activeLayoutTree) || DEFAULT_LAYOUT_TREE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replicate getLayout's fallback precedence WITHOUT reading the pluginName field the
|
||||||
|
// last-installed fallback omits (state.ts:419-425). A request renders via theme-builder when
|
||||||
|
// layout_by_role[role] names us OR (no layout_by_role entry for that role AND theme-builder is
|
||||||
|
// the last-installed/only registered layout). (folded review fix -- see 5.7/7.6)
|
||||||
|
function requestRendersViaThemeBuilder(req) {
|
||||||
|
const state = getState();
|
||||||
|
if (!state.layouts[PLUGIN_NAME]) return false; // not registered as a layout (Phase 1)
|
||||||
|
const role = req.user?.role_id ?? ROLE_PUBLIC;
|
||||||
|
const byRole = state.getConfig("layout_by_role", {}) || {};
|
||||||
|
const mapped = byRole[role] ?? byRole[String(role)];
|
||||||
|
if (mapped != null) return mapped === PLUGIN_NAME; // role branch is authoritative
|
||||||
|
// no layout_by_role entry for this role -> falls through to per-user / last-installed.
|
||||||
|
// theme-builder is selected by the fallback iff it is the last-installed layout key.
|
||||||
|
const keys = Object.keys(state.layouts);
|
||||||
|
return keys.length > 0 && keys[keys.length - 1] === PLUGIN_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getActiveThemeId,
|
||||||
|
getActiveByRole,
|
||||||
|
activeThemeIdForRole,
|
||||||
|
isLayoutMode,
|
||||||
|
activeHashHint,
|
||||||
|
activeLayoutTree,
|
||||||
|
requestRendersViaThemeBuilder,
|
||||||
|
};
|
||||||
212
lib/compile.js
Normal file
212
lib/compile.js
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
// Phase 1 overlay compiler: tokens -> CSS custom-property overlay that loads
|
||||||
|
// AFTER Bootstrap and wins by cascade order. Pure and NEVER throws (a bad theme
|
||||||
|
// degrades to empty-but-valid CSS = active theme renders unchanged = no
|
||||||
|
// white-screen). The same compileTheme doubles as the import robustness check.
|
||||||
|
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { sanitizeValue, sanitizeSelector } = require("./sanitize");
|
||||||
|
const { TOKEN_SCHEMA, TOKEN_KEY_RE, MAX_CSS_BYTES } = require("./tokenSchema");
|
||||||
|
const deepOverlay = require("./deepOverlay");
|
||||||
|
|
||||||
|
// Bound the emitted overlay so a pathological theme cannot balloon the output.
|
||||||
|
const MAX_ROOT_DECLS = 2000;
|
||||||
|
const MAX_RULES = 500;
|
||||||
|
|
||||||
|
// Nested token sections flattened into the kebab-case keys TOKEN_SCHEMA uses.
|
||||||
|
const FLAT_SECTIONS = ["colors", "typography", "borders", "components"];
|
||||||
|
|
||||||
|
|
||||||
|
function camelToKebab(key) {
|
||||||
|
return String(key).replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Merge colors/typography/borders/components into one { kebabKey: value } map.
|
||||||
|
// `custom` (raw "--var": value) is handled separately by emitOverlayCss.
|
||||||
|
function flattenTokens(tokens) {
|
||||||
|
const flat = {};
|
||||||
|
if (!tokens || typeof tokens !== "object") {
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
for (const sec of FLAT_SECTIONS) {
|
||||||
|
const obj = tokens[sec];
|
||||||
|
if (!obj || typeof obj !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (v != null) {
|
||||||
|
flat[camelToKebab(k)] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// "#ff0000" / "#f00" -> "255, 0, 0" for the --bs-<x>-rgb companion. Returns null
|
||||||
|
// for non-hex values (the companion is simply not emitted).
|
||||||
|
function deriveRgb(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const s = value.trim();
|
||||||
|
const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(s);
|
||||||
|
const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(s);
|
||||||
|
let r;
|
||||||
|
let g;
|
||||||
|
let b;
|
||||||
|
if (m6) {
|
||||||
|
r = parseInt(m6[1], 16);
|
||||||
|
g = parseInt(m6[2], 16);
|
||||||
|
b = parseInt(m6[3], 16);
|
||||||
|
} else if (m3) {
|
||||||
|
r = parseInt(m3[1] + m3[1], 16);
|
||||||
|
g = parseInt(m3[2] + m3[2], 16);
|
||||||
|
b = parseInt(m3[3] + m3[3], 16);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Build the overlay. Each --bs-x:v; is its own declaration and each targeted
|
||||||
|
// rule is its own {} block, so the CSS parser's standard error recovery drops a
|
||||||
|
// single bad declaration/rule without discarding siblings.
|
||||||
|
function emitOverlayCss(tokens) {
|
||||||
|
const warnings = [];
|
||||||
|
const rootDecls = [];
|
||||||
|
const rules = [];
|
||||||
|
const flat = flattenTokens(tokens);
|
||||||
|
|
||||||
|
for (const [key, rawVal] of Object.entries(flat)) {
|
||||||
|
if (rootDecls.length >= MAX_ROOT_DECLS || rules.length >= MAX_RULES) {
|
||||||
|
warnings.push("token cap reached; remaining tokens dropped");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!TOKEN_KEY_RE.test(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sv = sanitizeValue(rawVal, warnings);
|
||||||
|
if (sv == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const schema = TOKEN_SCHEMA[key];
|
||||||
|
if (schema && schema.kind === "bsvar") {
|
||||||
|
rootDecls.push(`--bs-${schema.cssVar}: ${sv};`);
|
||||||
|
if (Array.isArray(schema.derive)) {
|
||||||
|
const rgb = deriveRgb(sv);
|
||||||
|
if (rgb != null) {
|
||||||
|
rootDecls.push(`--bs-${schema.cssVar}-rgb: ${rgb};`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (schema && schema.kind === "rule") {
|
||||||
|
const sel = sanitizeSelector(schema.selector);
|
||||||
|
if (sel != null) {
|
||||||
|
rules.push(`${sel}{${schema.prop}: ${sv};}`);
|
||||||
|
}
|
||||||
|
} else if (schema && schema.kind === "ignore") {
|
||||||
|
// known-but-not-emitted (forward-compat)
|
||||||
|
} else {
|
||||||
|
// unknown-but-valid token -> harmless passthrough custom property
|
||||||
|
rootDecls.push(`--tb-${key}: ${sv};`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw custom CSS variables ("--my-var": "value")
|
||||||
|
const custom = tokens && tokens.custom;
|
||||||
|
if (custom && typeof custom === "object") {
|
||||||
|
for (const [k, v] of Object.entries(custom)) {
|
||||||
|
if (rootDecls.length >= MAX_ROOT_DECLS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!/^--[-a-zA-Z0-9_]{1,64}$/.test(k)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sv = sanitizeValue(v, warnings);
|
||||||
|
if (sv != null) {
|
||||||
|
rootDecls.push(`${k}: ${sv};`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let css = "";
|
||||||
|
if (rootDecls.length > 0) {
|
||||||
|
css += `:root{${rootDecls.join("")}}\n`;
|
||||||
|
}
|
||||||
|
if (rules.length > 0) {
|
||||||
|
css += rules.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEEP OVERLAY: recompute the per-component --bs-btn-* variables Bootstrap
|
||||||
|
// bakes at compile time, so buttons actually recolor under the overlay. A
|
||||||
|
// plain :root{--bs-primary} override does NOT reach .btn-primary. Opt out
|
||||||
|
// with tokens.deepOverlay === false. Skipped silently for non-hex colors.
|
||||||
|
if (!tokens || tokens.deepOverlay !== false) {
|
||||||
|
const btnCss = deepOverlay.emitButtonRules((tokens && tokens.colors) || {});
|
||||||
|
if (btnCss) {
|
||||||
|
css += btnCss + "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { css, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Final structural backstop. Bounds size and verifies balanced braces; on
|
||||||
|
// failure degrades to an empty-but-valid sentinel rather than emitting CSS that
|
||||||
|
// could break the whole page.
|
||||||
|
function robustnessGuard(css, warnings) {
|
||||||
|
if (Buffer.byteLength(css, "utf8") > MAX_CSS_BYTES) {
|
||||||
|
warnings.push("css exceeds cap");
|
||||||
|
const rootLine = css.split("\n").find((l) => l.startsWith(":root{")) || "";
|
||||||
|
css = (rootLine && Buffer.byteLength(rootLine, "utf8") <= MAX_CSS_BYTES)
|
||||||
|
? rootLine
|
||||||
|
: "/* theme-builder: empty (over cap) */";
|
||||||
|
}
|
||||||
|
let depth = 0;
|
||||||
|
let ok = true;
|
||||||
|
for (const ch of css) {
|
||||||
|
if (ch === "{") {
|
||||||
|
depth += 1;
|
||||||
|
} else if (ch === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth < 0) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok || depth !== 0) {
|
||||||
|
warnings.push("unbalanced braces; emitting empty");
|
||||||
|
css = "/* theme-builder: empty (invalid) */";
|
||||||
|
}
|
||||||
|
return { css, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function contentHash(css) {
|
||||||
|
return crypto.createHash("sha1").update(css).digest("hex").slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Pure; never throws. opts.engine "sass" routes to the Phase-2 recompiler
|
||||||
|
// (lazy-required), anything else to the overlay.
|
||||||
|
function compileTheme(tokens, opts = {}) {
|
||||||
|
const engine = opts.engine === "sass" ? "sass" : "overlay";
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = engine === "sass"
|
||||||
|
? require("./sassCompile").compileSass(tokens, opts)
|
||||||
|
: emitOverlayCss(tokens);
|
||||||
|
} catch (e) {
|
||||||
|
out = { css: "/* theme-builder: empty (compile error) */", warnings: ["compile threw: " + e.message] };
|
||||||
|
}
|
||||||
|
const guarded = robustnessGuard(out.css, out.warnings || []);
|
||||||
|
return { css: guarded.css, hash: contentHash(guarded.css), warnings: guarded.warnings, engine };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
compileTheme, emitOverlayCss, robustnessGuard, contentHash, deriveRgb,
|
||||||
|
flattenTokens, camelToKebab,
|
||||||
|
};
|
||||||
112
lib/configWorkflow.js
Normal file
112
lib/configWorkflow.js
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// lib/configWorkflow.js
|
||||||
|
// The presence of configuration_workflow is the single switch that makes every
|
||||||
|
// facility (headers/routes/layout) a (cfg)=>value factory (ARCHITECTURE.md 3.1/5.1).
|
||||||
|
// This workflow mounts:
|
||||||
|
// (1) an informational step linking to the builder SPA (URL_PREFIX + "/editor"), and
|
||||||
|
// (2) the layoutMode toggle, HARD-BLOCKED unless layout_by_role covers every live
|
||||||
|
// role so the last-installed fallback is never the live selector and per-request
|
||||||
|
// overlay suppression stays deterministic (ARCHITECTURE.md 5.7 / 12.3).
|
||||||
|
const Workflow = require("@saltcorn/data/models/workflow");
|
||||||
|
const Form = require("@saltcorn/data/models/form");
|
||||||
|
const Role = require("@saltcorn/data/models/role");
|
||||||
|
const { getState } = require("@saltcorn/data/db/state");
|
||||||
|
const { PLUGIN_NAME, URL_PREFIX, CFG } = require("./constants");
|
||||||
|
|
||||||
|
const EDITOR_URL = URL_PREFIX + "/editor";
|
||||||
|
|
||||||
|
|
||||||
|
// Every role row in _sc_roles is a "live" role. A role is covered when
|
||||||
|
// layout_by_role names theme-builder for it (keys are JSON strings, role.id is
|
||||||
|
// numeric -> normalize to String on both sides). Returns the list of uncovered
|
||||||
|
// role names; empty means full coverage (ARCHITECTURE.md 5.7 / 12.3).
|
||||||
|
async function uncoveredRoles() {
|
||||||
|
const byRole = getState().getConfig("layout_by_role", {}) || {};
|
||||||
|
const roles = await Role.find({}, { orderBy: "id" });
|
||||||
|
const out = [];
|
||||||
|
for (const role of roles) {
|
||||||
|
const mapped = byRole[role.id] != null ? byRole[role.id] : byRole[String(role.id)];
|
||||||
|
if (mapped !== PLUGIN_NAME) {
|
||||||
|
out.push(role.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function configuration_workflow() {
|
||||||
|
return new Workflow({
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
name: "Theme builder",
|
||||||
|
form: async () =>
|
||||||
|
new Form({
|
||||||
|
blurb:
|
||||||
|
"Design and manage themes in the visual builder, then activate one " +
|
||||||
|
"from the theme list. The builder opens in a full-page editor.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "_builder_link",
|
||||||
|
label: "Open the builder",
|
||||||
|
input_type: "custom_html",
|
||||||
|
attributes: {
|
||||||
|
html:
|
||||||
|
'<a class="btn btn-primary" target="_blank" rel="noopener" href="' +
|
||||||
|
EDITOR_URL +
|
||||||
|
'">Open theme builder »</a>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Layout mode",
|
||||||
|
form: async () => {
|
||||||
|
// Resolve coverage up front so the (synchronous) form validator can
|
||||||
|
// hard-block enabling layoutMode when any live role is uncovered.
|
||||||
|
const uncovered = await uncoveredRoles();
|
||||||
|
return new Form({
|
||||||
|
blurb:
|
||||||
|
"By default theme-builder only RECOLORS your current theme (a CSS " +
|
||||||
|
"overlay on top of your active layout). Turn this on to let " +
|
||||||
|
"theme-builder provide the PAGE LAYOUT itself (navbar / sidebar / " +
|
||||||
|
"content), making it a selectable site layout. It stays off until " +
|
||||||
|
"you opt in so that installing the plugin never changes your site's " +
|
||||||
|
"layout (Saltcorn uses the last-installed layout as the default). " +
|
||||||
|
"When on, assign theme-builder to roles under 'layout by role'; it " +
|
||||||
|
"must cover every role before this can be enabled." +
|
||||||
|
(uncovered.length
|
||||||
|
? '<div class="alert alert-warning mt-2">Roles not yet mapped to ' +
|
||||||
|
"theme-builder: " +
|
||||||
|
uncovered.join(", ") +
|
||||||
|
". Map them under Settings -> Users and security -> Roles before " +
|
||||||
|
"enabling layout mode.</div>"
|
||||||
|
: ""),
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: CFG.LAYOUT_MODE,
|
||||||
|
label: "Use theme-builder as the page layout",
|
||||||
|
type: "Bool",
|
||||||
|
default: false,
|
||||||
|
sublabel:
|
||||||
|
"Hard-blocked unless layout_by_role covers every live role.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
validator(values) {
|
||||||
|
if (values[CFG.LAYOUT_MODE] && uncovered.length) {
|
||||||
|
return (
|
||||||
|
"Cannot use theme-builder as the page layout yet: assign it to " +
|
||||||
|
"every role under 'layout by role' first. Uncovered roles: " +
|
||||||
|
uncovered.join(", ") +
|
||||||
|
". (This prevents some roles from silently falling back to a " +
|
||||||
|
"different layout.)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = configuration_workflow;
|
||||||
32
lib/constants.js
Normal file
32
lib/constants.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// One source of truth for plugin metadata, route paths, table name, caps, and
|
||||||
|
// config keys. Pure module: imports nothing (so it is safe to require from the
|
||||||
|
// unit-testable core as well as from the Saltcorn-dependent wiring).
|
||||||
|
|
||||||
|
const PLUGIN_NAME = "theme-builder"; // canonical plugin_name
|
||||||
|
const API_VERSION = 1;
|
||||||
|
const THEME_TABLE = "_themebuilder_themes";
|
||||||
|
const URL_PREFIX = "/theme-builder"; // top-level, NOT under core /admin
|
||||||
|
const API_BASE = `${URL_PREFIX}/api`;
|
||||||
|
const CSS_ROUTE = `${URL_PREFIX}/theme.css`;
|
||||||
|
const FORMAT_VERSION = 1; // export envelope formatVersion
|
||||||
|
const SCHEMA_ID = "saltcorn-theme";
|
||||||
|
const ENGINE = "bootstrap5";
|
||||||
|
const MAX_IMPORT_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||||
|
const MAX_TOKENS = 2000;
|
||||||
|
const ROLE_ADMIN = 1;
|
||||||
|
const ROLE_PUBLIC = 100;
|
||||||
|
|
||||||
|
// config keys (live in plugin.configuration):
|
||||||
|
const CFG = {
|
||||||
|
ACTIVE: "activeThemeId",
|
||||||
|
BY_ROLE: "activeByRole",
|
||||||
|
HASH: "activeHash",
|
||||||
|
LAYOUT_MODE: "layoutMode",
|
||||||
|
SCHEMA_VER: "schemaVersion",
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PLUGIN_NAME, API_VERSION, THEME_TABLE, URL_PREFIX, API_BASE, CSS_ROUTE,
|
||||||
|
FORMAT_VERSION, SCHEMA_ID, ENGINE, MAX_IMPORT_BYTES, MAX_TOKENS,
|
||||||
|
ROLE_ADMIN, ROLE_PUBLIC, CFG,
|
||||||
|
};
|
||||||
23
lib/cssCache.js
Normal file
23
lib/cssCache.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// lib/cssCache.js
|
||||||
|
// Per-tenant compiled-CSS cache + cache-busting (ARCHITECTURE.md 7.7)
|
||||||
|
const db = require("@saltcorn/data/db");
|
||||||
|
const { compileTheme } = require("./compile");
|
||||||
|
const themeStore = require("./themeStore");
|
||||||
|
|
||||||
|
const cache = new Map(); // tenantSchema -> Map<`${themeId}:${role??"_"}`, CompiledEntry>
|
||||||
|
function tkey() { return db.getTenantSchema(); }
|
||||||
|
function ck(themeId, role) { return `${themeId}:${role ?? "_"}`; }
|
||||||
|
function getCached(themeId, role) { return cache.get(tkey())?.get(ck(themeId, role)); }
|
||||||
|
function setCached(e) { let t = cache.get(tkey()); if (!t) cache.set(tkey(), (t = new Map())); t.set(ck(e.themeId, e.role), e); }
|
||||||
|
function bustTheme(themeId) { const t = cache.get(tkey()); if (t) for (const k of [...t.keys()]) if (k.startsWith(themeId + ":")) t.delete(k); }
|
||||||
|
function bustAll() { cache.delete(tkey()); }
|
||||||
|
async function warm(themeId, role) { // used by onLoad rehydration + activate
|
||||||
|
const theme = await themeStore.getById(themeId); // resolves rows AND builtins (SEAM FIX)
|
||||||
|
if (!theme) return null;
|
||||||
|
const compiled = compileTheme(theme.tokens, { engine: theme.engine });
|
||||||
|
const entry = { ...compiled, themeId, role: role ?? null, builtAt: Date.now() };
|
||||||
|
setCached(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getCached, setCached, bustTheme, bustAll, warm };
|
||||||
89
lib/deepOverlay.js
Normal file
89
lib/deepOverlay.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// The "deep overlay": emit the per-component --bs-btn-* variables that Bootstrap
|
||||||
|
// 5.3 bakes at Sass compile time, recomputed from the theme's colors -- so solid
|
||||||
|
// and outline BUTTONS actually recolor under the CSS-variable overlay (a plain
|
||||||
|
// :root{--bs-primary} override does NOT recolor .btn-primary, whose colors are
|
||||||
|
// build-time literals). Mirrors scss/_buttons.scss + mixins/_buttons.scss.
|
||||||
|
|
||||||
|
const {
|
||||||
|
parseHexColor, toHex, toRgbString, mix, shade, tint, colorContrast,
|
||||||
|
} = require("./bootstrapColor");
|
||||||
|
|
||||||
|
// The themeable button variants, in Bootstrap order.
|
||||||
|
const BTN_VARIANTS = ["primary", "secondary", "success", "info", "warning", "danger", "light", "dark"];
|
||||||
|
|
||||||
|
// button-variant hover/active shade & tint amounts (scss/_variables.scss).
|
||||||
|
const A = {
|
||||||
|
hoverBgShade: 15, hoverBgTint: 15,
|
||||||
|
hoverBorderShade: 20, hoverBorderTint: 10,
|
||||||
|
activeBgShade: 20, activeBgTint: 20,
|
||||||
|
activeBorderShade: 25, activeBorderTint: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// One solid .btn-<name> rule. `name` selects the light/dark force-overrides:
|
||||||
|
// .btn-light -> always SHADE; .btn-dark -> always TINT; else by text color.
|
||||||
|
function solidButtonRule(name, color) {
|
||||||
|
const c = parseHexColor(color);
|
||||||
|
if (!c) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const textColor = colorContrast(c); // "#ffffff" | "#000000"
|
||||||
|
const shadeMode = name === "light" ? true : name === "dark" ? false : textColor === "#ffffff";
|
||||||
|
|
||||||
|
const hoverBg = shadeMode ? shade(c, A.hoverBgShade) : tint(c, A.hoverBgTint);
|
||||||
|
const hoverBorder = shadeMode ? shade(c, A.hoverBorderShade) : tint(c, A.hoverBorderTint);
|
||||||
|
const activeBg = shadeMode ? shade(c, A.activeBgShade) : tint(c, A.activeBgTint);
|
||||||
|
const activeBorder = shadeMode ? shade(c, A.activeBorderShade) : tint(c, A.activeBorderTint);
|
||||||
|
const focusRgb = toRgbString(mix(parseHexColor(textColor), c, 0.15));
|
||||||
|
|
||||||
|
const bg = toHex(c);
|
||||||
|
return `.btn-${name}{`
|
||||||
|
+ `--bs-btn-color:${textColor};--bs-btn-bg:${bg};--bs-btn-border-color:${bg};`
|
||||||
|
+ `--bs-btn-hover-color:${colorContrast(hoverBg)};--bs-btn-hover-bg:${toHex(hoverBg)};--bs-btn-hover-border-color:${toHex(hoverBorder)};`
|
||||||
|
+ `--bs-btn-focus-shadow-rgb:${focusRgb};`
|
||||||
|
+ `--bs-btn-active-color:${colorContrast(activeBg)};--bs-btn-active-bg:${toHex(activeBg)};--bs-btn-active-border-color:${toHex(activeBorder)};`
|
||||||
|
+ `--bs-btn-disabled-color:${textColor};--bs-btn-disabled-bg:${bg};--bs-btn-disabled-border-color:${bg};`
|
||||||
|
+ `}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// One .btn-outline-<name> rule (button-outline-variant): transparent fill that
|
||||||
|
// inverts to the variant color on hover/active.
|
||||||
|
function outlineButtonRule(name, color) {
|
||||||
|
const c = parseHexColor(color);
|
||||||
|
if (!c) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const hex = toHex(c);
|
||||||
|
const onColor = colorContrast(c);
|
||||||
|
return `.btn-outline-${name}{`
|
||||||
|
+ `--bs-btn-color:${hex};--bs-btn-border-color:${hex};`
|
||||||
|
+ `--bs-btn-hover-color:${onColor};--bs-btn-hover-bg:${hex};--bs-btn-hover-border-color:${hex};`
|
||||||
|
+ `--bs-btn-focus-shadow-rgb:${toRgbString(c)};`
|
||||||
|
+ `--bs-btn-active-color:${onColor};--bs-btn-active-bg:${hex};--bs-btn-active-border-color:${hex};`
|
||||||
|
+ `--bs-btn-disabled-color:${hex};--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:${hex};`
|
||||||
|
+ `}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Emit the full deep-overlay CSS for the given color map { primary, secondary,
|
||||||
|
// ... }. Variants whose value is not a hex color are skipped (the :root overlay
|
||||||
|
// still sets --bs-<name> for them).
|
||||||
|
function emitButtonRules(colors) {
|
||||||
|
if (!colors || typeof colors !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const name of BTN_VARIANTS) {
|
||||||
|
const color = colors[name];
|
||||||
|
if (!color || !parseHexColor(color)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(solidButtonRule(name, color));
|
||||||
|
out.push(outlineButtonRule(name, color));
|
||||||
|
}
|
||||||
|
return out.filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { emitButtonRules, solidButtonRule, outlineButtonRule, BTN_VARIANTS };
|
||||||
54
lib/headers.js
Normal file
54
lib/headers.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// lib/headers.js
|
||||||
|
// headers(cfg) injection + per-role / layout-mode suppression (ARCHITECTURE.md 7.6)
|
||||||
|
//
|
||||||
|
// Grounding (real Saltcorn source under packages/):
|
||||||
|
// - server/wrapper.js:137 -- a header is emitted only when `h.only_if(req) === true`
|
||||||
|
// (strict equality), so every predicate here returns an explicit boolean.
|
||||||
|
// - saltcorn-data/db/state.ts:1122-1135 -- only `script` URLs are de-duped; `css`
|
||||||
|
// headers are not, so distinct per-role ?v/&theme/&role links coexist.
|
||||||
|
const { CSS_ROUTE } = require("./constants");
|
||||||
|
const {
|
||||||
|
getActiveThemeId,
|
||||||
|
getActiveByRole,
|
||||||
|
activeHashHint,
|
||||||
|
isLayoutMode,
|
||||||
|
requestRendersViaThemeBuilder,
|
||||||
|
} = require("./cfgReaders");
|
||||||
|
|
||||||
|
function headers(cfg) {
|
||||||
|
const def = getActiveThemeId(cfg);
|
||||||
|
const byRole = getActiveByRole(cfg);
|
||||||
|
const list = [];
|
||||||
|
if (!def) return list;
|
||||||
|
|
||||||
|
// ONE header; only_if decides per-request whether this role gets the overlay.
|
||||||
|
list.push({
|
||||||
|
css: `${CSS_ROUTE}?v=${activeHashHint(cfg)}`,
|
||||||
|
only_if: (req) => {
|
||||||
|
const role = req.user?.role_id ?? 100;
|
||||||
|
// LAYOUT MODE: suppress ONLY for requests that actually render via theme-builder.
|
||||||
|
// Detected by replicating getLayout's fallback precedence (cfgReaders), NOT by reading
|
||||||
|
// getLayout().pluginName -- the last-installed fallback omits pluginName, so an uncovered
|
||||||
|
// role would otherwise be double-themed (overlay + wrap()). (folded review fix)
|
||||||
|
if (isLayoutMode(cfg) && requestRendersViaThemeBuilder(req)) return false;
|
||||||
|
// role with an explicit override gets its own header below, not this one
|
||||||
|
return byRole[role] === undefined || byRole[role] === def;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// per-role overlays for explicit overrides (Phase 1)
|
||||||
|
for (const [roleStr, tid] of Object.entries(byRole)) {
|
||||||
|
if (tid === def) continue;
|
||||||
|
const role = Number(roleStr);
|
||||||
|
list.push({
|
||||||
|
css: `${CSS_ROUTE}?v=${activeHashHint(cfg)}&theme=${encodeURIComponent(tid)}&role=${role}`,
|
||||||
|
only_if: (req) => {
|
||||||
|
if (isLayoutMode(cfg) && requestRendersViaThemeBuilder(req)) return false;
|
||||||
|
return (req.user?.role_id ?? 100) === role; // MUST strictly return true (wrapper.js:137)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { headers };
|
||||||
27
lib/httpUtils.js
Normal file
27
lib/httpUtils.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// lib/httpUtils.js
|
||||||
|
// Admin guard -- JSON-aware (ARCHITECTURE.md 6.4, folded review fix)
|
||||||
|
//
|
||||||
|
// Uniform error envelope: { error: { code, message, ...context } }, where
|
||||||
|
// code in { forbidden, not_found, version_conflict, name_taken, active_theme,
|
||||||
|
// builtin_immutable, bad_format, too_large, uncompilable,
|
||||||
|
// bad_request, compile_failed, activation_failed, initializing }.
|
||||||
|
const db = require("@saltcorn/data/db");
|
||||||
|
const { ROLE_ADMIN, API_BASE } = require("./constants");
|
||||||
|
|
||||||
|
function isAdminReq(req) { // mirror core isAdmin (utils.js:85-100)
|
||||||
|
return !!(req.user && req.user.role_id === ROLE_ADMIN && req.user.tenant === db.getTenantSchema());
|
||||||
|
}
|
||||||
|
function wantsJson(req) {
|
||||||
|
return req.xhr
|
||||||
|
|| (req.path || "").startsWith(API_BASE) // API routes ALWAYS answer JSON (fixes multipart-import misclassify)
|
||||||
|
|| (req.get("accept") || "").includes("application/json")
|
||||||
|
|| (req.get("content-type") || "").includes("application/json");
|
||||||
|
}
|
||||||
|
function guardAdmin(req, res) {
|
||||||
|
if (isAdminReq(req)) return true;
|
||||||
|
if (wantsJson(req)) res.status(req.user ? 403 : 401).json({ error: { code: "forbidden", message: "Must be admin" } });
|
||||||
|
else res.redirect(req.user ? "/" : `/auth/login?dest=${encodeURIComponent(req.originalUrl)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function jsonError(res, status, code, message, extra) { res.status(status).json({ error: { code, message, ...(extra || {}) } }); }
|
||||||
|
module.exports = { isAdminReq, wantsJson, guardAdmin, jsonError };
|
||||||
81
lib/layout.js
Normal file
81
lib/layout.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// lib/layout.js
|
||||||
|
// layout(cfg) -- Phase-1 returns undefined; Phase-2 returns a real PluginLayout
|
||||||
|
// (ARCHITECTURE.md 5.1, 7.10). When Phase 2 is enabled the plugin registers into
|
||||||
|
// state.layouts["theme-builder"] (state.ts:1113-1117) and is selectable via
|
||||||
|
// layout_by_role[role] === "theme-builder" (or user._attributes.layout).
|
||||||
|
//
|
||||||
|
// wrap() OWNS the document: it links the vendored stock Bootstrap CSS + the
|
||||||
|
// theme overlay (var(--bs-*)) CSS, renders the page chrome from the active
|
||||||
|
// layoutTree (renderTree.js), and threads Saltcorn's headersInHead/Body. Because
|
||||||
|
// wrap() emits the token <link> itself, headers(cfg)'s only_if suppresses the
|
||||||
|
// Phase-1 overlay header for theme-builder-served requests (7.6). Sass is the
|
||||||
|
// documented opt-in; the default engine is the overlay on stock Bootstrap.
|
||||||
|
|
||||||
|
const { PLUGIN_NAME, CSS_ROUTE } = require("./constants");
|
||||||
|
const { isLayoutMode, activeHashHint, activeLayoutTree } = require("./cfgReaders");
|
||||||
|
const { renderTreeToHtml, renderToasts, esc } = require("./renderTree");
|
||||||
|
const { headersInHead, headersInBody } = require("@saltcorn/markup/layout_utils");
|
||||||
|
const { renderForm } = require("@saltcorn/markup");
|
||||||
|
|
||||||
|
const ASSET_BASE = `/plugins/public/${PLUGIN_NAME}`;
|
||||||
|
const BOOTSTRAP_CSS = `${ASSET_BASE}/themeBootstrap.min.css`;
|
||||||
|
const BOOTSTRAP_JS = `${ASSET_BASE}/themeBootstrap.bundle.min.js`;
|
||||||
|
|
||||||
|
|
||||||
|
function headLinks(cfg, role, headers) {
|
||||||
|
const v = activeHashHint(cfg, role);
|
||||||
|
return `<meta charset="utf-8">`
|
||||||
|
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`
|
||||||
|
+ `<link rel="stylesheet" href="${BOOTSTRAP_CSS}">`
|
||||||
|
+ `<link rel="stylesheet" href="${CSS_ROUTE}?v=${v}">`
|
||||||
|
+ headersInHead(headers || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function layout(cfg) {
|
||||||
|
if (!isLayoutMode(cfg)) {
|
||||||
|
return undefined; // falsy => not registered as a layout (layout mode off)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pluginName: PLUGIN_NAME,
|
||||||
|
|
||||||
|
wrap: ({ title, body, brand, menu, alerts, headers, bodyClass, role, req, currentUrl } = {}) => {
|
||||||
|
const chrome = renderTreeToHtml(activeLayoutTree(cfg, role), {
|
||||||
|
title, body, brand, menu, alerts: alerts || [], role, req, currentUrl,
|
||||||
|
});
|
||||||
|
return `<!doctype html><html lang="en"><head>${headLinks(cfg, role, headers)}`
|
||||||
|
+ `<title>${esc(title || "")}</title></head>`
|
||||||
|
+ `<body class="${esc(bodyClass || "")}">${chrome}`
|
||||||
|
+ `<script src="${BOOTSTRAP_JS}"></script>${headersInBody(headers || [])}`
|
||||||
|
+ `</body></html>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth pages (login/signup): a centered card. The login/signup FORM arrives
|
||||||
|
// as a Saltcorn Form OBJECT under `form` and MUST be rendered via
|
||||||
|
// renderForm(form, csrfToken) -- it carries the _csrf field. authLinks are
|
||||||
|
// the login/forgot/signup cross-links; afterForm is extra HTML.
|
||||||
|
authWrap: ({ title, form, afterForm, authLinks, alerts, headers, csrfToken, role, req } = {}) => {
|
||||||
|
const links = [];
|
||||||
|
if (authLinks) {
|
||||||
|
if (authLinks.login) links.push(`<a href="${esc(authLinks.login)}">Login</a>`);
|
||||||
|
if (authLinks.forgot) links.push(`<a href="${esc(authLinks.forgot)}">Forgot password?</a>`);
|
||||||
|
if (authLinks.signup) links.push(`<a href="${esc(authLinks.signup)}">Create an account</a>`);
|
||||||
|
}
|
||||||
|
const formHtml = form ? renderForm(form, csrfToken || "") : "";
|
||||||
|
return `<!doctype html><html lang="en"><head>${headLinks(cfg, role, headers)}`
|
||||||
|
+ `<title>${esc(title || "")}</title></head>`
|
||||||
|
+ `<body class="tb-auth"><div class="container" style="max-width:28rem"><div class="py-5">`
|
||||||
|
+ `<div class="card shadow-sm"><div class="card-body p-4">`
|
||||||
|
+ `<h1 class="h4 mb-3 text-center">${esc(title || "")}</h1>`
|
||||||
|
+ `${formHtml}<div class="mt-3 text-center small">${links.join(" | ")}</div>${afterForm || ""}`
|
||||||
|
+ `</div></div></div></div>${renderToasts(alerts || [])}`
|
||||||
|
+ `<script src="${BOOTSTRAP_JS}"></script>${headersInBody(headers || [])}`
|
||||||
|
+ `</body></html>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// renderBody: passthrough -- the body content is already rendered HTML.
|
||||||
|
renderBody: ({ body } = {}) => body || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { layout };
|
||||||
127
lib/layoutTree.js
Normal file
127
lib/layoutTree.js
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
// Phase-2 layout "bones": the layoutTree schema, the structural node-type set
|
||||||
|
// (the server-side resolver mirror), built-in PRESETS, and validation. A theme's
|
||||||
|
// layout_tree (stored JSON) is one of these trees; renderTree.js emits HTML from
|
||||||
|
// it. Pure module -- no @saltcorn, no DB.
|
||||||
|
//
|
||||||
|
// A node is { type, props?, children? }. Slots are filled at render time from the
|
||||||
|
// wrap() context (brand/menu -> Navbar/Sidebar, body -> Content, alerts -> toasts).
|
||||||
|
|
||||||
|
// The structural subset every serialized tree may use (resolver completeness:
|
||||||
|
// renderTree must handle each of these or it falls back to a passthrough).
|
||||||
|
const NODE_TYPES = ["Root", "Navbar", "Sidebar", "Content", "Footer", "Row", "Col", "Html", "AlertsSlot"];
|
||||||
|
|
||||||
|
const MAX_NODES = 200;
|
||||||
|
const MAX_DEPTH = 12;
|
||||||
|
|
||||||
|
|
||||||
|
// ---- built-in presets ------------------------------------------------------
|
||||||
|
|
||||||
|
const TOPNAV = Object.freeze({
|
||||||
|
type: "Root",
|
||||||
|
props: { className: "min-vh-100 d-flex flex-column" },
|
||||||
|
children: [
|
||||||
|
{ type: "Navbar", props: { variant: "dark", bg: "primary", expand: "lg", brand: true, menu: true, fluid: false } },
|
||||||
|
{ type: "Content", props: { container: "container" } },
|
||||||
|
{ type: "Footer", props: { text: "" } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const SIDEBAR = Object.freeze({
|
||||||
|
type: "Root",
|
||||||
|
props: { className: "min-vh-100 d-flex" },
|
||||||
|
children: [
|
||||||
|
{ type: "Sidebar", props: { variant: "dark", bg: "dark", brand: true, menu: true, width: "240px" } },
|
||||||
|
{ type: "Content", props: { container: "fluid", grow: true } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const PRESETS = Object.freeze([
|
||||||
|
Object.freeze({ id: "topnav", label: "Top navbar", tree: TOPNAV }),
|
||||||
|
Object.freeze({ id: "sidebar", label: "Left sidebar", tree: SIDEBAR }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_LAYOUT_TREE = TOPNAV;
|
||||||
|
|
||||||
|
|
||||||
|
function listPresets() {
|
||||||
|
return PRESETS.map((p) => ({ id: p.id, label: p.label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getPreset(id) {
|
||||||
|
const p = PRESETS.find((x) => x.id === id);
|
||||||
|
return p ? structuredClone(p.tree) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- validation ------------------------------------------------------------
|
||||||
|
|
||||||
|
function isPlainObject(x) {
|
||||||
|
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Walk the tree counting nodes / Content slots and checking node types + depth.
|
||||||
|
function walk(node, depth, acc) {
|
||||||
|
if (!isPlainObject(node)) {
|
||||||
|
acc.errors.push("node is not an object");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acc.count += 1;
|
||||||
|
if (acc.count > MAX_NODES) {
|
||||||
|
acc.errors.push("too many nodes");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth > MAX_DEPTH) {
|
||||||
|
acc.errors.push("tree too deep");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!NODE_TYPES.includes(node.type)) {
|
||||||
|
acc.errors.push(`unknown node type: ${node.type}`);
|
||||||
|
}
|
||||||
|
if (node.type === "Content") {
|
||||||
|
acc.contentSlots += 1;
|
||||||
|
}
|
||||||
|
if (node.children != null) {
|
||||||
|
if (!Array.isArray(node.children)) {
|
||||||
|
acc.errors.push(`${node.type}.children must be an array`);
|
||||||
|
} else {
|
||||||
|
for (const c of node.children) {
|
||||||
|
walk(c, depth + 1, acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Returns { ok, errors[] }. A valid tree has a Root, exactly one Content slot,
|
||||||
|
// only known node types, and is within the size/depth caps.
|
||||||
|
function validateLayoutTree(tree) {
|
||||||
|
const acc = { errors: [], count: 0, contentSlots: 0 };
|
||||||
|
if (!isPlainObject(tree)) {
|
||||||
|
return { ok: false, errors: ["layoutTree must be an object"] };
|
||||||
|
}
|
||||||
|
if (tree.type !== "Root") {
|
||||||
|
acc.errors.push("layoutTree root must be a Root node");
|
||||||
|
}
|
||||||
|
walk(tree, 0, acc);
|
||||||
|
if (acc.contentSlots !== 1) {
|
||||||
|
acc.errors.push(`exactly one Content slot required (found ${acc.contentSlots})`);
|
||||||
|
}
|
||||||
|
return { ok: acc.errors.length === 0, errors: acc.errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Return the tree if it is valid, else the default preset (never white-screen).
|
||||||
|
function normalizeLayoutTree(tree) {
|
||||||
|
if (tree == null) {
|
||||||
|
return structuredClone(DEFAULT_LAYOUT_TREE);
|
||||||
|
}
|
||||||
|
return validateLayoutTree(tree).ok ? tree : structuredClone(DEFAULT_LAYOUT_TREE);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NODE_TYPES, MAX_NODES, MAX_DEPTH, PRESETS, DEFAULT_LAYOUT_TREE,
|
||||||
|
listPresets, getPreset, validateLayoutTree, normalizeLayoutTree,
|
||||||
|
};
|
||||||
35
lib/onLoad.js
Normal file
35
lib/onLoad.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// lib/onLoad.js
|
||||||
|
// Idempotent per-tenant bootstrap + active-CSS rehydration (ARCHITECTURE.md 5.6).
|
||||||
|
const db = require("@saltcorn/data/db");
|
||||||
|
const { getState } = require("@saltcorn/data/db/state");
|
||||||
|
const { eachTenant } = require("@saltcorn/admin-models/models/tenant"); // canonical (folded fix)
|
||||||
|
const themeStore = require("./themeStore");
|
||||||
|
const cssCache = require("./cssCache");
|
||||||
|
const { getActivePointer } = require("./activePointer");
|
||||||
|
const { PLUGIN_NAME } = require("./constants");
|
||||||
|
|
||||||
|
async function bootstrapTenant() {
|
||||||
|
// 1) HOT-PATH SHORT-CIRCUIT: if the table already exists for THIS tenant, skip all DDL
|
||||||
|
// work cheaply (loadPlugin re-runs onLoad on every activate). (folded review fix)
|
||||||
|
await themeStore.ensureTableForCurrentTenant(); // Table.findOne first; Table.create only if null
|
||||||
|
|
||||||
|
// 2) Rehydrate the active-CSS cache from the persisted pointer so the live site is themed
|
||||||
|
// immediately after a restart/new worker (folded review fix: rehydration was unowned).
|
||||||
|
try {
|
||||||
|
const { activeThemeId, activeByRole } = getActivePointer();
|
||||||
|
const keys = new Set();
|
||||||
|
if (activeThemeId) keys.add(null); // default key
|
||||||
|
for (const r of Object.keys(activeByRole)) keys.add(Number(r));
|
||||||
|
for (const roleKey of keys) {
|
||||||
|
const id = roleKey == null ? activeThemeId : activeByRole[roleKey];
|
||||||
|
if (id) await cssCache.warm(id, roleKey); // recompile -> cache
|
||||||
|
}
|
||||||
|
} catch (e) { getState().log(3, `theme-builder rehydrate failed: ${e.message}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// eachTenant self-selects single vs multi: it runs default_schema FIRST then iterates
|
||||||
|
// DB-backed tenants only in MT mode. No hand-rolled is_it_multi_tenant branch. (folded fix)
|
||||||
|
async function onLoad(/* rawCfg */) {
|
||||||
|
await eachTenant(bootstrapTenant);
|
||||||
|
}
|
||||||
|
module.exports = { onLoad, bootstrapTenant };
|
||||||
112
lib/page.js
Normal file
112
lib/page.js
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// lib/page.js
|
||||||
|
// The Builder SPA shell page (ARCHITECTURE.md 8.2).
|
||||||
|
//
|
||||||
|
// renderEditorShell(req, res) builds a COMPLETE, dependency-free HTML document
|
||||||
|
// (no React in the served shell) that:
|
||||||
|
// (a) loads the compiled Phase-1 editor from the plugin's static public dir,
|
||||||
|
// /plugins/public/theme-builder/builderApp.js (plugins.js:1246-1276),
|
||||||
|
// (b) bootstraps window.__TB__ = { apiBase, cssRoute, csrfToken } -- the CSRF
|
||||||
|
// token is read from req exactly the way core does it (req.csrfToken(),
|
||||||
|
// e.g. server/wrapper.js:259, server/app.js:74 where it defaults to ""),
|
||||||
|
// (c) exposes a single mount point <div id="theme-builder-root"> the editor
|
||||||
|
// attaches to.
|
||||||
|
//
|
||||||
|
// The function RETURNS the complete document string; when a res is supplied
|
||||||
|
// (the apiHandlers.getEditor path) it also res.send()s that document. We send a
|
||||||
|
// standalone document rather than res.sendWrap()'ing a body fragment so the SPA
|
||||||
|
// owns the whole page and stays free of the core React chrome -- per the task's
|
||||||
|
// "COMPLETE HTML document / dependency-free shell" contract.
|
||||||
|
|
||||||
|
const { API_BASE, CSS_ROUTE, URL_PREFIX, PLUGIN_NAME } = require("./constants");
|
||||||
|
|
||||||
|
// The plugin's committed editor bundle, served by core's static plugin route.
|
||||||
|
const BUNDLE_URL = `/plugins/public/${PLUGIN_NAME}/builderApp.js`;
|
||||||
|
|
||||||
|
|
||||||
|
// Mirror core's CSRF read: req.csrfToken is a function (csurf adds it; in some
|
||||||
|
// modes app.js stubs it to return ""). Guard so a missing token never throws.
|
||||||
|
function readCsrfToken(req) {
|
||||||
|
if (req && typeof req.csrfToken === "function") {
|
||||||
|
try {
|
||||||
|
return req.csrfToken() || "";
|
||||||
|
} catch (e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Minimal HTML-escaper for the <title> and any attribute text. The JSON blob is
|
||||||
|
// emitted via JSON.stringify with "<" escaped so it cannot break out of the
|
||||||
|
// <script> element.
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// JSON for inline <script>: prevent "</script>" / "<!--" from terminating the
|
||||||
|
// element, and keep it ASCII-only.
|
||||||
|
function safeJson(value) {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
.replace(/</g, "\\u003c")
|
||||||
|
.replace(/>/g, "\\u003e")
|
||||||
|
.replace(/&/g, "\\u0026")
|
||||||
|
.replace(/[\u2028]/g, "\\u2028")
|
||||||
|
.replace(/[\u2029]/g, "\\u2029");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Build the complete HTML document string. openThemeId (?id=) lets a deep-link
|
||||||
|
// open straight into a theme; the editor reads it from the boot blob.
|
||||||
|
function buildShellHtml(req) {
|
||||||
|
const csrfToken = readCsrfToken(req);
|
||||||
|
const openThemeId = (req && req.query && req.query.id) ? String(req.query.id) : null;
|
||||||
|
const boot = {
|
||||||
|
apiBase: API_BASE,
|
||||||
|
cssRoute: CSS_ROUTE,
|
||||||
|
base: URL_PREFIX,
|
||||||
|
csrfToken,
|
||||||
|
openThemeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<title>${escapeHtml("Theme Builder")}</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||||
|
#theme-builder-root { height: 100%; }
|
||||||
|
#theme-builder-root .tb-loading { padding: 2rem; color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="theme-builder-root"><div class="tb-loading">Loading Theme Builder...</div></div>
|
||||||
|
<script>window.__TB__ = ${safeJson(boot)};</script>
|
||||||
|
<script src="${BUNDLE_URL}" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Public entry point. Returns the document string; if res is provided, sends it.
|
||||||
|
function renderEditorShell(req, res) {
|
||||||
|
const html = buildShellHtml(req);
|
||||||
|
if (res && typeof res.send === "function") {
|
||||||
|
res.set("Content-Type", "text/html; charset=utf-8");
|
||||||
|
res.send(html);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { renderEditorShell, buildShellHtml, BUNDLE_URL };
|
||||||
119
lib/portability.js
Normal file
119
lib/portability.js
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// Export/import as a versioned, self-describing JSON envelope. Import is
|
||||||
|
// admin-only and performs NO security sanitization (an admin already owns site
|
||||||
|
// CSS/JS via page_custom_css / custom headers / plugin install -- see README
|
||||||
|
// SECURITY). Only four ROBUSTNESS checks run: size cap, format+version upcast,
|
||||||
|
// shape validation, and a "won't white-screen" compile check.
|
||||||
|
//
|
||||||
|
// CONTINGENCY: if a non-admin role is ever allowed to import/manage themes,
|
||||||
|
// real sanitization (CSS/selector/@import/url()/expression filtering, layoutTree
|
||||||
|
// resolver allow-listing) MUST be reintroduced before that capability ships.
|
||||||
|
|
||||||
|
const {
|
||||||
|
SCHEMA_ID, FORMAT_VERSION, ENGINE, MAX_IMPORT_BYTES, MAX_TOKENS,
|
||||||
|
} = require("./constants");
|
||||||
|
const { normalizeTokens, tokensAreValid } = require("./themeSchema");
|
||||||
|
const { compileTheme } = require("./compile");
|
||||||
|
|
||||||
|
|
||||||
|
class ImportError extends Error {}
|
||||||
|
|
||||||
|
|
||||||
|
// The export envelope carries NO id, version, timestamps, or compiled CSS --
|
||||||
|
// identity is instance-local (import mints fresh), so import is structurally
|
||||||
|
// non-destructive.
|
||||||
|
function buildEnvelope(theme) {
|
||||||
|
const env = {
|
||||||
|
$schema: SCHEMA_ID,
|
||||||
|
formatVersion: FORMAT_VERSION,
|
||||||
|
name: theme.name,
|
||||||
|
engine: theme.engine || ENGINE,
|
||||||
|
tokens: theme.tokens,
|
||||||
|
};
|
||||||
|
if (theme.layoutTree) {
|
||||||
|
env.layoutTree = theme.layoutTree;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Envelope-axis upcasters (formatVersion). The token-shape axis
|
||||||
|
// (tokens.$tokensVersion) is handled separately by normalizeTokens.
|
||||||
|
const FORMAT_UPCASTERS = {
|
||||||
|
0: (e) => ({
|
||||||
|
$schema: SCHEMA_ID, formatVersion: 1, name: e.name || "Imported",
|
||||||
|
engine: e.engine || ENGINE, tokens: e.vars || e.tokens || {}, layoutTree: e.layoutTree || null,
|
||||||
|
}),
|
||||||
|
// future: 1 -> 2 here
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function upcastEnvelope(e) {
|
||||||
|
let cur = e;
|
||||||
|
let v = typeof e.formatVersion === "number" ? e.formatVersion : 0;
|
||||||
|
while (v < FORMAT_VERSION) {
|
||||||
|
if (!FORMAT_UPCASTERS[v]) {
|
||||||
|
throw new ImportError(`no upcaster for formatVersion ${v}`);
|
||||||
|
}
|
||||||
|
cur = FORMAT_UPCASTERS[v](cur);
|
||||||
|
v = cur.formatVersion;
|
||||||
|
}
|
||||||
|
if (v > FORMAT_VERSION) {
|
||||||
|
throw new ImportError(`formatVersion ${v} newer than supported ${FORMAT_VERSION}`);
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function parseEnvelope(rawText) {
|
||||||
|
// (d) size cap -- operational hygiene, BEFORE parse
|
||||||
|
if (Buffer.byteLength(rawText, "utf8") > MAX_IMPORT_BYTES) {
|
||||||
|
return { ok: false, error: "Theme file exceeds size limit" };
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawText);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Not valid JSON" };
|
||||||
|
}
|
||||||
|
// (a) format + discriminator + upcast
|
||||||
|
if (!parsed || parsed.$schema !== SCHEMA_ID) {
|
||||||
|
return { ok: false, error: "Not a saltcorn-theme file" };
|
||||||
|
}
|
||||||
|
let env;
|
||||||
|
try {
|
||||||
|
env = upcastEnvelope(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e.message };
|
||||||
|
}
|
||||||
|
if (env.engine && env.engine !== ENGINE) {
|
||||||
|
return { ok: false, error: `Unsupported engine: ${env.engine}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = normalizeTokens(env.tokens);
|
||||||
|
if (Object.keys(tokens).length > MAX_TOKENS) {
|
||||||
|
return { ok: false, error: "Too many tokens" };
|
||||||
|
}
|
||||||
|
const tv = tokensAreValid(tokens);
|
||||||
|
if (!tv.ok) {
|
||||||
|
return { ok: false, error: tv.errors.join("; ") };
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) light "won't white-screen" check: actually compile + verify balanced braces.
|
||||||
|
const compiled = compileTheme(tokens, { engine: env.engine || ENGINE });
|
||||||
|
if (compiled.warnings.some((w) => /unbalanced|empty \(invalid\)/.test(w))) {
|
||||||
|
return { ok: false, error: "Theme tokens do not compile to valid CSS" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
draft: {
|
||||||
|
name: (env.name || "Imported Theme").trim(),
|
||||||
|
engine: env.engine || ENGINE,
|
||||||
|
tokens,
|
||||||
|
layoutTree: env.layoutTree ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { ImportError, buildEnvelope, upcastEnvelope, FORMAT_UPCASTERS, parseEnvelope };
|
||||||
154
lib/renderTree.js
Normal file
154
lib/renderTree.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// Phase-2 server-side tree -> HTML emitter. Walks a layoutTree (see layoutTree.js)
|
||||||
|
// and produces the page chrome, filling slots from the wrap() context:
|
||||||
|
// brand/menu -> Navbar/Sidebar, body -> Content, alerts -> toasts.
|
||||||
|
//
|
||||||
|
// body/menu/brand are server-generated HTML from Saltcorn (trusted) and are NOT
|
||||||
|
// escaped; only admin/user TEXT (brand name, footer text) is escaped. Pure module.
|
||||||
|
|
||||||
|
const { normalizeLayoutTree } = require("./layoutTree");
|
||||||
|
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function bsAlertType(t) {
|
||||||
|
const map = { error: "danger", danger: "danger", success: "success", warning: "warning", info: "info", primary: "primary" };
|
||||||
|
return map[t] || "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderBrand(brand) {
|
||||||
|
if (!brand) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (typeof brand === "string") {
|
||||||
|
return brand; // already HTML
|
||||||
|
}
|
||||||
|
const logo = brand.logo ? `<img src="${esc(brand.logo)}" height="30" class="me-2" alt="">` : "";
|
||||||
|
return `<a class="navbar-brand" href="/">${logo}${esc(brand.name || "")}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Render Saltcorn's menu array into Bootstrap nav markup. Defensive about shape:
|
||||||
|
// menu is an array of sections, each with an `items` array (or an item itself).
|
||||||
|
function renderMenu(menu, variant, ctx) {
|
||||||
|
if (!Array.isArray(menu) || menu.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const ulClass = variant === "sidebar" ? "nav flex-column" : "navbar-nav me-auto mb-2 mb-lg-0";
|
||||||
|
const items = [];
|
||||||
|
for (const section of menu) {
|
||||||
|
const list = Array.isArray(section?.items) ? section.items : [section];
|
||||||
|
for (const it of list) {
|
||||||
|
if (!it || (it.label == null && it.link == null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const icon = it.icon ? `<i class="${esc(it.icon)}"></i> ` : "";
|
||||||
|
const active = it.link && it.link === ctx.currentUrl ? " active" : "";
|
||||||
|
if (Array.isArray(it.subitems) && it.subitems.length) {
|
||||||
|
const sub = it.subitems
|
||||||
|
.filter((s) => s && (s.label != null || s.link != null))
|
||||||
|
.map((s) => `<li><a class="dropdown-item" href="${esc(s.link || "#")}">${esc(s.label || "")}</a></li>`)
|
||||||
|
.join("");
|
||||||
|
items.push(
|
||||||
|
`<li class="nav-item dropdown"><a class="nav-link dropdown-toggle${active}" href="#" role="button" data-bs-toggle="dropdown">${icon}${esc(it.label || "")}</a><ul class="dropdown-menu">${sub}</ul></li>`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
items.push(`<li class="nav-item"><a class="nav-link${active}" href="${esc(it.link || "#")}">${icon}${esc(it.label || "")}</a></li>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<ul class="${ulClass}">${items.join("")}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderToasts(alerts) {
|
||||||
|
if (!Array.isArray(alerts) || alerts.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const items = alerts
|
||||||
|
.map((a) => `<div class="alert alert-${bsAlertType(a.type)} shadow-sm" role="alert">${a.msg || ""}</div>`)
|
||||||
|
.join("");
|
||||||
|
return `<div class="tb-alerts position-fixed top-0 end-0 p-3" style="z-index:1080;max-width:90vw">${items}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderChildren(node, ctx) {
|
||||||
|
if (!Array.isArray(node.children)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return node.children.map((c) => renderNode(c, ctx)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderNode(node, ctx) {
|
||||||
|
const p = node.props || {};
|
||||||
|
switch (node.type) {
|
||||||
|
case "Root":
|
||||||
|
return `<div id="tb-root" class="${esc(p.className || "")}">${renderChildren(node, ctx)}</div>`;
|
||||||
|
case "Navbar": {
|
||||||
|
const variantClass = p.variant === "light" ? "navbar-light" : "navbar-dark";
|
||||||
|
const expand = `navbar-expand-${p.expand || "lg"}`;
|
||||||
|
const bg = p.bg ? `bg-${esc(p.bg)}` : "";
|
||||||
|
const brand = p.brand ? renderBrand(ctx.brand) : "";
|
||||||
|
const menu = p.menu
|
||||||
|
? `<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#tbNav" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>`
|
||||||
|
+ `<div class="collapse navbar-collapse" id="tbNav">${renderMenu(ctx.menu, "navbar", ctx)}</div>`
|
||||||
|
: "";
|
||||||
|
return `<nav class="navbar ${expand} ${variantClass} ${bg}"><div class="container${p.fluid ? "-fluid" : ""}">${brand}${menu}</div></nav>`;
|
||||||
|
}
|
||||||
|
case "Sidebar": {
|
||||||
|
const variantClass = p.variant === "light" ? "navbar-light" : "navbar-dark";
|
||||||
|
const bg = p.bg ? `bg-${esc(p.bg)}` : "bg-dark";
|
||||||
|
const brand = p.brand ? `<div class="mb-3">${renderBrand(ctx.brand)}</div>` : "";
|
||||||
|
const menu = p.menu ? renderMenu(ctx.menu, "sidebar", ctx) : "";
|
||||||
|
return `<aside class="tb-sidebar ${variantClass} ${bg} p-3" style="width:${esc(p.width || "240px")};min-height:100vh">${brand}${menu}</aside>`;
|
||||||
|
}
|
||||||
|
case "Content": {
|
||||||
|
ctx._bodyEmitted = true;
|
||||||
|
const container = p.container === "fluid" ? "container-fluid" : "container";
|
||||||
|
const grow = p.grow ? " flex-grow-1" : "";
|
||||||
|
return `<main class="tb-content${grow}" style="min-width:0"><div class="${container} py-3">${ctx.body || ""}</div></main>`;
|
||||||
|
}
|
||||||
|
case "Footer": {
|
||||||
|
const inner = p.html != null ? String(p.html) : esc(p.text || "");
|
||||||
|
return `<footer class="tb-footer border-top py-3 mt-auto text-center text-muted small"><div class="container">${inner}</div></footer>`;
|
||||||
|
}
|
||||||
|
case "Row":
|
||||||
|
return `<div class="row ${esc(p.className || "")}">${renderChildren(node, ctx)}</div>`;
|
||||||
|
case "Col":
|
||||||
|
return `<div class="col ${esc(p.className || "")}">${renderChildren(node, ctx)}</div>`;
|
||||||
|
case "Html":
|
||||||
|
return p.html != null ? String(p.html) : "";
|
||||||
|
case "AlertsSlot":
|
||||||
|
ctx._alertsEmitted = true;
|
||||||
|
return renderToasts(ctx.alerts);
|
||||||
|
default:
|
||||||
|
// resolver fallback: render children so an unknown wrapper never drops the body
|
||||||
|
return renderChildren(node, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// renderTreeToHtml(tree, ctx) -> HTML string. ctx = {title, body, brand, menu,
|
||||||
|
// alerts, role, req, currentUrl}. Guarantees the body and alerts are emitted even
|
||||||
|
// if the tree omits a Content/AlertsSlot node (never silently drop page content).
|
||||||
|
function renderTreeToHtml(tree, ctx) {
|
||||||
|
const t = normalizeLayoutTree(tree);
|
||||||
|
const c = { ...ctx, _bodyEmitted: false, _alertsEmitted: false };
|
||||||
|
let html = renderNode(t, c);
|
||||||
|
if (!c._bodyEmitted) {
|
||||||
|
html += `<div class="container py-3">${ctx.body || ""}</div>`;
|
||||||
|
}
|
||||||
|
if (!c._alertsEmitted) {
|
||||||
|
html += renderToasts(ctx.alerts);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { renderTreeToHtml, renderMenu, renderBrand, renderToasts, esc };
|
||||||
23
lib/routes.js
Normal file
23
lib/routes.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// lib/routes.js
|
||||||
|
// routes(cfg) factory (ARCHITECTURE.md 6.3)
|
||||||
|
const { URL_PREFIX, API_BASE, CSS_ROUTE } = require("./constants");
|
||||||
|
const h = require("./apiHandlers");
|
||||||
|
|
||||||
|
function routes(cfg) {
|
||||||
|
return [
|
||||||
|
{ url: CSS_ROUTE, method: "get", callback: (req, res) => h.getThemeCss(req, res, cfg) }, // PUBLIC
|
||||||
|
{ url: `${URL_PREFIX}/editor`, method: "get", callback: h.getEditor },
|
||||||
|
{ url: `${API_BASE}/state`, method: "get", callback: h.getState_ },
|
||||||
|
{ url: `${API_BASE}/themes/:id`, method: "get", callback: h.loadTheme },
|
||||||
|
{ url: `${API_BASE}/themes`, method: "post", callback: h.createTheme },
|
||||||
|
{ url: `${API_BASE}/themes/:id/duplicate`, method: "post", callback: h.duplicateTheme },
|
||||||
|
{ url: `${API_BASE}/themes/:id/save`, method: "post", callback: h.saveTheme },
|
||||||
|
{ url: `${API_BASE}/themes/:id/rename`, method: "post", callback: h.renameTheme },
|
||||||
|
{ url: `${API_BASE}/themes/:id/delete`, method: "post", callback: h.deleteTheme },
|
||||||
|
{ url: `${API_BASE}/themes/:id/activate`, method: "post", callback: h.activateTheme },
|
||||||
|
{ url: `${API_BASE}/themes/:id/export`, method: "get", callback: h.exportTheme },
|
||||||
|
{ url: `${API_BASE}/import`, method: "post", callback: h.importTheme },
|
||||||
|
{ url: `${API_BASE}/preview-css`, method: "post", callback: h.previewCss }, // editor WYSIWYG (no save/activate)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
module.exports = { routes };
|
||||||
57
lib/sanitize.js
Normal file
57
lib/sanitize.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// Robustness guards shared by compile.js (overlay) AND sassCompile.js (Phase 2).
|
||||||
|
// NOT a security boundary (see README SECURITY section): an admin can already
|
||||||
|
// inject site CSS/JS by other means. These exist solely so a single malformed
|
||||||
|
// token value cannot white-screen every page -- a value that could close a CSS
|
||||||
|
// block early ("}"), terminate a declaration (";"), or open/close a comment is
|
||||||
|
// DROPPED from output (not escaped), confining each token to its own declaration.
|
||||||
|
|
||||||
|
const { MAX_VALUE_LEN } = require("./tokenSchema");
|
||||||
|
|
||||||
|
// Substrings that would let a value escape its own declaration/block.
|
||||||
|
const BAD_SUBSTRINGS = ["{", "}", ";", "</", "/*", "*/"];
|
||||||
|
|
||||||
|
|
||||||
|
// Returns the value unchanged if safe to emit, or null if it must be dropped.
|
||||||
|
// When dropped, pushes a human-readable note onto the optional warnings array.
|
||||||
|
function sanitizeValue(value, warnings) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const v = String(value);
|
||||||
|
if (v.length > MAX_VALUE_LEN) {
|
||||||
|
if (warnings) {
|
||||||
|
warnings.push(`value dropped (too long): ${v.slice(0, 24)}...`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const bad of BAD_SUBSTRINGS) {
|
||||||
|
if (v.includes(bad)) {
|
||||||
|
if (warnings) {
|
||||||
|
warnings.push(`value dropped (illegal "${bad}")`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Returns a conservative selector unchanged, or null if it could break out of a
|
||||||
|
// rule block. Selectors come only from TOKEN_SCHEMA (trusted), but we guard
|
||||||
|
// anyway so a future data-driven selector cannot escape.
|
||||||
|
function sanitizeSelector(selector) {
|
||||||
|
if (selector == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const s = String(selector);
|
||||||
|
if (s.length > 256) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (/[{};<>]/.test(s) || s.includes("/*") || s.includes("*/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { sanitizeValue, sanitizeSelector };
|
||||||
41
lib/sassCompile.js
Normal file
41
lib/sassCompile.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Phase 2 dart-sass recompile (ARCHITECTURE.md 7.5). Lazy and defensive: the
|
||||||
|
// `sass` dependency and the vendored Bootstrap SCSS under ../scss are Phase-2
|
||||||
|
// only, so a missing dep or compile error degrades to the Phase-1 overlay
|
||||||
|
// rather than white-screening. Only ever reached for engine:"sass" themes.
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
const { sanitizeValue } = require("./sanitize"); // shared guard (folded fix)
|
||||||
|
|
||||||
|
|
||||||
|
function compileSass(tokens, opts = {}) {
|
||||||
|
const warnings = [];
|
||||||
|
try {
|
||||||
|
const sass = require("sass"); // lazy; Phase-2 dep
|
||||||
|
const flat = require("./compile").flattenTokens(tokens);
|
||||||
|
const varBlock = Object.entries(flat)
|
||||||
|
.filter(([k]) => /^[-a-zA-Z0-9_]{1,64}$/.test(k))
|
||||||
|
.map(([k, v]) => {
|
||||||
|
const sv = sanitizeValue(v, warnings);
|
||||||
|
return sv == null ? "" : `$${k}: ${sv};`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
const scssDir = path.join(__dirname, "..", "scss");
|
||||||
|
// relative module id resolved via loadPaths; no absolute @import string.
|
||||||
|
const entry = `@use "bootstrap/bootstrap" with (\n${varBlock}\n);\n`;
|
||||||
|
const result = sass.compileString(entry, {
|
||||||
|
loadPaths: [scssDir],
|
||||||
|
style: "compressed",
|
||||||
|
logger: { warn: (m) => warnings.push(m), debug: () => {} },
|
||||||
|
});
|
||||||
|
return { css: result.css, warnings };
|
||||||
|
} catch (e) {
|
||||||
|
warnings.push("sass compile unavailable/failed: " + e.message);
|
||||||
|
// fall back to the overlay; never white-screen.
|
||||||
|
const overlay = require("./compile").emitOverlayCss(tokens);
|
||||||
|
return { css: overlay.css, warnings: warnings.concat(overlay.warnings || []) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { compileSass };
|
||||||
101
lib/themeSchema.js
Normal file
101
lib/themeSchema.js
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// Token shape: defaults, deep-merge-on-read normalization, and validation.
|
||||||
|
// Pure module (only depends on constants for the token-count cap). `tokens` is
|
||||||
|
// the SINGLE SOURCE OF TRUTH for a theme; every read passes through
|
||||||
|
// normalizeTokens so the compiler always receives a fully-populated, current
|
||||||
|
// shape (a sparse/empty theme can never white-screen).
|
||||||
|
|
||||||
|
const { MAX_TOKENS } = require("./constants");
|
||||||
|
|
||||||
|
const DEFAULT_TOKENS = Object.freeze({
|
||||||
|
$tokensVersion: 1,
|
||||||
|
colors: {
|
||||||
|
primary: "#0d6efd", secondary: "#6c757d", success: "#198754",
|
||||||
|
info: "#0dcaf0", warning: "#ffc107", danger: "#dc3545",
|
||||||
|
light: "#f8f9fa", dark: "#212529",
|
||||||
|
bodyBg: "#ffffff", bodyColor: "#212529",
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
rootFontSize: "16px", bodyFontSize: "1rem",
|
||||||
|
bodyFontWeight: "400", bodyLineHeight: "1.5",
|
||||||
|
},
|
||||||
|
borders: { borderRadius: "0.375rem", borderWidth: "1px" },
|
||||||
|
components: {},
|
||||||
|
custom: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function isPlainObject(x) {
|
||||||
|
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Recursive merge; `over` wins. Non-object values are replaced wholesale.
|
||||||
|
function deepMerge(base, over) {
|
||||||
|
const out = isPlainObject(base) ? { ...base } : {};
|
||||||
|
if (!isPlainObject(over)) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(over)) {
|
||||||
|
if (isPlainObject(v) && isPlainObject(out[k])) {
|
||||||
|
out[k] = deepMerge(out[k], v);
|
||||||
|
} else {
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The single chokepoint every token read passes through. v1: validate + deep-
|
||||||
|
// merge over DEFAULT_TOKENS (no transform yet). Future: a TOKEN_UPCASTERS chain
|
||||||
|
// keyed on $tokensVersion can be added here, mirroring the envelope upcaster.
|
||||||
|
function normalizeTokens(raw) {
|
||||||
|
const merged = deepMerge(structuredClone(DEFAULT_TOKENS), isPlainObject(raw) ? raw : {});
|
||||||
|
merged.$tokensVersion = 1;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function emptyTokens() {
|
||||||
|
return structuredClone(DEFAULT_TOKENS);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Shape check used by import. Returns { ok, errors[] }. Every leaf under a known
|
||||||
|
// section must be a string; the token count is capped.
|
||||||
|
function tokensAreValid(tokens) {
|
||||||
|
const errors = [];
|
||||||
|
if (!isPlainObject(tokens)) {
|
||||||
|
return { ok: false, errors: ["tokens must be an object"] };
|
||||||
|
}
|
||||||
|
if (tokens.$tokensVersion != null && typeof tokens.$tokensVersion !== "number") {
|
||||||
|
errors.push("$tokensVersion must be a number");
|
||||||
|
}
|
||||||
|
if (tokens.sass != null && typeof tokens.sass !== "string") {
|
||||||
|
errors.push("sass must be a string");
|
||||||
|
}
|
||||||
|
let leafCount = 0;
|
||||||
|
for (const sec of ["colors", "typography", "borders", "components", "custom"]) {
|
||||||
|
const obj = tokens[sec];
|
||||||
|
if (obj == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isPlainObject(obj)) {
|
||||||
|
errors.push(`${sec} must be an object`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
leafCount += 1;
|
||||||
|
if (typeof v !== "string") {
|
||||||
|
errors.push(`${sec}.${k} must be a string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (leafCount > MAX_TOKENS) {
|
||||||
|
errors.push("too many tokens");
|
||||||
|
}
|
||||||
|
return { ok: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { DEFAULT_TOKENS, deepMerge, normalizeTokens, emptyTokens, tokensAreValid };
|
||||||
308
lib/themeStore.js
Normal file
308
lib/themeStore.js
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
// The ONLY module that touches the themes Table or treats builtins as themes.
|
||||||
|
// All theme persistence (per-tenant Table CRUD) and the builtins-as-themes merge
|
||||||
|
// live here; everything above this layer speaks the canonical ThemeDTO and never
|
||||||
|
// sees a raw row. See ARCHITECTURE.md 4.1 (schema), 4.2 (builtins merge),
|
||||||
|
// 4.6 (multi-tenant), 6.6 (save CAS + delete semantics).
|
||||||
|
//
|
||||||
|
// ThemeDTO:
|
||||||
|
// { id, name, engine, tokens(object, normalized), layoutTree(object|null),
|
||||||
|
// version(>=1 rows, 0 builtins), builtin(bool), created_at?, updated_at? }
|
||||||
|
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const db = require("@saltcorn/data/db");
|
||||||
|
const Table = require("@saltcorn/data/models/table");
|
||||||
|
const Field = require("@saltcorn/data/models/field");
|
||||||
|
|
||||||
|
const { THEME_TABLE, ENGINE } = require("./constants");
|
||||||
|
const { normalizeTokens } = require("./themeSchema");
|
||||||
|
const builtins = require("./builtins");
|
||||||
|
|
||||||
|
|
||||||
|
// --- column model (ARCHITECTURE.md 4.1) ------------------------------------
|
||||||
|
// id (serial pk) is created automatically by Table.create; the stable theme
|
||||||
|
// identity is the separate theme_id String column. JSON columns are String
|
||||||
|
// (text) because the base plugin registers no JSON type; the store does the
|
||||||
|
// stringify/parse itself.
|
||||||
|
const FIELD_DEFS = [
|
||||||
|
{ name: "theme_id", type: "String", required: true, is_unique: true },
|
||||||
|
{ name: "name", type: "String", required: true },
|
||||||
|
{ name: "engine", type: "String", required: true },
|
||||||
|
{ name: "tokens", type: "String", required: true },
|
||||||
|
{ name: "layout_tree", type: "String", required: false },
|
||||||
|
{ name: "version", type: "Integer", required: true },
|
||||||
|
{ name: "created_at", type: "Date", required: true },
|
||||||
|
{ name: "updated_at", type: "Date", required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Coerce a stored timestamp (Date on PG, text on SQLite) to an ISO string.
|
||||||
|
function toIso(v) {
|
||||||
|
if (v == null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (v instanceof Date) {
|
||||||
|
return v.toISOString();
|
||||||
|
}
|
||||||
|
return new Date(v).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Normalize a builtin's frozen definition into the canonical ThemeDTO. Builtins
|
||||||
|
// carry version 0, builtin:true, and no timestamps.
|
||||||
|
function builtinToTheme(b) {
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
engine: b.engine || ENGINE,
|
||||||
|
tokens: normalizeTokens(b.tokens),
|
||||||
|
layoutTree: b.layoutTree || null,
|
||||||
|
version: 0,
|
||||||
|
builtin: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Create the per-tenant Table once. Cheap fast-path when it already exists.
|
||||||
|
// All CRUD is tenant-implicit (the Table model schema-qualifies from
|
||||||
|
// async-local storage); ensureTable is iterated per tenant by onLoad.
|
||||||
|
async function ensureTableForCurrentTenant() {
|
||||||
|
const existing = Table.findOne({ name: THEME_TABLE });
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const table = await Table.create(THEME_TABLE, { min_role_read: 1, min_role_write: 1 });
|
||||||
|
for (const f of FIELD_DEFS) {
|
||||||
|
await Field.create({
|
||||||
|
table: table,
|
||||||
|
name: f.name,
|
||||||
|
label: f.name,
|
||||||
|
type: f.type,
|
||||||
|
required: !!f.required,
|
||||||
|
is_unique: !!f.is_unique,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Namespacing makes a collision impossible (row ids are uuid v4, builtin
|
||||||
|
// ids are "builtin:<slug>"); assert it so a stored "builtin:" id can never
|
||||||
|
// shadow a code-shipped builtin in list()/getById().
|
||||||
|
const rows = await db.select(THEME_TABLE, {});
|
||||||
|
const clash = rows.find((r) => typeof r.theme_id === "string" && r.theme_id.startsWith("builtin:"));
|
||||||
|
if (clash) {
|
||||||
|
throw new Error(`themeStore: stored theme_id may not start with "builtin:" (${clash.theme_id})`);
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Raw row (stored theme) -> canonical ThemeDTO. JSON columns are parsed here;
|
||||||
|
// tokens always pass through normalizeTokens so the compiler sees a current,
|
||||||
|
// fully-populated shape. builtin:false for every stored row.
|
||||||
|
function rowToTheme(row) {
|
||||||
|
let tokens = {};
|
||||||
|
try {
|
||||||
|
tokens = row.tokens ? JSON.parse(row.tokens) : {};
|
||||||
|
} catch (e) {
|
||||||
|
tokens = {};
|
||||||
|
}
|
||||||
|
let layoutTree = null;
|
||||||
|
if (row.layout_tree) {
|
||||||
|
try {
|
||||||
|
layoutTree = JSON.parse(row.layout_tree);
|
||||||
|
} catch (e) {
|
||||||
|
layoutTree = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.theme_id,
|
||||||
|
name: row.name,
|
||||||
|
engine: row.engine || ENGINE,
|
||||||
|
tokens: normalizeTokens(tokens),
|
||||||
|
layoutTree: layoutTree,
|
||||||
|
version: row.version,
|
||||||
|
builtin: false,
|
||||||
|
created_at: toIso(row.created_at),
|
||||||
|
updated_at: toIso(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// All themes visible to the admin: stored rows PLUS the code-shipped builtins.
|
||||||
|
// Builtins are merged at read time so they appear even on a fresh, empty install.
|
||||||
|
async function list() {
|
||||||
|
const rows = await db.select(THEME_TABLE, {}, { orderBy: "name" });
|
||||||
|
const stored = rows.map(rowToTheme);
|
||||||
|
const builtinThemes = builtins.listBuiltins().map(builtinToTheme);
|
||||||
|
return [...stored, ...builtinThemes];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Resolve a theme by its public id. SEAM FIX: builtin ids resolve too, so
|
||||||
|
// getThemeCss / cssCache.warm / activate handle ANY active theme uniformly
|
||||||
|
// (activating a builtin is allowed; ARCHITECTURE.md 4.2).
|
||||||
|
async function getById(id) {
|
||||||
|
if (builtins.isBuiltinId(id)) {
|
||||||
|
const b = builtins.get(id);
|
||||||
|
return b ? builtinToTheme(b) : null;
|
||||||
|
}
|
||||||
|
const row = await db.selectMaybeOne(THEME_TABLE, { theme_id: id });
|
||||||
|
return row ? rowToTheme(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// App-enforced name uniqueness (no DB constraint). Returns the input name if
|
||||||
|
// free, else the first available "Name (2)", "Name (3)", ... suffix.
|
||||||
|
async function dedupName(name) {
|
||||||
|
const base = (name == null ? "" : String(name)).trim() || "Untitled";
|
||||||
|
const rows = await db.select(THEME_TABLE, {});
|
||||||
|
const taken = new Set(rows.map((r) => r.name));
|
||||||
|
if (!taken.has(base)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let n = 2;
|
||||||
|
while (taken.has(`${base} (${n})`)) {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
return `${base} (${n})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Insert a fresh stored theme (uuid theme_id, version 1, timestamps).
|
||||||
|
async function create({ name, engine, tokens, layoutTree }) {
|
||||||
|
const themeId = crypto.randomUUID();
|
||||||
|
const finalName = await dedupName(name);
|
||||||
|
const ts = nowIso();
|
||||||
|
await db.insert(THEME_TABLE, {
|
||||||
|
theme_id: themeId,
|
||||||
|
name: finalName,
|
||||||
|
engine: engine || ENGINE,
|
||||||
|
tokens: JSON.stringify(normalizeTokens(tokens)),
|
||||||
|
layout_tree: layoutTree == null ? null : JSON.stringify(layoutTree),
|
||||||
|
version: 1,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
return getById(themeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Copy an existing theme (stored OR builtin) into a fresh stored row. This is
|
||||||
|
// the "load a builtin for edit = duplicate-to-edit" path.
|
||||||
|
async function duplicate(id, name) {
|
||||||
|
const src = await getById(id);
|
||||||
|
if (!src) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const dupName = name != null ? name : `${src.name} copy`;
|
||||||
|
return create({
|
||||||
|
name: dupName,
|
||||||
|
engine: src.engine,
|
||||||
|
tokens: src.tokens,
|
||||||
|
layoutTree: src.layoutTree,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Positional placeholder for raw parameterized SQL ("$1" on PG, "?" on SQLite).
|
||||||
|
// mkWhere is dialect-aware but cannot express version=version+1, so the two
|
||||||
|
// version-mutating updates issue raw SQL and pick the placeholder here.
|
||||||
|
function ph(n) {
|
||||||
|
return db.isSQLite ? "?" : `$${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Optimistic compare-and-swap on version: UPDATE ... WHERE theme_id=$ AND
|
||||||
|
// version=$base, SET version=version+1. Returns the updated ThemeDTO, or null
|
||||||
|
// when no row matched (stale baseVersion / unknown id) so the handler can 409.
|
||||||
|
// RETURNING + rows.length is the matched-row count (portable; rowCount is
|
||||||
|
// PG-only and absent from the SQLite driver's {rows} result).
|
||||||
|
async function casUpdate(id, baseVersion, { tokens, layoutTree }) {
|
||||||
|
const schema = db.getTenantSchemaPrefix();
|
||||||
|
const ts = nowIso();
|
||||||
|
const sql = `update ${schema}"${THEME_TABLE}" `
|
||||||
|
+ `set tokens=${ph(1)}, layout_tree=${ph(2)}, version=version+1, updated_at=${ph(3)} `
|
||||||
|
+ `where theme_id=${ph(4)} and version=${ph(5)} returning theme_id`;
|
||||||
|
const params = [
|
||||||
|
JSON.stringify(normalizeTokens(tokens)),
|
||||||
|
layoutTree == null ? null : JSON.stringify(layoutTree),
|
||||||
|
ts,
|
||||||
|
id,
|
||||||
|
baseVersion,
|
||||||
|
];
|
||||||
|
const result = await db.query(sql, params);
|
||||||
|
if (!result || !result.rows || result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Unconditional save (force overwrite): bumps version, ignores baseVersion.
|
||||||
|
async function forceUpdate(id, { tokens, layoutTree }) {
|
||||||
|
const schema = db.getTenantSchemaPrefix();
|
||||||
|
const ts = nowIso();
|
||||||
|
const sql = `update ${schema}"${THEME_TABLE}" `
|
||||||
|
+ `set tokens=${ph(1)}, layout_tree=${ph(2)}, version=version+1, updated_at=${ph(3)} `
|
||||||
|
+ `where theme_id=${ph(4)}`;
|
||||||
|
await db.query(sql, [
|
||||||
|
JSON.stringify(normalizeTokens(tokens)),
|
||||||
|
layoutTree == null ? null : JSON.stringify(layoutTree),
|
||||||
|
ts,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
return getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Rename: name only, app-enforced uniqueness, NO version bump (rename does not
|
||||||
|
// touch the optimistic-lock counter; ARCHITECTURE.md 6.7).
|
||||||
|
async function rename(id, name) {
|
||||||
|
const finalName = await dedupName(name);
|
||||||
|
const ts = nowIso();
|
||||||
|
await db.updateWhere(THEME_TABLE, { name: finalName, updated_at: ts }, { theme_id: id });
|
||||||
|
return getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Delete a stored theme by theme_id. Returns whether a row was removed.
|
||||||
|
async function remove(id) {
|
||||||
|
const before = await db.selectMaybeOne(THEME_TABLE, { theme_id: id });
|
||||||
|
if (!before) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await db.deleteWhere(THEME_TABLE, { theme_id: id });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// First stored theme that is NOT id, used as a delete-of-active successor.
|
||||||
|
// Returns a ThemeDTO or null (caller falls back to builtins.defaultId()).
|
||||||
|
async function firstOther(id) {
|
||||||
|
const rows = await db.select(THEME_TABLE, {}, { orderBy: "name" });
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.theme_id !== id) {
|
||||||
|
return rowToTheme(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureTableForCurrentTenant,
|
||||||
|
rowToTheme,
|
||||||
|
list,
|
||||||
|
getById,
|
||||||
|
create,
|
||||||
|
duplicate,
|
||||||
|
casUpdate,
|
||||||
|
forceUpdate,
|
||||||
|
rename,
|
||||||
|
remove,
|
||||||
|
firstOther,
|
||||||
|
dedupName,
|
||||||
|
};
|
||||||
43
lib/tokenSchema.js
Normal file
43
lib/tokenSchema.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
// The single server-side source of truth for HOW a token maps to CSS. Kept
|
||||||
|
// structurally parallel to the SPA TokenManifest. Pure data + caps; no imports.
|
||||||
|
//
|
||||||
|
// kind:"bsvar" -> :root { --bs-<cssVar>: <value>; } (+ optional --bs-<x>-rgb companion)
|
||||||
|
// kind:"rule" -> a targeted selector { prop: value; } for things --bs-* can't reach
|
||||||
|
// kind:"ignore" -> known-but-not-emitted (forward-compat)
|
||||||
|
//
|
||||||
|
// Keys are the FLATTENED, kebab-case token names (see compile.flattenTokens,
|
||||||
|
// which camel->kebab maps the nested tokens object onto these keys).
|
||||||
|
|
||||||
|
const TOKEN_SCHEMA = {
|
||||||
|
primary: { kind: "bsvar", cssVar: "primary", derive: ["primary-rgb"] },
|
||||||
|
secondary: { kind: "bsvar", cssVar: "secondary", derive: ["secondary-rgb"] },
|
||||||
|
success: { kind: "bsvar", cssVar: "success", derive: ["success-rgb"] },
|
||||||
|
info: { kind: "bsvar", cssVar: "info", derive: ["info-rgb"] },
|
||||||
|
warning: { kind: "bsvar", cssVar: "warning", derive: ["warning-rgb"] },
|
||||||
|
danger: { kind: "bsvar", cssVar: "danger", derive: ["danger-rgb"] },
|
||||||
|
light: { kind: "bsvar", cssVar: "light", derive: ["light-rgb"] },
|
||||||
|
dark: { kind: "bsvar", cssVar: "dark", derive: ["dark-rgb"] },
|
||||||
|
"body-bg": { kind: "bsvar", cssVar: "body-bg", derive: ["body-bg-rgb"] },
|
||||||
|
"body-color": { kind: "bsvar", cssVar: "body-color", derive: ["body-color-rgb"] },
|
||||||
|
"font-sans-serif": { kind: "bsvar", cssVar: "font-sans-serif" },
|
||||||
|
"font-monospace": { kind: "bsvar", cssVar: "font-monospace" },
|
||||||
|
"root-font-size": { kind: "bsvar", cssVar: "root-font-size" },
|
||||||
|
"body-font-size": { kind: "bsvar", cssVar: "body-font-size" },
|
||||||
|
"body-font-weight":{ kind: "bsvar", cssVar: "body-font-weight" },
|
||||||
|
"body-line-height":{ kind: "bsvar", cssVar: "body-line-height" },
|
||||||
|
"headings-font-family": { kind: "bsvar", cssVar: "headings-font-family" },
|
||||||
|
"border-radius": { kind: "bsvar", cssVar: "border-radius" },
|
||||||
|
"border-width": { kind: "bsvar", cssVar: "border-width" },
|
||||||
|
"border-color": { kind: "bsvar", cssVar: "border-color" },
|
||||||
|
"link-color": { kind: "bsvar", cssVar: "link-color", derive: ["link-color-rgb"] },
|
||||||
|
"link-hover-color":{ kind: "bsvar", cssVar: "link-hover-color" },
|
||||||
|
"navbar-bg": { kind: "rule", selector: ".navbar", prop: "--bs-navbar-bg" },
|
||||||
|
"card-bg": { kind: "rule", selector: ".card", prop: "--bs-card-bg" },
|
||||||
|
"sidebar-bg": { kind: "rule", selector: ".sidebar, #accordionSidebar", prop: "background-color" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN_KEY_RE = /^[-a-zA-Z0-9_]{1,64}$/;
|
||||||
|
const MAX_VALUE_LEN = 2048;
|
||||||
|
const MAX_CSS_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
module.exports = { TOKEN_SCHEMA, TOKEN_KEY_RE, MAX_VALUE_LEN, MAX_CSS_BYTES };
|
||||||
18
package.json
Normal file
18
package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "theme-builder",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Saltcorn plugin: visual theme builder (Phase 1 Bootstrap-5 skin overlay; Phase 2 Craft.js layout + optional dart-sass).",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/*.test.js",
|
||||||
|
"test:integration": "./test/runIntegration.sh",
|
||||||
|
"build:ui": "webpack --config builder/webpack.config.js --mode production",
|
||||||
|
"watch:ui": "webpack --config builder/webpack.config.js --watch --mode development"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=20" },
|
||||||
|
"comment": "@saltcorn/* are provided by the host at runtime (see dev-deploy/idp convention) and are intentionally NOT declared as dependencies. Phase 1 has no third-party runtime deps; Phase 2 adds sass + bootstrap.",
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {},
|
||||||
|
"author": "Scott Duensing",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
1346
public/builderApp.js
Normal file
1346
public/builderApp.js
Normal file
File diff suppressed because it is too large
Load diff
7
public/themeBootstrap.bundle.min.js
vendored
Normal file
7
public/themeBootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
public/themeBootstrap.min.css
vendored
Normal file
6
public/themeBootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
46
test/builtins.test.js
Normal file
46
test/builtins.test.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const builtins = require("../lib/builtins");
|
||||||
|
const { compileTheme } = require("../lib/compile");
|
||||||
|
const { normalizeTokens } = require("../lib/themeSchema");
|
||||||
|
|
||||||
|
|
||||||
|
test("listBuiltins returns frozen starters with namespaced ids", () => {
|
||||||
|
const list = builtins.listBuiltins();
|
||||||
|
assert.ok(list.length >= 3);
|
||||||
|
for (const t of list) {
|
||||||
|
assert.ok(builtins.isBuiltinId(t.id), `${t.id} is namespaced`);
|
||||||
|
assert.ok(Object.isFrozen(t));
|
||||||
|
assert.equal(t.engine, "bootstrap5");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("getBuiltin / get resolve by id, default is a real builtin", () => {
|
||||||
|
assert.equal(builtins.getBuiltin("builtin:flatly").name, "Flatly");
|
||||||
|
assert.equal(builtins.get("builtin:darkly").name, "Darkly");
|
||||||
|
assert.equal(builtins.getBuiltin("builtin:nope"), null);
|
||||||
|
assert.ok(builtins.getBuiltin(builtins.defaultId()));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("isBuiltinId distinguishes builtins from uuid rows", () => {
|
||||||
|
assert.equal(builtins.isBuiltinId("builtin:flatly"), true);
|
||||||
|
assert.equal(builtins.isBuiltinId("ab12cd34-..."), false);
|
||||||
|
assert.equal(builtins.isBuiltinId(undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("fallbackCss is valid, brace-free CSS", () => {
|
||||||
|
const css = builtins.fallbackCss();
|
||||||
|
assert.equal(typeof css, "string");
|
||||||
|
assert.doesNotMatch(css, /[{}]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("every builtin compiles to balanced CSS", () => {
|
||||||
|
for (const t of builtins.listBuiltins()) {
|
||||||
|
const out = compileTheme(normalizeTokens(t.tokens));
|
||||||
|
assert.ok(!out.warnings.some((w) => /unbalanced|invalid/.test(w)), `${t.id} compiles cleanly`);
|
||||||
|
}
|
||||||
|
});
|
||||||
74
test/compile.test.js
Normal file
74
test/compile.test.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { compileTheme, emitOverlayCss, robustnessGuard, contentHash, deriveRgb } = require("../lib/compile");
|
||||||
|
const { normalizeTokens } = require("../lib/themeSchema");
|
||||||
|
|
||||||
|
|
||||||
|
function bracesBalanced(css) {
|
||||||
|
let depth = 0;
|
||||||
|
for (const ch of css) {
|
||||||
|
if (ch === "{") depth += 1;
|
||||||
|
else if (ch === "}") { depth -= 1; if (depth < 0) return false; }
|
||||||
|
}
|
||||||
|
return depth === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
test("compileTheme emits --bs-* overlay with derived rgb companion", () => {
|
||||||
|
const out = compileTheme(normalizeTokens({ colors: { primary: "#ff0000" } }));
|
||||||
|
assert.match(out.css, /--bs-primary: #ff0000;/);
|
||||||
|
assert.match(out.css, /--bs-primary-rgb: 255, 0, 0;/);
|
||||||
|
assert.equal(out.engine, "overlay");
|
||||||
|
assert.ok(bracesBalanced(out.css));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("compileTheme NEVER throws and is balanced even for a brace-injecting token", () => {
|
||||||
|
// A value containing "}" would close :root{} early -> it must be DROPPED.
|
||||||
|
const out = compileTheme(normalizeTokens({ colors: { primary: "#fff} body{display:none" } }));
|
||||||
|
assert.ok(bracesBalanced(out.css), "braces stay balanced");
|
||||||
|
assert.doesNotMatch(out.css, /display:none/);
|
||||||
|
assert.ok(out.warnings.length >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("targeted rule tokens emit their own block", () => {
|
||||||
|
const out = emitOverlayCss(normalizeTokens({ components: { navbarBg: "#101010" } }));
|
||||||
|
assert.match(out.css, /\.navbar\{--bs-navbar-bg: #101010;\}/);
|
||||||
|
assert.ok(bracesBalanced(out.css));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("empty/default theme compiles to valid non-white-screen CSS", () => {
|
||||||
|
const out = compileTheme(normalizeTokens({}));
|
||||||
|
assert.ok(bracesBalanced(out.css));
|
||||||
|
assert.match(out.css, /:root\{/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("custom raw vars pass through, malformed keys are skipped", () => {
|
||||||
|
const out = emitOverlayCss(normalizeTokens({ custom: { "--my-var": "10px", "bad key": "x" } }));
|
||||||
|
assert.match(out.css, /--my-var: 10px;/);
|
||||||
|
assert.doesNotMatch(out.css, /bad key/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("deriveRgb handles #rgb, #rrggbb, and rejects non-hex", () => {
|
||||||
|
assert.equal(deriveRgb("#fff"), "255, 255, 255");
|
||||||
|
assert.equal(deriveRgb("#0d6efd"), "13, 110, 253");
|
||||||
|
assert.equal(deriveRgb("rebeccapurple"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("robustnessGuard collapses unbalanced CSS to an empty sentinel", () => {
|
||||||
|
const g = robustnessGuard(":root{--x: 1;", []);
|
||||||
|
assert.match(g.css, /empty \(invalid\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("contentHash is stable and 8 hex chars", () => {
|
||||||
|
const a = contentHash(":root{--bs-primary: #000;}");
|
||||||
|
const b = contentHash(":root{--bs-primary: #000;}");
|
||||||
|
assert.equal(a, b);
|
||||||
|
assert.match(a, /^[0-9a-f]{8}$/);
|
||||||
|
});
|
||||||
72
test/deepOverlay.test.js
Normal file
72
test/deepOverlay.test.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { colorContrast, shade, tint, toHex, parseHexColor } = require("../lib/bootstrapColor");
|
||||||
|
const { solidButtonRule, outlineButtonRule, emitButtonRules } = require("../lib/deepOverlay");
|
||||||
|
|
||||||
|
// Ground truth extracted from the vendored Bootstrap 5.3.3 compiled CSS:
|
||||||
|
// each variant -> { bg, color, hoverBg, activeBorder }.
|
||||||
|
const ORACLES = {
|
||||||
|
primary: { bg: "#0d6efd", color: "#ffffff", hoverBg: "#0b5ed7", activeBorder: "#0a53be" },
|
||||||
|
secondary: { bg: "#6c757d", color: "#ffffff", hoverBg: "#5c636a", activeBorder: "#51585e" },
|
||||||
|
success: { bg: "#198754", color: "#ffffff", hoverBg: "#157347", activeBorder: "#13653f" },
|
||||||
|
info: { bg: "#0dcaf0", color: "#000000", hoverBg: "#31d2f2", activeBorder: "#25cff2" },
|
||||||
|
warning: { bg: "#ffc107", color: "#000000", hoverBg: "#ffca2c", activeBorder: "#ffc720" },
|
||||||
|
danger: { bg: "#dc3545", color: "#ffffff", hoverBg: "#bb2d3b", activeBorder: "#a52834" },
|
||||||
|
light: { bg: "#f8f9fa", color: "#000000", hoverBg: "#d3d4d5", activeBorder: "#babbbc" },
|
||||||
|
dark: { bg: "#212529", color: "#ffffff", hoverBg: "#424649", activeBorder: "#373b3e" },
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
test("colorContrast matches Bootstrap's --bs-btn-color for all 8 variants", () => {
|
||||||
|
for (const [name, o] of Object.entries(ORACLES)) {
|
||||||
|
assert.equal(colorContrast(o.bg), o.color, `${name} (${o.bg})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("solid .btn-<variant> rules match Bootstrap's compiled hover/active colors", () => {
|
||||||
|
for (const [name, o] of Object.entries(ORACLES)) {
|
||||||
|
const rule = solidButtonRule(name, o.bg);
|
||||||
|
assert.ok(rule.includes(`--bs-btn-hover-bg:${o.hoverBg};`), `${name} hover-bg -> ${o.hoverBg}\n${rule}`);
|
||||||
|
assert.ok(rule.includes(`--bs-btn-active-border-color:${o.activeBorder};`), `${name} active-border -> ${o.activeBorder}`);
|
||||||
|
assert.ok(rule.includes(`--bs-btn-bg:${o.bg};`), `${name} bg`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("primary focus-shadow-rgb matches Bootstrap (49, 132, 253)", () => {
|
||||||
|
assert.ok(solidButtonRule("primary", "#0d6efd").includes("--bs-btn-focus-shadow-rgb:49, 132, 253;"));
|
||||||
|
assert.ok(solidButtonRule("warning", "#ffc107").includes("--bs-btn-focus-shadow-rgb:217, 164, 6;"));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("shade/tint match Bootstrap's Sass math", () => {
|
||||||
|
assert.equal(toHex(shade(parseHexColor("#0d6efd"), 15)), "#0b5ed7");
|
||||||
|
assert.equal(toHex(tint(parseHexColor("#ffc107"), 15)), "#ffca2c");
|
||||||
|
assert.equal(toHex(shade(parseHexColor("#f8f9fa"), 25)), "#babbbc"); // light force-shade
|
||||||
|
assert.equal(toHex(tint(parseHexColor("#212529"), 10)), "#373b3e"); // dark force-tint
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("outline button inverts to the variant color", () => {
|
||||||
|
const r = outlineButtonRule("primary", "#0d6efd");
|
||||||
|
assert.ok(r.includes("--bs-btn-color:#0d6efd;"));
|
||||||
|
assert.ok(r.includes("--bs-btn-hover-bg:#0d6efd;"));
|
||||||
|
assert.ok(r.includes("--bs-btn-disabled-bg:transparent;"));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("a recolored primary produces a recolored button (the whole point)", () => {
|
||||||
|
const css = emitButtonRules({ primary: "#e83e8c" });
|
||||||
|
assert.match(css, /\.btn-primary\{[^}]*--bs-btn-bg:#e83e8c;/);
|
||||||
|
// #e83e8c gets BLACK text via color-contrast -> tint mode -> hover LIGHTENS:
|
||||||
|
assert.match(css, /--bs-btn-hover-bg:#eb5b9d;/); // tint(#e83e8c,15%)
|
||||||
|
assert.match(css, /--bs-btn-color:#000000;/); // contrast picks dark text
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("non-hex variant colors are skipped (no throw)", () => {
|
||||||
|
const css = emitButtonRules({ primary: "var(--x)", success: "#198754" });
|
||||||
|
assert.doesNotMatch(css, /\.btn-primary\{/);
|
||||||
|
assert.match(css, /\.btn-success\{/);
|
||||||
|
});
|
||||||
219
test/integration.js
Normal file
219
test/integration.js
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
// In-process integration smoke test against a REAL Saltcorn DB (not the unit
|
||||||
|
// suite -- needs @saltcorn + an initialized DB, so it is NOT a *.test.js file).
|
||||||
|
//
|
||||||
|
// ./test/runIntegration.sh (self-contained: throwaway DB + NODE_PATH)
|
||||||
|
//
|
||||||
|
// Registers theme-builder by location (runs the real onLoad -> Table.create),
|
||||||
|
// then drives the actual apiHandlers with mock req/res through the full Phase-1
|
||||||
|
// lifecycle. Exits non-zero on the first failure.
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
const db = require("@saltcorn/data/db");
|
||||||
|
const { getState } = require("@saltcorn/data/db/state");
|
||||||
|
const Plugin = require("@saltcorn/data/models/plugin");
|
||||||
|
const Table = require("@saltcorn/data/models/table");
|
||||||
|
|
||||||
|
const PLUGIN_DIR = path.resolve(__dirname, "..");
|
||||||
|
const { PLUGIN_NAME, THEME_TABLE } = require("../lib/constants");
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
|
||||||
|
function ok(label, cond, detail) {
|
||||||
|
const tag = cond ? "PASS" : "FAIL";
|
||||||
|
if (!cond) failures += 1;
|
||||||
|
console.log(` [${tag}] ${label}${detail ? " -- " + detail : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function mkRes() {
|
||||||
|
const r = { statusCode: 200, headers: {}, body: undefined };
|
||||||
|
r.status = (c) => { r.statusCode = c; return r; };
|
||||||
|
r.json = (o) => { r.body = o; r._json = o; return r; };
|
||||||
|
r.send = (s) => { r.body = s; r._sent = s; return r; };
|
||||||
|
r.set = (k, v) => { if (k && typeof k === "object") Object.assign(r.headers, k); else r.headers[String(k).toLowerCase()] = v; return r; };
|
||||||
|
r.get = (k) => r.headers[String(k).toLowerCase()];
|
||||||
|
r.redirect = (u) => { r._redirect = u; return r; };
|
||||||
|
r.end = () => { r._ended = true; return r; };
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function mkReq(over) {
|
||||||
|
return Object.assign({
|
||||||
|
user: { role_id: 1, tenant: db.getTenantSchema(), email: "admin@test" },
|
||||||
|
params: {}, query: {}, body: {}, headers: {}, xhr: true,
|
||||||
|
path: "/theme-builder/api/x", originalUrl: "/theme-builder/api/x",
|
||||||
|
get: () => undefined, csrfToken: () => "testcsrf",
|
||||||
|
}, over);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function step(label, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (e) {
|
||||||
|
failures += 1;
|
||||||
|
console.log(` [FAIL] ${label} THREW -- ${e && (e.stack || e.message)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`DB = ${process.env.SQLITE_FILEPATH}`);
|
||||||
|
|
||||||
|
// --- bootstrap state + register the plugin by location (runs onLoad) ---
|
||||||
|
await Plugin.loadAllPlugins();
|
||||||
|
const plugin = new Plugin({ name: PLUGIN_NAME, source: "local", location: PLUGIN_DIR, configuration: {} });
|
||||||
|
await Plugin.loadAndSaveNewPlugin(plugin, true, false);
|
||||||
|
console.log("registered theme-builder; cfg =", JSON.stringify(getState().plugin_cfgs[PLUGIN_NAME] || {}));
|
||||||
|
|
||||||
|
const h = require("../lib/apiHandlers");
|
||||||
|
const liveCfg = () => getState().plugin_cfgs[PLUGIN_NAME] || {};
|
||||||
|
|
||||||
|
console.log("\n# onLoad / table");
|
||||||
|
await step("ensureTable created the themes Table", async () => {
|
||||||
|
const t = Table.findOne({ name: THEME_TABLE });
|
||||||
|
ok("Table registered in _sc_tables", !!t, t ? `id=${t.id}` : "missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n# GET /api/state");
|
||||||
|
let createdId;
|
||||||
|
await step("buildState lists builtins", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
await h.getState_(mkReq(), res);
|
||||||
|
const themes = res.body && res.body.themes;
|
||||||
|
ok("state returns themes array", Array.isArray(themes), `count=${themes && themes.length}`);
|
||||||
|
ok("builtins present (>=3)", themes && themes.filter((x) => x.builtin).length >= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n# create -> save -> activate");
|
||||||
|
await step("create a theme", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
await h.createTheme(mkReq({ body: { name: "My Theme" } }), res);
|
||||||
|
ok("create returns a theme with uuid id + version 1", !!(res.body && res.body.theme && res.body.theme.id && res.body.theme.version === 1), JSON.stringify(res.body && res.body.theme));
|
||||||
|
createdId = res.body && res.body.theme && res.body.theme.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await step("save tokens (CAS on version)", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
const tokens = { $tokensVersion: 1, colors: { primary: "#abcdef" } };
|
||||||
|
await h.saveTheme(mkReq({ params: { id: createdId }, body: { tokens, baseVersion: 1 } }), res);
|
||||||
|
ok("save bumps version to 2", !!(res.body && res.body.theme && res.body.theme.version === 2), JSON.stringify(res.body && (res.body.theme || res.body.error)));
|
||||||
|
});
|
||||||
|
|
||||||
|
await step("stale save -> 409 version_conflict", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
await h.saveTheme(mkReq({ params: { id: createdId }, body: { tokens: { colors: {} }, baseVersion: 1 } }), res);
|
||||||
|
ok("stale baseVersion rejected with 409", res.statusCode === 409 && res.body.error && res.body.error.code === "version_conflict", `status=${res.statusCode}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step("activate the theme (the only live-mutating op)", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
await h.activateTheme(mkReq({ params: { id: createdId } }), res);
|
||||||
|
ok("activate ok + pointer set", !!(res.body && res.body.ok && res.body.activeThemeId === createdId), JSON.stringify(res.body));
|
||||||
|
ok("activeThemeId persisted to cfg", liveCfg().activeThemeId === createdId, `cfg.activeThemeId=${liveCfg().activeThemeId}`);
|
||||||
|
ok("activeHash written", !!liveCfg().activeHash, `hash=${liveCfg().activeHash}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n# GET /theme.css (public)");
|
||||||
|
await step("serve active overlay CSS", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
await h.getThemeCss(mkReq({ query: {}, user: undefined }), res, liveCfg());
|
||||||
|
const css = res.body || "";
|
||||||
|
ok("returns text/css", (res.get("content-type") || "").includes("text/css"));
|
||||||
|
ok("css contains the saved --bs-primary", css.includes("--bs-primary: #abcdef;"), css.slice(0, 80));
|
||||||
|
ok("deep overlay recolors buttons", css.includes(".btn-primary{") && css.includes("--bs-btn-bg:#abcdef;"));
|
||||||
|
ok("braces balanced", (css.match(/{/g) || []).length === (css.match(/}/g) || []).length);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n# export -> import");
|
||||||
|
let exportedJson;
|
||||||
|
await step("export the theme", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
await h.exportTheme(mkReq({ params: { id: createdId } }), res);
|
||||||
|
const raw = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
|
||||||
|
exportedJson = raw;
|
||||||
|
const env = JSON.parse(raw);
|
||||||
|
ok("envelope has $schema + tokens", env.$schema === "saltcorn-theme" && !!env.tokens, env.$schema);
|
||||||
|
ok("Content-Disposition attachment set", (res.get("content-disposition") || "").includes("attachment"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await step("import the exported envelope -> fresh id", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
await h.importTheme(mkReq({ body: JSON.parse(exportedJson) }), res);
|
||||||
|
ok("import creates a new theme", !!(res.body && res.body.theme && res.body.theme.id && res.body.theme.id !== createdId), JSON.stringify(res.body && (res.body.theme || res.body.error)));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n# activate a BUILTIN (seam fix)");
|
||||||
|
await step("activate builtin:darkly", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
await h.activateTheme(mkReq({ params: { id: "builtin:darkly" } }), res);
|
||||||
|
ok("builtin activate ok", !!(res.body && res.body.ok), JSON.stringify(res.body));
|
||||||
|
const cssRes = mkRes();
|
||||||
|
await h.getThemeCss(mkReq({ query: {}, user: undefined }), cssRes, liveCfg());
|
||||||
|
ok("builtin CSS served (darkly primary #375a7f)", String(cssRes.body || "").includes("--bs-primary: #375a7f;"), String(cssRes.body || "").slice(0, 80));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n# preview-css (editor WYSIWYG, same compiler as production)");
|
||||||
|
await step("preview-css compiles draft tokens incl. deep button overlay", async () => {
|
||||||
|
const res = mkRes();
|
||||||
|
await h.previewCss(mkReq({ body: { tokens: { colors: { primary: "#e83e8c" } } } }), res);
|
||||||
|
const css = res.body || "";
|
||||||
|
ok("returns text/css no-store", (res.get("content-type") || "").includes("text/css") && res.get("cache-control") === "no-store");
|
||||||
|
ok("preview recolors .btn-primary to draft color", css.includes(".btn-primary{") && css.includes("--bs-btn-bg:#e83e8c;"));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n# LAYOUT MODE: enable -> register as layout -> wrap() renders the page");
|
||||||
|
const activePointer = require("../lib/activePointer");
|
||||||
|
const cfgReaders = require("../lib/cfgReaders");
|
||||||
|
const { getPreset } = require("../lib/layoutTree");
|
||||||
|
let p2Id;
|
||||||
|
|
||||||
|
await step("enable layout mode registers theme-builder as a layout", async () => {
|
||||||
|
await activePointer.setActivePointer({ layoutMode: true });
|
||||||
|
ok("cfg.layoutMode is true", liveCfg().layoutMode === true);
|
||||||
|
ok("registered in getState().layouts", !!getState().layouts[PLUGIN_NAME]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step("create + save a theme with a sidebar layout tree, then activate", async () => {
|
||||||
|
let res = mkRes();
|
||||||
|
await h.createTheme(mkReq({ body: { name: "P2 Theme" } }), res);
|
||||||
|
p2Id = res.body.theme.id;
|
||||||
|
res = mkRes();
|
||||||
|
await h.saveTheme(mkReq({ params: { id: p2Id }, body: { tokens: { colors: { primary: "#112233" } }, layoutTree: getPreset("sidebar"), baseVersion: 1 } }), res);
|
||||||
|
ok("save persisted layoutTree", !!(res.body.theme && res.body.theme.layoutTree), JSON.stringify(res.body.theme && res.body.theme.layoutTree ? res.body.theme.layoutTree.type : res.body.error));
|
||||||
|
res = mkRes();
|
||||||
|
await h.activateTheme(mkReq({ params: { id: p2Id } }), res);
|
||||||
|
ok("activate (layout mode) persisted activeLayoutTree", !!liveCfg().activeLayoutTree, liveCfg().activeLayoutTree && liveCfg().activeLayoutTree.type);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step("layout(cfg).wrap() renders the full themed document", async () => {
|
||||||
|
const L = require("../lib/layout").layout(liveCfg());
|
||||||
|
ok("layout(cfg) returns a PluginLayout", !!(L && typeof L.wrap === "function"));
|
||||||
|
const html = L.wrap({
|
||||||
|
title: "Home", body: "<p id='pg'>page</p>", brand: { name: "Acme" },
|
||||||
|
menu: [{ items: [{ label: "Home", link: "/" }] }], alerts: [{ type: "success", msg: "ok" }],
|
||||||
|
headers: [], role: 1, currentUrl: "/",
|
||||||
|
});
|
||||||
|
ok("links vendored Bootstrap", html.includes("/themeBootstrap.min.css"));
|
||||||
|
ok("links the theme overlay css", html.includes("/theme-builder/theme.css?v="));
|
||||||
|
ok("renders the layout tree (tb-root + sidebar)", html.includes("id=\"tb-root\"") && html.includes("tb-sidebar"));
|
||||||
|
ok("injects the page body + brand", html.includes("id='pg'") && html.includes("Acme"));
|
||||||
|
ok("angle brackets balanced", (html.match(/</g) || []).length === (html.match(/>/g) || []).length);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step("Phase-2 overlay header is suppressed for theme-builder-served requests", async () => {
|
||||||
|
const req = mkReq({ user: { role_id: 1, tenant: db.getTenantSchema() } });
|
||||||
|
const rendersTB = cfgReaders.requestRendersViaThemeBuilder(req);
|
||||||
|
const hdrs = require("../lib/headers").headers(liveCfg());
|
||||||
|
ok("overlay only_if returns boolean", typeof hdrs[0].only_if(req) === "boolean");
|
||||||
|
ok("overlay suppressed exactly when rendered via theme-builder", hdrs[0].only_if(req) === !rendersTB, `rendersTB=${rendersTB}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n==== ${failures === 0 ? "ALL GREEN" : failures + " FAILURE(S)"} ====`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
main().catch((e) => { console.error("HARNESS CRASH:", e && (e.stack || e.message)); process.exit(2); });
|
||||||
48
test/layoutTree.test.js
Normal file
48
test/layoutTree.test.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const {
|
||||||
|
PRESETS, DEFAULT_LAYOUT_TREE, listPresets, getPreset,
|
||||||
|
validateLayoutTree, normalizeLayoutTree,
|
||||||
|
} = require("../lib/layoutTree");
|
||||||
|
|
||||||
|
|
||||||
|
test("built-in presets are valid layout trees", () => {
|
||||||
|
assert.ok(PRESETS.length >= 2);
|
||||||
|
for (const p of PRESETS) {
|
||||||
|
const v = validateLayoutTree(p.tree);
|
||||||
|
assert.ok(v.ok, `${p.id} valid: ${v.errors.join("; ")}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("listPresets / getPreset", () => {
|
||||||
|
const ids = listPresets().map((p) => p.id);
|
||||||
|
assert.ok(ids.includes("topnav") && ids.includes("sidebar"));
|
||||||
|
assert.equal(getPreset("topnav").type, "Root");
|
||||||
|
assert.equal(getPreset("nope"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("validateLayoutTree requires Root + exactly one Content", () => {
|
||||||
|
assert.equal(validateLayoutTree({ type: "Navbar" }).ok, false);
|
||||||
|
const noContent = { type: "Root", children: [{ type: "Navbar" }] };
|
||||||
|
assert.equal(validateLayoutTree(noContent).ok, false);
|
||||||
|
const twoContent = { type: "Root", children: [{ type: "Content" }, { type: "Content" }] };
|
||||||
|
assert.equal(validateLayoutTree(twoContent).ok, false);
|
||||||
|
assert.equal(validateLayoutTree({ type: "Root", children: [{ type: "Content" }] }).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("validateLayoutTree rejects unknown node types", () => {
|
||||||
|
const v = validateLayoutTree({ type: "Root", children: [{ type: "Content" }, { type: "Evil" }] });
|
||||||
|
assert.equal(v.ok, false);
|
||||||
|
assert.ok(v.errors.some((e) => e.includes("Evil")));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("normalizeLayoutTree falls back to default for junk", () => {
|
||||||
|
assert.deepEqual(normalizeLayoutTree(null), DEFAULT_LAYOUT_TREE);
|
||||||
|
assert.deepEqual(normalizeLayoutTree({ type: "Bad" }), DEFAULT_LAYOUT_TREE);
|
||||||
|
const good = getPreset("sidebar");
|
||||||
|
assert.deepEqual(normalizeLayoutTree(good), good); // valid tree preserved
|
||||||
|
});
|
||||||
61
test/portability.test.js
Normal file
61
test/portability.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { buildEnvelope, parseEnvelope, upcastEnvelope } = require("../lib/portability");
|
||||||
|
const { normalizeTokens } = require("../lib/themeSchema");
|
||||||
|
const { SCHEMA_ID, FORMAT_VERSION, ENGINE } = require("../lib/constants");
|
||||||
|
|
||||||
|
|
||||||
|
test("buildEnvelope produces a self-describing, id-less envelope", () => {
|
||||||
|
const env = buildEnvelope({ name: "Solar", engine: ENGINE, tokens: { colors: { primary: "#111" } } });
|
||||||
|
assert.equal(env.$schema, SCHEMA_ID);
|
||||||
|
assert.equal(env.formatVersion, FORMAT_VERSION);
|
||||||
|
assert.equal(env.name, "Solar");
|
||||||
|
assert.ok(!("id" in env) && !("version" in env));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("export -> import round-trips", async () => {
|
||||||
|
const env = buildEnvelope({ name: "Solar", engine: ENGINE, tokens: normalizeTokens({ colors: { primary: "#abcdef" } }) });
|
||||||
|
const res = await parseEnvelope(JSON.stringify(env));
|
||||||
|
assert.equal(res.ok, true);
|
||||||
|
assert.equal(res.draft.name, "Solar");
|
||||||
|
assert.equal(res.draft.tokens.colors.primary, "#abcdef");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("import rejects a non-saltcorn file", async () => {
|
||||||
|
const res = await parseEnvelope(JSON.stringify({ hello: "world" }));
|
||||||
|
assert.equal(res.ok, false);
|
||||||
|
assert.match(res.error, /Not a saltcorn-theme/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("import rejects invalid JSON and oversized files", async () => {
|
||||||
|
assert.equal((await parseEnvelope("{not json")).ok, false);
|
||||||
|
const huge = JSON.stringify({ $schema: SCHEMA_ID, formatVersion: 1, x: "y".repeat(3 * 1024 * 1024) });
|
||||||
|
const res = await parseEnvelope(huge);
|
||||||
|
assert.equal(res.ok, false);
|
||||||
|
assert.match(res.error, /size limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("upcastEnvelope lifts a formatVersion-0 (vars) export to current", () => {
|
||||||
|
const up = upcastEnvelope({ $schema: SCHEMA_ID, name: "Old", vars: { colors: { primary: "#222" } } });
|
||||||
|
assert.equal(up.formatVersion, FORMAT_VERSION);
|
||||||
|
assert.equal(up.tokens.colors.primary, "#222");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("import upcasts a legacy envelope end-to-end", async () => {
|
||||||
|
const legacy = JSON.stringify({ $schema: SCHEMA_ID, name: "Legacy", vars: { colors: { primary: "#333" } } });
|
||||||
|
const res = await parseEnvelope(legacy);
|
||||||
|
assert.equal(res.ok, true);
|
||||||
|
assert.equal(res.draft.tokens.colors.primary, "#333");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("import rejects an unsupported engine", async () => {
|
||||||
|
const res = await parseEnvelope(JSON.stringify({ $schema: SCHEMA_ID, formatVersion: 1, name: "X", engine: "tailwind", tokens: {} }));
|
||||||
|
assert.equal(res.ok, false);
|
||||||
|
assert.match(res.error, /Unsupported engine/);
|
||||||
|
});
|
||||||
66
test/renderTree.test.js
Normal file
66
test/renderTree.test.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { renderTreeToHtml, renderMenu, esc } = require("../lib/renderTree");
|
||||||
|
const { getPreset } = require("../lib/layoutTree");
|
||||||
|
|
||||||
|
const CTX = {
|
||||||
|
title: "T",
|
||||||
|
body: "<p id='the-body'>BODY</p>",
|
||||||
|
brand: { name: "Acme", logo: null },
|
||||||
|
menu: [{ items: [{ label: "Home", link: "/" }, { label: "Views", link: "/viewedit", subitems: [{ label: "New", link: "/viewedit/new" }] }] }],
|
||||||
|
alerts: [{ type: "success", msg: "saved" }],
|
||||||
|
currentUrl: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function balanced(html) {
|
||||||
|
// crude tag-name-agnostic sanity: angle brackets balance
|
||||||
|
return (html.match(/</g) || []).length === (html.match(/>/g) || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
test("topnav preset renders navbar + body slot + footer", () => {
|
||||||
|
const html = renderTreeToHtml(getPreset("topnav"), CTX);
|
||||||
|
assert.match(html, /<nav class="navbar/);
|
||||||
|
assert.match(html, /id='the-body'/, "body injected into Content slot");
|
||||||
|
assert.match(html, /navbar-brand[^>]*>.*Acme/s, "brand rendered");
|
||||||
|
assert.match(html, /<footer/);
|
||||||
|
assert.ok(balanced(html));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("sidebar preset renders an aside + body", () => {
|
||||||
|
const html = renderTreeToHtml(getPreset("sidebar"), CTX);
|
||||||
|
assert.match(html, /<aside class="tb-sidebar/);
|
||||||
|
assert.match(html, /id='the-body'/);
|
||||||
|
assert.ok(balanced(html));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("body + alerts always emitted even if tree omits the slots", () => {
|
||||||
|
const bare = { type: "Root", children: [{ type: "Content" }] }; // no AlertsSlot
|
||||||
|
const html = renderTreeToHtml(bare, CTX);
|
||||||
|
assert.match(html, /id='the-body'/);
|
||||||
|
assert.match(html, /alert-success/, "alerts appended when no AlertsSlot");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("junk tree falls back to default (still renders body)", () => {
|
||||||
|
const html = renderTreeToHtml({ type: "Nope" }, CTX);
|
||||||
|
assert.match(html, /id='the-body'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("menu renders links + a dropdown for subitems; marks active", () => {
|
||||||
|
const html = renderMenu(CTX.menu, "navbar", CTX);
|
||||||
|
assert.match(html, /href="\/"[^>]*>.*Home/s);
|
||||||
|
assert.match(html, /dropdown/);
|
||||||
|
assert.match(html, /nav-link active/, "current url marked active");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("esc escapes text but body HTML is passed through", () => {
|
||||||
|
assert.equal(esc(`<b>&"'`), "<b>&"'");
|
||||||
|
const html = renderTreeToHtml(getPreset("topnav"), { ...CTX, body: "<script>x</script>" });
|
||||||
|
assert.match(html, /<script>x<\/script>/, "trusted server body not escaped");
|
||||||
|
});
|
||||||
30
test/runIntegration.sh
Executable file
30
test/runIntegration.sh
Executable file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Reproducible, SELF-CONTAINED in-process integration test for theme-builder.
|
||||||
|
# Spins up a throwaway SQLite DB under ./.test-state, runs Saltcorn migrations,
|
||||||
|
# then drives the real apiHandlers via test/integration.js. Touches no other
|
||||||
|
# instance and no shared plugins folder.
|
||||||
|
#
|
||||||
|
# ./test/runIntegration.sh
|
||||||
|
#
|
||||||
|
# @saltcorn/* are host-provided (this plugin declares no deps, per the
|
||||||
|
# dev-deploy/idp convention), so the harness gets them via NODE_PATH pointing at
|
||||||
|
# the upstream checkout's node_modules.
|
||||||
|
set -e
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PLUGIN="$(dirname "$HERE")"
|
||||||
|
REPO="$(dirname "$PLUGIN")"
|
||||||
|
SC_BIN="$REPO/saltcorn/packages/saltcorn-cli/bin/saltcorn"
|
||||||
|
STATE="$PLUGIN/.test-state"
|
||||||
|
|
||||||
|
mkdir -p "$STATE/files"
|
||||||
|
export SQLITE_FILEPATH="$STATE/test.sqlite"
|
||||||
|
export SALTCORN_FILE_STORE="$STATE/files"
|
||||||
|
export SALTCORN_SESSION_SECRET="theme-builder-integration-test-secret"
|
||||||
|
export SALTCORN_NWORKERS=1
|
||||||
|
export NODE_PATH="$REPO/saltcorn/node_modules"
|
||||||
|
|
||||||
|
rm -f "$SQLITE_FILEPATH"
|
||||||
|
rm -rf "/tmp/$USER/saltcorn-tmp/temp_install/theme-builder" 2>/dev/null || true
|
||||||
|
|
||||||
|
node "$SC_BIN" reset-schema --force >/dev/null
|
||||||
|
exec node "$HERE/integration.js"
|
||||||
34
test/sanitize.test.js
Normal file
34
test/sanitize.test.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { sanitizeValue, sanitizeSelector } = require("../lib/sanitize");
|
||||||
|
|
||||||
|
|
||||||
|
test("sanitizeValue passes ordinary CSS values", () => {
|
||||||
|
assert.equal(sanitizeValue("#ff0000"), "#ff0000");
|
||||||
|
assert.equal(sanitizeValue("1.5rem"), "1.5rem");
|
||||||
|
assert.equal(sanitizeValue("'Lato', sans-serif"), "'Lato', sans-serif");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("sanitizeValue drops values that could escape their declaration", () => {
|
||||||
|
const w = [];
|
||||||
|
assert.equal(sanitizeValue("#fff}", w), null); // closes :root{} early
|
||||||
|
assert.equal(sanitizeValue("red; }body{display:none", w), null);
|
||||||
|
assert.equal(sanitizeValue("red/* comment", w), null);
|
||||||
|
assert.equal(sanitizeValue("a</style>", w), null);
|
||||||
|
assert.ok(w.length >= 4, "each drop pushes a warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("sanitizeValue drops over-length values and null", () => {
|
||||||
|
assert.equal(sanitizeValue(null), null);
|
||||||
|
assert.equal(sanitizeValue("x".repeat(5000)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("sanitizeSelector allows conservative selectors, drops breakouts", () => {
|
||||||
|
assert.equal(sanitizeSelector(".navbar"), ".navbar");
|
||||||
|
assert.equal(sanitizeSelector(".sidebar, #accordionSidebar"), ".sidebar, #accordionSidebar");
|
||||||
|
assert.equal(sanitizeSelector("a{color:red}"), null);
|
||||||
|
assert.equal(sanitizeSelector("x;y"), null);
|
||||||
|
});
|
||||||
54
test/themeSchema.test.js
Normal file
54
test/themeSchema.test.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const {
|
||||||
|
DEFAULT_TOKENS, deepMerge, normalizeTokens, emptyTokens, tokensAreValid,
|
||||||
|
} = require("../lib/themeSchema");
|
||||||
|
|
||||||
|
|
||||||
|
test("normalizeTokens deep-merges a sparse theme over the defaults", () => {
|
||||||
|
const t = normalizeTokens({ colors: { primary: "#123456" } });
|
||||||
|
assert.equal(t.colors.primary, "#123456"); // override wins
|
||||||
|
assert.equal(t.colors.success, DEFAULT_TOKENS.colors.success); // default preserved
|
||||||
|
assert.equal(t.$tokensVersion, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("normalizeTokens never mutates the frozen DEFAULT_TOKENS", () => {
|
||||||
|
const before = DEFAULT_TOKENS.colors.primary;
|
||||||
|
const t = normalizeTokens({ colors: { primary: "#000000" } });
|
||||||
|
t.colors.primary = "#ffffff";
|
||||||
|
assert.equal(DEFAULT_TOKENS.colors.primary, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("normalizeTokens tolerates junk input", () => {
|
||||||
|
assert.equal(normalizeTokens(null).$tokensVersion, 1);
|
||||||
|
assert.equal(normalizeTokens("nope").colors.primary, DEFAULT_TOKENS.colors.primary);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("emptyTokens returns a mutable clone of the defaults", () => {
|
||||||
|
const e = emptyTokens();
|
||||||
|
e.colors.primary = "#abcdef";
|
||||||
|
assert.notEqual(DEFAULT_TOKENS.colors.primary, "#abcdef");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("deepMerge replaces scalars and merges nested objects", () => {
|
||||||
|
const out = deepMerge({ a: 1, n: { x: 1, y: 2 } }, { a: 2, n: { y: 3 } });
|
||||||
|
assert.deepEqual(out, { a: 2, n: { x: 1, y: 3 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("tokensAreValid accepts strings, rejects non-string leaves", () => {
|
||||||
|
assert.equal(tokensAreValid(normalizeTokens({})).ok, true);
|
||||||
|
const bad = tokensAreValid({ colors: { primary: 123 } });
|
||||||
|
assert.equal(bad.ok, false);
|
||||||
|
assert.ok(bad.errors.some((e) => e.includes("colors.primary")));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("tokensAreValid rejects non-object input", () => {
|
||||||
|
assert.equal(tokensAreValid(null).ok, false);
|
||||||
|
assert.equal(tokensAreValid([]).ok, false);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue