@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 +2 -0
- package/lib/controllers/api.js +302 -20
- package/lib/controllers/branding.js +546 -50
- package/lib/controllers/features.js +1 -5
- package/lib/controllers/identity.js +1 -3
- package/lib/controllers/layout.js +1 -5
- package/lib/render/resolve-tier2.js +101 -0
- package/lib/render/surface-presets.js +16 -2
- package/lib/render/write-critical-css.js +166 -0
- package/lib/render/write-site-json.js +2 -1
- package/lib/render/write-theme-css.js +217 -46
- package/lib/storage/defaults.js +66 -13
- package/lib/validators/color.js +65 -5
- package/lib/validators/contrast.js +205 -0
- package/locales/en.json +63 -11
- package/locales/fr.json +1 -3
- package/package.json +3 -1
- package/views/partials/color-picker.njk +22 -7
- package/views/partials/role-override.njk +114 -0
- package/views/partials/tab-strip.njk +30 -4
- package/views/site-config-branding.njk +941 -59
- package/views/site-config-features.njk +160 -29
- package/views/site-config-identity.njk +162 -32
- package/views/site-config-layout.njk +220 -44
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);
|
package/lib/controllers/api.js
CHANGED
|
@@ -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
|
|
11
|
-
const
|
|
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(
|
|
16
|
-
|
|
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 {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
</
|
|
34
|
-
</
|
|
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
|
-
|
|
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) {
|