@rmdes/indiekit-endpoint-site-config 1.0.0-alpha.3 → 1.0.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,95 @@
1
1
  # @rmdes/indiekit-endpoint-site-config
2
2
 
3
- Site identity, branding, layout, and feature flag configuration plugin for Indiekit.
3
+ Site identity, branding, layout, and feature-flag configuration endpoint for [Indiekit](https://getindiekit.com).
4
4
 
5
- Documentation lives in `documentation-central/docs/site-config-plugin.md` in the workspace.
5
+ Provides an admin UI for configuring a multi-tenant Indiekit deployment from a single canonical theme. Runtime CSS generation lets operators customize colors, typography, and layout without redeploying the theme.
6
+
7
+ ## Status
8
+
9
+ `1.0.0-alpha.7` — usable, in production on [rmendes.net](https://rmendes.net). API may still shift before `1.0.0`.
10
+
11
+ ## Features
12
+
13
+ - **12-control admin UI** for site theming (palette presets, semantic role overrides, mode preference)
14
+ - **Runtime CSS generation** — writes `theme.css` and `critical.css` to disk on each save; Eleventy picks them up via `inlineFile` filter on next rebuild
15
+ - **APCA Lc contrast validation** — blocks saves with unreadable color combinations (Lc < 30 hard, < 45 warn)
16
+ - **Version history** — last 10 saves snapshot to MongoDB; one-click revert
17
+ - **Reset per-section + global** — undo any subsection or all branding back to defaults
18
+ - **Live preview iframe** — pending form state previewed before save via query-param-driven endpoint
19
+ - **Mode-aware preview toggle** — preview light or dark mode independently of OS preference
20
+
21
+ ## Architecture — 3-Tier Token System
22
+
23
+ | Tier | What | Examples |
24
+ |------|------|----------|
25
+ | **1. Reference (palette)** | Derived OKLCH-based color scales | `--c-surface-50..950`, `--c-accent-50..950` |
26
+ | **2. Semantic (roles)** | What templates actually USE | `--c-bg`, `--c-fg`, `--c-fg-muted`, `--c-heading`, `--c-link`, `--c-action`, `--c-action-fg`, `--c-surface`, `--c-border`, `--c-focus` |
27
+ | **3. Alert states** | Fixed for accessibility | `--c-success`, `--c-warning`, `--c-danger` (with `-fg` variants) |
28
+
29
+ Templates reference Tier 2 utility classes (`text-heading`, `bg-action`, `border-border`, etc.). When the admin saves a role override, only that semantic token changes — every template element bound to that role updates within one Eleventy rebuild cycle.
30
+
31
+ This mirrors the established CMS pattern documented by [WordPress theme.json](https://developer.wordpress.org/themes/global-settings-and-styles/), [Material Design 3](https://m3.material.io/styles/color/system/overview), and [W3C Design Tokens Community Group](https://design-tokens.github.io/community-group/format/).
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ npm install @rmdes/indiekit-endpoint-site-config
37
+ ```
38
+
39
+ ## Configuration
40
+
41
+ In your `indiekit.config.js`:
42
+
43
+ ```js
44
+ import SiteConfigEndpoint from "@rmdes/indiekit-endpoint-site-config";
45
+
46
+ export default {
47
+ plugins: [
48
+ new SiteConfigEndpoint({
49
+ mountPath: "/site-config", // default
50
+ }),
51
+ // ... other plugins
52
+ ],
53
+ };
54
+ ```
55
+
56
+ ## Storage
57
+
58
+ - MongoDB collection: `siteConfig`
59
+ - Document `_id`: `"primary"` (singleton per deployment)
60
+ - Schema version: `2` (Path D, Phase 2a+)
61
+
62
+ ## Theme integration
63
+
64
+ The companion Eleventy theme [`indiekit-eleventy-theme`](https://github.com/rmdes/indiekit-eleventy-theme) reads:
65
+ - `/app/data/content/_data/theme.css` (runtime CSS vars, via `inlineFile` filter in a `theme.css.njk` template)
66
+ - `/app/data/content/_data/critical.css` (per-site critical CSS for first paint)
67
+ - `/app/data/content/_data/site-config.json` (structured config for `_data/site.js`)
68
+
69
+ The theme's `tailwind.config.js` exposes Tier 2 utility classes (`text-heading`, `bg-action`, `border-border`, etc.) bound to the CSS variables this plugin emits.
70
+
71
+ ## Mode handling
72
+
73
+ Three states: `light`, `dark`, `auto`. In `auto` mode the plugin emits both `@media (prefers-color-scheme: dark)` AND a `.dark` class block, with the `@media` rule scoped to `:root:not(.light)` so an explicit user override (via JS toggle adding `.light`) wins over OS preference.
74
+
75
+ ## Testing
76
+
77
+ ```bash
78
+ npm test
79
+ ```
80
+
81
+ Currently 159 tests covering schema, storage, palette derivation, semantic resolution, contrast validation, history snapshotting, reset paths, and form parsing.
6
82
 
7
83
  ## Dependencies
8
84
 
9
- - `culori` used for OKLCH-based palette derivation (see `lib/render/derive-palette.js`, added in a later task).
85
+ - `apca-w3` + `colorparsley` APCA Lc contrast calculation
86
+ - `culori` — OKLCH palette derivation
87
+ - `@indiekit/error`, `@indiekit/frontend`, `express@^5`
88
+
89
+ ## Development
90
+
91
+ This plugin is developed inside the [Indiekit development workspace](https://github.com/rmdes/indiekit-dev). The design spec lives at `documentation-central/plans/2026-05-24-theming-v2-design.md` in that workspace.
92
+
93
+ ## License
94
+
95
+ MIT
package/index.js CHANGED
@@ -10,6 +10,7 @@ import { apiRouter } from "./lib/controllers/api.js";
10
10
  import { getSiteConfig } from "./lib/storage/get-site-config.js";
11
11
  import { maybeSeedFromEnv } from "./lib/storage/seed-from-env.js";
12
12
  import { writeThemeCss } from "./lib/render/write-theme-css.js";
13
+ import { writeCriticalCss } from "./lib/render/write-critical-css.js";
13
14
  import { writeSiteJson } from "./lib/render/write-site-json.js";
14
15
 
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -69,6 +70,7 @@ export default class SiteConfigEndpoint {
69
70
  await maybeSeedFromEnv(Indiekit);
70
71
  const config = await getSiteConfig(Indiekit);
71
72
  await writeThemeCss(config);
73
+ await writeCriticalCss(config);
72
74
  await writeSiteJson(config);
73
75
  } catch (error) {
74
76
  console.warn("[site-config] initial render skipped:", error.message);
@@ -1,43 +1,325 @@
1
1
  import express from "express";
2
- import { getSiteConfig } from "../storage/get-site-config.js";
2
+ import { getSiteConfig, mergeWithDefaults } from "../storage/get-site-config.js";
3
3
  import { renderThemeCss } from "../render/write-theme-css.js";
4
+ import { parseBrandingForm } from "./branding.js";
5
+ import { validateBranding } from "../validators/contrast.js";
4
6
 
5
7
  export function apiRouter(Indiekit) {
6
8
  const router = express.Router();
7
9
 
10
+ /**
11
+ * GET /site-config/api/preview
12
+ *
13
+ * Renders a live preview of a theme — either the persisted state (when no
14
+ * query params are present) or a pending state derived from query params.
15
+ *
16
+ * The branding form's color pickers submit `input` events to the iframe
17
+ * (debounced ~200ms) and re-set the iframe's src with a serialized form
18
+ * snapshot. This endpoint runs the same parser the POST handler uses —
19
+ * with `skipContrastCheck: true` so a low-contrast color choice can
20
+ * still be PREVIEWED, only blocked from being SAVED.
21
+ *
22
+ * Query params (all optional):
23
+ * surfacePreset — Tier 1 input
24
+ * accentBase — Tier 1 input (hex)
25
+ * accentPreset — metadata
26
+ * mode — light | dark | auto
27
+ * surfaceCustom_<tone> — when surfacePreset=custom
28
+ * roles_<role>_inherit=1 — inherit palette default
29
+ * roles_<role>_light/dark — override (both required if neither inherit)
30
+ * typography_sans/serif/mono/hosting
31
+ * previewMode — light | dark | auto (overrides mode for preview only)
32
+ *
33
+ * The endpoint always returns 200 with HTML; for invalid inputs (e.g. bad
34
+ * accent hex), it falls back to the persisted state with a warning banner
35
+ * so the user can SEE the broken state and recover from it.
36
+ */
8
37
  router.get("/preview", async (req, res, next) => {
9
38
  try {
10
- const config = await getSiteConfig(Indiekit);
11
- const themeCss = renderThemeCss(config);
39
+ const persisted = await getSiteConfig(Indiekit);
40
+ const hasQuery = req.query && Object.keys(req.query).length > 0;
41
+
42
+ let configForPreview = persisted;
43
+ let parseError = null;
44
+ let contrastResults = [];
45
+
46
+ if (hasQuery) {
47
+ // Bridge multipart-friendly query params to the urlencoded shape
48
+ // parseBrandingForm expects. Express already gives us a plain
49
+ // object via req.query — Object.entries casts arrays to strings
50
+ // when a key appears more than once (we ignore that edge case).
51
+ const parsed = parseBrandingForm(
52
+ req.query,
53
+ persisted.branding?.roles || {},
54
+ { skipContrastCheck: true },
55
+ );
56
+
57
+ if (parsed.ok) {
58
+ // Build a synthetic full config: take the persisted state and
59
+ // overlay the pending branding subtree. mergeWithDefaults guards
60
+ // against missing keys.
61
+ configForPreview = mergeWithDefaults({
62
+ ...persisted,
63
+ branding: {
64
+ ...persisted.branding,
65
+ ...parsed.patch.branding,
66
+ },
67
+ });
68
+ } else {
69
+ parseError = parsed.message;
70
+ }
71
+ }
72
+
73
+ // Per-mode preview override: the iframe's light/dark toggle button
74
+ // sets `previewMode` independent of the persisted mode setting.
75
+ const previewMode =
76
+ typeof req.query.previewMode === "string" &&
77
+ ["light", "dark", "auto"].includes(req.query.previewMode)
78
+ ? req.query.previewMode
79
+ : configForPreview.branding.mode;
80
+
81
+ // We render theme.css with the pending mode so the preview iframe
82
+ // shows colors for the chosen side without needing JS to flip
83
+ // .dark on the iframe (which works too — both paths are wired).
84
+ const themeConfig = {
85
+ ...configForPreview,
86
+ branding: { ...configForPreview.branding, mode: previewMode },
87
+ };
88
+
89
+ // Run contrast check on the preview's resolved state so the iframe
90
+ // can show whether the visible colors are passing. Wrapped in try
91
+ // so a broken accentBase doesn't blow up the preview.
92
+ try {
93
+ contrastResults = validateBranding(configForPreview.branding);
94
+ } catch {
95
+ contrastResults = [];
96
+ }
97
+
98
+ const themeCss = renderThemeCss(themeConfig);
12
99
  res.setHeader("Content-Type", "text/html");
13
100
  res.setHeader("X-Content-Type-Options", "nosniff");
14
101
  res.setHeader("Cache-Control", "no-store");
15
- res.send(`<!doctype html>
16
- <html lang="${escapeHtml(config.identity.locale || "en")}">
102
+ res.send(renderPreviewHtml({
103
+ themeCss,
104
+ config: configForPreview,
105
+ previewMode,
106
+ parseError,
107
+ contrastResults,
108
+ }));
109
+ } catch (error) {
110
+ next(error);
111
+ }
112
+ });
113
+
114
+ return router;
115
+ }
116
+
117
+ /**
118
+ * Render the preview HTML body. Includes a comprehensive sample of theme
119
+ * elements (heading, body text, link, action button, card, focus state,
120
+ * alert pills) so users see how each token affects real surfaces.
121
+ *
122
+ * The light/dark mode toggle is a vanilla `<button>` with inline JS that
123
+ * adds/removes a `.dark` class on the document element AND persists the
124
+ * choice in localStorage. The persisted choice is read on load and
125
+ * applied to the html element.
126
+ *
127
+ * Exported for unit testing.
128
+ */
129
+ export function renderPreviewHtml({ themeCss, config, previewMode, parseError, contrastResults }) {
130
+ const locale = config.identity?.locale || "en";
131
+ const siteName = config.identity?.name || "Untitled site";
132
+ const tagline = config.identity?.tagline || "";
133
+ const description = config.identity?.description || "Sample description for the preview.";
134
+
135
+ const failures = (contrastResults || []).filter((r) => r.status === "fail");
136
+ const warnings = (contrastResults || []).filter((r) => r.status === "warn");
137
+
138
+ // The button toggles `.dark` on <html> regardless of preview mode so
139
+ // operators can flip between sides without re-saving.
140
+ return `<!doctype html>
141
+ <html lang="${escapeHtml(locale)}">
17
142
  <head>
18
143
  <meta charset="utf-8">
19
144
  <title>Preview</title>
20
145
  <style>
21
146
  ${themeCss}
22
- body { font-family: var(--font-sans); background: rgb(var(--c-surface-50)); color: rgb(var(--c-surface-900)); margin: 0; padding: 1.5rem; }
23
- h1 { font-family: var(--font-serif); color: rgb(var(--c-primary)); margin: 0 0 0.5em; font-size: 1.5em; }
24
- p { color: rgb(var(--c-surface-700)); line-height: 1.5; margin: 0 0 1em; }
25
- .button { background: rgb(var(--c-accent-500)); color: rgb(var(--c-surface-50)); padding: 0.4em 0.8em; border-radius: 0.4em; display: inline-block; font-weight: 600; }
26
- a { color: rgb(var(--c-link)); }
147
+ html, body { margin: 0; padding: 0; }
148
+ body {
149
+ font-family: var(--font-sans);
150
+ background: rgb(var(--c-bg));
151
+ color: rgb(var(--c-fg));
152
+ padding: 1.5rem;
153
+ min-height: 100vh;
154
+ }
155
+ h1, h2, h3 { font-family: var(--font-serif); color: rgb(var(--c-heading)); margin: 0 0 0.5rem; }
156
+ h1 { font-size: 1.75rem; }
157
+ h2 { font-size: 1.25rem; margin-top: 1.5rem; }
158
+ h3 { font-size: 1rem; }
159
+ p { line-height: 1.6; margin: 0 0 0.75rem; color: rgb(var(--c-fg)); }
160
+ .muted { color: rgb(var(--c-fg-muted)); font-size: 0.875rem; }
161
+ a { color: rgb(var(--c-link)); text-decoration: underline; }
162
+ .button {
163
+ background: rgb(var(--c-action));
164
+ color: rgb(var(--c-action-fg));
165
+ padding: 0.5rem 1rem;
166
+ border-radius: 0.4rem;
167
+ display: inline-block;
168
+ font-weight: 600;
169
+ border: none;
170
+ cursor: pointer;
171
+ }
172
+ .button:focus {
173
+ outline: 2px solid rgb(var(--c-focus));
174
+ outline-offset: 2px;
175
+ }
176
+ .card {
177
+ background: rgb(var(--c-surface));
178
+ border: 1px solid rgb(var(--c-border));
179
+ border-radius: 0.5rem;
180
+ padding: 1rem;
181
+ margin-block: 1rem;
182
+ }
183
+ code {
184
+ font-family: var(--font-mono);
185
+ background: rgb(var(--c-surface));
186
+ padding: 0.1em 0.3em;
187
+ border-radius: 0.2em;
188
+ border: 1px solid rgb(var(--c-border));
189
+ }
190
+ .pills { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-block: 0.75rem; }
191
+ .pill {
192
+ display: inline-block;
193
+ padding: 0.25rem 0.6rem;
194
+ border-radius: 999px;
195
+ font-size: 0.75rem;
196
+ font-weight: 600;
197
+ }
198
+ .pill--success { background: rgb(var(--c-success)); color: rgb(var(--c-success-fg)); }
199
+ .pill--warning { background: rgb(var(--c-warning)); color: rgb(var(--c-warning-fg)); }
200
+ .pill--danger { background: rgb(var(--c-danger)); color: rgb(var(--c-danger-fg)); }
201
+
202
+ .pv-toolbar {
203
+ position: sticky;
204
+ top: 0;
205
+ display: flex;
206
+ gap: 0.5rem;
207
+ align-items: center;
208
+ padding: 0.5rem 0.75rem;
209
+ margin: -1.5rem -1.5rem 1rem;
210
+ background: rgb(var(--c-surface));
211
+ border-bottom: 1px solid rgb(var(--c-border));
212
+ font-size: 0.75rem;
213
+ }
214
+ .pv-toggle {
215
+ background: transparent;
216
+ color: rgb(var(--c-fg));
217
+ border: 1px solid rgb(var(--c-border));
218
+ padding: 0.25rem 0.6rem;
219
+ border-radius: 0.25rem;
220
+ cursor: pointer;
221
+ font: inherit;
222
+ }
223
+ .pv-toggle--active {
224
+ background: rgb(var(--c-action));
225
+ color: rgb(var(--c-action-fg));
226
+ border-color: rgb(var(--c-action));
227
+ }
228
+ .pv-note { color: rgb(var(--c-fg-muted)); margin-left: auto; }
229
+ .pv-error {
230
+ background: rgb(var(--c-danger));
231
+ color: rgb(var(--c-danger-fg));
232
+ padding: 0.5rem 0.75rem;
233
+ border-radius: 0.25rem;
234
+ margin-block-end: 1rem;
235
+ font-size: 0.875rem;
236
+ }
237
+ .pv-warn {
238
+ background: rgb(var(--c-warning));
239
+ color: rgb(var(--c-warning-fg));
240
+ padding: 0.5rem 0.75rem;
241
+ border-radius: 0.25rem;
242
+ margin-block-end: 1rem;
243
+ font-size: 0.875rem;
244
+ }
245
+ .pv-fail {
246
+ background: rgb(var(--c-danger));
247
+ color: rgb(var(--c-danger-fg));
248
+ padding: 0.5rem 0.75rem;
249
+ border-radius: 0.25rem;
250
+ margin-block-end: 1rem;
251
+ font-size: 0.875rem;
252
+ }
27
253
  </style>
28
254
  </head>
29
255
  <body>
30
- <h1>${escapeHtml(config.identity.name || "Untitled site")}</h1>
31
- <p>${escapeHtml(config.identity.description || "Sample description")}</p>
32
- <p><a href="#">A sample link</a> and <span class="button">an action button</span>.</p>
33
- </body>
34
- </html>`);
35
- } catch (error) {
36
- next(error);
37
- }
38
- });
256
+ <div class="pv-toolbar">
257
+ <button type="button" class="pv-toggle" data-pv-mode="light">Light</button>
258
+ <button type="button" class="pv-toggle" data-pv-mode="dark">Dark</button>
259
+ <span class="pv-note">Mode: ${escapeHtml(previewMode)}</span>
260
+ </div>
39
261
 
