@drawnagency/primitives 0.1.106 → 0.1.108

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/src/lib/dexie.ts CHANGED
@@ -50,19 +50,21 @@ interface ContentCacheRow {
50
50
  savedBranchSha?: string | null;
51
51
  changedSectionIds?: string[];
52
52
  mainIndex?: SiteIndex | null;
53
- // The document sidecar at this sha. Optional for rows written by older
54
- // packages; readers fall back to {} (same posture as the fetch path's
55
- // `data.documentManifest ?? {}`). Without this, a cache-hit editor load left
56
- // documentManifest null and the whole documents feature dead for the session.
53
+ // Both sidecars AT THIS SHA. They live HERE and nowhere else, and cacheContent
54
+ // takes them as a required argument, because every way of storing them apart
55
+ // from the sha drifted: the media manifest was a separate singleton row and the
56
+ // document manifest was an optional extra, and both were written only by the
57
+ // cache-miss load path — so every save/publish that re-primed this row advanced
58
+ // the sha and left them behind. A later cache hit then restored pre-upload
59
+ // media (freshly uploaded images rendered "No Image" in the editor while the
60
+ // published site was correct) and an empty document manifest (committed
61
+ // documents read "File missing"). Optional in the TYPE only because Dexie rows
62
+ // are not schema-enforced; readers treat absence as a MISS (getCachedContent),
63
+ // never as "nothing here".
64
+ mediaManifest?: MediaManifest | null;
57
65
  documentManifest?: DocumentManifest | null;
58
66
  }
59
67
 
60
- interface MediaManifestRow {
61
- key: string;
62
- manifest: MediaManifest;
63
- updatedAt: string;
64
- }
65
-
66
68
  interface PendingMediaRow {
67
69
  id: string;
68
70
  item: MediaItem;
@@ -101,7 +103,6 @@ class EditorDatabase extends Dexie {
101
103
  meta!: Dexie.Table<MetaRow, string>;
102
104
  siteConfig!: Dexie.Table<SiteConfigRow, string>;
103
105
  contentCache!: Dexie.Table<ContentCacheRow, string>;
104
- mediaManifest!: Dexie.Table<MediaManifestRow, string>;
105
106
  pendingMedia!: Dexie.Table<PendingMediaRow, string>;
106
107
  pendingMediaDeletions!: Dexie.Table<PendingMediaDeletionRow, string>;
107
108
  preservedEdits!: Dexie.Table<PreservedEditRow, string>;
@@ -223,6 +224,28 @@ class EditorDatabase extends Dexie {
223
224
  preservedEdits: "id, sectionId",
224
225
  pendingDocuments: "id",
225
226
  });
227
+ // v11: the media manifest moved onto the sha-keyed contentCache row and the
228
+ // standalone `mediaManifest` store is deleted. It was written by the
229
+ // cache-miss load path ONLY, so every save/publish that re-primed
230
+ // contentCache advanced the cached sha while leaving the manifest at its
231
+ // older value — and the next cache hit served that stale manifest, blanking
232
+ // every image uploaded since. Dropping the store makes the drift
233
+ // unrepresentable. contentCache is cleared because no pre-v11 row carries a
234
+ // manifest; the next load re-fetches and writes both together.
235
+ this.version(11).stores({
236
+ sections: "sectionId",
237
+ siteIndex: "key",
238
+ meta: "key",
239
+ siteConfig: "key",
240
+ contentCache: "key",
241
+ mediaManifest: null,
242
+ pendingMedia: "id",
243
+ pendingMediaDeletions: "id",
244
+ preservedEdits: "id, sectionId",
245
+ pendingDocuments: "id",
246
+ }).upgrade(async (tx) => {
247
+ await tx.table("contentCache").clear();
248
+ });
226
249
  }
227
250
  }
228
251
 
@@ -478,12 +501,28 @@ export async function getDeletedSections(): Promise<string[]> {
478
501
  return row?.deletedSections ?? [];
479
502
  }
480
503
 
