@drawnagency/primitives 0.1.114 → 0.1.115

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 (103) hide show
  1. package/dist/{chunk-OAGJBCSH.js → chunk-4WQ36ZP7.js} +19 -3
  2. package/dist/{chunk-PTY2RG2K.js → chunk-67P34WNW.js} +75 -130
  3. package/dist/chunk-APIJPTFG.js +64 -0
  4. package/dist/chunk-CDBURDPX.js +90 -0
  5. package/dist/chunk-DFKTF3CH.js +0 -0
  6. package/dist/chunk-DJABADOO.js +131 -0
  7. package/dist/{chunk-IP2SEBAJ.js → chunk-DJOGPSRU.js} +31 -72
  8. package/dist/{chunk-N3G7DOU3.js → chunk-FHTKSELX.js} +6 -6
  9. package/dist/chunk-GNF6KFQ7.js +18 -0
  10. package/dist/{chunk-QHMKIODQ.js → chunk-HHNPSKPT.js} +2 -89
  11. package/dist/{chunk-PZPGJZRG.js → chunk-JXATNKLV.js} +5 -5
  12. package/dist/{chunk-PLANHXS7.js → chunk-NX7TFHA4.js} +9 -9
  13. package/dist/{chunk-PNSCDBU7.js → chunk-O37AV27P.js} +6 -49
  14. package/dist/{chunk-5KSAS4VY.js → chunk-RIITGTV3.js} +1 -1
  15. package/dist/chunk-S7QRHUWO.js +8 -0
  16. package/dist/{chunk-7SAAJVDE.js → chunk-T26HAKW3.js} +37 -35
  17. package/dist/chunk-VY3IFGE3.js +70 -0
  18. package/dist/{chunk-VUYCRBJK.js → chunk-WYOFQHKI.js} +6 -4
  19. package/dist/{chunk-Y5QECJX7.js → chunk-YV3YSZEB.js} +5 -5
  20. package/dist/collab/rebase.d.ts.map +1 -1
  21. package/dist/collab/rebase.js +15 -15
  22. package/dist/components/editor/index.js +13 -13
  23. package/dist/components/primitives/index.js +16 -11
  24. package/dist/components/sections/brand-guide.js +13 -8
  25. package/dist/components/sections/register-schemas.js +20 -15
  26. package/dist/components/sections/register.js +20 -15
  27. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  28. package/dist/deck-builder/index.js +9 -9
  29. package/dist/hooks/useEditorPublish.d.ts +4 -1
  30. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  31. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  32. package/dist/index.js +85 -56
  33. package/dist/lib/dexie.d.ts +7 -1
  34. package/dist/lib/dexie.d.ts.map +1 -1
  35. package/dist/lib/dexie.js +3 -2
  36. package/dist/lib/image-variant-plan.d.ts +42 -0
  37. package/dist/lib/image-variant-plan.d.ts.map +1 -0
  38. package/dist/lib/image-variant-plan.js +53 -0
  39. package/dist/lib/index-ops.d.ts.map +1 -1
  40. package/dist/lib/index-ops.js +20 -16
  41. package/dist/lib/index.d.ts +2 -1
  42. package/dist/lib/index.d.ts.map +1 -1
  43. package/dist/lib/index.js +43 -22
  44. package/dist/lib/loader.d.ts +16 -17
  45. package/dist/lib/loader.d.ts.map +1 -1
  46. package/dist/lib/media-ops.d.ts +73 -0
  47. package/dist/lib/media-ops.d.ts.map +1 -0
  48. package/dist/lib/media-ops.js +60 -0
  49. package/dist/lib/nav-tree.d.ts +6 -0
  50. package/dist/lib/nav-tree.d.ts.map +1 -1
  51. package/dist/lib/pages.d.ts +2 -1
  52. package/dist/lib/pages.d.ts.map +1 -1
  53. package/dist/lib/sidecar-diagnostics.d.ts +20 -0
  54. package/dist/lib/sidecar-diagnostics.d.ts.map +1 -0
  55. package/dist/lib/sidecars.d.ts +118 -0
  56. package/dist/lib/sidecars.d.ts.map +1 -0
  57. package/dist/lib/sidecars.js +23 -0
  58. package/dist/lib/slug.d.ts +2 -0
  59. package/dist/lib/slug.d.ts.map +1 -0
  60. package/dist/lib/storage-tier.d.ts +17 -0
  61. package/dist/lib/storage-tier.d.ts.map +1 -0
  62. package/dist/lib/storage-tier.js +29 -0
  63. package/dist/lib/tags.d.ts +6 -0
  64. package/dist/lib/tags.d.ts.map +1 -1
  65. package/dist/media/index.js +15 -15
  66. package/dist/media/queue.d.ts +2 -0
  67. package/dist/media/queue.d.ts.map +1 -1
  68. package/dist/media/worker.d.ts +1 -35
  69. package/dist/media/worker.d.ts.map +1 -1
  70. package/dist/schemas/index.js +18 -12
  71. package/dist/schemas/media.d.ts +4 -0
  72. package/dist/schemas/media.d.ts.map +1 -1
  73. package/dist/storage/index.js +2 -1
  74. package/dist/storage/memory.d.ts +21 -0
  75. package/dist/storage/memory.d.ts.map +1 -0
  76. package/dist/storage/memory.js +129 -0
  77. package/dist/storage/types.d.ts +9 -0
  78. package/dist/storage/types.d.ts.map +1 -1
  79. package/package.json +21 -1
  80. package/src/collab/rebase.ts +12 -16
  81. package/src/components/shell/EditorShell.tsx +7 -4
  82. package/src/hooks/useEditorPublish.ts +9 -4
  83. package/src/hooks/useMediaPipeline.ts +29 -23
  84. package/src/lib/dexie.ts +9 -1
  85. package/src/lib/image-variant-plan.ts +92 -0
  86. package/src/lib/index-ops.ts +9 -18
  87. package/src/lib/index.ts +15 -0
  88. package/src/lib/loader.ts +18 -77
  89. package/src/lib/media-ops.ts +112 -0
  90. package/src/lib/nav-tree.ts +33 -4
  91. package/src/lib/pages.ts +2 -3
  92. package/src/lib/sidecar-diagnostics.ts +29 -0
  93. package/src/lib/sidecars.ts +221 -0
  94. package/src/lib/slug.ts +6 -0
  95. package/src/lib/storage-tier.ts +38 -0
  96. package/src/lib/tags.ts +20 -3
  97. package/src/media/queue.ts +3 -0
  98. package/src/media/utils.ts +1 -1
  99. package/src/media/worker.ts +17 -8
  100. package/src/schemas/media.ts +7 -2
  101. package/src/storage/memory.ts +178 -0
  102. package/src/storage/types.ts +15 -2
  103. /package/dist/{chunk-45FA7Z55.js → chunk-UUUSJDIG.js} +0 -0
