@marktiderman/genesis-sites 0.1.0 → 0.3.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.
Files changed (60) hide show
  1. package/README.md +241 -13
  2. package/dist/assets/islands.77d11f5697.js +1 -0
  3. package/dist/assets/sites-forms.732cc7e0c1.css +10 -0
  4. package/dist/assets/sites-forms.css +1 -1
  5. package/dist/assets/sites.716b4ac1a5.css +2 -0
  6. package/dist/assets/sites.css +1 -1
  7. package/dist/assets.generated-QSB7EJWX.js +3 -0
  8. package/dist/assets.generated-QSB7EJWX.js.map +1 -0
  9. package/dist/blocks.d.ts +1 -1
  10. package/dist/blocks.js +5 -1
  11. package/dist/blocks.js.map +1 -1
  12. package/dist/chunk-3VKTBE7S.js +69 -0
  13. package/dist/chunk-3VKTBE7S.js.map +1 -0
  14. package/dist/chunk-5IXDFFEG.js +465 -0
  15. package/dist/chunk-5IXDFFEG.js.map +1 -0
  16. package/dist/chunk-C375ZPU2.js +2241 -0
  17. package/dist/chunk-C375ZPU2.js.map +1 -0
  18. package/dist/{chunk-GA7U3CIN.js → chunk-FBQF2L7O.js} +317 -97
  19. package/dist/chunk-FBQF2L7O.js.map +1 -0
  20. package/dist/chunk-H6ED3K4R.js +18 -0
  21. package/dist/chunk-H6ED3K4R.js.map +1 -0
  22. package/dist/chunk-KPOX7D4S.js +250 -0
  23. package/dist/chunk-KPOX7D4S.js.map +1 -0
  24. package/dist/chunk-VZUDRPFG.js +13 -0
  25. package/dist/chunk-VZUDRPFG.js.map +1 -0
  26. package/dist/{chunk-KEF3V5ZN.js → chunk-ZGWGQGT2.js} +169 -73
  27. package/dist/chunk-ZGWGQGT2.js.map +1 -0
  28. package/dist/editor.d.ts +526 -0
  29. package/dist/editor.js +2480 -0
  30. package/dist/editor.js.map +1 -0
  31. package/dist/handler-QHH453YG.js +9 -0
  32. package/dist/handler-QHH453YG.js.map +1 -0
  33. package/dist/import.d.ts +130 -0
  34. package/dist/import.js +330 -0
  35. package/dist/import.js.map +1 -0
  36. package/dist/index.d.ts +47 -8
  37. package/dist/index.js +5 -250
  38. package/dist/index.js.map +1 -1
  39. package/dist/{registry-CdgVymsu.d.ts → registry-HBn1X60x.d.ts} +274 -8
  40. package/dist/sections.d.ts +422 -6
  41. package/dist/sections.js +4 -2
  42. package/dist/{store-BzGw2i5J.d.ts → store-BXiZeZzH.d.ts} +119 -53
  43. package/dist/supabase.d.ts +5 -5
  44. package/dist/supabase.js +115 -6
  45. package/dist/supabase.js.map +1 -1
  46. package/dist/web.d.ts +26 -8
  47. package/dist/web.js +7 -353
  48. package/dist/web.js.map +1 -1
  49. package/migrations/0001_genesis_sites.sql +64 -6
  50. package/migrations/0002_genesis_sites_posts.sql +404 -0
  51. package/migrations/tests/0001_genesis_sites.test.sql +30 -1
  52. package/migrations/tests/0002_genesis_sites_posts.test.sql +242 -0
  53. package/package.json +28 -10
  54. package/dist/assets/islands.230b20b9c4.js +0 -1
  55. package/dist/assets/sites-forms.5e6ebf31c3.css +0 -10
  56. package/dist/assets/sites.b28b796c6f.css +0 -2
  57. package/dist/chunk-43XUAO63.js +0 -1255
  58. package/dist/chunk-43XUAO63.js.map +0 -1
  59. package/dist/chunk-GA7U3CIN.js.map +0 -1
  60. package/dist/chunk-KEF3V5ZN.js.map +0 -1
