@marktiderman/genesis-sites 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,658 @@
1
+ import { getSection, pagePathProblems, isReservedPath, ReleaseSnapshot, DEFAULT_RESERVED_PATHS, validatePagePath, redirectsAfterMove, PageDoc, SiteDoc } from './chunk-KEF3V5ZN.js';
2
+ import { genesisBrand, themeFromBrand } from '@marktiderman/genesis-design-system';
3
+
4
+ // src/core/migrate.ts
5
+ function migrateNode(node, lookup = getSection) {
6
+ const def = lookup(node.type);
7
+ const where = { sectionId: node.id, type: node.type };
8
+ if (!def) {
9
+ return {
10
+ node: null,
11
+ changed: false,
12
+ issues: [{ ...where, severity: "error", code: "unknown-section", message: `No section "${node.type}" is registered; section ${node.id} can't be shown.` }]
13
+ };
14
+ }
15
+ if (node.v > def.version) {
16
+ return {
17
+ node: null,
18
+ changed: false,
19
+ issues: [
20
+ {
21
+ ...where,
22
+ severity: "error",
23
+ code: "newer-version",
24
+ message: `Section ${node.id} was saved by a newer "${node.type}" (v${node.v}) than this code knows (v${def.version}).`
25
+ }
26
+ ]
27
+ };
28
+ }
29
+ let props = node.props;
30
+ for (let n = node.v + 1; n <= def.version; n++) {
31
+ const step = def.migrations?.[n];
32
+ try {
33
+ props = step ? step(structuredClone(props)) : props;
34
+ } catch (err) {
35
+ return {
36
+ node: null,
37
+ changed: false,
38
+ issues: [{ ...where, severity: "error", code: "migration-failed", message: `Migrating section ${node.id} to v${n} failed: ${err.message}` }]
39
+ };
40
+ }
41
+ }
42
+ const parsed = def.schema.safeParse(props);
43
+ if (!parsed.success) {
44
+ return {
45
+ node: null,
46
+ changed: false,
47
+ issues: [
48
+ {
49
+ ...where,
50
+ severity: "error",
51
+ code: "invalid-props",
52
+ message: `Section ${node.id} (${def.label}): ${parsed.error.issues.map((i) => `${i.path.join(".") || "props"}: ${i.message}`).join("; ")}`
53
+ }
54
+ ]
55
+ };
56
+ }
57
+ const issues = [];
58
+ let variant = node.variant ?? def.defaultVariant;
59
+ if (!def.variants.some((v) => v.id === variant)) {
60
+ issues.push({ ...where, severity: "warning", code: "unknown-variant", message: `Section ${node.id} asks for variant "${variant}", which "${node.type}" doesn't have; showing "${def.defaultVariant}".` });
61
+ variant = def.defaultVariant;
62
+ }
63
+ const next = { ...node, v: def.version, variant, props: parsed.data };
64
+ const changed = node.v !== def.version || variant !== node.variant || JSON.stringify(parsed.data) !== JSON.stringify(node.props);
65
+ return { node: next, changed, issues };
66
+ }
67
+ function migratePage(doc, lookup) {
68
+ const issues = [];
69
+ let changed = false;
70
+ const content = doc.content.map((node) => {
71
+ const m = migrateNode(node, lookup);
72
+ issues.push(...m.issues);
73
+ if (m.node && m.changed) changed = true;
74
+ return m.node ?? node;
75
+ });
76
+ return { doc: changed ? { ...doc, content } : doc, changed, issues };
77
+ }
78
+ function migrateSite(site, lookup) {
79
+ const issues = [];
80
+ let changed = false;
81
+ const run = (nodes) => nodes.map((node) => {
82
+ const m = migrateNode(node, lookup);
83
+ issues.push(...m.issues);
84
+ if (m.node && m.changed) changed = true;
85
+ return m.node ?? node;
86
+ });
87
+ const header = run(site.header);
88
+ const footer = run(site.footer);
89
+ return { site: changed ? { ...site, header, footer } : site, changed, issues };
90
+ }
91
+ var FONT_CHOICES = Object.freeze({
92
+ system: { label: "System", stack: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' },
93
+ inter: { label: "Inter", stack: "Inter, ui-sans-serif, system-ui, sans-serif", google: "Inter:wght@400;500;600;700" },
94
+ "dm-sans": { label: "DM Sans", stack: '"DM Sans", ui-sans-serif, system-ui, sans-serif', google: "DM+Sans:wght@400;500;700" },
95
+ "work-sans": { label: "Work Sans", stack: '"Work Sans", ui-sans-serif, system-ui, sans-serif', google: "Work+Sans:wght@400;500;700" },
96
+ montserrat: { label: "Montserrat", stack: "Montserrat, ui-sans-serif, system-ui, sans-serif", google: "Montserrat:wght@400;600;700" },
97
+ poppins: { label: "Poppins", stack: "Poppins, ui-sans-serif, system-ui, sans-serif", google: "Poppins:wght@400;600;700" },
98
+ "space-grotesk": { label: "Space Grotesk", stack: '"Space Grotesk", ui-sans-serif, system-ui, sans-serif', google: "Space+Grotesk:wght@400;500;700" },
99
+ "source-serif": { label: "Source Serif", stack: '"Source Serif 4", Georgia, serif', google: "Source+Serif+4:wght@400;600;700" },
100
+ merriweather: { label: "Merriweather", stack: "Merriweather, Georgia, serif", google: "Merriweather:wght@400;700" },
101
+ "playfair-display": { label: "Playfair Display", stack: '"Playfair Display", Georgia, serif', google: "Playfair+Display:wght@400;600;700" },
102
+ lora: { label: "Lora", stack: "Lora, Georgia, serif", google: "Lora:wght@400;600;700" }
103
+ });
104
+ var RADIUS = { none: "0rem", sm: "0.25rem", md: "0.5rem", lg: "0.75rem", full: "1.25rem" };
105
+ var HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
106
+ function parseHex(hex) {
107
+ if (!HEX.test(hex)) throw new Error(`"${hex}" is not a #rgb or #rrggbb colour.`);
108
+ const h = hex.length === 4 ? [...hex.slice(1)].map((c) => c + c).join("") : hex.slice(1);
109
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
110
+ }
111
+ var toHex = (rgb) => `#${rgb.map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, "0")).join("")}`;
112
+ function luminance(hex) {
113
+ const [r, g, b] = parseHex(hex).map((c) => {
114
+ const s = c / 255;
115
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
116
+ });
117
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
118
+ }
119
+ function contrastRatio(a, b) {
120
+ const la = luminance(a);
121
+ const lb = luminance(b);
122
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
123
+ }
124
+ function mix(hex, amount) {
125
+ const target = amount >= 0 ? 255 : 0;
126
+ const t = Math.abs(amount);
127
+ return toHex(parseHex(hex).map((c) => c + (target - c) * t));
128
+ }
129
+ function hexToHslTriplet(hex) {
130
+ const [r, g, b] = parseHex(hex).map((c) => c / 255);
131
+ const max = Math.max(r, g, b);
132
+ const min = Math.min(r, g, b);
133
+ const l = (max + min) / 2;
134
+ let h = 0;
135
+ let s = 0;
136
+ if (max !== min) {
137
+ const d = max - min;
138
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
139
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
140
+ else if (max === g) h = (b - r) / d + 2;
141
+ else h = (r - g) / d + 4;
142
+ h *= 60;
143
+ }
144
+ const round = (n) => Math.round(n * 10) / 10;
145
+ return `${round(h)} ${round(s * 100)}% ${round(l * 100)}%`;
146
+ }
147
+ function readableOn(background, a, b) {
148
+ return contrastRatio(background, a) >= contrastRatio(background, b) ? a : b;
149
+ }
150
+ function isSiteBrand(brand) {
151
+ return typeof brand.colors === "object" && !("neutral" in brand);
152
+ }
153
+ function toCanonicalBrand(brand) {
154
+ if (!isSiteBrand(brand)) {
155
+ const { logo: _logo, displayName: _displayName, ...canonical } = brand;
156
+ return canonical;
157
+ }
158
+ const primary = brand.colors.primary.toLowerCase();
159
+ parseHex(primary);
160
+ const primaryDark = (brand.colors.primaryDark ?? mix(primary, 0.35)).toLowerCase();
161
+ parseHex(primaryDark);
162
+ const heading = FONT_CHOICES[brand.fonts?.heading ?? "inter"] ?? FONT_CHOICES.inter;
163
+ const body = FONT_CHOICES[brand.fonts?.body ?? "inter"] ?? FONT_CHOICES.inter;
164
+ return {
165
+ ...genesisBrand,
166
+ name: brand.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "site",
167
+ accent: { DEFAULT: primary, light: primary, dark: primaryDark },
168
+ typography: {
169
+ fontFamily: { body: body.stack.split(/,\s*/), display: heading.stack.split(/,\s*/) }
170
+ }
171
+ };
172
+ }
173
+ function brandDisplayName(brand) {
174
+ return isSiteBrand(brand) ? brand.name : brand.displayName ?? brand.name;
175
+ }
176
+ function modeColours(brand, mode) {
177
+ const n = brand.neutral;
178
+ const light = mode === "light";
179
+ const background = (light ? brand.surface.light : brand.surface.dark) ?? brand.surface.DEFAULT;
180
+ const foreground = (light ? brand.text.light : brand.text.dark) ?? brand.text.DEFAULT;
181
+ const primary = (light ? brand.accent.light : brand.accent.dark) ?? brand.accent.DEFAULT;
182
+ const muted = light ? n["100"] ?? mix(background, -0.04) : n["800"] ?? mix(background, 0.08);
183
+ const mutedForeground = light ? n["600"] ?? mix(foreground, 0.3) : n["400"] ?? mix(foreground, -0.3);
184
+ const border = light ? n["200"] ?? mix(background, -0.1) : n["700"] ?? mix(background, 0.15);
185
+ let primaryForeground = readableOn(primary, "#ffffff", n["900"] ?? "#09090b");
186
+ if (contrastRatio(primary, primaryForeground) < 4.5) primaryForeground = readableOn(primary, "#ffffff", "#000000");
187
+ const ring = contrastRatio(primary, background) >= 3 ? primary : foreground;
188
+ return { background, foreground, muted, mutedForeground, primary, primaryForeground, ring, border };
189
+ }
190
+ function roleVars(c) {
191
+ const hsl = hexToHslTriplet;
192
+ return {
193
+ "--background": hsl(c.background),
194
+ "--foreground": hsl(c.foreground),
195
+ "--card": hsl(c.background),
196
+ "--card-foreground": hsl(c.foreground),
197
+ "--popover": hsl(c.background),
198
+ "--popover-foreground": hsl(c.foreground),
199
+ "--primary": hsl(c.primary),
200
+ "--primary-foreground": hsl(c.primaryForeground),
201
+ "--secondary": hsl(c.muted),
202
+ "--secondary-foreground": hsl(c.foreground),
203
+ "--muted": hsl(c.muted),
204
+ "--muted-foreground": hsl(c.mutedForeground),
205
+ "--accent": hsl(c.muted),
206
+ "--accent-foreground": hsl(c.foreground),
207
+ "--border": hsl(c.border),
208
+ "--input": hsl(c.border),
209
+ "--ring": hsl(c.ring)
210
+ };
211
+ }
212
+ function pairsFor(c, mode) {
213
+ return [
214
+ { name: "body text on the page", mode, foreground: c.foreground, background: c.background, minimum: 4.5 },
215
+ { name: "quiet text on the page", mode, foreground: c.mutedForeground, background: c.background, minimum: 4.5 },
216
+ { name: "quiet text on a muted band", mode, foreground: c.mutedForeground, background: c.muted, minimum: 4.5 },
217
+ { name: "body text on a muted band", mode, foreground: c.foreground, background: c.muted, minimum: 4.5 },
218
+ { name: "button and accent-band text on the primary colour", mode, foreground: c.primaryForeground, background: c.primary, minimum: 4.5 },
219
+ { name: "text on an inverse band", mode, foreground: c.background, background: c.foreground, minimum: 4.5 },
220
+ { name: "the focus ring on the page", mode, foreground: c.ring, background: c.background, minimum: 3 }
221
+ ];
222
+ }
223
+ function stableHash(value) {
224
+ const text = JSON.stringify(value, (_k, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : 1)) : v);
225
+ let h1 = 2166136261;
226
+ let h2 = 16777619 ^ text.length;
227
+ for (let i = 0; i < text.length; i++) {
228
+ const c = text.charCodeAt(i);
229
+ h1 = Math.imul(h1 ^ c, 16777619) >>> 0;
230
+ h2 = Math.imul(h2 ^ c, 1540483477) >>> 0;
231
+ }
232
+ return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
233
+ }
234
+ function siteTheme(brand, theme = {}) {
235
+ const canonical = toCanonicalBrand(brand);
236
+ const overrides = theme.overrides ?? {};
237
+ const base = themeFromBrand(canonical).variables;
238
+ const fontStack = (family) => canonical.typography?.fontFamily?.[family]?.join(", ");
239
+ const headingFont = overrides.headingFont ? FONT_CHOICES[overrides.headingFont] : void 0;
240
+ const bodyFont = overrides.bodyFont ? FONT_CHOICES[overrides.bodyFont] : void 0;
241
+ const fontVars = {};
242
+ const sans = bodyFont?.stack ?? fontStack("body");
243
+ const display = headingFont?.stack ?? fontStack("display") ?? sans;
244
+ if (sans) fontVars["--font-sans"] = sans;
245
+ if (display) fontVars["--font-display"] = display;
246
+ if (overrides.radius) fontVars["--radius"] = RADIUS[overrides.radius];
247
+ const light = modeColours(canonical, "light");
248
+ const dark = modeColours(canonical, "dark");
249
+ const usedFonts = /* @__PURE__ */ new Map();
250
+ for (const stack of [sans, display]) {
251
+ const info = Object.values(FONT_CHOICES).find((f) => f.stack === stack);
252
+ if (info) usedFonts.set(info.label, info);
253
+ }
254
+ const colorScheme = theme.colorScheme ?? "light";
255
+ const pairs = colorScheme === "light" ? pairsFor(light, "light") : colorScheme === "dark" ? pairsFor(dark, "dark") : [...pairsFor(light, "light"), ...pairsFor(dark, "dark")];
256
+ return {
257
+ light: { ...base.light, ...roleVars(light), ...fontVars },
258
+ dark: { ...base.dark, ...roleVars(dark), ...fontVars },
259
+ colorScheme,
260
+ fonts: [...usedFonts.values()],
261
+ pairs,
262
+ brandHash: stableHash({ brand: canonical, overrides, colorScheme })
263
+ };
264
+ }
265
+ function cssValue(value) {
266
+ return value.replace(/[<>{};\\]/g, "");
267
+ }
268
+ function block(selector, vars) {
269
+ const lines = Object.entries(vars).filter(([name]) => /^--[a-z0-9-]+$/i.test(name)).map(([name, value]) => `${name}:${cssValue(value)}`);
270
+ return `${selector}{${lines.join(";")}}`;
271
+ }
272
+ function themeCss(theme) {
273
+ if (theme.colorScheme === "dark") return block(":root", theme.dark) + ":root{color-scheme:dark}";
274
+ if (theme.colorScheme === "auto") {
275
+ return `${block(":root", theme.light)}:root{color-scheme:light dark}@media (prefers-color-scheme: dark){${block(":root", theme.dark)}}`;
276
+ }
277
+ return block(":root", theme.light) + ":root{color-scheme:light}";
278
+ }
279
+
280
+ // src/core/a11y.ts
281
+ var isPublishable = (issues) => !issues.some((i) => i.severity === "error");
282
+ function images(value, path = []) {
283
+ if (!value || typeof value !== "object") return [];
284
+ if (Array.isArray(value)) return value.flatMap((v, i) => images(v, [...path, String(i)]));
285
+ const record = value;
286
+ const found = typeof record.src === "string" && ("alt" in record || "decorative" in record || /\.(png|jpe?g|webp|avif|gif|svg)(\?|$)/i.test(record.src)) ? [{ path: path.join("."), image: record }] : [];
287
+ return [...found, ...Object.entries(record).flatMap(([k, v]) => k === "src" ? [] : images(v, [...path, k]))];
288
+ }
289
+ function richTextHeadings(value) {
290
+ if (!value || typeof value !== "object") return [];
291
+ if (Array.isArray(value)) return value.flatMap(richTextHeadings);
292
+ const node = value;
293
+ if (node.type === "heading") return typeof node.attrs?.level === "number" ? [node.attrs.level] : [];
294
+ if (node.type === "doc" || typeof node.type === "string") return richTextHeadings(node.content);
295
+ return Object.values(value).flatMap(richTextHeadings);
296
+ }
297
+ function nodeIssues(node, ctx, lookup) {
298
+ const base = { ...ctx.page ? { pageId: ctx.page.id, path: ctx.page.path } : {}, sectionId: node.id };
299
+ const at = ctx.page ? `${ctx.page.path}` : `the site ${ctx.where}`;
300
+ const migrated = migrateNode(node, lookup);
301
+ const issues = migrated.issues.map((i) => ({ ...base, severity: i.severity, code: i.code, message: `${at}: ${i.message}` }));
302
+ const def = lookup(node.type);
303
+ if (!def || !migrated.node) return issues;
304
+ if (node.placeholder) {
305
+ issues.push({ ...base, severity: "error", code: "placeholder", message: `${at}: the ${def.label} section still has the template's sample content. Replace it, or hide the section.` });
306
+ }
307
+ const placement = def.placement ?? "body";
308
+ if (placement !== ctx.where) {
309
+ issues.push({ ...base, severity: "error", code: "placement", message: `${at}: a ${def.label} section belongs in the ${placement === "body" ? "page" : `site ${placement}`}, not the ${ctx.where === "body" ? "page" : `site ${ctx.where}`}.` });
310
+ }
311
+ if (ctx.kind && def.enabledOn && !def.enabledOn.includes(ctx.kind)) {
312
+ issues.push({ ...base, severity: "error", code: "enabled-on", message: `${at}: a ${def.label} section can't go on a ${ctx.kind} page.` });
313
+ }
314
+ for (const { path, image } of images(migrated.node.props)) {
315
+ const alt = typeof image.alt === "string" ? image.alt.trim() : "";
316
+ if (!alt && image.decorative !== true) {
317
+ issues.push({ ...base, severity: "error", code: "image-alt", message: `${at}: an image in the ${def.label} section (${path || "image"}) needs alt text, or to be marked decorative.` });
318
+ }
319
+ }
320
+ const levels = richTextHeadings(migrated.node.props);
321
+ for (let i = 0; i < levels.length; i++) {
322
+ const level = levels[i];
323
+ const previous = i === 0 ? 2 : levels[i - 1];
324
+ if (level === 1) issues.push({ ...base, severity: "error", code: "heading-h1", message: `${at}: rich text can't hold a level 1 heading; the page has one already.` });
325
+ else if (level > previous + 1) {
326
+ issues.push({ ...base, severity: "error", code: "heading-order", message: `${at}: a level ${level} heading follows a level ${previous} in the ${def.label} section; don't skip a level.` });
327
+ }
328
+ }
329
+ return issues;
330
+ }
331
+ function checkPage(page, lookup = getSection, reserved) {
332
+ const issues = [];
333
+ const base = { pageId: page.id, path: page.path };
334
+ for (const problem of pagePathProblems(page.path, reserved)) issues.push({ ...base, severity: "error", code: "path", message: `${page.path}: ${problem}` });
335
+ const visible = page.doc.content.filter((n) => !n.hidden);
336
+ const counts = /* @__PURE__ */ new Map();
337
+ let h1 = 0;
338
+ for (const node of visible) {
339
+ issues.push(...nodeIssues(node, { where: "body", kind: page.kind, page }, lookup));
340
+ counts.set(node.type, (counts.get(node.type) ?? 0) + 1);
341
+ if (lookup(node.type)?.providesH1) h1++;
342
+ }
343
+ for (const [type, count] of counts) {
344
+ const def = lookup(type);
345
+ if (def?.limit && count > def.limit) {
346
+ issues.push({ ...base, severity: "error", code: "limit", message: `${page.path}: ${count} ${def.label} sections; a page has at most ${def.limit}.` });
347
+ }
348
+ }
349
+ if (h1 === 0) {
350
+ issues.push({ ...base, severity: "warning", code: "no-h1", message: `${page.path}: no hero, so the page title "${page.doc.root.props.title}" becomes its hidden main heading.` });
351
+ }
352
+ if (!page.doc.seo?.description) {
353
+ issues.push({ ...base, severity: "warning", code: "seo-description", message: `${page.path}: no description for search results and link previews.` });
354
+ }
355
+ return issues;
356
+ }
357
+ function checkSiteDoc(site, lookup = getSection) {
358
+ const issues = [];
359
+ for (const node of site.header.filter((n) => !n.hidden)) issues.push(...nodeIssues(node, { where: "header" }, lookup));
360
+ for (const node of site.footer.filter((n) => !n.hidden)) issues.push(...nodeIssues(node, { where: "footer" }, lookup));
361
+ const froms = /* @__PURE__ */ new Set();
362
+ for (const r of site.redirects) {
363
+ if (froms.has(r.from)) issues.push({ severity: "error", code: "redirect-duplicate", path: r.from, message: `Two redirects from ${r.from}.` });
364
+ froms.add(r.from);
365
+ if (r.to === r.from) issues.push({ severity: "error", code: "redirect-loop", path: r.from, message: `The redirect from ${r.from} points at itself.` });
366
+ }
367
+ return issues;
368
+ }
369
+ function checkContrast(theme) {
370
+ return theme.pairs.map((pair) => ({ pair, ratio: contrastRatio(pair.foreground, pair.background) })).filter(({ pair, ratio }) => ratio < pair.minimum).map(({ pair, ratio }) => ({
371
+ severity: "error",
372
+ code: "contrast",
373
+ message: `In ${pair.mode} mode, ${pair.name} is ${ratio.toFixed(2)}:1 (${pair.foreground} on ${pair.background}); it needs ${pair.minimum}:1. Adjust the brand colours.`
374
+ }));
375
+ }
376
+ function checkRelease(snapshot, options = {}) {
377
+ const lookup = options.lookup ?? getSection;
378
+ const issues = [...checkSiteDoc(snapshot.site, lookup)];
379
+ const paths = /* @__PURE__ */ new Map();
380
+ for (const page of snapshot.pages) {
381
+ const other = paths.get(page.path);
382
+ if (other) issues.push({ severity: "error", code: "path-duplicate", pageId: page.id, path: page.path, message: `Two pages at ${page.path}.` });
383
+ paths.set(page.path, page.id);
384
+ issues.push(...checkPage(page, lookup, options.reserved));
385
+ }
386
+ if (!paths.has("/")) issues.push({ severity: "error", code: "no-home", message: "The site has no home page (a page at /)." });
387
+ for (const r of snapshot.site.redirects) {
388
+ if (paths.has(r.from)) issues.push({ severity: "error", code: "redirect-shadows-page", path: r.from, message: `The redirect from ${r.from} hides the page there; remove the redirect.` });
389
+ const target = r.to.split(/[?#]/)[0];
390
+ if (r.to.startsWith("/") && !paths.has(target)) {
391
+ issues.push({ severity: "warning", code: "redirect-to-missing-page", path: r.from, message: `The redirect from ${r.from} goes to ${target}, where there's no page (was it deleted?).` });
392
+ }
393
+ if (options.reserved !== void 0 ? isReservedPath(r.from, options.reserved) : isReservedPath(r.from)) {
394
+ issues.push({ severity: "error", code: "redirect-reserved", path: r.from, message: `${r.from} is reserved for the app; a site can't redirect it.` });
395
+ }
396
+ }
397
+ if (options.theme) issues.push(...checkContrast(options.theme));
398
+ return issues;
399
+ }
400
+ function checkHtmlOutline(html) {
401
+ const issues = [];
402
+ const body = html.replace(/<script\b[\s\S]*?<\/script>/gi, "").replace(/<style\b[\s\S]*?<\/style>/gi, "");
403
+ const levels = [...body.matchAll(/<h([1-6])\b/gi)].map((m) => Number(m[1]));
404
+ const h1 = levels.filter((l) => l === 1).length;
405
+ if (h1 !== 1) issues.push({ severity: "error", code: "h1-count", message: `The page has ${h1} h1 elements; it needs exactly one.` });
406
+ if (levels[0] !== void 0 && levels[0] !== 1) issues.push({ severity: "error", code: "heading-order", message: `The first heading is an h${levels[0]}, not the h1.` });
407
+ for (let i = 1; i < levels.length; i++) {
408
+ if (levels[i] > levels[i - 1] + 1) issues.push({ severity: "error", code: "heading-order", message: `An h${levels[i]} follows an h${levels[i - 1]}.` });
409
+ }
410
+ for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
411
+ if (!/\salt=/.test(m[0])) issues.push({ severity: "error", code: "image-alt", message: `An image has no alt attribute: ${m[0].slice(0, 120)}` });
412
+ }
413
+ return issues;
414
+ }
415
+
416
+ // src/core/store.ts
417
+ var isConflict = (result) => "conflict" in result;
418
+ var PublishBlockedError = class extends Error {
419
+ constructor(issues) {
420
+ const errors = issues.filter((i) => i.severity === "error");
421
+ super(`The site can't be published yet:
422
+ ${errors.map((i) => ` - ${i.message}`).join("\n")}`);
423
+ this.issues = issues;
424
+ this.name = "PublishBlockedError";
425
+ }
426
+ issues;
427
+ };
428
+ var SiteConflictError = class extends Error {
429
+ constructor(message = "Someone else changed this site; reload it and try again.") {
430
+ super(message);
431
+ this.name = "SiteConflictError";
432
+ }
433
+ };
434
+ function prepareRelease(site, pages, options = {}) {
435
+ const migratedSite = migrateSite(site);
436
+ const migratedPages = [];
437
+ const releasePages = pages.map((p) => {
438
+ const m = migratePage(p.doc);
439
+ if (m.changed) migratedPages.push({ id: p.id, doc: m.doc });
440
+ return { id: p.id, path: p.path, kind: p.kind, doc: m.doc };
441
+ });
442
+ const visible = (nodes) => nodes.filter((n) => !n.hidden);
443
+ const snapshot = ReleaseSnapshot.parse({
444
+ schemaVersion: 1,
445
+ site: { ...migratedSite.site, header: visible(migratedSite.site.header), footer: visible(migratedSite.site.footer) },
446
+ pages: releasePages.map((p) => ({ ...p, doc: { ...p.doc, content: visible(p.doc.content) } }))
447
+ });
448
+ const theme = options.brand ? siteTheme(options.brand, snapshot.site.theme) : void 0;
449
+ const issues = checkRelease(snapshot, { ...theme ? { theme } : {}, reserved: options.reserved ?? DEFAULT_RESERVED_PATHS });
450
+ if (!theme) issues.push({ severity: "error", code: "brand-missing", message: "Publish with the site's brand (`brand`), so its colours can be checked for contrast." });
451
+ if (!isPublishable(issues)) throw new PublishBlockedError(issues);
452
+ return { snapshot, issues, migratedPages, migratedSite: migratedSite.changed ? migratedSite.site : null, brandHash: theme?.brandHash ?? null };
453
+ }
454
+ function siteCacheTag(siteId) {
455
+ return `site:${siteId}`;
456
+ }
457
+ function formCacheTag(slug) {
458
+ return `form:${slug}`;
459
+ }
460
+ function withCacheInvalidation(store, invalidate) {
461
+ const purge = async (siteId, action) => {
462
+ try {
463
+ await invalidate([siteCacheTag(siteId)], { siteId, action });
464
+ } catch (err) {
465
+ console.error(`[genesis-sites] published site ${siteId}, but purging its cache failed; the old pages may be served until their cache expires.`, err);
466
+ throw err;
467
+ }
468
+ };
469
+ return {
470
+ ...store,
471
+ getLive: (id) => store.getLive(id),
472
+ getDraft: (id) => store.getDraft(id),
473
+ saveSiteDraft: (id, rev, site) => store.saveSiteDraft(id, rev, site),
474
+ listPages: (id) => store.listPages(id),
475
+ getPageDraft: (id) => store.getPageDraft(id),
476
+ savePageDraft: (id, rev, doc) => store.savePageDraft(id, rev, doc),
477
+ createPage: (id, page) => store.createPage(id, page),
478
+ movePage: (id, path) => store.movePage(id, path),
479
+ deletePage: (id) => store.deletePage(id),
480
+ listReleases: (id) => store.listReleases(id),
481
+ ...store.putAsset ? { putAsset: store.putAsset.bind(store) } : {},
482
+ async publish(siteId, options) {
483
+ const release = await store.publish(siteId, options);
484
+ await purge(siteId, "publish");
485
+ return release;
486
+ },
487
+ async rollback(siteId, releaseId) {
488
+ const release = await store.rollback(siteId, releaseId);
489
+ await purge(siteId, "rollback");
490
+ return release;
491
+ }
492
+ };
493
+ }
494
+ var clone = (value) => structuredClone(value);
495
+ function createMemorySiteStore(options = {}) {
496
+ const reserved = options.reserved ?? DEFAULT_RESERVED_PATHS;
497
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
498
+ const sites = /* @__PURE__ */ new Map();
499
+ const pages = /* @__PURE__ */ new Map();
500
+ const releases = /* @__PURE__ */ new Map();
501
+ let seq = 0;
502
+ const id = (prefix) => `${prefix}_${(++seq).toString(36).padStart(4, "0")}`;
503
+ const editable = (siteId) => {
504
+ const s = sites.get(siteId);
505
+ if (!s) throw new Error(`No site ${siteId}.`);
506
+ if (options.canEdit && !options.canEdit(siteId)) throw new Error(`Not allowed to edit site ${siteId}.`);
507
+ return s;
508
+ };
509
+ const livePages = (siteId) => [...pages.values()].filter((p) => p.siteId === siteId && !p.deleted).sort((a, b) => a.sort - b.sort || a.path.localeCompare(b.path));
510
+ const meta = (p) => ({ id: p.id, siteId: p.siteId, path: p.path, kind: p.kind, title: p.doc.root.props.title, sort: p.sort, rev: p.rev });
511
+ const releaseMeta = ({ snapshot: _s, ...m }) => clone(m);
512
+ const pathTaken = (siteId, path, except) => livePages(siteId).some((p) => p.path === path && p.id !== except);
513
+ const store = {
514
+ async createSite(input) {
515
+ const siteId = input.id ?? id("site");
516
+ if (sites.has(siteId)) throw new Error(`Site ${siteId} exists.`);
517
+ const site = SiteDoc.parse(input.site);
518
+ sites.set(siteId, { id: siteId, site, rev: 1, liveReleaseId: null, status: "draft" });
519
+ const pageIds = [];
520
+ input.pages.forEach((p, i) => {
521
+ validatePagePath(p.path, reserved);
522
+ if (pathTaken(siteId, p.path)) throw new Error(`Two pages at ${p.path}.`);
523
+ const pageId = p.id ?? id("page");
524
+ pages.set(pageId, { id: pageId, siteId, path: p.path, kind: p.kind ?? "page", doc: PageDoc.parse(clone(p.doc)), rev: 1, sort: i, deleted: false });
525
+ pageIds.push(pageId);
526
+ });
527
+ return { siteId, pageIds };
528
+ },
529
+ async setStatus(siteId, status) {
530
+ editable(siteId).status = status;
531
+ },
532
+ async getLive(siteId) {
533
+ const s = sites.get(siteId);
534
+ if (!s || s.status !== "live" || !s.liveReleaseId) return null;
535
+ const r = releases.get(s.liveReleaseId);
536
+ return r ? clone(r) : null;
537
+ },
538
+ async getDraft(siteId) {
539
+ const s = sites.get(siteId);
540
+ if (!s || options.canEdit && !options.canEdit(siteId)) return null;
541
+ return { site: clone(s.site), rev: s.rev };
542
+ },
543
+ async saveSiteDraft(siteId, rev, doc) {
544
+ const s = editable(siteId);
545
+ if (s.rev !== rev) return { conflict: clone(s.site), rev: s.rev };
546
+ s.site = SiteDoc.parse(clone(doc));
547
+ s.rev += 1;
548
+ return { rev: s.rev };
549
+ },
550
+ async listPages(siteId) {
551
+ if (options.canEdit && !options.canEdit(siteId)) return [];
552
+ return livePages(siteId).map(meta);
553
+ },
554
+ async getPageDraft(pageId) {
555
+ const p = pages.get(pageId);
556
+ if (!p || p.deleted || options.canEdit && !options.canEdit(p.siteId)) return null;
557
+ return { doc: clone(p.doc), rev: p.rev, siteId: p.siteId, path: p.path, kind: p.kind };
558
+ },
559
+ async savePageDraft(pageId, rev, doc) {
560
+ const p = pages.get(pageId);
561
+ if (!p || p.deleted) throw new Error(`No page ${pageId}.`);
562
+ editable(p.siteId);
563
+ if (p.rev !== rev) return { conflict: clone(p.doc), rev: p.rev };
564
+ p.doc = PageDoc.parse(clone(doc));
565
+ p.rev += 1;
566
+ return { rev: p.rev };
567
+ },
568
+ async createPage(siteId, page) {
569
+ const s = editable(siteId);
570
+ validatePagePath(page.path, reserved);
571
+ if (pathTaken(siteId, page.path)) throw new Error(`A page is already at ${page.path}.`);
572
+ const pageId = id("page");
573
+ const sort = Math.max(-1, ...livePages(siteId).map((p2) => p2.sort)) + 1;
574
+ const p = { id: pageId, siteId, path: page.path, kind: page.kind ?? "page", doc: PageDoc.parse(clone(page.doc)), rev: 1, sort, deleted: false };
575
+ pages.set(pageId, p);
576
+ if (s.site.redirects.some((r) => r.from === page.path)) {
577
+ s.site = { ...s.site, redirects: s.site.redirects.filter((r) => r.from !== page.path) };
578
+ s.rev += 1;
579
+ }
580
+ return meta(p);
581
+ },
582
+ async movePage(pageId, path) {
583
+ const p = pages.get(pageId);
584
+ if (!p || p.deleted) throw new Error(`No page ${pageId}.`);
585
+ const s = editable(p.siteId);
586
+ validatePagePath(path, reserved);
587
+ if (p.path === "/") throw new Error("The home page stays at /.");
588
+ if (pathTaken(p.siteId, path, pageId)) throw new Error(`A page is already at ${path}.`);
589
+ const from = p.path;
590
+ p.path = path;
591
+ p.rev += 1;
592
+ s.site = { ...s.site, redirects: redirectsAfterMove(s.site.redirects, from, path) };
593
+ s.rev += 1;
594
+ return meta(p);
595
+ },
596
+ async deletePage(pageId) {
597
+ const p = pages.get(pageId);
598
+ if (!p || p.deleted) return;
599
+ editable(p.siteId);
600
+ if (p.path === "/") throw new Error("The home page can't be deleted; replace its content instead.");
601
+ p.deleted = true;
602
+ },
603
+ async publish(siteId, opts = {}) {
604
+ const s = editable(siteId);
605
+ const list = livePages(siteId);
606
+ const prepared = prepareRelease(s.site, list, { reserved, ...opts });
607
+ for (const { id: pid, doc } of prepared.migratedPages) {
608
+ const p = pages.get(pid);
609
+ p.doc = doc;
610
+ p.rev += 1;
611
+ }
612
+ if (prepared.migratedSite) {
613
+ s.site = prepared.migratedSite;
614
+ s.rev += 1;
615
+ }
616
+ const number = Math.max(0, ...[...releases.values()].filter((r) => r.siteId === siteId).map((r) => r.number)) + 1;
617
+ const at = now().toISOString();
618
+ const release = {
619
+ id: id("rel"),
620
+ siteId,
621
+ number,
622
+ note: opts.note ?? null,
623
+ createdAt: at,
624
+ publishedAt: at,
625
+ brandHash: prepared.brandHash,
626
+ snapshot: Object.freeze(clone(prepared.snapshot))
627
+ };
628
+ releases.set(release.id, release);
629
+ s.liveReleaseId = release.id;
630
+ s.status = "live";
631
+ return clone(release);
632
+ },
633
+ async rollback(siteId, releaseId) {
634
+ const s = editable(siteId);
635
+ const r = releases.get(releaseId);
636
+ if (!r || r.siteId !== siteId) throw new Error(`Release ${releaseId} isn't one of site ${siteId}'s.`);
637
+ s.liveReleaseId = r.id;
638
+ s.status = "live";
639
+ return clone(r);
640
+ },
641
+ async listReleases(siteId) {
642
+ if (options.canEdit && !options.canEdit(siteId)) return [];
643
+ return [...releases.values()].filter((r) => r.siteId === siteId).sort((a, b) => b.number - a.number).map(releaseMeta);
644
+ },
645
+ async putAsset(siteId, file, assetMeta = {}) {
646
+ editable(siteId);
647
+ const assetId = id("asset");
648
+ const name = (assetMeta.name ?? "file").toLowerCase().replace(/[^a-z0-9.]+/g, "-").slice(0, 80) || "file";
649
+ const path = `sites/${siteId}/${assetId}-${name}`;
650
+ return { id: assetId, siteId, path, url: `/${path}`, mime: file.type || "application/octet-stream", alt: assetMeta.alt ?? null, width: assetMeta.width ?? null, height: assetMeta.height ?? null };
651
+ }
652
+ };
653
+ return store;
654
+ }
655
+
656
+ export { FONT_CHOICES, PublishBlockedError, SiteConflictError, brandDisplayName, checkContrast, checkHtmlOutline, checkPage, checkRelease, checkSiteDoc, contrastRatio, createMemorySiteStore, formCacheTag, hexToHslTriplet, isConflict, isPublishable, isSiteBrand, luminance, migrateNode, migratePage, migrateSite, mix, parseHex, prepareRelease, siteCacheTag, siteTheme, stableHash, themeCss, toCanonicalBrand, withCacheInvalidation };
657
+ //# sourceMappingURL=chunk-GA7U3CIN.js.map
658
+ //# sourceMappingURL=chunk-GA7U3CIN.js.map