package/src/lib/dexie.ts CHANGED
@@ -511,6 +511,12 @@ export async function persistAll(
511
511
  * cache hit then served pre-upload media ("No Image" on images the published site
512
512
  * rendered fine) and an empty document manifest ("File missing" on committed
513
513
  * documents). Passing them positionally makes forgetting a compile error.
514
+ *
515
+ * `tagsDegraded` is required for the same reason. A degraded tags read caches
516
+ * as "not loaded" (the index without `tags`), so a later cache-hit load stays
517
+ * read-only for tags instead of trusting the partial list. Only the cold load
518
+ * used to strip it, so a conflict recovery, remote sync or save re-primed the
519
+ * row with the partial list and the next cache hit re-enabled tag editing.
514
520
  */
515
521
  export async function cacheContent(
516
522
  sha: string,
@@ -518,6 +524,7 @@ export async function cacheContent(
518
524
  index: SiteIndex,
519
525
  siteConfig: Record<string, unknown>,
520
526
  manifests: { media: MediaManifest; documents: DocumentManifest },
527
+ tagsDegraded: boolean,
521
528
  diff?: {
522
529
  savedBranchSha?: string | null;
523
530
  changedSectionIds?: string[];
@@ -526,11 +533,12 @@ export async function cacheContent(
526
533
  },
527
534
  ): Promise<void> {
528
535
  const now = new Date().toISOString();
536
+ const { tags: _partialTags, ...untaggedIndex } = index;
529
537
  await getDb().contentCache.put({
530
538
  key: "current",
531
539
  sha,
532
540
  sections,
533
- index,
541
+ index: tagsDegraded ? untaggedIndex : index,
534
542
  siteConfig,
535
543
  updatedAt: now,
536
544
  savedBranchSha: diff?.savedBranchSha ?? null,
@@ -0,0 +1,92 @@
1
+ import { sanitizeMediaName } from "../media/utils";
2
+ import type { MediaConfig, MediaItem, Variant } from "../schemas/media";
3
+
4
+ // The one place an uploaded image's identity, storage folder, variant widths,
5
+ // variant paths and manifest entry are decided. Two pixel adapters sit behind
6
+ // it: the editor's OffscreenCanvas worker (media/worker.ts) and the sharp path
7
+ // (authoring processImageBuffers, which the MCP connector calls). They used to
8
+ // build all of this separately and agree only because a comment said so; the
9
+ // site's media settings and dedup each drifted between them once. Pure and
10
+ // browser-safe — the worker imports it.
11
+
12
+ export interface MediaIdentity {
13
+ id: string;
14
+ hash: string;
15
+ originalName: string;
16
+ /** Storage dir under assets/images/. Never changes once committed. */
17
+ folder: string;
18
+ }
19
+
20
+ /** `hash` is the 16-hex content hash both sides compute (hashFileBuffer / hashBuffer). */
21
+ export function mediaIdentity(hash: string, fileName: string): MediaIdentity {
22
+ const originalName = sanitizeMediaName(fileName);
23
+ return { id: hash, hash, originalName, folder: `${originalName}-${hash}` };
24
+ }
25
+
26
+ /** Repo path of one stored file in a media folder: a variant width, or `poster`. */
27
+ export function imageVariantPath(folder: string, key: number | string): string {
28
+ return `assets/images/${folder}/${key}.webp`;
29
+ }
30
+
31
+ export interface ImageVariantPlan extends MediaIdentity {
32
+ width: number;
33
+ height: number;
34
+ /** WebP quality, 1-100. */
35
+ quality: number;
36
+ /** Ascending by width. */
37
+ variants: { width: number; path: string }[];
38
+ }
39
+
40
+ export function planImageVariants(input: {
41
+ hash: string;
42
+ fileName: string;
43
+ width: number;
44
+ height: number;
45
+ media: Pick<MediaConfig, "sizes" | "quality">;
46
+ }): ImageVariantPlan {
47
+ const identity = mediaIdentity(input.hash, input.fileName);
48
+ // Never upscale; an image narrower than every configured size still gets one
49
+ // variant, at its own width.
50
+ const widths = input.media.sizes.filter((s) => s <= input.width).sort((a, b) => a - b);
51
+ if (widths.length === 0) widths.push(input.width);
52
+ return {
53
+ ...identity,
54
+ width: input.width,
55
+ height: input.height,
56
+ quality: input.media.quality,
57
+ variants: widths.map((width) => ({ width, path: imageVariantPath(identity.folder, width) })),
58
+ };
59
+ }
60
+
61
+ export type ImageManifestEntry = Omit<MediaItem, "kind" | "storage" | "url" | "duration" | "folderId" | "hidden"> & {
62
+ kind: "image";
63
+ storage: "github";
64
+ };
65
+
66
+ /**
67
+ * `rendered` is what the adapter measured for each planned width (heights come
68
+ * from the encoder, not the plan). `size` is the adapter's byte count for the
69
+ * item: the editor records its largest variant, the sharp path the original's
70
+ * length — they differ today and this module does not decide that.
71
+ */
72
+ export function imageManifestEntry(plan: ImageVariantPlan, rendered: Variant[], size: number): ImageManifestEntry {
73
+ const variants = plan.variants.map(({ width }) => {
74
+ const out = rendered.find((r) => r.width === width);
75
+ if (!out) throw new Error(`[image-variant-plan] no rendered variant for planned width ${width}`);
76
+ return { width, height: out.height, size: out.size };
77
+ });
78
+ return {
79
+ id: plan.id,
80
+ hash: plan.hash,
81
+ kind: "image",
82
+ originalName: plan.originalName,
83
+ width: plan.width,
84
+ height: plan.height,
85
+ mimeType: "image/webp",
86
+ size,
87
+ folder: plan.folder,
88
+ variants,
89
+ alt: "",
90
+ storage: "github",
91
+ };
92
+ }
@@ -1,11 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { IndexSchema, PageStatusSchema, RESERVED_SLUGS, SLUG_RE, type SiteIndex, type SectionMeta } from "../schemas/site-config";
3
- import type { NavEntry, NavGroupEntry } from "../schemas/nav";
3
+ import type { NavGroupEntry } from "../schemas/nav";
4
4
  import type { NavLocation } from "./nav-tree";
5
5
  // Page and nav mutations are shared with the editor rather than reimplemented:
6
6
  // both surfaces must agree on what "delete a page" or "remove a group" means, and
7
7
  // a second implementation here is how they would drift.
8
8
  import { addPage as addPageToIndex, deletePage, slugifyPageSlug, uniquePageSlug } from "./pages";
9
+ import { liftSidecars } from "./sidecars";
9
10
  import {
10
11
  addGroup, deleteGroup, groupOfPage, moveNavEntry, navEntries, reconcileNav,
11
12
  renameGroup, setGroupStartCollapsed,
@@ -254,11 +255,12 @@ export function applyIndexOps(
254
255
  };
255
256
  }
256
257
 
257
- // IndexSchema strips `nav` (that strip is what keeps the sidecar out of
258
- // index.json), so the authored nav has to be lifted off the RAW input to be
259
- // available to the nav ops below. reconcileNav is total, so an unparseable
260
- // shape here degrades to a flat nav rather than failing.
261
- const rawNav = (rawIndex as { nav?: unknown } | null | undefined)?.nav;
258
+ // IndexSchema strips `nav` (that strip is what keeps the Sidecar out of
259
+ // index.json), so the authored nav is lifted off the RAW input to be
260
+ // available to the nav ops below — as fresh entries, so no op can mutate the
261
+ // caller's index. reconcileNav is total, so an unreadable nav degrades to a
262
+ // flat one rather than failing.
263
+ const lifted = liftSidecars(rawIndex);
262
264
 
263
265
  // Own copy: the caller's index is also its knownSiteTypes source and may be
264
266
  // logged, so nothing here mutates it. Reassigned by the page/nav ops, which
@@ -267,18 +269,7 @@ export function applyIndexOps(
267
269
  ...base.data,
268
270
  pages: base.data.pages.map((p) => ({ ...p, order: [...p.order] })),
269
271
  sections: Object.fromEntries(Object.entries(base.data.sections).map(([k, v]) => [k, { ...v }])),
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
- : {}),
272
+ ...(lifted.nav ? { nav: lifted.nav.entries } : {}),
282
273
  };
283
274
 
284
275
  const removed = new Set<string>();
package/src/lib/index.ts CHANGED
@@ -5,6 +5,20 @@ export { resolveChatbotName, resolveThinkingPhrases, DEFAULT_CHATBOT_NAME } from
5
5
  export { generateNavLinks, toSectionId, type NavItem } from "./nav";
6
6
  export { navEntries, pagesInNavOrder, groupOfPage, groupDisplayLabel, reconcileNav } from "./nav-tree";
7
7
  export { siteTags, tagDisplayLabel, reconcileTags } from "./tags";
8
+ export {
9
+ NAV,
10
+ TAGS,
11
+ SIDECARS,
12
+ readSidecar,
13
+ readSidecarText,
14
+ liftSidecars,
15
+ planSidecarWrite,
16
+ type SidecarDefinition,
17
+ type SidecarFileState,
18
+ type SidecarWritePlan,
19
+ type SidecarRead,
20
+ type SidecarDiagnostic,
21
+ } from "./sidecars";
8
22
  export { resolveReference, type ReferenceContent } from "./references";
9
23
  export { REFERENCE_TYPE } from "./reference-type";
10
24
  export {
@@ -46,4 +60,5 @@ export {
46
60
  loadStaticSiteContent,
47
61
  type LoadedSection,
48
62
  type SiteContent,
63
+ type SidecarReads,
49
64
  } from "./loader";
package/src/lib/loader.ts CHANGED
@@ -3,8 +3,9 @@ import { IndexSchema, type SiteIndex, type SectionMeta } from "../schemas/site-c
3
3
  import { getAllSchemas } from "./registry";
4
4
  import { blockDepth, MAX_BLOCK_DEPTH } from "../schemas/block";
5
5
  import { upgradeLegacySection } from "./migrate-sections-transform";
6
- import { NavEntrySchema, type NavEntry } from "../schemas/nav";
7
- import { TagEntrySchema, type TagEntry } from "../schemas/tags";
6
+ import type { NavEntry } from "../schemas/nav";
7
+ import type { TagEntry } from "../schemas/tags";
8
+ import type { SidecarRead } from "./sidecars";
8
9
 
9
10
  export interface LoadedSection {
10
11
  section: Section;
@@ -17,11 +18,9 @@ export interface LoadedSection {
17
18
  export interface SiteContent {
18
19
  sections: LoadedSection[];
19
20
  index: SiteIndex;
20
- /** True when nav.json was present but held entries this package version could
21
- * not represent (unknown kinds, invalid shapes) — the returned nav is missing
22
- * them. Writers must treat a degraded nav as read-only: wholesale-replacing
23
- * nav.json from a degraded read permanently destroys the entries this
24
- * version dropped. */
21
+ /** A Degraded read of nav.json (see SidecarRead): the returned nav is missing
22
+ * entries, so writers treat it as read-only — wholesale-replacing nav.json
23
+ * from it permanently destroys the entries this version dropped. */
25
24
  navDegraded: boolean;
26
25
  /** Same contract as navDegraded, for tags.json: a degraded read is
27
26
  * read-only for tags. */
@@ -29,68 +28,13 @@ export interface SiteContent {
29
28
  }
30
29
 
31
30
  /**
32
- * Parse the nav.json sidecar defensively, entry by entry. A malformed sidecar
33
- * must degrade, never brick the site — nav.json is optional content, and the
34
- * whole point of the sidecar is that it tolerates writers that don't understand
35
- * it. Tolerance is PER ENTRY: one unknown entry kind (a newer package version's
36
- * addition, or an authoring typo) drops that entry alone, not every group on
37
- * the site. Any drop is reported as `degraded` so write paths can refuse to
38
- * persist the lossy tree back over the original.
31
+ * The Sidecars a load read, from the Sidecar module's readers. A Sidecar the
32
+ * load did not look for is omitted. Tags then stay unattached ("not loaded":
33
+ * writers leave tags.json alone, where an attached `[]` saved back would delete
34
+ * every tag). Nav is attached as `[]` either way — the editor has no nav gate
35
+ * yet to make "not loaded" safe for nav (exploration B).
39
36
  */
40
- function parseNavFile(navFile: unknown): { entries: NavEntry[]; degraded: boolean } {
41
- if (!navFile) return { entries: [], degraded: false };
42
- const rawEntries = (navFile as { entries?: unknown }).entries;
43
- if (!Array.isArray(rawEntries)) {
44
- console.warn("[loader] Invalid nav.json, falling back to a flat navigation");
45
- return { entries: [], degraded: true };
46
- }
47
- const entries: NavEntry[] = [];
48
- let dropped = 0;
49
- for (const raw of rawEntries) {
50
- const result = NavEntrySchema.safeParse(raw);
51
- if (result.success) entries.push(result.data);
52
- else dropped++;
53
- }
54
- if (dropped > 0) {
55
- console.warn(`[loader] nav.json: dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} this package version cannot represent; navigation is degraded`);
56
- }
57
- return { entries, degraded: dropped > 0 };
58
- }
59
-
60
- /**
61
- * Parse the tags.json sidecar with parseNavFile's per-entry tolerance and the
62
- * same `degraded` contract. Assignments to deleted sections are NOT pruned
63
- * here — that is reconcileTags' job on every read (siteTags), so the runtime
64
- * field stays a faithful copy of the file.
65
- */
66
- function parseTagsFile(tagsFile: unknown): { tags: TagEntry[]; degraded: boolean } {
67
- if (!tagsFile) return { tags: [], degraded: false };
68
- const rawTags = (tagsFile as { tags?: unknown }).tags;
69
- if (!Array.isArray(rawTags)) {
70
- console.warn("[loader] Invalid tags.json, falling back to no tags");
71
- return { tags: [], degraded: true };
72
- }
73
- const tags: TagEntry[] = [];
74
- let dropped = 0;
75
- for (const raw of rawTags) {
76
- const result = TagEntrySchema.safeParse(raw);
77
- if (result.success) tags.push(result.data);
78
- else dropped++;
79
- }
80
- if (dropped > 0) {
81
- console.warn(`[loader] tags.json: dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} this package version cannot represent; tags are degraded`);
82
- }
83
- return { tags, degraded: dropped > 0 };
84
- }
85
-
86
- /**
87
- * Whether a load looked for tags.json. Not loaded ⇒ no `index.tags` is attached
88
- * and writers leave the file alone; loaded ⇒ `file` is its parsed contents,
89
- * with null/undefined meaning absent (an empty registry). Unlike nav, the
90
- * difference is load-bearing — an attached `[]` that a writer saves back
91
- * deletes every tag — hence an explicit flag rather than undefined-vs-null.
92
- */
93
- export type TagsSidecar = { loaded: false } | { loaded: true; file: unknown };
37
+ export type SidecarReads = { nav?: SidecarRead<NavEntry>; tags?: SidecarRead<TagEntry> };
94
38
 
95
39
  /**
96
40
  * Merge a validated index with raw section file contents.
@@ -100,8 +44,7 @@ export type TagsSidecar = { loaded: false } | { loaded: true; file: unknown };
100
44
  export function mergeSiteContent(
101
45
  index: SiteIndex,
102
46
  sectionFiles: Record<string, unknown>,
103
- navFile?: unknown,
104
- tagsSidecar: TagsSidecar = { loaded: false },
47
+ sidecars: SidecarReads = {},
105
48
  ): SiteContent {
106
49
  const sections: LoadedSection[] = [];
107
50
 
@@ -154,12 +97,11 @@ export function mergeSiteContent(
154
97
  }
155
98
  }
156
99
 
157
- const nav = parseNavFile(navFile);
158
- const tags = tagsSidecar.loaded ? parseTagsFile(tagsSidecar.file) : null;
100
+ const { nav, tags } = sidecars;
159
101
  return {
160
102
  sections,
161
- index: { ...index, nav: nav.entries, ...(tags ? { tags: tags.tags } : {}) },
162
- navDegraded: nav.degraded,
103
+ index: { ...index, nav: nav?.entries ?? [], ...(tags ? { tags: tags.entries } : {}) },
104
+ navDegraded: nav?.degraded ?? false,
163
105
  tagsDegraded: tags?.degraded ?? false,
164
106
  };
165
107
  }
@@ -172,8 +114,7 @@ export function mergeSiteContent(
172
114
  export function loadStaticSiteContent(
173
115
  index: SiteIndex,
174
116
  sectionGlob: Record<string, unknown>,
175
- navFile?: unknown,
176
- tagsSidecar: TagsSidecar = { loaded: false },
117
+ sidecars: SidecarReads = {},
177
118
  ): SiteContent {
178
119
  const sectionFiles: Record<string, unknown> = {};
179
120
  for (const [path, data] of Object.entries(sectionGlob)) {
@@ -181,5 +122,5 @@ export function loadStaticSiteContent(
181
122
  const id = filename.replace(".json", "");
182
123
  sectionFiles[id] = data;
183
124
  }
184
- return mergeSiteContent(index, sectionFiles, navFile, tagsSidecar);
125
+ return mergeSiteContent(index, sectionFiles, sidecars);
185
126
  }
@@ -0,0 +1,112 @@
1
+ import type { MediaItem, MediaManifest } from "../schemas/media";
2
+ import type { DocumentManifest, DocumentManifestEntry } from "../schemas/documents";
3
+
4
+ // Media intents (CONTEXT.md: Index op / Media intent): a change to the media
5
+ // library stated as what to do, applied by the write path to its own fresh read
6
+ // of the manifests. A writer that sent a whole manifest instead could only
7
+ // send back what it had read — dropping another writer's images, or `folders`,
8
+ // the moment its copy was stale. Intents leave everything they do not name as
9
+ // the branch has it. Pure: the write path does the reads and the commit.
10
+
11
+ export type MediaBlob = { path: string; base64: string };
12
+
13
+ export type MediaOp =
14
+ /**
15
+ * A new image and its variant files, keyed by its id (the content hash).
16
+ * `alt` is the caller's alt text, if it sent one — the only alt applied when
17
+ * the image turns out to be in the library already (`entry.alt` is just the
18
+ * new entry's default).
19
+ */
20
+ | { op: "addImage"; entry: MediaItem; blobs: MediaBlob[]; alt?: string }
21
+ | { op: "setImageAlt"; imageId: string; alt: string }
22
+ | { op: "deleteImage"; imageId: string }
23
+ /**
24
+ * A document's manifest entry, with its bytes when they ride in the commit
25
+ * (git tier; bucket-tier bytes are uploaded beforehand). For an id already in
26
+ * the library — git-tier ids are the content hash, so the same bytes — only
27
+ * the filename is applied: its stored bytes and path stay. That is how a
28
+ * rename lands.
29
+ */
30
+ | { op: "putDocument"; documentId: string; entry: DocumentManifestEntry; blob?: MediaBlob };
31
+
32
+ /**
33
+ * Per op, in order. `existed` means `addImage` found the image already in the
34
+ * library: its bytes are not committed again, and only a differing alt text is
35
+ * applied.
36
+ */
37
+ export type MediaOpOutcome = "added" | "existed" | "updated" | "deleted";
38
+
39
+ export type AppliedMediaOps = {
40
+ ok: true;
41
+ images: MediaManifest;
42
+ /** Null when no op touched documents, so the write leaves that file alone. */
43
+ documents: DocumentManifest | null;
44
+ uploads: { item: { id: string; folder: string }; blobs: MediaBlob[] }[];
45
+ /** Images whose variant folders the write deletes. */
46
+ deletions: { id: string; folder: string }[];
47
+ outcomes: MediaOpOutcome[];
48
+ };
49
+
50
+ export function applyMediaOps(
51
+ manifests: { images: MediaManifest; documents: DocumentManifest },
52
+ ops: MediaOp[],
53
+ ): AppliedMediaOps | { ok: false; code: "not_found"; error: string } {
54
+ const images: MediaManifest = { ...manifests.images, images: { ...manifests.images.images } };
55
+ let documents: DocumentManifest | null = null;
56
+ const uploads: AppliedMediaOps["uploads"] = [];
57
+ const deletions: AppliedMediaOps["deletions"] = [];
58
+ const outcomes: MediaOpOutcome[] = [];
59
+
60
+ // Keys are content hashes and normally equal the entry's id; find by id so a
61
+ // caller naming the id it was given always reaches the right entry.
62
+ const keyOf = (imageId: string) =>
63
+ imageId in images.images ? imageId : Object.keys(images.images).find((key) => images.images[key].id === imageId);
64
+ const notFound = (imageId: string) =>
65
+ ({ ok: false, code: "not_found", error: `No media with imageId "${imageId}" in the site's media library.` }) as const;
66
+
67
+ for (const op of ops) {
68
+ switch (op.op) {
69
+ case "addImage": {
70
+ const key = keyOf(op.entry.id);
71
+ if (key === undefined) {
72
+ images.images[op.entry.id] = op.entry;
73
+ uploads.push({ item: { id: op.entry.id, folder: op.entry.folder }, blobs: op.blobs });
74
+ outcomes.push("added");
75
+ } else {
76
+ if (op.alt !== undefined) images.images[key] = { ...images.images[key], alt: op.alt };
77
+ outcomes.push("existed");
78
+ }
79
+ break;
80
+ }
81
+ case "setImageAlt": {
82
+ const key = keyOf(op.imageId);
83
+ if (key === undefined) return notFound(op.imageId);
84
+ images.images[key] = { ...images.images[key], alt: op.alt };
85
+ outcomes.push("updated");
86
+ break;
87
+ }
88
+ case "deleteImage": {
89
+ const key = keyOf(op.imageId);
90
+ if (key === undefined) return notFound(op.imageId);
91
+ deletions.push({ id: key, folder: images.images[key].folder });
92
+ delete images.images[key];
93
+ outcomes.push("deleted");
94
+ break;
95
+ }
96
+ case "putDocument": {
97
+ documents ??= { ...manifests.documents };
98
+ const existing = documents[op.documentId];
99
+ if (existing) {
100
+ documents[op.documentId] = { ...existing, filename: op.entry.filename };
101
+ outcomes.push(existing.filename === op.entry.filename ? "existed" : "updated");
102
+ } else {
103
+ documents[op.documentId] = op.entry;
104
+ if (op.blob) uploads.push({ item: { id: op.documentId, folder: op.blob.path.split("/")[2] ?? "" }, blobs: [op.blob] });
105
+ outcomes.push("added");
106
+ }
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ return { ok: true, images, documents, uploads, deletions, outcomes };
112
+ }
@@ -1,5 +1,6 @@
1
1
  import type { NavEntry, NavGroupEntry } from "../schemas/nav";
2
2
  import type { Page, SiteIndex } from "../schemas/site-config";
3
+ import { diagnosticsCollector, type SidecarDiagnostic } from "./sidecar-diagnostics";
3
4
 
4
5
  /**
5
6
  * Repair an authored nav against the current page set.
@@ -19,6 +20,15 @@ import type { Page, SiteIndex } from "../schemas/site-config";
19
20
  * prior state and defeat identity-based re-render checks.
20
21
  */
21
22
  export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
23
+ return reconcileNavReport(entries, pages).entries;
24
+ }
25
+
26
+ /** reconcileNav plus what the repair changed — what a validator reports. */
27
+ export function reconcileNavReport(
28
+ entries: NavEntry[],
29
+ pages: Page[],
30
+ ): { entries: NavEntry[]; diagnostics: SidecarDiagnostic[] } {
31
+ const report = diagnosticsCollector();
22
32
  const entryList = Array.isArray(entries) ? entries : [];
23
33
  const pageList = Array.isArray(pages) ? pages : [];
24
34
 
@@ -33,7 +43,14 @@ export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
33
43
 
34
44
  for (const entry of entryList) {
35
45
  if (entry?.kind === "page") {
36
- if (!validIds.has(entry.pageId) || placed.has(entry.pageId)) continue;
46
+ if (!validIds.has(entry.pageId)) {
47
+ report.add("dangling", entry.pageId);
48
+ continue;
49
+ }
50
+ if (placed.has(entry.pageId)) {
51
+ report.add("duplicate-member", entry.pageId);
52
+ continue;
53
+ }
37
54
  placed.add(entry.pageId);
38
55
  result.push({ ...entry });
39
56
  continue;
@@ -46,12 +63,23 @@ export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
46
63
  // and "total" would be a lie. Dropping it spills its members to the tail
47
64
  // append below: a group lost, never a page.
48
65
  if (typeof entry.id !== "string" || entry.id === "") continue;
49
- if (seenGroupIds.has(entry.id)) continue;
66
+ if (seenGroupIds.has(entry.id)) {
67
+ report.add("duplicate-id", entry.id);
68
+ continue;
69
+ }
50
70
  seenGroupIds.add(entry.id);
51
71
 
52
72
  const claimed = Array.isArray(entry.pageIds) ? entry.pageIds : [];
53
73
  const pageIds = claimed.filter((id) => {
54
- if (typeof id !== "string" || !validIds.has(id) || placed.has(id)) return false;
74
+ if (typeof id !== "string") return false;
75
+ if (!validIds.has(id)) {
76
+ report.add("dangling", id);
77
+ return false;
78
+ }
79
+ if (placed.has(id)) {
80
+ report.add("duplicate-member", id);
81
+ return false;
82
+ }
55
83
  placed.add(id);
56
84
  return true;
57
85
  });
@@ -70,10 +98,11 @@ export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
70
98
  for (const p of pageList) {
71
99
  if (!p?.id || placed.has(p.id)) continue;
72
100
  placed.add(p.id);
101
+ report.add("unlisted", p.id);
73
102
  result.push({ kind: "page", pageId: p.id });
74
103
  }
75
104
 
76
- return result;
105
+ return { entries: result, diagnostics: report.list() };
77
106
  }
78
107
 
79
108
  /**
package/src/lib/pages.ts CHANGED
@@ -5,6 +5,7 @@ import { RESERVED_SLUGS } from "../schemas/site-config";
5
5
  // block (mirrors the same sanctioned import in schemas/site-config.ts).
6
6
  import { documentOptionsSchema, type DocumentOptions } from "../document/engine/settings";
7
7
  import { navEntries } from "./nav-tree";
8
+ import { slugifyPageSlug } from "./slug";
8
9
 
9
10
  /**
10
11
  * Display fallback for blank page titles. addPage creates pages with an empty
@@ -15,9 +16,7 @@ export function pageDisplayTitle(title: string): string {
15
16
  return title || "Untitled";
16
17
  }
17
18
 
18
- export function slugifyPageSlug(input: string): string {
19
- return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
20
- }
19
+ export { slugifyPageSlug };
21
20
 
22
21
  export function uniquePageSlug(base: string, existing: string[]): string {
23
22
  const taken = new Set(existing);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What a Sidecar reconcile changed. Reconcile repairs silently on every read, so
3
+ * these are only ever warnings: a validator reports them, nothing rejects on them.
4
+ * - dangling: ids of pages/sections the index does not have, dropped
5
+ * - duplicate-id: a group/tag id used again, later uses dropped
6
+ * - duplicate-member: a page placed twice / a section tagged twice, later placements dropped
7
+ * - unlisted: pages missing from the nav, appended to its end (nav only)
8
+ *
9
+ * Its own leaf so nav-tree and tags can report without importing the Sidecar
10
+ * module that imports them.
11
+ */
12
+ export type SidecarDiagnostic = {
13
+ code: "dangling" | "duplicate-id" | "duplicate-member" | "unlisted";
14
+ ids: string[];
15
+ };
16
+
17
+ export function diagnosticsCollector() {
18
+ const byCode = new Map<SidecarDiagnostic["code"], Set<string>>();
19
+ return {
20
+ add(code: SidecarDiagnostic["code"], id: string) {
21
+ const ids = byCode.get(code) ?? new Set<string>();
22
+ ids.add(id);
23
+ byCode.set(code, ids);
24
+ },
25
+ list(): SidecarDiagnostic[] {
26
+ return [...byCode].map(([code, ids]) => ({ code, ids: [...ids] }));
27
+ },
28
+ };
29
+ }