@drawnagency/primitives 0.1.108 → 0.1.110

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 (216) hide show
  1. package/dist/auth/cookies.d.ts +8 -0
  2. package/dist/auth/cookies.d.ts.map +1 -1
  3. package/dist/auth/index.d.ts +1 -1
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +3 -1
  6. package/dist/auth/types.d.ts +11 -4
  7. package/dist/auth/types.d.ts.map +1 -1
  8. package/dist/{chunk-5IZ2KTMU.js → chunk-3GAG6ZXB.js} +55 -46
  9. package/dist/{chunk-PZJL2S3Z.js → chunk-3ZRQ4G5F.js} +1 -1
  10. package/dist/{chunk-FM4M4Q2W.js → chunk-4TZJZU2T.js} +23 -3
  11. package/dist/{chunk-LUAI5DWR.js → chunk-7MI3HCOF.js} +60 -23
  12. package/dist/{chunk-ZHHJVKU7.js → chunk-7WQJ57XG.js} +63 -204
  13. package/dist/{chunk-JIQBLG6Y.js → chunk-ALMEC3XN.js} +12 -13
  14. package/dist/{chunk-TZQPOR5A.js → chunk-DMCGN5AS.js} +10 -3
  15. package/dist/{chunk-D6TLUVOP.js → chunk-ELQ4C677.js} +48 -2
  16. package/dist/chunk-F2LYQPXV.js +77 -0
  17. package/dist/{chunk-5BM2Y3NJ.js → chunk-FRVO75DK.js} +14 -2
  18. package/dist/{chunk-VKAGMEKE.js → chunk-FTPETWXE.js} +1 -0
  19. package/dist/{chunk-3TO4RRHY.js → chunk-GPPVYLEI.js} +7 -0
  20. package/dist/{chunk-NLQ5F3RQ.js → chunk-HBULBDGP.js} +1 -1
  21. package/dist/{chunk-IPKZ5FU6.js → chunk-NXGIHSL5.js} +72 -55
  22. package/dist/{chunk-CPTFFLK2.js → chunk-OTDA6KWL.js} +10 -3
  23. package/dist/chunk-OUYB2GCW.js +194 -0
  24. package/dist/{chunk-72CG6AA2.js → chunk-RA5IOFPY.js} +10 -2
  25. package/dist/{chunk-GEENI3OC.js → chunk-SW6PCQQD.js} +4 -0
  26. package/dist/{chunk-BOZUEKWL.js → chunk-UVIYI4J6.js} +1 -1
  27. package/dist/{chunk-AB3XVXI3.js → chunk-VCCNKTFD.js} +37 -29
  28. package/dist/{chunk-XBYJQ5RS.js → chunk-XQMDWXD6.js} +21 -6
  29. package/dist/collab/rebase.d.ts +6 -0
  30. package/dist/collab/rebase.d.ts.map +1 -1
  31. package/dist/collab/rebase.js +12 -3
  32. package/dist/collab/recovery.d.ts +0 -20
  33. package/dist/collab/recovery.d.ts.map +1 -1
  34. package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
  35. package/dist/components/brandguide/ColorsView.d.ts.map +1 -1
  36. package/dist/components/brandguide/colors-shared.d.ts +10 -1
  37. package/dist/components/brandguide/colors-shared.d.ts.map +1 -1
  38. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  39. package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
  40. package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -1
  41. package/dist/components/docfile/doc-file-state.d.ts.map +1 -1
  42. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  43. package/dist/components/editor/NavGroupRow.d.ts +7 -2
  44. package/dist/components/editor/NavGroupRow.d.ts.map +1 -1
  45. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  46. package/dist/components/editor/index.js +14 -13
  47. package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
  48. package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
  49. package/dist/components/primitives/index.d.ts +2 -0
  50. package/dist/components/primitives/index.d.ts.map +1 -1
  51. package/dist/components/primitives/index.js +22 -13
  52. package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
  53. package/dist/components/primitives/useEditablePlainText.d.ts.map +1 -1
  54. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  55. package/dist/components/sections/Prose/Prose.d.ts.map +1 -1
  56. package/dist/components/sections/brand-guide.js +10 -10
  57. package/dist/components/sections/register-schemas.js +16 -16
  58. package/dist/components/sections/register.js +16 -16
  59. package/dist/components/shared/FontPicker.d.ts.map +1 -1
  60. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -1
  61. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  62. package/dist/components/shared/Navigation.d.ts.map +1 -1
  63. package/dist/components/shared/Popover.d.ts.map +1 -1
  64. package/dist/components/shell/AudienceAddForm.d.ts +1 -5
  65. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  66. package/dist/components/shell/AudienceRow.d.ts +2 -3
  67. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  68. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  69. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  70. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  71. package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
  72. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  73. package/dist/components/shell/view-branch.d.ts.map +1 -1
  74. package/dist/document/components/DocRail.d.ts.map +1 -1
  75. package/dist/document/components/DocViewer.d.ts.map +1 -1
  76. package/dist/document/components/measure.d.ts.map +1 -1
  77. package/dist/document/components/useDocPages.d.ts.map +1 -1
  78. package/dist/document/engine/blocks.d.ts.map +1 -1
  79. package/dist/document/engine/masonry.d.ts.map +1 -1
  80. package/dist/document/engine/paginate.d.ts.map +1 -1
  81. package/dist/document/engine/settings.d.ts +3 -0
  82. package/dist/document/engine/settings.d.ts.map +1 -1
  83. package/dist/document/index.js +79 -28
  84. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  85. package/dist/hooks/useDocumentUploads.d.ts +1 -0
  86. package/dist/hooks/useDocumentUploads.d.ts.map +1 -1
  87. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  88. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  89. package/dist/hooks/useFocusTrap.d.ts +6 -0
  90. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  91. package/dist/hooks/useMediaPipeline.d.ts +3 -2
  92. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  93. package/dist/hooks/useSanitizedHtml.d.ts +12 -0
  94. package/dist/hooks/useSanitizedHtml.d.ts.map +1 -0
  95. package/dist/index.js +20 -14
  96. package/dist/lib/block-move.d.ts.map +1 -1
  97. package/dist/lib/contrast.d.ts.map +1 -1
  98. package/dist/lib/dexie.d.ts +2 -8
  99. package/dist/lib/dexie.d.ts.map +1 -1
  100. package/dist/lib/dexie.js +21 -49
  101. package/dist/lib/index-ops.d.ts.map +1 -1
  102. package/dist/lib/index-ops.js +43 -23
  103. package/dist/lib/index.js +8 -8
  104. package/dist/lib/loader.d.ts.map +1 -1
  105. package/dist/lib/media-folders.d.ts +12 -4
  106. package/dist/lib/media-folders.d.ts.map +1 -1
  107. package/dist/lib/media-folders.js +12 -2
  108. package/dist/lib/migrate-sections-transform.js +1 -1
  109. package/dist/lib/nav-tree.d.ts.map +1 -1
  110. package/dist/lib/nav.d.ts.map +1 -1
  111. package/dist/lib/pages.d.ts.map +1 -1
  112. package/dist/lib/registry.d.ts.map +1 -1
  113. package/dist/lib/registry.js +1 -1
  114. package/dist/lib/sanitize.d.ts +6 -0
  115. package/dist/lib/sanitize.d.ts.map +1 -1
  116. package/dist/lib/timestamp.d.ts.map +1 -1
  117. package/dist/media/index.js +4 -4
  118. package/dist/media/queue.d.ts.map +1 -1
  119. package/dist/media/resolve.d.ts.map +1 -1
  120. package/dist/media/utils.d.ts.map +1 -1
  121. package/dist/media/videoPoster.d.ts.map +1 -1
  122. package/dist/schemas/audience.d.ts +5 -1
  123. package/dist/schemas/audience.d.ts.map +1 -1
  124. package/dist/schemas/audience.js +1 -1
  125. package/dist/schemas/index.js +9 -7
  126. package/dist/schemas/link.d.ts.map +1 -1
  127. package/dist/schemas/link.js +1 -1
  128. package/dist/schemas/sections.d.ts +12 -0
  129. package/dist/schemas/sections.d.ts.map +1 -1
  130. package/dist/storage/index.d.ts +1 -1
  131. package/dist/storage/index.d.ts.map +1 -1
  132. package/dist/storage/index.js +3 -1
  133. package/dist/storage/types.d.ts +9 -0
  134. package/dist/storage/types.d.ts.map +1 -1
  135. package/dist/types/database.d.ts +0 -3
  136. package/dist/types/database.d.ts.map +1 -1
  137. package/package.json +1 -1
  138. package/src/auth/cookies.ts +11 -0
  139. package/src/auth/index.ts +1 -1
  140. package/src/auth/types.ts +18 -4
  141. package/src/collab/rebase.ts +26 -1
  142. package/src/collab/recovery.ts +29 -3
  143. package/src/components/brandguide/ColorSwatchSettings.tsx +40 -13
  144. package/src/components/brandguide/ColorsEditable.tsx +7 -9
  145. package/src/components/brandguide/ColorsView.tsx +12 -11
  146. package/src/components/brandguide/colors-shared.ts +17 -1
  147. package/src/components/chat/ChatPanel.tsx +19 -0
  148. package/src/components/docfile/DocFilePane.tsx +11 -0
  149. package/src/components/docfile/PdfCanvasViewer.tsx +46 -5
  150. package/src/components/docfile/doc-file-state.ts +7 -0
  151. package/src/components/editor/IndicatorPill.tsx +5 -1
  152. package/src/components/editor/NavGroupRow.tsx +8 -3
  153. package/src/components/editor/PagesModal.tsx +10 -2
  154. package/src/components/primitives/EditableList.tsx +10 -2
  155. package/src/components/primitives/EditableRichText.tsx +4 -3
  156. package/src/components/primitives/LinkPopover.tsx +23 -1
  157. package/src/components/primitives/index.ts +5 -0
  158. package/src/components/primitives/useEditableCollection.ts +61 -41
  159. package/src/components/primitives/useEditablePlainText.ts +24 -3
  160. package/src/components/sections/IconList/index.tsx +8 -1
  161. package/src/components/sections/Prose/Prose.tsx +4 -2
  162. package/src/components/shared/FontPicker.tsx +16 -4
  163. package/src/components/shared/HistoryChangesModal.tsx +12 -1
  164. package/src/components/shared/HistoryPopover.tsx +18 -4
  165. package/src/components/shared/LinkField.tsx +1 -1
  166. package/src/components/shared/Navigation.tsx +20 -2
  167. package/src/components/shared/Popover.tsx +9 -5
  168. package/src/components/shell/AudienceAddForm.tsx +6 -26
  169. package/src/components/shell/AudienceRow.tsx +29 -39
  170. package/src/components/shell/BugReportFAB.tsx +20 -2
  171. package/src/components/shell/EditorShell.tsx +200 -50
  172. package/src/components/shell/MediaLibraryModal.tsx +52 -10
  173. package/src/components/shell/SiteSettingsUsers.tsx +3 -0
  174. package/src/components/shell/SiteSettingsViewerAccess.tsx +21 -16
  175. package/src/components/shell/view-branch.ts +4 -1
  176. package/src/document/components/DocRail.tsx +27 -2
  177. package/src/document/components/DocViewer.tsx +5 -1
  178. package/src/document/components/measure.tsx +22 -6
  179. package/src/document/components/useDocPages.ts +13 -3
  180. package/src/document/engine/blocks.ts +33 -4
  181. package/src/document/engine/masonry.ts +8 -2
  182. package/src/document/engine/paginate.ts +4 -1
  183. package/src/document/engine/settings.ts +40 -3
  184. package/src/hooks/useBuildStatus.ts +6 -0
  185. package/src/hooks/useDocumentUploads.ts +46 -3
  186. package/src/hooks/useEditorPersistence.ts +33 -19
  187. package/src/hooks/useEditorPublish.ts +83 -34
  188. package/src/hooks/useFocusTrap.ts +18 -0
  189. package/src/hooks/useMediaPipeline.ts +111 -38
  190. package/src/hooks/useSanitizedHtml.ts +29 -0
  191. package/src/lib/audience-secret.ts +1 -0
  192. package/src/lib/block-move.ts +13 -2
  193. package/src/lib/contrast.ts +14 -14
  194. package/src/lib/dexie.ts +46 -49
  195. package/src/lib/index-ops.ts +63 -20
  196. package/src/lib/loader.ts +41 -12
  197. package/src/lib/media-folders.ts +31 -5
  198. package/src/lib/migrate-sections-transform.ts +22 -2
  199. package/src/lib/nav-tree.ts +16 -2
  200. package/src/lib/nav.ts +10 -1
  201. package/src/lib/pages.ts +24 -6
  202. package/src/lib/registry.ts +3 -0
  203. package/src/lib/sanitize.ts +9 -0
  204. package/src/lib/timestamp.ts +5 -0
  205. package/src/media/queue.ts +4 -0
  206. package/src/media/resolve.ts +14 -1
  207. package/src/media/utils.ts +3 -1
  208. package/src/media/videoPoster.ts +14 -3
  209. package/src/schemas/audience.ts +10 -2
  210. package/src/schemas/link.ts +4 -1
  211. package/src/schemas/sections.ts +23 -1
  212. package/src/storage/index.ts +1 -1
  213. package/src/storage/types.ts +13 -0
  214. package/src/types/database.ts +0 -3
  215. package/dist/chunk-5THS4K5D.js +0 -123
  216. package/dist/chunk-ZSPCE6MZ.js +0 -70
