@marktiderman/genesis-sites 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +465 -2
- package/dist/assets/form-island.c0f6fac859.js +51 -0
- package/dist/assets/islands.77d11f5697.js +1 -0
- package/dist/assets/sites-forms.4e6aa42497.css +10 -0
- package/dist/assets/sites-forms.css +10 -0
- package/dist/assets/sites.css +2 -0
- package/dist/assets/sites.e1ffd2708b.css +2 -0
- package/dist/blocks.d.ts +13 -0
- package/dist/blocks.js +26 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-3VKTBE7S.js +69 -0
- package/dist/chunk-3VKTBE7S.js.map +1 -0
- package/dist/chunk-C375ZPU2.js +2241 -0
- package/dist/chunk-C375ZPU2.js.map +1 -0
- package/dist/chunk-IXMY4B56.js +877 -0
- package/dist/chunk-IXMY4B56.js.map +1 -0
- package/dist/chunk-VZUDRPFG.js +13 -0
- package/dist/chunk-VZUDRPFG.js.map +1 -0
- package/dist/chunk-ZGWGQGT2.js +595 -0
- package/dist/chunk-ZGWGQGT2.js.map +1 -0
- package/dist/import.d.ts +130 -0
- package/dist/import.js +330 -0
- package/dist/import.js.map +1 -0
- package/dist/index.d.ts +273 -0
- package/dist/index.js +254 -0
- package/dist/index.js.map +1 -0
- package/dist/registry-D_DCWRTc.d.ts +1030 -0
- package/dist/sections.d.ts +802 -0
- package/dist/sections.js +6 -0
- package/dist/sections.js.map +1 -0
- package/dist/store-DkSycDhq.d.ts +443 -0
- package/dist/supabase.d.ts +41 -0
- package/dist/supabase.js +283 -0
- package/dist/supabase.js.map +1 -0
- package/dist/web.d.ts +136 -0
- package/dist/web.js +479 -0
- package/dist/web.js.map +1 -0
- package/migrations/0001_genesis_sites.sql +821 -0
- package/migrations/0002_genesis_sites_posts.sql +404 -0
- package/migrations/tests/0001_genesis_sites.test.sql +349 -0
- package/migrations/tests/0002_genesis_sites_posts.test.sql +242 -0
- package/package.json +152 -3
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import { getSection, PostSlug, PostDoc, ReleaseSnapshot, validatePostSlug, MAX_IMPORT_POSTS, MAX_LIVE_POSTS, postSummary, redirectsAfterMove, PageDoc, SiteDoc } from './chunk-ZGWGQGT2.js';
|
|
2
|
+
import { pagePathProblems, isReservedPath, DEFAULT_RESERVED_PATHS, validatePagePath } from './chunk-3VKTBE7S.js';
|
|
3
|
+
import { genesisBrand, themeFromBrand } from '@marktiderman/genesis-design-system';
|
|
4
|
+
|
|
5
|
+
// src/core/migrate.ts
|
|
6
|
+
function migrateNode(node, lookup = getSection) {
|
|
7
|
+
const def = lookup(node.type);
|
|
8
|
+
const where = { sectionId: node.id, type: node.type };
|
|
9
|
+
if (!def) {
|
|
10
|
+
return {
|
|
11
|
+
node: null,
|
|
12
|
+
changed: false,
|
|
13
|
+
issues: [{ ...where, severity: "error", code: "unknown-section", message: `No section "${node.type}" is registered; section ${node.id} can't be shown.` }]
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (node.v > def.version) {
|
|
17
|
+
return {
|
|
18
|
+
node: null,
|
|
19
|
+
changed: false,
|
|
20
|
+
issues: [
|
|
21
|
+
{
|
|
22
|
+
...where,
|
|
23
|
+
severity: "error",
|
|
24
|
+
code: "newer-version",
|
|
25
|
+
message: `Section ${node.id} was saved by a newer "${node.type}" (v${node.v}) than this code knows (v${def.version}).`
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
let props = node.props;
|
|
31
|
+
for (let n = node.v + 1; n <= def.version; n++) {
|
|
32
|
+
const step = def.migrations?.[n];
|
|
33
|
+
try {
|
|
34
|
+
props = step ? step(structuredClone(props)) : props;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return {
|
|
37
|
+
node: null,
|
|
38
|
+
changed: false,
|
|
39
|
+
issues: [{ ...where, severity: "error", code: "migration-failed", message: `Migrating section ${node.id} to v${n} failed: ${err.message}` }]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const parsed = def.schema.safeParse(props);
|
|
44
|
+
if (!parsed.success) {
|
|
45
|
+
return {
|
|
46
|
+
node: null,
|
|
47
|
+
changed: false,
|
|
48
|
+
issues: [
|
|
49
|
+
{
|
|
50
|
+
...where,
|
|
51
|
+
severity: "error",
|
|
52
|
+
code: "invalid-props",
|
|
53
|
+
message: `Section ${node.id} (${def.label}): ${parsed.error.issues.map((i) => `${i.path.join(".") || "props"}: ${i.message}`).join("; ")}`
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const issues = [];
|
|
59
|
+
let variant = node.variant ?? def.defaultVariant;
|
|
60
|
+
if (!def.variants.some((v) => v.id === variant)) {
|
|
61
|
+
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}".` });
|
|
62
|
+
variant = def.defaultVariant;
|
|
63
|
+
}
|
|
64
|
+
const next = { ...node, v: def.version, variant, props: parsed.data };
|
|
65
|
+
const changed = node.v !== def.version || variant !== node.variant || JSON.stringify(parsed.data) !== JSON.stringify(node.props);
|
|
66
|
+
return { node: next, changed, issues };
|
|
67
|
+
}
|
|
68
|
+
function migratePage(doc, lookup) {
|
|
69
|
+
const issues = [];
|
|
70
|
+
let changed = false;
|
|
71
|
+
const content = doc.content.map((node) => {
|
|
72
|
+
const m = migrateNode(node, lookup);
|
|
73
|
+
issues.push(...m.issues);
|
|
74
|
+
if (m.node && m.changed) changed = true;
|
|
75
|
+
return m.node ?? node;
|
|
76
|
+
});
|
|
77
|
+
return { doc: changed ? { ...doc, content } : doc, changed, issues };
|
|
78
|
+
}
|
|
79
|
+
function migrateSite(site, lookup) {
|
|
80
|
+
const issues = [];
|
|
81
|
+
let changed = false;
|
|
82
|
+
const run = (nodes) => nodes.map((node) => {
|
|
83
|
+
const m = migrateNode(node, lookup);
|
|
84
|
+
issues.push(...m.issues);
|
|
85
|
+
if (m.node && m.changed) changed = true;
|
|
86
|
+
return m.node ?? node;
|
|
87
|
+
});
|
|
88
|
+
const header = run(site.header);
|
|
89
|
+
const footer = run(site.footer);
|
|
90
|
+
return { site: changed ? { ...site, header, footer } : site, changed, issues };
|
|
91
|
+
}
|
|
92
|
+
var FONT_CHOICES = Object.freeze({
|
|
93
|
+
system: { label: "System", stack: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' },
|
|
94
|
+
inter: { label: "Inter", stack: "Inter, ui-sans-serif, system-ui, sans-serif", google: "Inter:wght@400;500;600;700" },
|
|
95
|
+
"dm-sans": { label: "DM Sans", stack: '"DM Sans", ui-sans-serif, system-ui, sans-serif', google: "DM+Sans:wght@400;500;700" },
|
|
96
|
+
"work-sans": { label: "Work Sans", stack: '"Work Sans", ui-sans-serif, system-ui, sans-serif', google: "Work+Sans:wght@400;500;700" },
|
|
97
|
+
montserrat: { label: "Montserrat", stack: "Montserrat, ui-sans-serif, system-ui, sans-serif", google: "Montserrat:wght@400;600;700" },
|
|
98
|
+
poppins: { label: "Poppins", stack: "Poppins, ui-sans-serif, system-ui, sans-serif", google: "Poppins:wght@400;600;700" },
|
|
99
|
+
"space-grotesk": { label: "Space Grotesk", stack: '"Space Grotesk", ui-sans-serif, system-ui, sans-serif', google: "Space+Grotesk:wght@400;500;700" },
|
|
100
|
+
"source-serif": { label: "Source Serif", stack: '"Source Serif 4", Georgia, serif', google: "Source+Serif+4:wght@400;600;700" },
|
|
101
|
+
merriweather: { label: "Merriweather", stack: "Merriweather, Georgia, serif", google: "Merriweather:wght@400;700" },
|
|
102
|
+
"playfair-display": { label: "Playfair Display", stack: '"Playfair Display", Georgia, serif', google: "Playfair+Display:wght@400;600;700" },
|
|
103
|
+
lora: { label: "Lora", stack: "Lora, Georgia, serif", google: "Lora:wght@400;600;700" }
|
|
104
|
+
});
|
|
105
|
+
var RADIUS = { none: "0rem", sm: "0.25rem", md: "0.5rem", lg: "0.75rem", full: "1.25rem" };
|
|
106
|
+
var HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
107
|
+
function parseHex(hex) {
|
|
108
|
+
if (!HEX.test(hex)) throw new Error(`"${hex}" is not a #rgb or #rrggbb colour.`);
|
|
109
|
+
const h = hex.length === 4 ? [...hex.slice(1)].map((c) => c + c).join("") : hex.slice(1);
|
|
110
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
111
|
+
}
|
|
112
|
+
var toHex = (rgb) => `#${rgb.map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, "0")).join("")}`;
|
|
113
|
+
function luminance(hex) {
|
|
114
|
+
const [r, g, b] = parseHex(hex).map((c) => {
|
|
115
|
+
const s = c / 255;
|
|
116
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
117
|
+
});
|
|
118
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
119
|
+
}
|
|
120
|
+
function contrastRatio(a, b) {
|
|
121
|
+
const la = luminance(a);
|
|
122
|
+
const lb = luminance(b);
|
|
123
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
124
|
+
}
|
|
125
|
+
function mix(hex, amount) {
|
|
126
|
+
const target = amount >= 0 ? 255 : 0;
|
|
127
|
+
const t = Math.abs(amount);
|
|
128
|
+
return toHex(parseHex(hex).map((c) => c + (target - c) * t));
|
|
129
|
+
}
|
|
130
|
+
function hexToHslTriplet(hex) {
|
|
131
|
+
const [r, g, b] = parseHex(hex).map((c) => c / 255);
|
|
132
|
+
const max = Math.max(r, g, b);
|
|
133
|
+
const min = Math.min(r, g, b);
|
|
134
|
+
const l = (max + min) / 2;
|
|
135
|
+
let h = 0;
|
|
136
|
+
let s = 0;
|
|
137
|
+
if (max !== min) {
|
|
138
|
+
const d = max - min;
|
|
139
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
140
|
+
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
|
141
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
142
|
+
else h = (r - g) / d + 4;
|
|
143
|
+
h *= 60;
|
|
144
|
+
}
|
|
145
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
146
|
+
return `${round(h)} ${round(s * 100)}% ${round(l * 100)}%`;
|
|
147
|
+
}
|
|
148
|
+
function readableOn(background, a, b) {
|
|
149
|
+
return contrastRatio(background, a) >= contrastRatio(background, b) ? a : b;
|
|
150
|
+
}
|
|
151
|
+
function isSiteBrand(brand) {
|
|
152
|
+
return typeof brand.colors === "object" && !("neutral" in brand);
|
|
153
|
+
}
|
|
154
|
+
function toCanonicalBrand(brand) {
|
|
155
|
+
if (!isSiteBrand(brand)) {
|
|
156
|
+
const { logo: _logo, displayName: _displayName, ...canonical } = brand;
|
|
157
|
+
return canonical;
|
|
158
|
+
}
|
|
159
|
+
const primary = brand.colors.primary.toLowerCase();
|
|
160
|
+
parseHex(primary);
|
|
161
|
+
const primaryDark = (brand.colors.primaryDark ?? mix(primary, 0.35)).toLowerCase();
|
|
162
|
+
parseHex(primaryDark);
|
|
163
|
+
const heading = FONT_CHOICES[brand.fonts?.heading ?? "inter"] ?? FONT_CHOICES.inter;
|
|
164
|
+
const body = FONT_CHOICES[brand.fonts?.body ?? "inter"] ?? FONT_CHOICES.inter;
|
|
165
|
+
return {
|
|
166
|
+
...genesisBrand,
|
|
167
|
+
name: brand.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "site",
|
|
168
|
+
accent: { DEFAULT: primary, light: primary, dark: primaryDark },
|
|
169
|
+
typography: {
|
|
170
|
+
fontFamily: { body: body.stack.split(/,\s*/), display: heading.stack.split(/,\s*/) }
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function brandDisplayName(brand) {
|
|
175
|
+
return isSiteBrand(brand) ? brand.name : brand.displayName ?? brand.name;
|
|
176
|
+
}
|
|
177
|
+
function modeColours(brand, mode) {
|
|
178
|
+
const n = brand.neutral;
|
|
179
|
+
const light = mode === "light";
|
|
180
|
+
const background = (light ? brand.surface.light : brand.surface.dark) ?? brand.surface.DEFAULT;
|
|
181
|
+
const foreground = (light ? brand.text.light : brand.text.dark) ?? brand.text.DEFAULT;
|
|
182
|
+
const primary = (light ? brand.accent.light : brand.accent.dark) ?? brand.accent.DEFAULT;
|
|
183
|
+
const muted = light ? n["100"] ?? mix(background, -0.04) : n["800"] ?? mix(background, 0.08);
|
|
184
|
+
const mutedForeground = light ? n["600"] ?? mix(foreground, 0.3) : n["400"] ?? mix(foreground, -0.3);
|
|
185
|
+
const border = light ? n["200"] ?? mix(background, -0.1) : n["700"] ?? mix(background, 0.15);
|
|
186
|
+
let primaryForeground = readableOn(primary, "#ffffff", n["900"] ?? "#09090b");
|
|
187
|
+
if (contrastRatio(primary, primaryForeground) < 4.5) primaryForeground = readableOn(primary, "#ffffff", "#000000");
|
|
188
|
+
const ring = contrastRatio(primary, background) >= 3 ? primary : foreground;
|
|
189
|
+
return { background, foreground, muted, mutedForeground, primary, primaryForeground, ring, border };
|
|
190
|
+
}
|
|
191
|
+
function roleVars(c) {
|
|
192
|
+
const hsl = hexToHslTriplet;
|
|
193
|
+
return {
|
|
194
|
+
"--background": hsl(c.background),
|
|
195
|
+
"--foreground": hsl(c.foreground),
|
|
196
|
+
"--card": hsl(c.background),
|
|
197
|
+
"--card-foreground": hsl(c.foreground),
|
|
198
|
+
"--popover": hsl(c.background),
|
|
199
|
+
"--popover-foreground": hsl(c.foreground),
|
|
200
|
+
"--primary": hsl(c.primary),
|
|
201
|
+
"--primary-foreground": hsl(c.primaryForeground),
|
|
202
|
+
"--secondary": hsl(c.muted),
|
|
203
|
+
"--secondary-foreground": hsl(c.foreground),
|
|
204
|
+
"--muted": hsl(c.muted),
|
|
205
|
+
"--muted-foreground": hsl(c.mutedForeground),
|
|
206
|
+
"--accent": hsl(c.muted),
|
|
207
|
+
"--accent-foreground": hsl(c.foreground),
|
|
208
|
+
"--border": hsl(c.border),
|
|
209
|
+
"--input": hsl(c.border),
|
|
210
|
+
"--ring": hsl(c.ring)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function pairsFor(c, mode) {
|
|
214
|
+
return [
|
|
215
|
+
{ name: "body text on the page", mode, foreground: c.foreground, background: c.background, minimum: 4.5 },
|
|
216
|
+
{ name: "quiet text on the page", mode, foreground: c.mutedForeground, background: c.background, minimum: 4.5 },
|
|
217
|
+
{ name: "quiet text on a muted band", mode, foreground: c.mutedForeground, background: c.muted, minimum: 4.5 },
|
|
218
|
+
{ name: "body text on a muted band", mode, foreground: c.foreground, background: c.muted, minimum: 4.5 },
|
|
219
|
+
{ name: "button and accent-band text on the primary colour", mode, foreground: c.primaryForeground, background: c.primary, minimum: 4.5 },
|
|
220
|
+
{ name: "text on an inverse band", mode, foreground: c.background, background: c.foreground, minimum: 4.5 },
|
|
221
|
+
{ name: "the focus ring on the page", mode, foreground: c.ring, background: c.background, minimum: 3 },
|
|
222
|
+
// Non-text (1.4.11): the primary colour as a border or marker (a timetable chip, the picked day, a timeline's step).
|
|
223
|
+
// Every such mark also has text or a shape (a chip names its type, a marker shows a dot), so it warns.
|
|
224
|
+
{ name: "the primary colour as a border or marker on the page", mode, foreground: c.primary, background: c.background, minimum: 3, severity: "warning" }
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
function stableHash(value) {
|
|
228
|
+
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);
|
|
229
|
+
let h1 = 2166136261;
|
|
230
|
+
let h2 = 16777619 ^ text.length;
|
|
231
|
+
for (let i = 0; i < text.length; i++) {
|
|
232
|
+
const c = text.charCodeAt(i);
|
|
233
|
+
h1 = Math.imul(h1 ^ c, 16777619) >>> 0;
|
|
234
|
+
h2 = Math.imul(h2 ^ c, 1540483477) >>> 0;
|
|
235
|
+
}
|
|
236
|
+
return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
|
|
237
|
+
}
|
|
238
|
+
function siteTheme(brand, theme = {}) {
|
|
239
|
+
const canonical = toCanonicalBrand(brand);
|
|
240
|
+
const overrides = theme.overrides ?? {};
|
|
241
|
+
const base = themeFromBrand(canonical).variables;
|
|
242
|
+
const fontStack = (family) => canonical.typography?.fontFamily?.[family]?.join(", ");
|
|
243
|
+
const headingFont = overrides.headingFont ? FONT_CHOICES[overrides.headingFont] : void 0;
|
|
244
|
+
const bodyFont = overrides.bodyFont ? FONT_CHOICES[overrides.bodyFont] : void 0;
|
|
245
|
+
const fontVars = {};
|
|
246
|
+
const sans = bodyFont?.stack ?? fontStack("body");
|
|
247
|
+
const display = headingFont?.stack ?? fontStack("display") ?? sans;
|
|
248
|
+
if (sans) fontVars["--font-sans"] = sans;
|
|
249
|
+
if (display) fontVars["--font-display"] = display;
|
|
250
|
+
if (overrides.radius) fontVars["--radius"] = RADIUS[overrides.radius];
|
|
251
|
+
const light = modeColours(canonical, "light");
|
|
252
|
+
const dark = modeColours(canonical, "dark");
|
|
253
|
+
const usedFonts = /* @__PURE__ */ new Map();
|
|
254
|
+
for (const stack of [sans, display]) {
|
|
255
|
+
const info = Object.values(FONT_CHOICES).find((f) => f.stack === stack);
|
|
256
|
+
if (info) usedFonts.set(info.label, info);
|
|
257
|
+
}
|
|
258
|
+
const colorScheme = theme.colorScheme ?? "light";
|
|
259
|
+
const pairs = colorScheme === "light" ? pairsFor(light, "light") : colorScheme === "dark" ? pairsFor(dark, "dark") : [...pairsFor(light, "light"), ...pairsFor(dark, "dark")];
|
|
260
|
+
return {
|
|
261
|
+
light: { ...base.light, ...roleVars(light), ...fontVars },
|
|
262
|
+
dark: { ...base.dark, ...roleVars(dark), ...fontVars },
|
|
263
|
+
colorScheme,
|
|
264
|
+
fonts: [...usedFonts.values()],
|
|
265
|
+
pairs,
|
|
266
|
+
brandHash: stableHash({ brand: canonical, overrides, colorScheme })
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function cssValue(value) {
|
|
270
|
+
return value.replace(/[<>{};\\]/g, "");
|
|
271
|
+
}
|
|
272
|
+
function block(selector, vars) {
|
|
273
|
+
const lines = Object.entries(vars).filter(([name]) => /^--[a-z0-9-]+$/i.test(name)).map(([name, value]) => `${name}:${cssValue(value)}`);
|
|
274
|
+
return `${selector}{${lines.join(";")}}`;
|
|
275
|
+
}
|
|
276
|
+
function themeCss(theme) {
|
|
277
|
+
if (theme.colorScheme === "dark") return block(":root", theme.dark) + ":root{color-scheme:dark}";
|
|
278
|
+
if (theme.colorScheme === "auto") {
|
|
279
|
+
return `${block(":root", theme.light)}:root{color-scheme:light dark}@media (prefers-color-scheme: dark){${block(":root", theme.dark)}}`;
|
|
280
|
+
}
|
|
281
|
+
return block(":root", theme.light) + ":root{color-scheme:light}";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/core/a11y.ts
|
|
285
|
+
var isPublishable = (issues) => !issues.some((i) => i.severity === "error");
|
|
286
|
+
function images(value, path = []) {
|
|
287
|
+
if (!value || typeof value !== "object") return [];
|
|
288
|
+
if (Array.isArray(value)) return value.flatMap((v, i) => images(v, [...path, String(i)]));
|
|
289
|
+
const record = value;
|
|
290
|
+
const found = typeof record.src === "string" && !("mime" in record) && ("alt" in record || "decorative" in record || /\.(png|jpe?g|webp|avif|gif|svg)(\?|$)/i.test(record.src)) ? [{ path: path.join("."), image: record }] : [];
|
|
291
|
+
return [...found, ...Object.entries(record).flatMap(([k, v]) => k === "src" ? [] : images(v, [...path, k]))];
|
|
292
|
+
}
|
|
293
|
+
function richTextHeadings(value) {
|
|
294
|
+
if (!value || typeof value !== "object") return [];
|
|
295
|
+
if (Array.isArray(value)) return value.flatMap(richTextHeadings);
|
|
296
|
+
const node = value;
|
|
297
|
+
if (node.type === "heading") return typeof node.attrs?.level === "number" ? [node.attrs.level] : [];
|
|
298
|
+
if (node.type === "doc" || typeof node.type === "string") return richTextHeadings(node.content);
|
|
299
|
+
return Object.values(value).flatMap(richTextHeadings);
|
|
300
|
+
}
|
|
301
|
+
function nodeIssues(node, ctx, lookup) {
|
|
302
|
+
const base = { ...ctx.page ? { pageId: ctx.page.id, path: ctx.page.path } : {}, sectionId: node.id };
|
|
303
|
+
const at = ctx.page ? `${ctx.page.path}` : `the site ${ctx.where}`;
|
|
304
|
+
const migrated = migrateNode(node, lookup);
|
|
305
|
+
const issues = migrated.issues.map((i) => ({ ...base, severity: i.severity, code: i.code, message: `${at}: ${i.message}` }));
|
|
306
|
+
const def = lookup(node.type);
|
|
307
|
+
if (!def || !migrated.node) return issues;
|
|
308
|
+
if (node.placeholder) {
|
|
309
|
+
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.` });
|
|
310
|
+
}
|
|
311
|
+
const placement = def.placement ?? "body";
|
|
312
|
+
if (placement !== ctx.where) {
|
|
313
|
+
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}`}.` });
|
|
314
|
+
}
|
|
315
|
+
if (ctx.kind && def.enabledOn && !def.enabledOn.includes(ctx.kind)) {
|
|
316
|
+
issues.push({ ...base, severity: "error", code: "enabled-on", message: `${at}: a ${def.label} section can't go on a ${ctx.kind} page.` });
|
|
317
|
+
}
|
|
318
|
+
for (const { path, image } of images(migrated.node.props)) {
|
|
319
|
+
const alt = typeof image.alt === "string" ? image.alt.trim() : "";
|
|
320
|
+
if (!alt && image.decorative !== true) {
|
|
321
|
+
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.` });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const levels = richTextHeadings(migrated.node.props);
|
|
325
|
+
for (let i = 0; i < levels.length; i++) {
|
|
326
|
+
const level = levels[i];
|
|
327
|
+
const previous = i === 0 ? 2 : levels[i - 1];
|
|
328
|
+
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.` });
|
|
329
|
+
else if (level > previous + 1) {
|
|
330
|
+
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.` });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return issues;
|
|
334
|
+
}
|
|
335
|
+
function checkPage(page, lookup = getSection, reserved) {
|
|
336
|
+
const issues = [];
|
|
337
|
+
const base = { pageId: page.id, path: page.path };
|
|
338
|
+
for (const problem of pagePathProblems(page.path, reserved)) issues.push({ ...base, severity: "error", code: "path", message: `${page.path}: ${problem}` });
|
|
339
|
+
const visible = page.doc.content.filter((n) => !n.hidden);
|
|
340
|
+
const counts = /* @__PURE__ */ new Map();
|
|
341
|
+
let h1 = 0;
|
|
342
|
+
for (const node of visible) {
|
|
343
|
+
issues.push(...nodeIssues(node, { where: "body", kind: page.kind, page }, lookup));
|
|
344
|
+
counts.set(node.type, (counts.get(node.type) ?? 0) + 1);
|
|
345
|
+
if (lookup(node.type)?.providesH1) h1++;
|
|
346
|
+
}
|
|
347
|
+
for (const [type, count] of counts) {
|
|
348
|
+
const def = lookup(type);
|
|
349
|
+
if (def?.limit && count > def.limit) {
|
|
350
|
+
issues.push({ ...base, severity: "error", code: "limit", message: `${page.path}: ${count} ${def.label} sections; a page has at most ${def.limit}.` });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (h1 === 0) {
|
|
354
|
+
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.` });
|
|
355
|
+
}
|
|
356
|
+
if (!page.doc.seo?.description) {
|
|
357
|
+
issues.push({ ...base, severity: "warning", code: "seo-description", message: `${page.path}: no description for search results and link previews.` });
|
|
358
|
+
}
|
|
359
|
+
return issues;
|
|
360
|
+
}
|
|
361
|
+
function checkSiteDoc(site, lookup = getSection) {
|
|
362
|
+
const issues = [];
|
|
363
|
+
for (const node of site.header.filter((n) => !n.hidden)) issues.push(...nodeIssues(node, { where: "header" }, lookup));
|
|
364
|
+
for (const node of site.footer.filter((n) => !n.hidden)) issues.push(...nodeIssues(node, { where: "footer" }, lookup));
|
|
365
|
+
const froms = /* @__PURE__ */ new Set();
|
|
366
|
+
for (const r of site.redirects) {
|
|
367
|
+
if (froms.has(r.from)) issues.push({ severity: "error", code: "redirect-duplicate", path: r.from, message: `Two redirects from ${r.from}.` });
|
|
368
|
+
froms.add(r.from);
|
|
369
|
+
if (r.to === r.from) issues.push({ severity: "error", code: "redirect-loop", path: r.from, message: `The redirect from ${r.from} points at itself.` });
|
|
370
|
+
}
|
|
371
|
+
return issues;
|
|
372
|
+
}
|
|
373
|
+
function checkContrast(theme) {
|
|
374
|
+
return theme.pairs.map((pair) => ({ pair, ratio: contrastRatio(pair.foreground, pair.background) })).filter(({ pair, ratio }) => ratio < pair.minimum).map(({ pair, ratio }) => ({
|
|
375
|
+
severity: pair.severity ?? "error",
|
|
376
|
+
code: "contrast",
|
|
377
|
+
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.`
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
function checkRelease(snapshot, options = {}) {
|
|
381
|
+
const lookup = options.lookup ?? getSection;
|
|
382
|
+
const issues = [...checkSiteDoc(snapshot.site, lookup)];
|
|
383
|
+
const paths = /* @__PURE__ */ new Map();
|
|
384
|
+
for (const page of snapshot.pages) {
|
|
385
|
+
const other = paths.get(page.path);
|
|
386
|
+
if (other) issues.push({ severity: "error", code: "path-duplicate", pageId: page.id, path: page.path, message: `Two pages at ${page.path}.` });
|
|
387
|
+
paths.set(page.path, page.id);
|
|
388
|
+
issues.push(...checkPage(page, lookup, options.reserved));
|
|
389
|
+
}
|
|
390
|
+
if (!paths.has("/")) issues.push({ severity: "error", code: "no-home", message: "The site has no home page (a page at /)." });
|
|
391
|
+
issues.push(...checkBlog(snapshot, lookup));
|
|
392
|
+
for (const r of snapshot.site.redirects) {
|
|
393
|
+
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.` });
|
|
394
|
+
const target = r.to.split(/[?#]/)[0];
|
|
395
|
+
if (r.to.startsWith("/") && !paths.has(target)) {
|
|
396
|
+
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?).` });
|
|
397
|
+
}
|
|
398
|
+
if (options.reserved !== void 0 ? isReservedPath(r.from, options.reserved) : isReservedPath(r.from)) {
|
|
399
|
+
issues.push({ severity: "error", code: "redirect-reserved", path: r.from, message: `${r.from} is reserved for the app; a site can't redirect it.` });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (options.theme) issues.push(...checkContrast(options.theme));
|
|
403
|
+
return issues;
|
|
404
|
+
}
|
|
405
|
+
function checkBlog(snapshot, lookup = getSection) {
|
|
406
|
+
const issues = [];
|
|
407
|
+
const postPages = snapshot.pages.filter((p) => p.kind === "post");
|
|
408
|
+
if (postPages.length > 1) {
|
|
409
|
+
issues.push({ severity: "error", code: "post-page-count", message: `The site has ${postPages.length} post pages (${postPages.map((p) => p.path).join(", ")}); posts render in one.` });
|
|
410
|
+
}
|
|
411
|
+
for (const page of postPages) {
|
|
412
|
+
const base = { pageId: page.id, path: page.path };
|
|
413
|
+
if (page.path === "/") issues.push({ ...base, severity: "error", code: "post-page-home", message: "The home page can't be the post page: posts would take over the site's paths. Put it at /blog." });
|
|
414
|
+
if (page.path.split("/").filter(Boolean).length >= 8) issues.push({ ...base, severity: "error", code: "post-page-depth", message: `${page.path}: the post page is too deep for posts to go below it.` });
|
|
415
|
+
if (!page.doc.content.some((n) => !n.hidden && n.type === "post-body")) {
|
|
416
|
+
issues.push({ ...base, severity: "error", code: "post-page-body", message: `${page.path}: the post page needs a Post section, where each post's title and text go.` });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!postPages.length) {
|
|
420
|
+
for (const page of snapshot.pages) {
|
|
421
|
+
for (const node of page.doc.content) {
|
|
422
|
+
if (!node.hidden && node.type === "blog-list" && lookup(node.type)) {
|
|
423
|
+
issues.push({ pageId: page.id, path: page.path, sectionId: node.id, severity: "error", code: "blog-list-no-post-page", message: `${page.path}: a post list needs the site's post page (a page of kind "post", such as /blog) for its links to open.` });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return issues;
|
|
429
|
+
}
|
|
430
|
+
function checkPost(slug, doc) {
|
|
431
|
+
const issues = [];
|
|
432
|
+
const at = `The post ${JSON.stringify(slug)}`;
|
|
433
|
+
if (!PostSlug.safeParse(slug).success) issues.push({ severity: "error", code: "post-slug", message: `${at}: a slug is lowercase letters, digits and single hyphens.` });
|
|
434
|
+
const parsed = PostDoc.safeParse(doc);
|
|
435
|
+
if (!parsed.success) {
|
|
436
|
+
issues.push({ severity: "error", code: "post-invalid", message: `${at}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "post"} ${i.message}`).join("; ")}` });
|
|
437
|
+
return issues;
|
|
438
|
+
}
|
|
439
|
+
for (const { path, image } of images(parsed.data)) {
|
|
440
|
+
if (path.startsWith("seo")) continue;
|
|
441
|
+
const alt = typeof image.alt === "string" ? image.alt.trim() : "";
|
|
442
|
+
if (!alt && image.decorative !== true) issues.push({ severity: "error", code: "image-alt", message: `${at}: an image (${path || "image"}) needs alt text, or to be marked decorative.` });
|
|
443
|
+
}
|
|
444
|
+
const levels = richTextHeadings(parsed.data.body);
|
|
445
|
+
for (let i = 0; i < levels.length; i++) {
|
|
446
|
+
const level = levels[i];
|
|
447
|
+
const previous = i === 0 ? 1 : levels[i - 1];
|
|
448
|
+
if (level === 1) issues.push({ severity: "error", code: "heading-h1", message: `${at}: the text can't hold a level 1 heading; the post's title is the page's.` });
|
|
449
|
+
else if (level > previous + 1) issues.push({ severity: "error", code: "heading-order", message: `${at}: a level ${level} heading follows a level ${previous}; don't skip a level.` });
|
|
450
|
+
}
|
|
451
|
+
if (!parsed.data.excerpt && !parsed.data.seo?.description) issues.push({ severity: "warning", code: "seo-description", message: `${at}: no excerpt for the list, search results and the feed.` });
|
|
452
|
+
return issues;
|
|
453
|
+
}
|
|
454
|
+
function checkHtmlOutline(html) {
|
|
455
|
+
const issues = [];
|
|
456
|
+
const body = html.replace(/<script\b[\s\S]*?<\/script>/gi, "").replace(/<style\b[\s\S]*?<\/style>/gi, "");
|
|
457
|
+
const levels = [...body.matchAll(/<h([1-6])\b/gi)].map((m) => Number(m[1]));
|
|
458
|
+
const h1 = levels.filter((l) => l === 1).length;
|
|
459
|
+
if (h1 !== 1) issues.push({ severity: "error", code: "h1-count", message: `The page has ${h1} h1 elements; it needs exactly one.` });
|
|
460
|
+
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.` });
|
|
461
|
+
for (let i = 1; i < levels.length; i++) {
|
|
462
|
+
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]}.` });
|
|
463
|
+
}
|
|
464
|
+
for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
|
|
465
|
+
if (!/\salt=/.test(m[0])) issues.push({ severity: "error", code: "image-alt", message: `An image has no alt attribute: ${m[0].slice(0, 120)}` });
|
|
466
|
+
}
|
|
467
|
+
return issues;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/core/store.ts
|
|
471
|
+
var isConflict = (result) => "conflict" in result;
|
|
472
|
+
var ASSET_TYPES = Object.freeze(["image/png", "image/jpeg", "image/webp", "image/avif", "image/gif", "application/pdf"]);
|
|
473
|
+
function assetPath(siteId, id, name, mime) {
|
|
474
|
+
const clean = (name ?? "file").toLowerCase().replace(/[^a-z0-9.]+/g, "-").replace(/\.{2,}/g, ".").replace(/^[.-]+/, "").slice(0, 80) || "file";
|
|
475
|
+
return `sites/${siteId}/${mime === "application/pdf" ? "files/" : ""}${id}-${clean}`;
|
|
476
|
+
}
|
|
477
|
+
var PublishBlockedError = class extends Error {
|
|
478
|
+
constructor(issues) {
|
|
479
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
480
|
+
super(`The site can't be published yet:
|
|
481
|
+
${errors.map((i) => ` - ${i.message}`).join("\n")}`);
|
|
482
|
+
this.issues = issues;
|
|
483
|
+
this.name = "PublishBlockedError";
|
|
484
|
+
}
|
|
485
|
+
issues;
|
|
486
|
+
};
|
|
487
|
+
var SiteConflictError = class extends Error {
|
|
488
|
+
constructor(message = "Someone else changed this site; reload it and try again.") {
|
|
489
|
+
super(message);
|
|
490
|
+
this.name = "SiteConflictError";
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
function prepareRelease(site, pages, options = {}) {
|
|
494
|
+
const migratedSite = migrateSite(site);
|
|
495
|
+
const migratedPages = [];
|
|
496
|
+
const releasePages = pages.map((p) => {
|
|
497
|
+
const m = migratePage(p.doc);
|
|
498
|
+
if (m.changed) migratedPages.push({ id: p.id, doc: m.doc });
|
|
499
|
+
return { id: p.id, path: p.path, kind: p.kind, doc: m.doc };
|
|
500
|
+
});
|
|
501
|
+
const visible = (nodes) => nodes.filter((n) => !n.hidden);
|
|
502
|
+
const snapshot = ReleaseSnapshot.parse({
|
|
503
|
+
schemaVersion: 1,
|
|
504
|
+
site: { ...migratedSite.site, header: visible(migratedSite.site.header), footer: visible(migratedSite.site.footer) },
|
|
505
|
+
pages: releasePages.map((p) => ({ ...p, doc: { ...p.doc, content: visible(p.doc.content) } }))
|
|
506
|
+
});
|
|
507
|
+
const theme = options.brand ? siteTheme(options.brand, snapshot.site.theme) : void 0;
|
|
508
|
+
const issues = checkRelease(snapshot, { ...theme ? { theme } : {}, reserved: options.reserved ?? DEFAULT_RESERVED_PATHS });
|
|
509
|
+
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." });
|
|
510
|
+
if (!isPublishable(issues)) throw new PublishBlockedError(issues);
|
|
511
|
+
return { snapshot, issues, migratedPages, migratedSite: migratedSite.changed ? migratedSite.site : null, brandHash: theme?.brandHash ?? null };
|
|
512
|
+
}
|
|
513
|
+
function siteCacheTag(siteId) {
|
|
514
|
+
return `site:${siteId}`;
|
|
515
|
+
}
|
|
516
|
+
function formCacheTag(slug) {
|
|
517
|
+
return `form:${slug}`;
|
|
518
|
+
}
|
|
519
|
+
function withCacheInvalidation(store, invalidate) {
|
|
520
|
+
const purge = async (siteId, action) => {
|
|
521
|
+
try {
|
|
522
|
+
await invalidate([siteCacheTag(siteId)], { siteId, action });
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.error(`[genesis-sites] published site ${siteId}, but purging its cache failed; the old pages may be served until their cache expires.`, err);
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
return {
|
|
529
|
+
...store,
|
|
530
|
+
getLive: (id) => store.getLive(id),
|
|
531
|
+
getDraft: (id) => store.getDraft(id),
|
|
532
|
+
saveSiteDraft: (id, rev, site) => store.saveSiteDraft(id, rev, site),
|
|
533
|
+
listPages: (id) => store.listPages(id),
|
|
534
|
+
getPageDraft: (id) => store.getPageDraft(id),
|
|
535
|
+
savePageDraft: (id, rev, doc) => store.savePageDraft(id, rev, doc),
|
|
536
|
+
createPage: (id, page) => store.createPage(id, page),
|
|
537
|
+
movePage: (id, path) => store.movePage(id, path),
|
|
538
|
+
deletePage: (id) => store.deletePage(id),
|
|
539
|
+
listReleases: (id) => store.listReleases(id),
|
|
540
|
+
...store.putAsset ? { putAsset: store.putAsset.bind(store) } : {},
|
|
541
|
+
...store.listLivePosts ? { listLivePosts: store.listLivePosts.bind(store) } : {},
|
|
542
|
+
...store.getLivePost ? { getLivePost: store.getLivePost.bind(store) } : {},
|
|
543
|
+
...store.listPosts ? { listPosts: store.listPosts.bind(store) } : {},
|
|
544
|
+
...store.getPost ? { getPost: store.getPost.bind(store) } : {},
|
|
545
|
+
...store.savePost ? { savePost: store.savePost.bind(store) } : {},
|
|
546
|
+
...store.publishPost ? {
|
|
547
|
+
async publishPost(postId) {
|
|
548
|
+
const meta = await store.publishPost(postId);
|
|
549
|
+
await purge(meta.siteId, "post");
|
|
550
|
+
return meta;
|
|
551
|
+
}
|
|
552
|
+
} : {},
|
|
553
|
+
...store.unpublishPost ? {
|
|
554
|
+
async unpublishPost(postId) {
|
|
555
|
+
const meta = await store.unpublishPost(postId);
|
|
556
|
+
await purge(meta.siteId, "post");
|
|
557
|
+
return meta;
|
|
558
|
+
}
|
|
559
|
+
} : {},
|
|
560
|
+
...store.deletePost ? {
|
|
561
|
+
async deletePost(postId) {
|
|
562
|
+
const deleted = await store.deletePost(postId);
|
|
563
|
+
if (deleted) await purge(deleted.siteId, "post");
|
|
564
|
+
return deleted;
|
|
565
|
+
}
|
|
566
|
+
} : {},
|
|
567
|
+
...store.importPosts ? {
|
|
568
|
+
async importPosts(siteId, list) {
|
|
569
|
+
const result = await store.importPosts(siteId, list);
|
|
570
|
+
await purge(siteId, "post");
|
|
571
|
+
return result;
|
|
572
|
+
}
|
|
573
|
+
} : {},
|
|
574
|
+
async publish(siteId, options) {
|
|
575
|
+
const release = await store.publish(siteId, options);
|
|
576
|
+
await purge(siteId, "publish");
|
|
577
|
+
return release;
|
|
578
|
+
},
|
|
579
|
+
async rollback(siteId, releaseId) {
|
|
580
|
+
const release = await store.rollback(siteId, releaseId);
|
|
581
|
+
await purge(siteId, "rollback");
|
|
582
|
+
return release;
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function prepareImportPost(item) {
|
|
587
|
+
const slug = typeof item?.slug === "string" ? item.slug : String(item?.slug);
|
|
588
|
+
try {
|
|
589
|
+
validatePostSlug(slug);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
return { slug, reason: err.message };
|
|
592
|
+
}
|
|
593
|
+
const parsed = PostDoc.safeParse(item.doc);
|
|
594
|
+
if (!parsed.success) return { slug, reason: `The post doesn't parse: ${parsed.error.issues.map((i) => `${i.path.join(".") || "post"} ${i.message}`).join("; ")}` };
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
const given = item.publishedAt ? new Date(item.publishedAt) : new Date(now);
|
|
597
|
+
if (Number.isNaN(given.getTime()) || given.getTime() < 0) return { slug, reason: `"${item.publishedAt}" isn't a usable publish date.` };
|
|
598
|
+
const date = new Date(Math.min(given.getTime(), now));
|
|
599
|
+
const errors = checkPost(slug, parsed.data).filter((i) => i.severity === "error").map((i) => i.message);
|
|
600
|
+
const status = item.status === "draft" || errors.length ? "draft" : "published";
|
|
601
|
+
return { doc: parsed.data, status, publishedAt: date.toISOString(), issues: errors };
|
|
602
|
+
}
|
|
603
|
+
var clone = (value) => structuredClone(value);
|
|
604
|
+
function createMemorySiteStore(options = {}) {
|
|
605
|
+
const reserved = options.reserved ?? DEFAULT_RESERVED_PATHS;
|
|
606
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
607
|
+
const sites = /* @__PURE__ */ new Map();
|
|
608
|
+
const pages = /* @__PURE__ */ new Map();
|
|
609
|
+
const releases = /* @__PURE__ */ new Map();
|
|
610
|
+
const posts = /* @__PURE__ */ new Map();
|
|
611
|
+
let seq = 0;
|
|
612
|
+
const id = (prefix) => `${prefix}_${(++seq).toString(36).padStart(4, "0")}`;
|
|
613
|
+
const editable = (siteId) => {
|
|
614
|
+
const s = sites.get(siteId);
|
|
615
|
+
if (!s) throw new Error(`No site ${siteId}.`);
|
|
616
|
+
if (options.canEdit && !options.canEdit(siteId)) throw new Error(`Not allowed to edit site ${siteId}.`);
|
|
617
|
+
return s;
|
|
618
|
+
};
|
|
619
|
+
const livePages = (siteId) => [...pages.values()].filter((p) => p.siteId === siteId && !p.deleted).sort((a, b) => a.sort - b.sort || a.path.localeCompare(b.path));
|
|
620
|
+
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 });
|
|
621
|
+
const releaseMeta = ({ snapshot: _s, ...m }) => clone(m);
|
|
622
|
+
const pathTaken = (siteId, path, except) => livePages(siteId).some((p) => p.path === path && p.id !== except);
|
|
623
|
+
const sitePosts = (siteId) => [...posts.values()].filter((p) => p.siteId === siteId && !p.deleted);
|
|
624
|
+
const postMeta = (p) => ({ id: p.id, siteId: p.siteId, slug: p.slug, title: p.doc.title, status: p.status, rev: p.rev, publishedAt: p.publishedAt, updatedAt: p.updatedAt });
|
|
625
|
+
const byNewest = (a, b) => (b.publishedAt ?? b.updatedAt).localeCompare(a.publishedAt ?? a.updatedAt);
|
|
626
|
+
const livePost = (p) => p.status === "published" && p.published && p.publishedAt ? { slug: p.slug, publishedAt: p.publishedAt, updatedAt: p.published.at, doc: clone(p.published.doc) } : null;
|
|
627
|
+
const siteIsLive = (siteId) => {
|
|
628
|
+
const s = sites.get(siteId);
|
|
629
|
+
return Boolean(s && s.status === "live" && s.liveReleaseId);
|
|
630
|
+
};
|
|
631
|
+
const editablePost = (postId) => {
|
|
632
|
+
const p = posts.get(postId);
|
|
633
|
+
if (!p || p.deleted) throw new Error(`No post ${postId}.`);
|
|
634
|
+
editable(p.siteId);
|
|
635
|
+
return p;
|
|
636
|
+
};
|
|
637
|
+
const store = {
|
|
638
|
+
async createSite(input) {
|
|
639
|
+
const siteId = input.id ?? id("site");
|
|
640
|
+
if (sites.has(siteId)) throw new Error(`Site ${siteId} exists.`);
|
|
641
|
+
const site = SiteDoc.parse(input.site);
|
|
642
|
+
sites.set(siteId, { id: siteId, site, rev: 1, liveReleaseId: null, status: "draft" });
|
|
643
|
+
const pageIds = [];
|
|
644
|
+
input.pages.forEach((p, i) => {
|
|
645
|
+
validatePagePath(p.path, reserved);
|
|
646
|
+
if (pathTaken(siteId, p.path)) throw new Error(`Two pages at ${p.path}.`);
|
|
647
|
+
const pageId = p.id ?? id("page");
|
|
648
|
+
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 });
|
|
649
|
+
pageIds.push(pageId);
|
|
650
|
+
});
|
|
651
|
+
return { siteId, pageIds };
|
|
652
|
+
},
|
|
653
|
+
async setStatus(siteId, status) {
|
|
654
|
+
editable(siteId).status = status;
|
|
655
|
+
},
|
|
656
|
+
async getLive(siteId) {
|
|
657
|
+
const s = sites.get(siteId);
|
|
658
|
+
if (!s || s.status !== "live" || !s.liveReleaseId) return null;
|
|
659
|
+
const r = releases.get(s.liveReleaseId);
|
|
660
|
+
return r ? clone(r) : null;
|
|
661
|
+
},
|
|
662
|
+
async getDraft(siteId) {
|
|
663
|
+
const s = sites.get(siteId);
|
|
664
|
+
if (!s || options.canEdit && !options.canEdit(siteId)) return null;
|
|
665
|
+
return { site: clone(s.site), rev: s.rev };
|
|
666
|
+
},
|
|
667
|
+
async saveSiteDraft(siteId, rev, doc) {
|
|
668
|
+
const s = editable(siteId);
|
|
669
|
+
if (s.rev !== rev) return { conflict: clone(s.site), rev: s.rev };
|
|
670
|
+
s.site = SiteDoc.parse(clone(doc));
|
|
671
|
+
s.rev += 1;
|
|
672
|
+
return { rev: s.rev };
|
|
673
|
+
},
|
|
674
|
+
async listPages(siteId) {
|
|
675
|
+
if (options.canEdit && !options.canEdit(siteId)) return [];
|
|
676
|
+
return livePages(siteId).map(meta);
|
|
677
|
+
},
|
|
678
|
+
async getPageDraft(pageId) {
|
|
679
|
+
const p = pages.get(pageId);
|
|
680
|
+
if (!p || p.deleted || options.canEdit && !options.canEdit(p.siteId)) return null;
|
|
681
|
+
return { doc: clone(p.doc), rev: p.rev, siteId: p.siteId, path: p.path, kind: p.kind };
|
|
682
|
+
},
|
|
683
|
+
async savePageDraft(pageId, rev, doc) {
|
|
684
|
+
const p = pages.get(pageId);
|
|
685
|
+
if (!p || p.deleted) throw new Error(`No page ${pageId}.`);
|
|
686
|
+
editable(p.siteId);
|
|
687
|
+
if (p.rev !== rev) return { conflict: clone(p.doc), rev: p.rev };
|
|
688
|
+
p.doc = PageDoc.parse(clone(doc));
|
|
689
|
+
p.rev += 1;
|
|
690
|
+
return { rev: p.rev };
|
|
691
|
+
},
|
|
692
|
+
async createPage(siteId, page) {
|
|
693
|
+
const s = editable(siteId);
|
|
694
|
+
validatePagePath(page.path, reserved);
|
|
695
|
+
if (pathTaken(siteId, page.path)) throw new Error(`A page is already at ${page.path}.`);
|
|
696
|
+
const pageId = id("page");
|
|
697
|
+
const sort = Math.max(-1, ...livePages(siteId).map((p2) => p2.sort)) + 1;
|
|
698
|
+
const p = { id: pageId, siteId, path: page.path, kind: page.kind ?? "page", doc: PageDoc.parse(clone(page.doc)), rev: 1, sort, deleted: false };
|
|
699
|
+
pages.set(pageId, p);
|
|
700
|
+
if (s.site.redirects.some((r) => r.from === page.path)) {
|
|
701
|
+
s.site = { ...s.site, redirects: s.site.redirects.filter((r) => r.from !== page.path) };
|
|
702
|
+
s.rev += 1;
|
|
703
|
+
}
|
|
704
|
+
return meta(p);
|
|
705
|
+
},
|
|
706
|
+
async movePage(pageId, path) {
|
|
707
|
+
const p = pages.get(pageId);
|
|
708
|
+
if (!p || p.deleted) throw new Error(`No page ${pageId}.`);
|
|
709
|
+
const s = editable(p.siteId);
|
|
710
|
+
validatePagePath(path, reserved);
|
|
711
|
+
if (p.path === "/") throw new Error("The home page stays at /.");
|
|
712
|
+
if (pathTaken(p.siteId, path, pageId)) throw new Error(`A page is already at ${path}.`);
|
|
713
|
+
const from = p.path;
|
|
714
|
+
p.path = path;
|
|
715
|
+
p.rev += 1;
|
|
716
|
+
s.site = { ...s.site, redirects: redirectsAfterMove(s.site.redirects, from, path) };
|
|
717
|
+
s.rev += 1;
|
|
718
|
+
return meta(p);
|
|
719
|
+
},
|
|
720
|
+
async deletePage(pageId) {
|
|
721
|
+
const p = pages.get(pageId);
|
|
722
|
+
if (!p || p.deleted) return;
|
|
723
|
+
editable(p.siteId);
|
|
724
|
+
if (p.path === "/") throw new Error("The home page can't be deleted; replace its content instead.");
|
|
725
|
+
p.deleted = true;
|
|
726
|
+
},
|
|
727
|
+
async publish(siteId, opts = {}) {
|
|
728
|
+
const s = editable(siteId);
|
|
729
|
+
const list = livePages(siteId);
|
|
730
|
+
const prepared = prepareRelease(s.site, list, { reserved, ...opts });
|
|
731
|
+
for (const { id: pid, doc } of prepared.migratedPages) {
|
|
732
|
+
const p = pages.get(pid);
|
|
733
|
+
p.doc = doc;
|
|
734
|
+
p.rev += 1;
|
|
735
|
+
}
|
|
736
|
+
if (prepared.migratedSite) {
|
|
737
|
+
s.site = prepared.migratedSite;
|
|
738
|
+
s.rev += 1;
|
|
739
|
+
}
|
|
740
|
+
const number = Math.max(0, ...[...releases.values()].filter((r) => r.siteId === siteId).map((r) => r.number)) + 1;
|
|
741
|
+
const at = now().toISOString();
|
|
742
|
+
const release = {
|
|
743
|
+
id: id("rel"),
|
|
744
|
+
siteId,
|
|
745
|
+
number,
|
|
746
|
+
note: opts.note ?? null,
|
|
747
|
+
createdAt: at,
|
|
748
|
+
publishedAt: at,
|
|
749
|
+
brandHash: prepared.brandHash,
|
|
750
|
+
snapshot: Object.freeze(clone(prepared.snapshot))
|
|
751
|
+
};
|
|
752
|
+
releases.set(release.id, release);
|
|
753
|
+
s.liveReleaseId = release.id;
|
|
754
|
+
s.status = "live";
|
|
755
|
+
return clone(release);
|
|
756
|
+
},
|
|
757
|
+
async rollback(siteId, releaseId) {
|
|
758
|
+
const s = editable(siteId);
|
|
759
|
+
const r = releases.get(releaseId);
|
|
760
|
+
if (!r || r.siteId !== siteId) throw new Error(`Release ${releaseId} isn't one of site ${siteId}'s.`);
|
|
761
|
+
s.liveReleaseId = r.id;
|
|
762
|
+
s.status = "live";
|
|
763
|
+
return clone(r);
|
|
764
|
+
},
|
|
765
|
+
async listReleases(siteId) {
|
|
766
|
+
if (options.canEdit && !options.canEdit(siteId)) return [];
|
|
767
|
+
return [...releases.values()].filter((r) => r.siteId === siteId).sort((a, b) => b.number - a.number).map(releaseMeta);
|
|
768
|
+
},
|
|
769
|
+
async putAsset(siteId, file, assetMeta = {}) {
|
|
770
|
+
editable(siteId);
|
|
771
|
+
const mime = file.type || "application/octet-stream";
|
|
772
|
+
if (!ASSET_TYPES.includes(mime)) throw new Error(`${mime} isn't an image (PNG, JPEG, WebP, AVIF, GIF) or a PDF.`);
|
|
773
|
+
const assetId = id("asset");
|
|
774
|
+
const path = assetPath(siteId, assetId, assetMeta.name, mime);
|
|
775
|
+
return { id: assetId, siteId, path, url: `/${path}`, mime, alt: assetMeta.alt ?? null, width: assetMeta.width ?? null, height: assetMeta.height ?? null };
|
|
776
|
+
},
|
|
777
|
+
async listLivePosts(siteId) {
|
|
778
|
+
if (!siteIsLive(siteId)) return [];
|
|
779
|
+
return sitePosts(siteId).map(livePost).filter((p) => p !== null).sort(byNewest).slice(0, MAX_LIVE_POSTS).map(postSummary);
|
|
780
|
+
},
|
|
781
|
+
async getLivePost(siteId, slug) {
|
|
782
|
+
if (!siteIsLive(siteId)) return null;
|
|
783
|
+
const p = sitePosts(siteId).find((x) => x.slug === slug);
|
|
784
|
+
return p ? livePost(p) : null;
|
|
785
|
+
},
|
|
786
|
+
async listPosts(siteId) {
|
|
787
|
+
if (options.canEdit && !options.canEdit(siteId)) return [];
|
|
788
|
+
return sitePosts(siteId).map(postMeta).sort(byNewest);
|
|
789
|
+
},
|
|
790
|
+
async getPost(postId) {
|
|
791
|
+
const p = posts.get(postId);
|
|
792
|
+
if (!p || p.deleted || options.canEdit && !options.canEdit(p.siteId)) return null;
|
|
793
|
+
return { ...postMeta(p), doc: clone(p.doc) };
|
|
794
|
+
},
|
|
795
|
+
async savePost(siteId, input) {
|
|
796
|
+
editable(siteId);
|
|
797
|
+
validatePostSlug(input.slug);
|
|
798
|
+
const doc = PostDoc.parse(clone(input.doc));
|
|
799
|
+
const at = now().toISOString();
|
|
800
|
+
if (!input.id) {
|
|
801
|
+
if (sitePosts(siteId).some((p2) => p2.slug === input.slug)) throw new Error(`A post is already at ${input.slug}.`);
|
|
802
|
+
const postId = id("post");
|
|
803
|
+
posts.set(postId, { id: postId, siteId, slug: input.slug, doc, rev: 1, status: "draft", published: null, publishedAt: null, updatedAt: at, deleted: false });
|
|
804
|
+
return { id: postId, rev: 1 };
|
|
805
|
+
}
|
|
806
|
+
const p = editablePost(input.id);
|
|
807
|
+
if (p.siteId !== siteId) throw new Error(`Post ${input.id} isn't one of site ${siteId}'s.`);
|
|
808
|
+
if (p.rev !== input.rev) return { id: p.id, conflict: clone(p.doc), rev: p.rev };
|
|
809
|
+
if (input.slug !== p.slug) {
|
|
810
|
+
if (p.publishedAt) throw new Error("A post keeps its slug once it has been published: its address is out in the world.");
|
|
811
|
+
if (sitePosts(siteId).some((x) => x.slug === input.slug && x.id !== p.id)) throw new Error(`A post is already at ${input.slug}.`);
|
|
812
|
+
p.slug = input.slug;
|
|
813
|
+
}
|
|
814
|
+
p.doc = doc;
|
|
815
|
+
p.rev += 1;
|
|
816
|
+
p.updatedAt = at;
|
|
817
|
+
return { id: p.id, rev: p.rev };
|
|
818
|
+
},
|
|
819
|
+
async publishPost(postId) {
|
|
820
|
+
const p = editablePost(postId);
|
|
821
|
+
if (sites.get(p.siteId)?.status === "archived") throw new Error("An archived site can't publish posts.");
|
|
822
|
+
const issues = checkPost(p.slug, p.doc);
|
|
823
|
+
if (!isPublishable(issues)) throw new PublishBlockedError(issues);
|
|
824
|
+
const at = now().toISOString();
|
|
825
|
+
p.published = { doc: clone(p.doc), at };
|
|
826
|
+
p.status = "published";
|
|
827
|
+
p.publishedAt ??= at;
|
|
828
|
+
p.updatedAt = at;
|
|
829
|
+
return postMeta(p);
|
|
830
|
+
},
|
|
831
|
+
async unpublishPost(postId) {
|
|
832
|
+
const p = editablePost(postId);
|
|
833
|
+
p.status = "draft";
|
|
834
|
+
p.published = null;
|
|
835
|
+
p.updatedAt = now().toISOString();
|
|
836
|
+
return postMeta(p);
|
|
837
|
+
},
|
|
838
|
+
async deletePost(postId) {
|
|
839
|
+
const p = posts.get(postId);
|
|
840
|
+
if (!p || p.deleted) return null;
|
|
841
|
+
editable(p.siteId);
|
|
842
|
+
p.deleted = true;
|
|
843
|
+
p.status = "draft";
|
|
844
|
+
p.published = null;
|
|
845
|
+
return { siteId: p.siteId };
|
|
846
|
+
},
|
|
847
|
+
async importPosts(siteId, list) {
|
|
848
|
+
if (editable(siteId).status === "archived") throw new Error(`No site ${siteId} to import into: it is archived.`);
|
|
849
|
+
if (list.length > MAX_IMPORT_POSTS) throw new Error(`Import at most ${MAX_IMPORT_POSTS} posts at a time.`);
|
|
850
|
+
const result = { imported: [], skipped: [] };
|
|
851
|
+
const seen = new Set(sitePosts(siteId).map((p) => p.slug));
|
|
852
|
+
for (const item of list) {
|
|
853
|
+
const prepared = prepareImportPost(item);
|
|
854
|
+
if ("reason" in prepared) {
|
|
855
|
+
result.skipped.push(prepared);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (seen.has(item.slug)) {
|
|
859
|
+
result.skipped.push({ slug: item.slug, reason: "The site already has a post at this slug." });
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
seen.add(item.slug);
|
|
863
|
+
const postId = id("post");
|
|
864
|
+
const at = now().toISOString();
|
|
865
|
+
const published = prepared.status === "published" ? { doc: clone(prepared.doc), at: prepared.publishedAt } : null;
|
|
866
|
+
posts.set(postId, { id: postId, siteId, slug: item.slug, doc: prepared.doc, rev: 1, status: prepared.status, published, publishedAt: published ? prepared.publishedAt : null, updatedAt: at, deleted: false });
|
|
867
|
+
result.imported.push({ slug: item.slug, id: postId, status: prepared.status, ...prepared.issues.length ? { issues: prepared.issues } : {} });
|
|
868
|
+
}
|
|
869
|
+
return result;
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
return store;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export { ASSET_TYPES, FONT_CHOICES, PublishBlockedError, SiteConflictError, assetPath, brandDisplayName, checkBlog, checkContrast, checkHtmlOutline, checkPage, checkPost, checkRelease, checkSiteDoc, contrastRatio, createMemorySiteStore, formCacheTag, hexToHslTriplet, isConflict, isPublishable, isSiteBrand, luminance, migrateNode, migratePage, migrateSite, mix, parseHex, prepareImportPost, prepareRelease, siteCacheTag, siteTheme, stableHash, themeCss, toCanonicalBrand, withCacheInvalidation };
|
|
876
|
+
//# sourceMappingURL=chunk-IXMY4B56.js.map
|
|
877
|
+
//# sourceMappingURL=chunk-IXMY4B56.js.map
|