@@ -1,93 +1,7 @@
1
- import { getSection, pagePathProblems, isReservedPath, ReleaseSnapshot, DEFAULT_RESERVED_PATHS, validatePagePath, redirectsAfterMove, PageDoc, SiteDoc } from './chunk-KEF3V5ZN.js';
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';
2
3
  import { genesisBrand, themeFromBrand } from '@marktiderman/genesis-design-system';
3
4
 
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
5
  var FONT_CHOICES = Object.freeze({
92
6
  system: { label: "System", stack: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' },
93
7
  inter: { label: "Inter", stack: "Inter, ui-sans-serif, system-ui, sans-serif", google: "Inter:wght@400;500;600;700" },
@@ -217,7 +131,10 @@ function pairsFor(c, mode) {
217
131
  { name: "body text on a muted band", mode, foreground: c.foreground, background: c.muted, minimum: 4.5 },
218
132
  { name: "button and accent-band text on the primary colour", mode, foreground: c.primaryForeground, background: c.primary, minimum: 4.5 },
219
133
  { 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 }
134
+ { name: "the focus ring on the page", mode, foreground: c.ring, background: c.background, minimum: 3 },
135
+ // Non-text (1.4.11): the primary colour as a border or marker (a timetable chip, the picked day, a timeline's step).
136
+ // Every such mark also has text or a shape (a chip names its type, a marker shows a dot), so it warns.
137
+ { name: "the primary colour as a border or marker on the page", mode, foreground: c.primary, background: c.background, minimum: 3, severity: "warning" }
221
138
  ];
222
139
  }
223
140
  function stableHash(value) {
@@ -277,13 +194,101 @@ function themeCss(theme) {
277
194
  return block(":root", theme.light) + ":root{color-scheme:light}";
278
195
  }
279
196
 
197
+ // src/core/migrate.ts
198
+ function migrateNode(node, lookup = getSection) {
199
+ const def = lookup(node.type);
200
+ const where = { sectionId: node.id, type: node.type };
201
+ if (!def) {
202
+ return {
203
+ node: null,
204
+ changed: false,
205
+ issues: [{ ...where, severity: "error", code: "unknown-section", message: `No section "${node.type}" is registered; section ${node.id} can't be shown.` }]
206
+ };
207
+ }
208
+ if (node.v > def.version) {
209
+ return {
210
+ node: null,
211
+ changed: false,
212
+ issues: [
213
+ {
214
+ ...where,
215
+ severity: "error",
216
+ code: "newer-version",
217
+ message: `Section ${node.id} was saved by a newer "${node.type}" (v${node.v}) than this code knows (v${def.version}).`
218
+ }
219
+ ]
220
+ };
221
+ }
222
+ let props = node.props;
223
+ for (let n = node.v + 1; n <= def.version; n++) {
224
+ const step = def.migrations?.[n];
225
+ try {
226
+ props = step ? step(structuredClone(props)) : props;
227
+ } catch (err) {
228
+ return {
229
+ node: null,
230
+ changed: false,
231
+ issues: [{ ...where, severity: "error", code: "migration-failed", message: `Migrating section ${node.id} to v${n} failed: ${err.message}` }]
232
+ };
233
+ }
234
+ }
235
+ const parsed = def.schema.safeParse(props);
236
+ if (!parsed.success) {
237
+ return {
238
+ node: null,
239
+ changed: false,
240
+ issues: [
241
+ {
242
+ ...where,
243
+ severity: "error",
244
+ code: "invalid-props",
245
+ message: `Section ${node.id} (${def.label}): ${parsed.error.issues.map((i) => `${i.path.join(".") || "props"}: ${i.message}`).join("; ")}`
246
+ }
247
+ ]
248
+ };
249
+ }
250
+ const issues = [];
251
+ let variant = node.variant ?? def.defaultVariant;
252
+ if (!def.variants.some((v) => v.id === variant)) {
253
+ 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}".` });
254
+ variant = def.defaultVariant;
255
+ }
256
+ const next = { ...node, v: def.version, variant, props: parsed.data };
257
+ const changed = node.v !== def.version || variant !== node.variant || stableHash(props) !== stableHash(node.props);
258
+ return { node: next, changed, issues };
259
+ }
260
+ function migratePage(doc, lookup) {
261
+ const issues = [];
262
+ let changed = false;
263
+ const content = doc.content.map((node) => {
264
+ const m = migrateNode(node, lookup);
265
+ issues.push(...m.issues);
266
+ if (m.node && m.changed) changed = true;
267
+ return m.node ?? node;
268
+ });
269
+ return { doc: { ...doc, content }, changed, issues };
270
+ }
271
+ function migrateSite(site, lookup) {
272
+ const issues = [];
273
+ let changed = false;
274
+ const run = (nodes) => nodes.map((node) => {
275
+ const m = migrateNode(node, lookup);
276
+ issues.push(...m.issues);
277
+ if (m.node && m.changed) changed = true;
278
+ return m.node ?? node;
279
+ });
280
+ const header = run(site.header);
281
+ const footer = run(site.footer);
282
+ return { site: { ...site, header, footer }, changed, issues };
283
+ }
284
+
280
285
  // src/core/a11y.ts
281
286
  var isPublishable = (issues) => !issues.some((i) => i.severity === "error");
282
287
  function images(value, path = []) {
283
288
  if (!value || typeof value !== "object") return [];
284
289
  if (Array.isArray(value)) return value.flatMap((v, i) => images(v, [...path, String(i)]));
285
290
  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 }] : [];
291
+ 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 }] : [];
287
292
  return [...found, ...Object.entries(record).flatMap(([k, v]) => k === "src" ? [] : images(v, [...path, k]))];