40
- return router;
262
+ ${parseError ? `<div class="pv-error">Preview parse error: ${escapeHtml(parseError)}</div>` : ""}
263
+ ${failures.length > 0 ? `<div class="pv-fail">Contrast fails: ${escapeHtml(failures.map((f) => f.message).join("; "))}</div>` : ""}
264
+ ${warnings.length > 0 ? `<div class="pv-warn">Contrast warnings: ${escapeHtml(warnings.map((w) => w.message).join("; "))}</div>` : ""}
265
+
266
+ <h1>${escapeHtml(siteName)}</h1>
267
+ ${tagline ? `<p class="muted">${escapeHtml(tagline)}</p>` : ""}
268
+ <p>${escapeHtml(description)}</p>
269
+
270
+ <p>This paragraph contains <a href="#">a sample link</a> and some <code>inline code</code> alongside <span class="muted">muted secondary text</span>.</p>
271
+
272
+ <h2>Card surface</h2>
273
+ <div class="card">
274
+ <h3>Card heading</h3>
275
+ <p>Cards use the <code>surface</code> role for their background and <code>border</code> for the outline. Body text inside cards inherits the page foreground color.</p>
276
+ <button class="button" type="button">Primary action</button>
277
+ </div>
278
+
279
+ <h2>Alert tokens</h2>
280
+ <p class="muted">Fixed Tier 3 colors — not user-configurable.</p>
281
+ <div class="pills">
282
+ <span class="pill pill--success">Success</span>
283
+ <span class="pill pill--warning">Warning</span>
284
+ <span class="pill pill--danger">Danger</span>
285
+ </div>
286
+
287
+ <h2>Focus ring</h2>
288
+ <p class="muted">Tab to the button below to see the focus outline.</p>
289
+ <p><button class="button" type="button">Focusable</button></p>
290
+
291
+ <script>
292
+ (function () {
293
+ var KEY = 'sc.preview.mode';
294
+ var stored = null;
295
+ try { stored = localStorage.getItem(KEY); } catch (e) {}
296
+ var html = document.documentElement;
297
+ var buttons = document.querySelectorAll('[data-pv-mode]');
298
+
299
+ function apply(mode) {
300
+ if (mode === 'dark') html.classList.add('dark');
301
+ else html.classList.remove('dark');
302
+ buttons.forEach(function (b) {
303
+ var active = b.getAttribute('data-pv-mode') === mode;
304
+ b.classList.toggle('pv-toggle--active', active);
305
+ });
306
+ }
307
+
308
+ // Initial state: stored preference > URL-given previewMode > light
309
+ var initial = stored || ${JSON.stringify(previewMode === "dark" ? "dark" : "light")};
310
+ apply(initial);
311
+
312
+ buttons.forEach(function (btn) {
313
+ btn.addEventListener('click', function () {
314
+ var mode = btn.getAttribute('data-pv-mode');
315
+ try { localStorage.setItem(KEY, mode); } catch (e) {}
316
+ apply(mode);
317
+ });
318
+ });
319
+ })();
320
+ </script>
321
+ </body>
322
+ </html>`;
41
323
  }
42
324
 
43
325
  export function escapeHtml(s) {