@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.
- package/dist/auth/cookies.d.ts +8 -0
- package/dist/auth/cookies.d.ts.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/auth/types.d.ts +11 -4
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/{chunk-5IZ2KTMU.js → chunk-3GAG6ZXB.js} +55 -46
- package/dist/{chunk-PZJL2S3Z.js → chunk-3ZRQ4G5F.js} +1 -1
- package/dist/{chunk-FM4M4Q2W.js → chunk-4TZJZU2T.js} +23 -3
- package/dist/{chunk-LUAI5DWR.js → chunk-7MI3HCOF.js} +60 -23
- package/dist/{chunk-ZHHJVKU7.js → chunk-7WQJ57XG.js} +63 -204
- package/dist/{chunk-JIQBLG6Y.js → chunk-ALMEC3XN.js} +12 -13
- package/dist/{chunk-TZQPOR5A.js → chunk-DMCGN5AS.js} +10 -3
- package/dist/{chunk-D6TLUVOP.js → chunk-ELQ4C677.js} +48 -2
- package/dist/chunk-F2LYQPXV.js +77 -0
- package/dist/{chunk-5BM2Y3NJ.js → chunk-FRVO75DK.js} +14 -2
- package/dist/{chunk-VKAGMEKE.js → chunk-FTPETWXE.js} +1 -0
- package/dist/{chunk-3TO4RRHY.js → chunk-GPPVYLEI.js} +7 -0
- package/dist/{chunk-NLQ5F3RQ.js → chunk-HBULBDGP.js} +1 -1
- package/dist/{chunk-IPKZ5FU6.js → chunk-NXGIHSL5.js} +72 -55
- package/dist/{chunk-CPTFFLK2.js → chunk-OTDA6KWL.js} +10 -3
- package/dist/chunk-OUYB2GCW.js +194 -0
- package/dist/{chunk-72CG6AA2.js → chunk-RA5IOFPY.js} +10 -2
- package/dist/{chunk-GEENI3OC.js → chunk-SW6PCQQD.js} +4 -0
- package/dist/{chunk-BOZUEKWL.js → chunk-UVIYI4J6.js} +1 -1
- package/dist/{chunk-AB3XVXI3.js → chunk-VCCNKTFD.js} +37 -29
- package/dist/{chunk-XBYJQ5RS.js → chunk-XQMDWXD6.js} +21 -6
- package/dist/collab/rebase.d.ts +6 -0
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +12 -3
- package/dist/collab/recovery.d.ts +0 -20
- package/dist/collab/recovery.d.ts.map +1 -1
- package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
- package/dist/components/brandguide/ColorsView.d.ts.map +1 -1
- package/dist/components/brandguide/colors-shared.d.ts +10 -1
- package/dist/components/brandguide/colors-shared.d.ts.map +1 -1
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
- package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -1
- package/dist/components/docfile/doc-file-state.d.ts.map +1 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/NavGroupRow.d.ts +7 -2
- package/dist/components/editor/NavGroupRow.d.ts.map +1 -1
- package/dist/components/editor/PagesModal.d.ts.map +1 -1
- package/dist/components/editor/index.js +14 -13
- package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
- package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
- package/dist/components/primitives/index.d.ts +2 -0
- package/dist/components/primitives/index.d.ts.map +1 -1
- package/dist/components/primitives/index.js +22 -13
- package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
- package/dist/components/primitives/useEditablePlainText.d.ts.map +1 -1
- package/dist/components/sections/IconList/index.d.ts.map +1 -1
- package/dist/components/sections/Prose/Prose.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +10 -10
- package/dist/components/sections/register-schemas.js +16 -16
- package/dist/components/sections/register.js +16 -16
- package/dist/components/shared/FontPicker.d.ts.map +1 -1
- package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -1
- package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Popover.d.ts.map +1 -1
- package/dist/components/shell/AudienceAddForm.d.ts +1 -5
- package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
- package/dist/components/shell/AudienceRow.d.ts +2 -3
- package/dist/components/shell/AudienceRow.d.ts.map +1 -1
- package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
- package/dist/components/shell/view-branch.d.ts.map +1 -1
- package/dist/document/components/DocRail.d.ts.map +1 -1
- package/dist/document/components/DocViewer.d.ts.map +1 -1
- package/dist/document/components/measure.d.ts.map +1 -1
- package/dist/document/components/useDocPages.d.ts.map +1 -1
- package/dist/document/engine/blocks.d.ts.map +1 -1
- package/dist/document/engine/masonry.d.ts.map +1 -1
- package/dist/document/engine/paginate.d.ts.map +1 -1
- package/dist/document/engine/settings.d.ts +3 -0
- package/dist/document/engine/settings.d.ts.map +1 -1
- package/dist/document/index.js +79 -28
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/hooks/useDocumentUploads.d.ts +1 -0
- package/dist/hooks/useDocumentUploads.d.ts.map +1 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.d.ts +6 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +3 -2
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/hooks/useSanitizedHtml.d.ts +12 -0
- package/dist/hooks/useSanitizedHtml.d.ts.map +1 -0
- package/dist/index.js +20 -14
- package/dist/lib/block-move.d.ts.map +1 -1
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/dexie.d.ts +2 -8
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +21 -49
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +43 -23
- package/dist/lib/index.js +8 -8
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/media-folders.d.ts +12 -4
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +12 -2
- package/dist/lib/migrate-sections-transform.js +1 -1
- package/dist/lib/nav-tree.d.ts.map +1 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/registry.js +1 -1
- package/dist/lib/sanitize.d.ts +6 -0
- package/dist/lib/sanitize.d.ts.map +1 -1
- package/dist/lib/timestamp.d.ts.map +1 -1
- package/dist/media/index.js +4 -4
- package/dist/media/queue.d.ts.map +1 -1
- package/dist/media/resolve.d.ts.map +1 -1
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/media/videoPoster.d.ts.map +1 -1
- package/dist/schemas/audience.d.ts +5 -1
- package/dist/schemas/audience.d.ts.map +1 -1
- package/dist/schemas/audience.js +1 -1
- package/dist/schemas/index.js +9 -7
- package/dist/schemas/link.d.ts.map +1 -1
- package/dist/schemas/link.js +1 -1
- package/dist/schemas/sections.d.ts +12 -0
- package/dist/schemas/sections.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +3 -1
- package/dist/storage/types.d.ts +9 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/types/database.d.ts +0 -3
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/auth/cookies.ts +11 -0
- package/src/auth/index.ts +1 -1
- package/src/auth/types.ts +18 -4
- package/src/collab/rebase.ts +26 -1
- package/src/collab/recovery.ts +29 -3
- package/src/components/brandguide/ColorSwatchSettings.tsx +40 -13
- package/src/components/brandguide/ColorsEditable.tsx +7 -9
- package/src/components/brandguide/ColorsView.tsx +12 -11
- package/src/components/brandguide/colors-shared.ts +17 -1
- package/src/components/chat/ChatPanel.tsx +19 -0
- package/src/components/docfile/DocFilePane.tsx +11 -0
- package/src/components/docfile/PdfCanvasViewer.tsx +46 -5
- package/src/components/docfile/doc-file-state.ts +7 -0
- package/src/components/editor/IndicatorPill.tsx +5 -1
- package/src/components/editor/NavGroupRow.tsx +8 -3
- package/src/components/editor/PagesModal.tsx +10 -2
- package/src/components/primitives/EditableList.tsx +10 -2
- package/src/components/primitives/EditableRichText.tsx +4 -3
- package/src/components/primitives/LinkPopover.tsx +23 -1
- package/src/components/primitives/index.ts +5 -0
- package/src/components/primitives/useEditableCollection.ts +61 -41
- package/src/components/primitives/useEditablePlainText.ts +24 -3
- package/src/components/sections/IconList/index.tsx +8 -1
- package/src/components/sections/Prose/Prose.tsx +4 -2
- package/src/components/shared/FontPicker.tsx +16 -4
- package/src/components/shared/HistoryChangesModal.tsx +12 -1
- package/src/components/shared/HistoryPopover.tsx +18 -4
- package/src/components/shared/LinkField.tsx +1 -1
- package/src/components/shared/Navigation.tsx +20 -2
- package/src/components/shared/Popover.tsx +9 -5
- package/src/components/shell/AudienceAddForm.tsx +6 -26
- package/src/components/shell/AudienceRow.tsx +29 -39
- package/src/components/shell/BugReportFAB.tsx +20 -2
- package/src/components/shell/EditorShell.tsx +200 -50
- package/src/components/shell/MediaLibraryModal.tsx +52 -10
- package/src/components/shell/SiteSettingsUsers.tsx +3 -0
- package/src/components/shell/SiteSettingsViewerAccess.tsx +21 -16
- package/src/components/shell/view-branch.ts +4 -1
- package/src/document/components/DocRail.tsx +27 -2
- package/src/document/components/DocViewer.tsx +5 -1
- package/src/document/components/measure.tsx +22 -6
- package/src/document/components/useDocPages.ts +13 -3
- package/src/document/engine/blocks.ts +33 -4
- package/src/document/engine/masonry.ts +8 -2
- package/src/document/engine/paginate.ts +4 -1
- package/src/document/engine/settings.ts +40 -3
- package/src/hooks/useBuildStatus.ts +6 -0
- package/src/hooks/useDocumentUploads.ts +46 -3
- package/src/hooks/useEditorPersistence.ts +33 -19
- package/src/hooks/useEditorPublish.ts +83 -34
- package/src/hooks/useFocusTrap.ts +18 -0
- package/src/hooks/useMediaPipeline.ts +111 -38
- package/src/hooks/useSanitizedHtml.ts +29 -0
- package/src/lib/audience-secret.ts +1 -0
- package/src/lib/block-move.ts +13 -2
- package/src/lib/contrast.ts +14 -14
- package/src/lib/dexie.ts +46 -49
- package/src/lib/index-ops.ts +63 -20
- package/src/lib/loader.ts +41 -12
- package/src/lib/media-folders.ts +31 -5
- package/src/lib/migrate-sections-transform.ts +22 -2
- package/src/lib/nav-tree.ts +16 -2
- package/src/lib/nav.ts +10 -1
- package/src/lib/pages.ts +24 -6
- package/src/lib/registry.ts +3 -0
- package/src/lib/sanitize.ts +9 -0
- package/src/lib/timestamp.ts +5 -0
- package/src/media/queue.ts +4 -0
- package/src/media/resolve.ts +14 -1
- package/src/media/utils.ts +3 -1
- package/src/media/videoPoster.ts +14 -3
- package/src/schemas/audience.ts +10 -2
- package/src/schemas/link.ts +4 -1
- package/src/schemas/sections.ts +23 -1
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +13 -0
- package/src/types/database.ts +0 -3
- package/dist/chunk-5THS4K5D.js +0 -123
- 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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/index-ops.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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 {
|
|
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
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
} catch {
|
|
153
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/media-folders.ts
CHANGED
|
@@ -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.
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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(
|
|
203
|
-
|
|
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
|
-
|
|
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)),
|
package/src/lib/nav-tree.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
order
|
|
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(
|
package/src/lib/registry.ts
CHANGED
|
@@ -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);
|
package/src/lib/sanitize.ts
CHANGED
|
@@ -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.
|