288
293
  }
289
294
  function richTextHeadings(value) {
@@ -368,7 +373,7 @@ function checkSiteDoc(site, lookup = getSection) {
368
373
  }
369
374
  function checkContrast(theme) {
370
375
  return theme.pairs.map((pair) => ({ pair, ratio: contrastRatio(pair.foreground, pair.background) })).filter(({ pair, ratio }) => ratio < pair.minimum).map(({ pair, ratio }) => ({
371
- severity: "error",
376
+ severity: pair.severity ?? "error",
372
377
  code: "contrast",
373
378
  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
379
  }));
@@ -384,6 +389,7 @@ function checkRelease(snapshot, options = {}) {
384
389
  issues.push(...checkPage(page, lookup, options.reserved));
385
390
  }
386
391
  if (!paths.has("/")) issues.push({ severity: "error", code: "no-home", message: "The site has no home page (a page at /)." });
392
+ issues.push(...checkBlog(snapshot, lookup));
387
393
  for (const r of snapshot.site.redirects) {
388
394
  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
395
  const target = r.to.split(/[?#]/)[0];
@@ -397,6 +403,55 @@ function checkRelease(snapshot, options = {}) {
397
403
  if (options.theme) issues.push(...checkContrast(options.theme));
398
404
  return issues;
399
405
  }
406
+ function checkBlog(snapshot, lookup = getSection) {
407
+ const issues = [];
408
+ const postPages = snapshot.pages.filter((p) => p.kind === "post");
409
+ if (postPages.length > 1) {
410
+ 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.` });
411
+ }
412
+ for (const page of postPages) {
413
+ const base = { pageId: page.id, path: page.path };
414
+ 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." });
415
+ 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.` });
416
+ if (!page.doc.content.some((n) => !n.hidden && n.type === "post-body")) {
417
+ 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.` });
418
+ }
419
+ }
420
+ if (!postPages.length) {
421
+ for (const page of snapshot.pages) {
422
+ for (const node of page.doc.content) {
423
+ if (!node.hidden && node.type === "blog-list" && lookup(node.type)) {
424
+ 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.` });
425
+ }
426
+ }
427
+ }
428
+ }
429
+ return issues;
430
+ }
431
+ function checkPost(slug, doc) {
432
+ const issues = [];
433
+ const at = `The post ${JSON.stringify(slug)}`;
434
+ if (!PostSlug.safeParse(slug).success) issues.push({ severity: "error", code: "post-slug", message: `${at}: a slug is lowercase letters, digits and single hyphens.` });
435
+ const parsed = PostDoc.safeParse(doc);
436
+ if (!parsed.success) {
437
+ issues.push({ severity: "error", code: "post-invalid", message: `${at}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "post"} ${i.message}`).join("; ")}` });
438
+ return issues;
439
+ }
440
+ for (const { path, image } of images(parsed.data)) {
441
+ if (path.startsWith("seo")) continue;
442
+ const alt = typeof image.alt === "string" ? image.alt.trim() : "";
443
+ 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.` });
444
+ }
445
+ const levels = richTextHeadings(parsed.data.body);
446
+ for (let i = 0; i < levels.length; i++) {
447
+ const level = levels[i];
448
+ const previous = i === 0 ? 1 : levels[i - 1];
449
+ 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.` });
450
+ 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.` });
451
+ }
452
+ 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.` });
453
+ return issues;
454
+ }
400
455
  function checkHtmlOutline(html) {
401
456
  const issues = [];
402
457
  const body = html.replace(/<script\b[\s\S]*?<\/script>/gi, "").replace(/<style\b[\s\S]*?<\/style>/gi, "");
@@ -415,6 +470,11 @@ function checkHtmlOutline(html) {
415
470
 
416
471
  // src/core/store.ts
417
472
  var isConflict = (result) => "conflict" in result;
473
+ var ASSET_TYPES = Object.freeze(["image/png", "image/jpeg", "image/webp", "image/avif", "image/gif", "application/pdf"]);
474
+ function assetPath(siteId, id, name, mime) {
475
+ const clean = (name ?? "file").toLowerCase().replace(/[^a-z0-9.]+/g, "-").replace(/\.{2,}/g, ".").replace(/^[.-]+/, "").slice(0, 80) || "file";
476
+ return `sites/${siteId}/${mime === "application/pdf" ? "files/" : ""}${id}-${clean}`;
477
+ }
418
478
  var PublishBlockedError = class extends Error {
419
479
  constructor(issues) {
420
480
  const errors = issues.filter((i) => i.severity === "error");
@@ -479,6 +539,39 @@ function withCacheInvalidation(store, invalidate) {
479
539
  deletePage: (id) => store.deletePage(id),
480
540
  listReleases: (id) => store.listReleases(id),
481
541
  ...store.putAsset ? { putAsset: store.putAsset.bind(store) } : {},
542
+ ...store.listLivePosts ? { listLivePosts: store.listLivePosts.bind(store) } : {},
543
+ ...store.getLivePost ? { getLivePost: store.getLivePost.bind(store) } : {},
544
+ ...store.listPosts ? { listPosts: store.listPosts.bind(store) } : {},
545
+ ...store.getPost ? { getPost: store.getPost.bind(store) } : {},
546
+ ...store.savePost ? { savePost: store.savePost.bind(store) } : {},
547
+ ...store.publishPost ? {
548
+ async publishPost(postId) {
549
+ const meta = await store.publishPost(postId);
550
+ await purge(meta.siteId, "post");
551
+ return meta;
552
+ }
553
+ } : {},
554
+ ...store.unpublishPost ? {
555
+ async unpublishPost(postId) {
556
+ const meta = await store.unpublishPost(postId);
557
+ await purge(meta.siteId, "post");
558
+ return meta;
559
+ }
560
+ } : {},
561
+ ...store.deletePost ? {
562
+ async deletePost(postId) {
563
+ const deleted = await store.deletePost(postId);
564
+ if (deleted) await purge(deleted.siteId, "post");
565
+ return deleted;
566
+ }
567
+ } : {},
568
+ ...store.importPosts ? {
569
+ async importPosts(siteId, list) {
570
+ const result = await store.importPosts(siteId, list);
571
+ await purge(siteId, "post");
572
+ return result;
573
+ }
574
+ } : {},
482
575
  async publish(siteId, options) {
483
576
  const release = await store.publish(siteId, options);
484
577
  await purge(siteId, "publish");
@@ -491,6 +584,23 @@ function withCacheInvalidation(store, invalidate) {
491
584
  }
492
585
  };
493
586
  }
587
+ function prepareImportPost(item) {
588
+ const slug = typeof item?.slug === "string" ? item.slug : String(item?.slug);
589
+ try {
590
+ validatePostSlug(slug);
591
+ } catch (err) {
592
+ return { slug, reason: err.message };
593
+ }
594
+ const parsed = PostDoc.safeParse(item.doc);
595
+ if (!parsed.success) return { slug, reason: `The post doesn't parse: ${parsed.error.issues.map((i) => `${i.path.join(".") || "post"} ${i.message}`).join("; ")}` };
596
+ const now = Date.now();
597
+ const given = item.publishedAt ? new Date(item.publishedAt) : new Date(now);
598
+ if (Number.isNaN(given.getTime()) || given.getTime() < 0) return { slug, reason: `"${item.publishedAt}" isn't a usable publish date.` };
599
+ const date = new Date(Math.min(given.getTime(), now));
600
+ const errors = checkPost(slug, parsed.data).filter((i) => i.severity === "error").map((i) => i.message);
601
+ const status = item.status === "draft" || errors.length ? "draft" : "published";
602
+ return { doc: parsed.data, status, publishedAt: date.toISOString(), issues: errors };
603
+ }
494
604
  var clone = (value) => structuredClone(value);