504
+ /**
505
+ * Write the content snapshot for one commit.
506
+ *
507
+ * `manifests` is REQUIRED, and that is the whole point: both sidecars belong to
508
+ * this sha, so every writer that advances the row must restate them. They used to
509
+ * be optional (the media one lived in a separate store entirely), and every
510
+ * save/publish that re-primed this row left them behind at an older sha — a later
511
+ * cache hit then served pre-upload media ("No Image" on images the published site
512
+ * rendered fine) and an empty document manifest ("File missing" on committed
513
+ * documents). Passing them positionally makes forgetting a compile error.
514
+ */
481
515
  export async function cacheContent(
482
516
  sha: string,
483
517
  sections: LoadedSection[],
484
518
  index: SiteIndex,
485
519
  siteConfig: Record<string, unknown>,
486
- diff?: { savedBranchSha: string | null; changedSectionIds: string[]; mainIndex: SiteIndex | null; documentManifest?: DocumentManifest },
520
+ manifests: { media: MediaManifest; documents: DocumentManifest },
521
+ diff?: {
522
+ savedBranchSha?: string | null;
523
+ changedSectionIds?: string[];
524
+ mainIndex?: SiteIndex | null;
525
+ },
487
526
  ): Promise<void> {
488
527
  const now = new Date().toISOString();
489
528
  await getDb().contentCache.put({
@@ -496,7 +535,8 @@ export async function cacheContent(
496
535
  savedBranchSha: diff?.savedBranchSha ?? null,
497
536
  changedSectionIds: diff?.changedSectionIds ?? [],
498
537
  mainIndex: diff?.mainIndex ?? null,
499
- documentManifest: diff?.documentManifest ?? null,
538
+ mediaManifest: manifests.media,
539
+ documentManifest: manifests.documents,
500
540
  });
501
541
  }
502
542
 
@@ -508,7 +548,13 @@ export async function getCachedContent(): Promise<{
508
548
  savedBranchSha: string | null;
509
549
  changedSectionIds: string[];
510
550
  mainIndex: SiteIndex | null;
511
- documentManifest: DocumentManifest;
551
+ /** Both are null ONLY for a row written before the sidecars moved onto it.
552
+ * Callers must treat null as an unusable cache and re-fetch — substituting an
553
+ * empty manifest would blank every image ("No Image") or every committed
554
+ * document ("File missing") on the page. An EMPTY manifest is a legitimate
555
+ * value and is not null. */
556
+ mediaManifest: MediaManifest | null;
557
+ documentManifest: DocumentManifest | null;
512
558
  } | null> {
513
559
  const row = await getDb().contentCache.get("current");
514
560
  if (!row) return null;
@@ -522,10 +568,8 @@ export async function getCachedContent(): Promise<{
522
568
  // No stored mainIndex (e.g. legacy or post-publish cache) means "no draft",
523
569
  // so the published index equals the current index.
524
570
  mainIndex: row.mainIndex ?? row.index,
525
- // Legacy row (pre-documents package): {} matches the fetch path's posture.
526
- // The narrow stale edge — an old row at a sha that DOES hold a manifest —
527
- // heals on the next fresh load or save.
528
- documentManifest: row.documentManifest ?? {},
571
+ mediaManifest: row.mediaManifest ?? null,
572
+ documentManifest: row.documentManifest ?? null,
529
573
  };
530
574
  }
531
575
 
@@ -536,16 +580,6 @@ export async function clearContentCache(): Promise<void> {
536
580
  await getDb().contentCache.clear();
537
581
  }
538
582
 
539
- export async function persistMediaManifest(manifest: MediaManifest): Promise<void> {
540
- const now = new Date().toISOString();
541
- await getDb().mediaManifest.put({ key: "current", manifest, updatedAt: now });
542
- }
543
-
544
- export async function getMediaManifest(): Promise<MediaManifest | null> {
545
- const row = await getDb().mediaManifest.get("current");
546
- return row?.manifest ?? null;
547
- }
548
-
549
583
  export async function addPendingMediaItem(
550
584
  item: MediaItem,
551
585
  localUrls: Record<string, string> = {},
@@ -12,9 +12,43 @@ export interface InsertMenuOption {
12
12
  icon?: ReactNode;
13
13
  }
14
14
 
15
- /** Every non-spacer section type, followed by presets contributed by registered
16
- * sections. Presets appear only when their owning section is registered, so a
17
- * core-only build (no brand-guide sections) shows no brand-guide presets. */
15
+ /** Authored display order for the Add-section menu, most-reached-for first.
16
+ * `container` leads because the picker gives it its own full-width row above a
17
+ * divider. Anything absent — client-repo custom sections and their presets —
18
+ * sorts alphabetically after these. */
19
+ const INSERT_MENU_ORDER = [
20
+ "container",
21
+ "link_heading",
22
+ "sub_heading",
23
+ "sub_sub_heading",
24
+ "prose",
25
+ "media",
26
+ "video",
27
+ "button",
28
+ "colors",
29
+ "document",
30
+ "icon_list",
31
+ `${PRESET_PREFIX}do_dont_list`,
32
+ "dodont_media",
33
+ ];
34
+
35
+ /** Sort insert-menu entries into `INSERT_MENU_ORDER`, with unlisted entries
36
+ * (custom sections) alphabetical by label at the end. Pure; returns a copy. */
37
+ export function sortInsertMenuOptions<T extends InsertMenuOption>(options: T[]): T[] {
38
+ return [...options].sort((a, b) => {
39
+ const ai = INSERT_MENU_ORDER.indexOf(a.type);
40
+ const bi = INSERT_MENU_ORDER.indexOf(b.type);
41
+ if (ai !== -1 && bi !== -1) return ai - bi;
42
+ if (ai !== -1) return -1;
43
+ if (bi !== -1) return 1;
44
+ return a.label.localeCompare(b.label);
45
+ });
46
+ }
47
+
48
+ /** Every non-spacer section type, plus presets contributed by registered
49
+ * sections, in `INSERT_MENU_ORDER`. Presets appear only when their owning
50
+ * section is registered, so a core-only build (no brand-guide sections) shows
51
+ * no brand-guide presets. */
18
52
  export function buildInsertMenuOptions(sections: SectionDefinition[]): InsertMenuOption[] {
19
53
  const typeOptions = sections
20
54
  .filter((def) => def.type !== "spacer")
@@ -22,7 +56,7 @@ export function buildInsertMenuOptions(sections: SectionDefinition[]): InsertMen
22
56
  const presetOptions = sections
23
57
  .flatMap((def) => def.presets ?? [])
24
58
  .map((p) => ({ type: `${PRESET_PREFIX}${p.id}`, label: p.label, icon: p.icon }));
25
- return [...typeOptions, ...presetOptions];
59
+ return sortInsertMenuOptions([...typeOptions, ...presetOptions]);
26
60
  }
27
61
 
28
62
  /** Resolve an Add-menu selection to ready-to-insert content. `newId` mints ids for