@rmdes/indiekit-endpoint-site-config 1.0.0-alpha.2 → 1.0.0-alpha.6

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/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) {