495
605
  function createMemorySiteStore(options = {}) {
496
606
  const reserved = options.reserved ?? DEFAULT_RESERVED_PATHS;
@@ -498,6 +608,7 @@ function createMemorySiteStore(options = {}) {
498
608
  const sites = /* @__PURE__ */ new Map();
499
609
  const pages = /* @__PURE__ */ new Map();
500
610
  const releases = /* @__PURE__ */ new Map();
611
+ const posts = /* @__PURE__ */ new Map();
501
612
  let seq = 0;
502
613
  const id = (prefix) => `${prefix}_${(++seq).toString(36).padStart(4, "0")}`;
503
614
  const editable = (siteId) => {
@@ -510,6 +621,20 @@ function createMemorySiteStore(options = {}) {
510
621
  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
622
  const releaseMeta = ({ snapshot: _s, ...m }) => clone(m);
512
623
  const pathTaken = (siteId, path, except) => livePages(siteId).some((p) => p.path === path && p.id !== except);
624
+ const sitePosts = (siteId) => [...posts.values()].filter((p) => p.siteId === siteId && !p.deleted);
625
+ 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 });
626
+ const byNewest = (a, b) => (b.publishedAt ?? b.updatedAt).localeCompare(a.publishedAt ?? a.updatedAt);
627
+ 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;
628
+ const siteIsLive = (siteId) => {
629
+ const s = sites.get(siteId);
630
+ return Boolean(s && s.status === "live" && s.liveReleaseId);
631
+ };
632
+ const editablePost = (postId) => {
633
+ const p = posts.get(postId);
634
+ if (!p || p.deleted) throw new Error(`No post ${postId}.`);
635
+ editable(p.siteId);
636
+ return p;
637
+ };
513
638
  const store = {
514
639
  async createSite(input) {
515
640
  const siteId = input.id ?? id("site");
@@ -644,15 +769,110 @@ function createMemorySiteStore(options = {}) {
644
769
  },
645
770
  async putAsset(siteId, file, assetMeta = {}) {
646
771
  editable(siteId);
772
+ const mime = file.type || "application/octet-stream";
773
+ if (!ASSET_TYPES.includes(mime)) throw new Error(`${mime} isn't an image (PNG, JPEG, WebP, AVIF, GIF) or a PDF.`);
647
774
  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 };
775
+ const path = assetPath(siteId, assetId, assetMeta.name, mime);
776
+ return { id: assetId, siteId, path, url: `/${path}`, mime, alt: assetMeta.alt ?? null, width: assetMeta.width ?? null, height: assetMeta.height ?? null };
777
+ },
778
+ async listLivePosts(siteId) {
779
+ if (!siteIsLive(siteId)) return [];
780
+ return sitePosts(siteId).map(livePost).filter((p) => p !== null).sort(byNewest).slice(0, MAX_LIVE_POSTS).map(postSummary);
781
+ },
782
+ async getLivePost(siteId, slug) {
783
+ if (!siteIsLive(siteId)) return null;
784
+ const p = sitePosts(siteId).find((x) => x.slug === slug);
785
+ return p ? livePost(p) : null;
786
+ },
787
+ async listPosts(siteId) {
788
+ if (options.canEdit && !options.canEdit(siteId)) return [];
789
+ return sitePosts(siteId).map(postMeta).sort(byNewest);
790
+ },
791
+ async getPost(postId) {
792
+ const p = posts.get(postId);
793
+ if (!p || p.deleted || options.canEdit && !options.canEdit(p.siteId)) return null;
794
+ return { ...postMeta(p), doc: clone(p.doc) };
795
+ },
796
+ async savePost(siteId, input) {
797
+ editable(siteId);
798
+ validatePostSlug(input.slug);
799
+ const doc = PostDoc.parse(clone(input.doc));
800
+ const at = now().toISOString();
801
+ if (!input.id) {
802
+ if (sitePosts(siteId).some((p2) => p2.slug === input.slug)) throw new Error(`A post is already at ${input.slug}.`);
803
+ const postId = id("post");
804
+ posts.set(postId, { id: postId, siteId, slug: input.slug, doc, rev: 1, status: "draft", published: null, publishedAt: null, updatedAt: at, deleted: false });
805
+ return { id: postId, rev: 1 };
806
+ }
807
+ const p = editablePost(input.id);
808
+ if (p.siteId !== siteId) throw new Error(`Post ${input.id} isn't one of site ${siteId}'s.`);
809
+ if (p.rev !== input.rev) return { id: p.id, conflict: clone(p.doc), rev: p.rev };
810
+ if (input.slug !== p.slug) {
811
+ if (p.publishedAt) throw new Error("A post keeps its slug once it has been published: its address is out in the world.");
812
+ if (sitePosts(siteId).some((x) => x.slug === input.slug && x.id !== p.id)) throw new Error(`A post is already at ${input.slug}.`);
813
+ p.slug = input.slug;
814
+ }
815
+ p.doc = doc;
816
+ p.rev += 1;
817
+ p.updatedAt = at;
818
+ return { id: p.id, rev: p.rev };
819
+ },
820
+ async publishPost(postId) {
821
+ const p = editablePost(postId);
822
+ if (sites.get(p.siteId)?.status === "archived") throw new Error("An archived site can't publish posts.");
823
+ const issues = checkPost(p.slug, p.doc);
824
+ if (!isPublishable(issues)) throw new PublishBlockedError(issues);
825
+ const at = now().toISOString();
826
+ p.published = { doc: clone(p.doc), at };
827
+ p.status = "published";
828
+ p.publishedAt ??= at;
829
+ p.updatedAt = at;
830
+ return postMeta(p);
831
+ },
832
+ async unpublishPost(postId) {
833
+ const p = editablePost(postId);
834
+ p.status = "draft";
835
+ p.published = null;
836
+ p.updatedAt = now().toISOString();
837
+ return postMeta(p);
838
+ },
839
+ async deletePost(postId) {
840
+ const p = posts.get(postId);
841
+ if (!p || p.deleted) return null;
842
+ editable(p.siteId);
843
+ p.deleted = true;
844
+ p.status = "draft";
845
+ p.published = null;
846
+ return { siteId: p.siteId };
847
+ },
848
+ async importPosts(siteId, list) {
849
+ if (editable(siteId).status === "archived") throw new Error(`No site ${siteId} to import into: it is archived.`);
850
+ if (list.length > MAX_IMPORT_POSTS) throw new Error(`Import at most ${MAX_IMPORT_POSTS} posts at a time.`);
851
+ const result = { imported: [], skipped: [] };
852
+ const seen = new Set(sitePosts(siteId).map((p) => p.slug));
853
+ for (const item of list) {
854
+ const prepared = prepareImportPost(item);
855
+ if ("reason" in prepared) {
856
+ result.skipped.push(prepared);
857
+ continue;
858
+ }
859
+ if (seen.has(item.slug)) {
860
+ result.skipped.push({ slug: item.slug, reason: "The site already has a post at this slug." });
861
+ continue;
862
+ }
863
+ seen.add(item.slug);
864
+ const postId = id("post");
865
+ const at = now().toISOString();
866
+ const published = prepared.status === "published" ? { doc: clone(prepared.doc), at: prepared.publishedAt } : null;
867
+ 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 });
868
+ result.imported.push({ slug: item.slug, id: postId, status: prepared.status, ...prepared.issues.length ? { issues: prepared.issues } : {} });
869
+ }
870
+ return result;
651
871
  }
652
872
  };
653
873
  return store;
654
874
  }
655
875
 
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
876
+ 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 };
877
+ //# sourceMappingURL=chunk-FBQF2L7O.js.map
878
+ //# sourceMappingURL=chunk-FBQF2L7O.js.map