package/src/lib/dexie.ts CHANGED
@@ -25,9 +25,6 @@ interface SiteIndexRow {
25
25
 
26
26
  interface MetaRow {
27
27
  key: string;
28
- lastSavedSha: string | null;
29
- mainSha: string | null;
30
- lastSavedAt: string | null;
31
28
  siteId: string;
32
29
  }
33
30
 
@@ -63,6 +60,10 @@ interface ContentCacheRow {
63
60
  // never as "nothing here".
64
61
  mediaManifest?: MediaManifest | null;
65
62
  documentManifest?: DocumentManifest | null;
63
+ // Section files the loader could not read at this sha. Optional for rows
64
+ // written by older packages. Without it a cache-hit load dropped the warning
65
+ // banner entirely, so damaged sections were invisible until a fresh fetch.
66
+ unparseableSectionIds?: string[];
66
67
  }
67
68
 
68
69
  interface PendingMediaRow {
@@ -108,8 +109,13 @@ class EditorDatabase extends Dexie {
108
109
  preservedEdits!: Dexie.Table<PreservedEditRow, string>;
109
110
  pendingDocuments!: Dexie.Table<PendingDocumentRow, string>;
110
111
 
112
+ /** The site this connection was opened for. Authoritative when the `meta` row
113
+ * is missing — see restoreLocalChanges. */
114
+ readonly siteId: string;
115
+
111
116
  constructor(siteId: string) {
112
117
  super(`brand-portal-${siteId}`);
118
+ this.siteId = siteId;
113
119
  this.version(1).stores({
114
120
  sections: "sectionId",
115
121
  siteIndex: "key",
@@ -257,6 +263,12 @@ function getDb(): EditorDatabase {
257
263
  }
258
264
 
259
265
  export function initEditorStore(siteId: string): void {
266
+ // Re-initialising for the site we already hold is a no-op: replacing the
267
+ // instance would leave the old connection open, and an open connection on the
268
+ // previous version is exactly what makes the next schema bump fire `blocked`
269
+ // and hang the upgrade forever.
270
+ if (db?.name === `brand-portal-${siteId}`) return;
271
+ db?.close();
260
272
  db = new EditorDatabase(siteId);
261
273
  }
262
274
 
@@ -293,7 +305,10 @@ export async function restoreLocalChanges(): Promise<{
293
305
 
294
306
  if (indexRow) {
295
307
  const metaRow = await getDb().meta.get("current");
296
- const siteId = metaRow?.siteId ?? "";
308
+ // Fall back to the connection's own site, never to "". An empty siteId is
309
+ // not a site — it rides the restored index into the editor and then into the
310
+ // next save's payload, where nothing downstream can tell it from a real one.
311
+ const siteId = metaRow?.siteId || getDb().siteId;
297
312
  return {
298
313
  sections,
299
314
  siteIndex: { siteId, pages: indexRow.pages, sections: indexRow.sections, nav: indexRow.nav },
@@ -307,13 +322,17 @@ export async function restoreLocalChanges(): Promise<{
307
322
 
308
323
  export async function discardLocalChanges(): Promise<void> {
309
324
  const database = getDb();
310
- await database.transaction("rw", [database.sections, database.siteIndex, database.meta, database.siteConfig, database.pendingMedia, database.pendingMediaDeletions], async () => {
325
+ await database.transaction("rw", [database.sections, database.siteIndex, database.meta, database.siteConfig, database.pendingMedia, database.pendingMediaDeletions, database.pendingDocuments], async () => {
311
326
  await database.sections.clear();
312
327
  await database.siteIndex.clear();
313
328
  await database.meta.clear();
314
329
  await database.siteConfig.clear();
315
330
  await database.pendingMedia.clear();
316
331
  await database.pendingMediaDeletions.clear();
332
+ // Without this a staged document survives discard AND history-restore, and
333
+ // hydratePendingDocuments re-stages it on the next mount — the next save
334
+ // then commits a document the user explicitly discarded.
335
+ await database.pendingDocuments.clear();
317
336
  });
318
337
  }
319
338
 
@@ -329,13 +348,7 @@ export async function persistSiteIndex(index: SiteIndex, deletedSections: string
329
348
  deletedSections,
330
349
  updatedAt: now,
331
350
  });
332
- await database.meta.put({
333
- key: "current",
334
- lastSavedSha: null,
335
- mainSha: null,
336
- lastSavedAt: null,
337
- siteId: index.siteId,
338
- });
351
+ await database.meta.put({ key: "current", siteId: index.siteId });
339
352
  });
340
353
  }
341
354
 
@@ -403,12 +416,14 @@ export async function discardSavedChanges(
403
416
  "rw",
404
417
  [database.sections, database.siteIndex, database.meta, database.siteConfig, database.contentCache],
405
418
  async () => {
406
- for (const { sectionId, updatedAt } of savedSections) {
407
- const row = await database.sections.get(sectionId);
408
- if (row && row.updatedAt === updatedAt) {
409
- await database.sections.delete(sectionId);
410
- }
411
- }
419
+ // One bulkGet, not a get per section: these are serial round-trips inside
420
+ // an open write transaction, so a 200-section save held the sections,
421
+ // index, meta, config and contentCache tables locked for 200 of them.
422
+ const rows = await database.sections.bulkGet(savedSections.map((s) => s.sectionId));
423
+ const untouched = savedSections
424
+ .filter((s, i) => rows[i]?.updatedAt === s.updatedAt)
425
+ .map((s) => s.sectionId);
426
+ if (untouched.length > 0) await database.sections.bulkDelete(untouched);
412
427
  await database.siteIndex.clear();
413
428
  await database.meta.clear();
414
429
  await database.siteConfig.clear();
@@ -458,13 +473,7 @@ export async function persistAll(
458
473
  deletedSections: deletedSectionIds ?? [],
459
474
  updatedAt: now,
460
475
  });
461
- await database.meta.put({
462
- key: "current",
463
- lastSavedSha: null,
464
- mainSha: null,
465
- lastSavedAt: null,
466
- siteId: siteIndex.siteId,
467
- });
476
+ await database.meta.put({ key: "current", siteId: siteIndex.siteId });
468
477
  }
469
478
 
470
479
  if (siteConfig) {
@@ -478,29 +487,6 @@ export async function persistAll(
478
487
  );
479
488
  }
480
489
 
481
- export async function updateBranchShas(savedSha: string | null, mainSha: string | null): Promise<void> {
482
- const database = getDb();
483
- const existing = await database.meta.get("current");
484
- if (!existing) return;
485
- await database.meta.put({
486
- ...existing,
487
- lastSavedSha: savedSha,
488
- mainSha,
489
- lastSavedAt: new Date().toISOString(),
490
- });
491
- }
492
-
493
- export async function getBranchShas(): Promise<{ savedSha: string | null; mainSha: string | null } | null> {
494
- const row = await getDb().meta.get("current");
495
- if (!row) return null;
496
- return { savedSha: row.lastSavedSha, mainSha: row.mainSha ?? null };
497
- }
498
-
499
- export async function getDeletedSections(): Promise<string[]> {
500
- const row = await getDb().siteIndex.get("current");
501
- return row?.deletedSections ?? [];
502
- }
503
-
504
490
  /**
505
491
  * Write the content snapshot for one commit.
506
492
  *
@@ -522,6 +508,7 @@ export async function cacheContent(
522
508
  savedBranchSha?: string | null;
523
509
  changedSectionIds?: string[];
524
510
  mainIndex?: SiteIndex | null;
511
+ unparseableSectionIds?: string[];
525
512
  },
526
513
  ): Promise<void> {
527
514
  const now = new Date().toISOString();
@@ -537,6 +524,7 @@ export async function cacheContent(
537
524
  mainIndex: diff?.mainIndex ?? null,
538
525
  mediaManifest: manifests.media,
539
526
  documentManifest: manifests.documents,
527
+ unparseableSectionIds: diff?.unparseableSectionIds ?? [],
540
528
  });
541
529
  }
542
530
 
@@ -555,6 +543,7 @@ export async function getCachedContent(): Promise<{
555
543
  * value and is not null. */
556
544
  mediaManifest: MediaManifest | null;
557
545
  documentManifest: DocumentManifest | null;
546
+ unparseableSectionIds: string[];
558
547
  } | null> {
559
548
  const row = await getDb().contentCache.get("current");
560
549
  if (!row) return null;
@@ -570,6 +559,7 @@ export async function getCachedContent(): Promise<{
570
559
  mainIndex: row.mainIndex ?? row.index,
571
560
  mediaManifest: row.mediaManifest ?? null,
572
561
  documentManifest: row.documentManifest ?? null,
562
+ unparseableSectionIds: row.unparseableSectionIds ?? [],
573
563
  };
574
564
  }
575
565
 
@@ -648,13 +638,20 @@ export async function clearPendingMediaByIds(
648
638
 
649
639
  /** Snapshot a local edit that is about to be superseded (takeover or conflict
650
640
  * resolution) so it is recoverable rather than silently destroyed. */
641
+ let preservedEditSeq = 0;
642
+
651
643
  export async function preserveEdit(
652
644
  sectionId: string,
653
645
  content: SectionContent,
654
646
  reason: "takeover" | "conflict",
655
647
  ): Promise<string> {
656
648
  const preservedAt = new Date().toISOString();
657
- const id = `${sectionId}:${preservedAt}`;
649
+ // A monotonic suffix, because the ISO timestamp only has millisecond
650
+ // resolution: resolving the same section twice inside one millisecond (a
651
+ // takeover immediately followed by a conflict, or two rapid recoveries) put
652
+ // both under the same key, and the `put` destroyed the edit it was called to
653
+ // rescue.
654
+ const id = `${sectionId}:${preservedAt}:${preservedEditSeq++}`;
658
655
  await getDb().preservedEdits.put({ id, sectionId, content, reason, preservedAt });
659
656
  return id;
660
657
  }
@@ -267,11 +267,31 @@ export function applyIndexOps(
267
267
  ...base.data,
268
268
  pages: base.data.pages.map((p) => ({ ...p, order: [...p.order] })),
269
269
  sections: Object.fromEntries(Object.entries(base.data.sections).map(([k, v]) => [k, { ...v }])),
270
- ...(Array.isArray(rawNav) ? { nav: rawNav as NavEntry[] } : {}),
270
+ // COPIED, not aliased. Taking the caller's array by reference meant any nav
271
+ // op that spliced in place would mutate the caller's index — the one thing
272
+ // this function's own comment promises it never does.
273
+ ...(Array.isArray(rawNav)
274
+ ? {
275
+ nav: (rawNav as NavEntry[]).map((entry) =>
276
+ entry && typeof entry === "object" && "pageIds" in entry
277
+ ? { ...entry, pageIds: [...(entry as { pageIds: string[] }).pageIds] }
278
+ : { ...entry },
279
+ ),
280
+ }
281
+ : {}),
271
282
  };
272
283
 
273
284
  const removed = new Set<string>();
274
285
  const warnings: string[] = [];
286
+ // Audience advisories are RECORDED here during the loop and decided after it,
287
+ // against the finished index. Deciding them at op time made them wrong in both
288
+ // directions whenever a later op in the same batch moved the section, removed
289
+ // it, or changed the page's access — insert-then-widen warned about nothing,
290
+ // and widen-then-insert stayed silent about a genuine stranding.
291
+ const advisories: (
292
+ | { kind: "section"; at: string; sectionId: string; pageId: string }
293
+ | { kind: "pageAccess"; at: string; pageId: string; dropped: string[] }
294
+ )[] = [];
275
295
  const fail = (error: string): ApplyIndexOpsResult => ({ ok: false, error });
276
296
  // Whether any op in this batch changed the navigation sidecar. Section-level
277
297
  // ops must leave `nav` OFF the result: content-ops writes nav.json only when
@@ -328,12 +348,7 @@ export function applyIndexOps(
328
348
  // Same advisory as `move`: an inserted section whose audiences the
329
349
  // destination page does not allow is invisible to every audienced
330
350
  // viewer, and saying nothing would make that look like a successful save.
331
- const stranded = page.access.length > 0 ? meta.access.filter((a) => !page.access.includes(a)) : [];
332
- if (stranded.length > 0) {
333
- warnings.push(
334
- `${at}: section "${s.id}" carries audience(s) ${fmt(stranded)} that page "${op.pageId}" does not allow, so no audienced viewer will see it — clear it with setMeta, or widen the page's access.`,
335
- );
336
- }
351
+ advisories.push({ kind: "section", at, sectionId: s.id, pageId: op.pageId });
337
352
  metas.push([s.id, meta]);
338
353
  }
339
354
  page.order.splice(position, 0, ...metas.map(([id]) => id));
@@ -366,12 +381,7 @@ export function applyIndexOps(
366
381
  // change who can see content without saying so. "no audienced viewer",
367
382
  // not "everyone" — audiencePasses returns true for a viewer with no
368
383
  // audience at all (lib/pages.ts).
369
- const stranded = dest.access.length > 0 ? meta.access.filter((a) => !dest.access.includes(a)) : [];
370
- if (stranded.length > 0) {
371
- warnings.push(
372
- `${at}: section "${op.id}" carries audience(s) ${fmt(stranded)} that page "${op.pageId}" does not allow, so no audienced viewer will see it — clear it with setMeta, or widen the page's access.`,
373
- );
374
- }
384
+ advisories.push({ kind: "section", at, sectionId: op.id, pageId: op.pageId });
375
385
  continue;
376
386
  }
377
387
 
@@ -534,6 +544,16 @@ export function applyIndexOps(
534
544
  if (op.slug !== undefined && (RESERVED_SLUGS as readonly string[]).includes(op.slug)) {
535
545
  return fail(`${at}: slug "${op.slug}" is reserved by a platform route and would shadow this page. Pick another slug.`);
536
546
  }
547
+ // Shape and uniqueness inline, exactly as addPage checks them. Leaving these
548
+ // to the final IndexSchema re-validation produced a message carrying none of
549
+ // the `indexOps[i]` prefix every other failure has, so an agent could not
550
+ // tell which op in a batch was rejected.
551
+ if (op.slug !== undefined && !SLUG_RE.test(op.slug)) {
552
+ return fail(`${at}: slug "${op.slug}" is not a valid URL slug — use lowercase words joined by dashes (e.g. "brand-guide").`);
553
+ }
554
+ if (op.slug !== undefined && index.pages.some((p) => p.id !== op.pageId && p.slug === op.slug)) {
555
+ return fail(`${at}: slug "${op.slug}" is already used by another page. Slugs must be unique.`);
556
+ }
537
557
  if (op.title !== undefined) page.title = op.title;
538
558
  if (op.slug !== undefined) page.slug = op.slug;
539
559
  if (op.showInNav !== undefined) page.showInNav = op.showInNav;
@@ -541,14 +561,37 @@ export function applyIndexOps(
541
561
  if (op.access !== undefined) {
542
562
  const dropped = page.access.filter((a) => !op.access!.includes(a));
543
563
  page.access = op.access;
544
- if (dropped.length > 0 && op.access.length > 0) {
545
- const stranded = page.order.filter((id) => index.sections[id]?.access.some((a) => dropped.includes(a)));
546
- if (stranded.length > 0) {
547
- warnings.push(
548
- `${at}: section(s) ${fmt(stranded)} still carry audience(s) ${fmt(dropped)} that page "${op.pageId}" no longer allows, so no audienced viewer will see them — clear them with setMeta.`,
549
- );
550
- }
564
+ if (dropped.length > 0) advisories.push({ kind: "pageAccess", at, pageId: op.pageId, dropped });
565
+ }
566
+ }
567
+
568
+ // Advisories, decided against the finished index (see the declaration above).
569
+ for (const advisory of advisories) {
570
+ const page = index.pages.find((p) => p.id === advisory.pageId);
571
+ if (!page || page.access.length === 0) continue;
572
+
573
+ if (advisory.kind === "section") {
574
+ const meta = index.sections[advisory.sectionId];
575
+ // A later op removed the section, or moved it off this page — either way
576
+ // the stranding this op would have caused no longer exists.
577
+ if (!meta || !page.order.includes(advisory.sectionId)) continue;
578
+ const stranded = meta.access.filter((a) => !page.access.includes(a));
579
+ if (stranded.length > 0) {
580
+ warnings.push(
581
+ `${advisory.at}: section "${advisory.sectionId}" carries audience(s) ${fmt(stranded)} that page "${advisory.pageId}" does not allow, so no audienced viewer will see it — clear it with setMeta, or widen the page's access.`,
582
+ );
551
583
  }
584
+ continue;
585
+ }
586
+
587
+ // A later setPageMeta may have put one of the dropped audiences back.
588
+ const dropped = advisory.dropped.filter((a) => !page.access.includes(a));
589
+ if (dropped.length === 0) continue;
590
+ const stranded = page.order.filter((id) => index.sections[id]?.access.some((a) => dropped.includes(a)));
591
+ if (stranded.length > 0) {
592
+ warnings.push(
593
+ `${advisory.at}: section(s) ${fmt(stranded)} still carry audience(s) ${fmt(dropped)} that page "${advisory.pageId}" no longer allows, so no audienced viewer will see them — clear them with setMeta.`,
594
+ );
552
595
  }
553
596
  }
554
597
 
package/src/lib/loader.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getSectionSchema, type Section } from "../schemas/sections";
1
+ import { getSectionSchemaForType, type Section } from "../schemas/sections";
2
2
  import { IndexSchema, type SiteIndex, type SectionMeta } from "../schemas/site-config";
3
3
  import { getAllSchemas } from "./registry";
4
4
  import { blockDepth, MAX_BLOCK_DEPTH } from "../schemas/block";
@@ -62,8 +62,12 @@ export function mergeSiteContent(
62
62
  ): SiteContent {
63
63
  const sections: LoadedSection[] = [];
64
64
 
65
+ // Validation is per-TYPE, not against a union of every registered schema.
66
+ // The union validates members in order and fully validates each miss, so a
67
+ // 200-section site with ~20 types paid O(N x M) object validations on every
68
+ // SSR load. Every section schema pins `type: z.literal(...)`, so selecting by
69
+ // type picks exactly the member the union would have matched.
65
70
  const canValidate = getAllSchemas().length >= 2;
66
- const schema = canValidate ? getSectionSchema() : null;
67
71
 
68
72
  const orderedIds = index.pages.flatMap((p) => p.order);
69
73
  for (const id of orderedIds) {
@@ -86,11 +90,15 @@ export function mergeSiteContent(
86
90
  ? { ...baseMeta, type: upgradedType }
87
91
  : baseMeta;
88
92
 
89
- if (canValidate && schema) {
90
- const result = schema.safeParse(raw);
91
- if (!result.success) {
92
- const type = (raw as Record<string, unknown>).type ?? "unknown";
93
- console.warn(`Skipping section "${id}" (type: ${type}): invalid schema`);
93
+ if (canValidate) {
94
+ const type = (raw as Record<string, unknown>)?.type;
95
+ // An unregistered type has no member in the union either, so this is the
96
+ // same outcome the whole-union parse produced — just without validating
97
+ // against every other type first.
98
+ const schema = typeof type === "string" ? getSectionSchemaForType(type) : null;
99
+ const result = schema ? schema.safeParse(raw) : null;
100
+ if (!result?.success) {
101
+ console.warn(`Skipping section "${id}" (type: ${type ?? "unknown"}): invalid schema`);
94
102
  continue;
95
103
  }
96
104
  if (blockDepth(result.data) > MAX_BLOCK_DEPTH) {
@@ -146,12 +154,33 @@ export async function loadSiteContent(contentDir: string): Promise<SiteContent>
146
154
 
147
155
  // nav.json is an optional sidecar — absent (every portal predating page
148
156
  // groups) or unreadable must degrade to a flat nav, never fail the load.
149
- let navFile: unknown;
157
+ //
158
+ // ABSENT and UNREADABLE are not the same answer, though: absent is the normal
159
+ // case and safe to overwrite, while a present-but-unparseable sidecar holds
160
+ // groups this load cannot see. Collapsing both to `undefined` reported
161
+ // navDegraded: false, so the next nav-touching save wholesale-wrote a flat
162
+ // nav.json over a file that was still hand-recoverable. Mirrors the
163
+ // GitHub-backed loader's navRaw/navFile split in @drawnagency/core.
164
+ let navRaw: string | undefined;
165
+ let readFailed = false;
150
166
  try {
151
- navFile = JSON.parse(await fs.readFile(path.join(contentDir, "nav.json"), "utf-8"));
152
- } catch {
153
- navFile = undefined;
167
+ navRaw = await fs.readFile(path.join(contentDir, "nav.json"), "utf-8");
168
+ } catch (err) {
169
+ // Only a genuinely missing file is "absent". A permissions/IO error leaves
170
+ // the sidecar's contents unknown, which must not license overwriting it.
171
+ readFailed = (err as { code?: string })?.code !== "ENOENT";
154
172
  }
155
173
 
156
- return mergeSiteContent(index, sectionFiles, navFile);
174
+ let navFile: unknown;
175
+ let parseFailed = false;
176
+ if (navRaw !== undefined) {
177
+ try {
178
+ navFile = JSON.parse(navRaw);
179
+ } catch {
180
+ parseFailed = true;
181
+ }
182
+ }
183
+
184
+ const content = mergeSiteContent(index, sectionFiles, navFile);
185
+ return readFailed || parseFailed ? { ...content, navDegraded: true } : content;
157
186
  }
@@ -195,12 +195,38 @@ export function healMediaManifest(manifest: MediaManifest): MediaManifest {
195
195
  *
196
196
  * Images union, ours winning, so local alt/folder-move edits survive; a pending
197
197
  * local deletion isn't removed from the manifest until save, so the union can't
198
- * resurrect one. Folders stay ours verbatim — deleting a folder removes it from
199
- * our map with no wire signal, so unioning would undo the delete. Mirrors the
200
- * server-side merge in core's content-ops.
198
+ * resurrect one. Mirrors the server-side merge in core's content-ops.
199
+ *
200
+ * Folders union too, minus the ones we deleted — which is why `base` (the
201
+ * manifest we diverged from, i.e. the last one we fetched) is needed: a folder
202
+ * deletion just removes the entry from our map, so without a baseline "we
203
+ * deleted it" and "they created it" are the same shape. Keeping folders ours
204
+ * verbatim, as this did, silently dropped a concurrent writer's new folders and
205
+ * then let `healMediaManifest` strip the now-dangling `folderId` off their
206
+ * images, flattening their whole organisation to root on our next save.
207
+ *
208
+ * Without `base` it degrades to the old folders-stay-ours behaviour.
201
209
  */
202
- export function rebaseMediaManifest(local: MediaManifest, remote: MediaManifest): MediaManifest {
203
- return { ...local, images: { ...remote.images, ...local.images } };
210
+ export function rebaseMediaManifest(
211
+ local: MediaManifest,
212
+ remote: MediaManifest,
213
+ base?: MediaManifest,
214
+ ): MediaManifest {
215
+ const images = { ...remote.images, ...local.images };
216
+ if (!base) return { ...local, images };
217
+
218
+ const deletedLocally = new Set(
219
+ Object.keys(base.folders).filter((id) => !(id in local.folders)),
220
+ );
221
+ const folders: Record<string, MediaFolder> = {};
222
+ for (const [id, folder] of Object.entries(remote.folders)) {
223
+ if (!deletedLocally.has(id)) folders[id] = folder;
224
+ }
225
+ // Ours last: a local rename or move of a folder we both have wins, matching
226
+ // the image rule above.
227
+ for (const [id, folder] of Object.entries(local.folders)) folders[id] = folder;
228
+
229
+ return { ...local, images, folders };
204
230
  }
205
231
 
206
232
  /**
@@ -43,7 +43,27 @@ function upgradeContainerChildren(section: Obj): unknown {
43
43
  return up;
44
44
  });
45
45
  if (!changed) return section;
46
- return { ...section, content: { ...content, children: mapped } };
46
+ // Normalize the container's OWN layout keys while we are rewriting it. A
47
+ // legacy container that never carried them emitted a content object the
48
+ // container schema then defaulted, so `transform(raw)` no longer deep-equalled
49
+ // `parse(transform(raw))` — the convergence contract this file is built on.
50
+ return {
51
+ ...section,
52
+ content: {
53
+ ...content,
54
+ layout: content?.layout ?? "grid",
55
+ columns: normalizeColumns(content?.columns),
56
+ equalizeRowHeight: content?.equalizeRowHeight ?? false,
57
+ children: mapped,
58
+ },
59
+ };
60
+ }
61
+
62
+ /** The container schema's `columns` default. Emitting `undefined` for a legacy
63
+ * grid with no columns key parsed to 1 anyway — but only AFTER the parse, which
64
+ * broke convergence. */
65
+ function normalizeColumns(columns: unknown): number {
66
+ return typeof columns === "number" && Number.isFinite(columns) ? columns : 1;
47
67
  }
48
68
 
49
69
  /** Carry only known BaseMediaRef (+ video) fields; drops render artifacts (src/srcset/alt) and any stray keys. */
@@ -98,7 +118,7 @@ function upgradeGrid(section: Obj): Obj {
98
118
  id,
99
119
  type: "container",
100
120
  content: {
101
- columns: content.columns,
121
+ columns: normalizeColumns(content.columns),
102
122
  layout: "grid",
103
123
  equalizeRowHeight: false,
104
124
  children: media.map((item, i) => mapRefItem(item, id, i)),
@@ -40,16 +40,30 @@ export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
40
40
  }
41
41
 
42
42
  if (entry?.kind !== "group") continue;
43
+ // `id` is the one group key with no schema default, so a group missing it
44
+ // (or carrying a non-string) cannot be repaired — spreading it through left
45
+ // an entry that NavFileSchema rejects, which is a nav the writer refuses,
46
+ // and "total" would be a lie. Dropping it spills its members to the tail
47
+ // append below: a group lost, never a page.
48
+ if (typeof entry.id !== "string" || entry.id === "") continue;
43
49
  if (seenGroupIds.has(entry.id)) continue;
44
50
  seenGroupIds.add(entry.id);
45
51
 
46
52
  const claimed = Array.isArray(entry.pageIds) ? entry.pageIds : [];
47
53
  const pageIds = claimed.filter((id) => {
48
- if (!validIds.has(id) || placed.has(id)) return false;
54
+ if (typeof id !== "string" || !validIds.has(id) || placed.has(id)) return false;
49
55
  placed.add(id);
50
56
  return true;
51
57
  });
52
- const group: NavGroupEntry = { ...entry, pageIds };
58
+ // Rebuilt key by key rather than spread: the other two keys default in the
59
+ // schema, so normalizing them here is what makes every output parseable.
60
+ const group: NavGroupEntry = {
61
+ kind: "group",
62
+ id: entry.id,
63
+ label: typeof entry.label === "string" ? entry.label : "",
64
+ startCollapsed: entry.startCollapsed === true,
65
+ pageIds,
66
+ };
53
67
  result.push(group);
54
68
  }
55
69
 
package/src/lib/nav.ts CHANGED
@@ -51,6 +51,10 @@ export function generateNavLinks(
51
51
  if (role === "h1") {
52
52
  if (content.excludeFromNav) {
53
53
  currentParent = null;
54
+ // Clear the child cursor too: leaving it pointing into the PREVIOUS
55
+ // h1's subtree made an h3 following an excluded h1 nest under that
56
+ // earlier branch instead of being dropped with its excluded parent.
57
+ currentChild = null;
54
58
  continue;
55
59
  }
56
60
  currentParent = {
@@ -62,7 +66,12 @@ export function generateNavLinks(
62
66
  currentChild = null;
63
67
  nav.push(currentParent);
64
68
  } else if (role === "h2") {
65
- if (content.excludeFromNav) continue;
69
+ // Same cursor reset as the excluded h1 above — an h3 under an excluded
70
+ // h2 belongs nowhere, not under the preceding h2.
71
+ if (content.excludeFromNav) {
72
+ currentChild = null;
73
+ continue;
74
+ }
66
75
  if (!currentParent) continue;
67
76
  currentChild = {
68
77
  href: `#${toSectionId(content.heading)}`,
package/src/lib/pages.ts CHANGED
@@ -71,8 +71,14 @@ export function setHomePage(index: SiteIndex, pageId: string): SiteIndex {
71
71
  const current = homePage(index);
72
72
  if (current.id === pageId) return index;
73
73
  if (!pageById(index, pageId)) return index;
74
- const existingSlugs = index.pages.map((p) => p.slug);
75
- const demotedSlug = uniquePageSlug(slugifyPageSlug(current.title) || "home", existingSlugs.filter((s) => s !== ""));
74
+ // Neither side of the swap counts against the other: the promoted page's slug
75
+ // is about to become "" and the demoted one's is about to be replaced, so
76
+ // leaving them in gave the demoted page a "-2" suffix it did not need.
77
+ const takenSlugs = index.pages
78
+ .filter((p) => p.id !== pageId && p.id !== current.id)
79
+ .map((p) => p.slug)
80
+ .filter((s) => s !== "");
81
+ const demotedSlug = uniquePageSlug(slugifyPageSlug(current.title) || "home", takenSlugs);
76
82
  return mapPages(index, (p) => {
77
83
  if (p.id === pageId) return { ...p, isHome: true, slug: "" };
78
84
  if (p.id === current.id) return { ...p, isHome: false, slug: demotedSlug };
@@ -167,10 +173,15 @@ export function addSectionToPage(
167
173
  meta: SectionMeta,
168
174
  insertIndex: number,
169
175
  ): SiteIndex {
176
+ // Strip any existing occurrence first. page.order and sections{} must
177
+ // partition exactly, and re-adding an id that already sits in some page's
178
+ // order produced a duplicate that IndexSchema rejects at save time — far from
179
+ // the call that caused it.
170
180
  const pages = index.pages.map((p) => {
171
- if (p.id !== pageId) return p;
172
- const order = [...p.order];
173
- order.splice(insertIndex, 0, sectionId);
181
+ const without = p.order.includes(sectionId) ? p.order.filter((id) => id !== sectionId) : p.order;
182
+ if (p.id !== pageId) return without === p.order ? p : { ...p, order: without };
183
+ const order = [...without];
184
+ order.splice(Math.min(insertIndex, order.length), 0, sectionId);
174
185
  return { ...p, order };
175
186
  });
176
187
  return { ...index, pages, sections: { ...index.sections, [sectionId]: meta } };
@@ -239,6 +250,7 @@ export function reorderVisibleSectionInPage(
239
250
  const fromId = visibleIds[fromIndex];
240
251
  if (fromId === undefined) return index;
241
252
  const withoutFrom = visibleIds.filter((id) => id !== fromId);
253
+ let changed = false;
242
254
  const pages = index.pages.map((p) => {
243
255
  if (p.id !== pageId) return p;
244
256
  const fromPos = p.order.indexOf(fromId);
@@ -246,9 +258,15 @@ export function reorderVisibleSectionInPage(
246
258
  const order = [...p.order];
247
259
  order.splice(fromPos, 1);
248
260
  order.splice(visibleInsertPosition(order, withoutFrom, toIndex), 0, fromId);
261
+ // A drop back onto the section's own slot resolves to the position it
262
+ // already held. Handing back a fresh object anyway defeated the caller's
263
+ // reference-based no-op check (applyBlockMove), so an inert drop armed a
264
+ // dirty index and a save.
265
+ if (order.length === p.order.length && order.every((id, i) => id === p.order[i])) return p;
266
+ changed = true;
249
267
  return { ...p, order };
250
268
  });
251
- return { ...index, pages };
269
+ return changed ? { ...index, pages } : index;
252
270
  }
253
271
 
254
272
  export function moveSection(
@@ -260,7 +260,10 @@ export function createRegistry(): SectionRegistry {
260
260
  return {
261
261
  registerSection(def) {
262
262
  sections.set(def.type, def);
263
+ // Clear on re-registration without the field: a stale entry would keep
264
+ // telling the server-side sanitizer to treat a plain field as rich text.
263
265
  if (def.richTextFields) richText.set(def.type, def.richTextFields);
266
+ else richText.delete(def.type);
264
267
  },
265
268
  registerSchema(type, schema) {
266
269
  schemas.set(type, schema);
@@ -40,6 +40,15 @@ export function sanitizeHtml(html: string): string {
40
40
  return clean;
41
41
  }
42
42
 
43
+ /**
44
+ * True when `sanitizeHtml` will actually sanitize. On the server DOMPurify never
45
+ * loads and never will, so callers there are on their own (core's sanitize-server
46
+ * covers the SSR viewer path); this reports ready so SSR output is unchanged.
47
+ */
48
+ export function isSanitizerReady(): boolean {
49
+ return typeof window === "undefined" || purifier !== null;
50
+ }
51
+
43
52
  /**
44
53
  * Await this during component mount (e.g. useEffect) to guarantee
45
54
  * DOMPurify is loaded before `sanitizeHtml` is called.