@drawnagency/primitives 0.1.114 → 0.1.116

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 (119) hide show
  1. package/dist/chunk-26V4YNOR.js +108 -0
  2. package/dist/{chunk-OAGJBCSH.js → chunk-4WQ36ZP7.js} +19 -3
  3. package/dist/{chunk-PTY2RG2K.js → chunk-67P34WNW.js} +75 -130
  4. package/dist/chunk-APIJPTFG.js +64 -0
  5. package/dist/chunk-CDBURDPX.js +90 -0
  6. package/dist/{chunk-PLANHXS7.js → chunk-CEFFP3CH.js} +2 -2
  7. package/dist/chunk-DFKTF3CH.js +0 -0
  8. package/dist/chunk-DJABADOO.js +131 -0
  9. package/dist/{chunk-IP2SEBAJ.js → chunk-DJOGPSRU.js} +31 -72
  10. package/dist/chunk-DKZCHCV3.js +64 -0
  11. package/dist/chunk-FGQXHUAE.js +39 -0
  12. package/dist/chunk-GNF6KFQ7.js +18 -0
  13. package/dist/{chunk-QHMKIODQ.js → chunk-HHNPSKPT.js} +2 -89
  14. package/dist/{chunk-PZPGJZRG.js → chunk-HUOW3ZV2.js} +3 -3
  15. package/dist/{chunk-Y5QECJX7.js → chunk-L7WTERU5.js} +2 -2
  16. package/dist/{chunk-N3G7DOU3.js → chunk-LNQUUC2S.js} +2 -2
  17. package/dist/{chunk-7SAAJVDE.js → chunk-MSBHMY3C.js} +17 -15
  18. package/dist/{chunk-PNSCDBU7.js → chunk-O37AV27P.js} +6 -49
  19. package/dist/{chunk-5KSAS4VY.js → chunk-RIITGTV3.js} +1 -1
  20. package/dist/chunk-S7QRHUWO.js +8 -0
  21. package/dist/chunk-VY3IFGE3.js +70 -0
  22. package/dist/{chunk-VUYCRBJK.js → chunk-W72BPGR3.js} +7 -5
  23. package/dist/collab/rebase.d.ts.map +1 -1
  24. package/dist/collab/rebase.js +15 -15
  25. package/dist/components/editor/index.js +8 -8
  26. package/dist/components/primitives/index.js +14 -9
  27. package/dist/components/sections/brand-guide.js +12 -7
  28. package/dist/components/sections/register-schemas.js +17 -12
  29. package/dist/components/sections/register.js +17 -12
  30. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  31. package/dist/deck-builder/index.js +6 -6
  32. package/dist/hooks/useEditorPublish.d.ts +4 -1
  33. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  34. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  35. package/dist/index.js +78 -49
  36. package/dist/lib/dexie.d.ts +7 -1
  37. package/dist/lib/dexie.d.ts.map +1 -1
  38. package/dist/lib/dexie.js +3 -2
  39. package/dist/lib/document-refs.d.ts +8 -0
  40. package/dist/lib/document-refs.d.ts.map +1 -1
  41. package/dist/lib/document-refs.js +14 -0
  42. package/dist/lib/image-variant-plan.d.ts +42 -0
  43. package/dist/lib/image-variant-plan.d.ts.map +1 -0
  44. package/dist/lib/image-variant-plan.js +53 -0
  45. package/dist/lib/index-ops.d.ts.map +1 -1
  46. package/dist/lib/index-ops.js +20 -16
  47. package/dist/lib/index.d.ts +2 -1
  48. package/dist/lib/index.d.ts.map +1 -1
  49. package/dist/lib/index.js +36 -15
  50. package/dist/lib/loader.d.ts +16 -17
  51. package/dist/lib/loader.d.ts.map +1 -1
  52. package/dist/lib/media-ops.d.ts +73 -0
  53. package/dist/lib/media-ops.d.ts.map +1 -0
  54. package/dist/lib/media-ops.js +60 -0
  55. package/dist/lib/nav-tree.d.ts +6 -0
  56. package/dist/lib/nav-tree.d.ts.map +1 -1
  57. package/dist/lib/pages.d.ts +2 -1
  58. package/dist/lib/pages.d.ts.map +1 -1
  59. package/dist/lib/reference-validation.js +4 -33
  60. package/dist/lib/section-check.d.ts +49 -0
  61. package/dist/lib/section-check.d.ts.map +1 -0
  62. package/dist/lib/section-check.js +14 -0
  63. package/dist/lib/sidecar-diagnostics.d.ts +20 -0
  64. package/dist/lib/sidecar-diagnostics.d.ts.map +1 -0
  65. package/dist/lib/sidecars.d.ts +118 -0
  66. package/dist/lib/sidecars.d.ts.map +1 -0
  67. package/dist/lib/sidecars.js +23 -0
  68. package/dist/lib/site-validation.d.ts +123 -0
  69. package/dist/lib/site-validation.d.ts.map +1 -0
  70. package/dist/lib/site-validation.js +230 -0
  71. package/dist/lib/slug.d.ts +2 -0
  72. package/dist/lib/slug.d.ts.map +1 -0
  73. package/dist/lib/storage-tier.d.ts +17 -0
  74. package/dist/lib/storage-tier.d.ts.map +1 -0
  75. package/dist/lib/storage-tier.js +29 -0
  76. package/dist/lib/tags.d.ts +6 -0
  77. package/dist/lib/tags.d.ts.map +1 -1
  78. package/dist/media/index.js +15 -15
  79. package/dist/media/queue.d.ts +2 -0
  80. package/dist/media/queue.d.ts.map +1 -1
  81. package/dist/media/worker.d.ts +1 -35
  82. package/dist/media/worker.d.ts.map +1 -1
  83. package/dist/schemas/index.js +18 -12
  84. package/dist/schemas/media.d.ts +4 -0
  85. package/dist/schemas/media.d.ts.map +1 -1
  86. package/dist/storage/index.js +2 -1
  87. package/dist/storage/memory.d.ts +21 -0
  88. package/dist/storage/memory.d.ts.map +1 -0
  89. package/dist/storage/memory.js +129 -0
  90. package/dist/storage/types.d.ts +9 -0
  91. package/dist/storage/types.d.ts.map +1 -1
  92. package/package.json +34 -1
  93. package/src/collab/rebase.ts +12 -16
  94. package/src/components/shell/EditorShell.tsx +7 -4
  95. package/src/hooks/useEditorPublish.ts +9 -4
  96. package/src/hooks/useMediaPipeline.ts +29 -23
  97. package/src/lib/dexie.ts +9 -1
  98. package/src/lib/document-refs.ts +30 -0
  99. package/src/lib/image-variant-plan.ts +92 -0
  100. package/src/lib/index-ops.ts +9 -18
  101. package/src/lib/index.ts +15 -0
  102. package/src/lib/loader.ts +18 -77
  103. package/src/lib/media-ops.ts +112 -0
  104. package/src/lib/nav-tree.ts +33 -4
  105. package/src/lib/pages.ts +2 -3
  106. package/src/lib/section-check.ts +166 -0
  107. package/src/lib/sidecar-diagnostics.ts +29 -0
  108. package/src/lib/sidecars.ts +221 -0
  109. package/src/lib/site-validation.ts +269 -0
  110. package/src/lib/slug.ts +6 -0
  111. package/src/lib/storage-tier.ts +38 -0
  112. package/src/lib/tags.ts +20 -3
  113. package/src/media/queue.ts +3 -0
  114. package/src/media/utils.ts +1 -1
  115. package/src/media/worker.ts +17 -8
  116. package/src/schemas/media.ts +7 -2
  117. package/src/storage/memory.ts +178 -0
  118. package/src/storage/types.ts +15 -2
  119. /package/dist/{chunk-45FA7Z55.js → chunk-UUUSJDIG.js} +0 -0
@@ -0,0 +1,166 @@
1
+ import { decodeHTMLStrict } from "entities";
2
+ import type { ZodError } from "zod";
3
+ import { blockDepth, MAX_BLOCK_DEPTH, LayoutEnvelopeSchema } from "../schemas/block";
4
+ import { getSchema, getRichTextFields } from "./registry";
5
+ import { REFERENCE_TYPE } from "./reference-type";
6
+
7
+ /**
8
+ * The first thing wrong with one section body: schema, then layout envelope,
9
+ * then nesting depth, then a Reference nested in a container. Only ever one — a
10
+ * body failing its schema is not also judged on its layout — so the write
11
+ * path's `errors[0]` stays the first failure it has always reported.
12
+ */
13
+ export type SectionCheckError =
14
+ | { code: "schema"; zodError: ZodError }
15
+ | { code: "layout"; zodError: ZodError }
16
+ | { code: "depth" }
17
+ | { code: "reference-in-container" };
18
+
19
+ /** HTML entities found in one plain-text field, by path relative to the section. */
20
+ export interface EntityFinding {
21
+ path: string;
22
+ entities: string[];
23
+ }
24
+
25
+ export interface SectionCheck {
26
+ /**
27
+ * No schema is registered for the type, so only the nesting bound was
28
+ * checked. Whether that is acceptable (a site-local type) or an unknown type
29
+ * is the caller's call.
30
+ */
31
+ unregistered: boolean;
32
+ error: SectionCheckError | null;
33
+ /** Empty whenever `error` is set. */
34
+ entities: EntityFinding[];
35
+ }
36
+
37
+ /**
38
+ * Judge one section body against `type`'s registered schema — the write path's
39
+ * (`validateContent`) per-section rules, kept here so the site validators can
40
+ * apply the same ones.
41
+ * Reads the schema registry only: callers register the schemas first.
42
+ */
43
+ export function checkSection(type: string, body: unknown): SectionCheck {
44
+ const schema = getSchema(type);
45
+ if (!schema) {
46
+ // Nesting is still bounded: MAX_BLOCK_DEPTH guards the renderer, and an
47
+ // unvalidated type can still carry a `children` tree.
48
+ return { unregistered: true, error: blockDepth(body) > MAX_BLOCK_DEPTH ? { code: "depth" } : null, entities: [] };
49
+ }
50
+ const fail = (error: SectionCheckError): SectionCheck => ({ unregistered: false, error, entities: [] });
51
+
52
+ const result = schema.safeParse(body);
53
+ if (!result.success) return fail({ code: "schema", zodError: result.error });
54
+ // The per-type schema doesn't declare the top-level `layout` envelope (it
55
+ // lives beside `type`/`content`/`options` in the section file), so validate
56
+ // it separately — the write path preserves it.
57
+ const rawLayout = (body as { layout?: unknown } | null)?.layout;
58
+ if (rawLayout !== undefined) {
59
+ const layoutResult = LayoutEnvelopeSchema.safeParse(rawLayout);
60
+ if (!layoutResult.success) return fail({ code: "layout", zodError: layoutResult.error });
61
+ }
62
+ if (blockDepth(result.data) > MAX_BLOCK_DEPTH) return fail({ code: "depth" });
63
+ // A Reference is expanded into its sources only at the top level of a page;
64
+ // as a container child it would silently render nothing.
65
+ const children = (result.data as { content?: { children?: unknown } }).content?.children;
66
+ if (Array.isArray(children) && children.some((child) => (child as { type?: unknown })?.type === REFERENCE_TYPE)) {
67
+ return fail({ code: "reference-in-container" });
68
+ }
69
+
70
+ const entities: EntityFinding[] = [];
71
+ walkSectionForEntities(type, result.data as { content?: unknown; options?: unknown }, "", (path, tokens) => {
72
+ entities.push({ path, entities: tokens });
73
+ });
74
+ return { unregistered: false, error: null, entities };
75
+ }
76
+
77
+ // Plain-text fields render HTML entities LITERALLY (only registry-declared
78
+ // rich-text fields are HTML) — an agent pasting "’" into a heading ships
79
+ // it verbatim to viewers. A token counts as an entity when it is a numeric
80
+ // character reference or when the HTML5 strict decoder recognizes it — strict
81
+ // decoding requires an exact known name + semicolon, so legitimate text like
82
+ // "R&D;" never matches.
83
+ const ENTITY_RE = /&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});/g;
84
+
85
+ function entitiesIn(value: string): string[] {
86
+ const found: string[] = [];
87
+ for (const m of value.matchAll(ENTITY_RE)) {
88
+ const token = m[0];
89
+ if ((m[1].startsWith("#") || decodeHTMLStrict(token) !== token) && !found.includes(token)) {
90
+ found.push(token);
91
+ }
92
+ }
93
+ return found;
94
+ }
95
+
96
+ function visitPlainLeaf(value: unknown, path: string, report: (path: string, entities: string[]) => void): void {
97
+ if (typeof value === "string") {
98
+ const tokens = entitiesIn(value);
99
+ if (tokens.length > 0) report(path, tokens);
100
+ return;
101
+ }
102
+ if (Array.isArray(value)) {
103
+ value.forEach((v, i) => visitPlainLeaf(v, `${path}[${i}]`, report));
104
+ return;
105
+ }
106
+ if (typeof value === "object" && value !== null) {
107
+ for (const [k, v] of Object.entries(value)) visitPlainLeaf(v, `${path}.${k}`, report);
108
+ }
109
+ }
110
+
111
+ // Mirrors sanitizeSectionContent's traversal: rich-text fields are top-level
112
+ // keys of a block's `content` (per type), and `content.children` holds nested
113
+ // blocks that carry their own type. Everything else — including the whole
114
+ // `options` subtree (plain-text config like the colors section's label) — is
115
+ // plain text. Reported paths are relative to the section object itself
116
+ // ("content.heading", "options.label", "content.children[0].content.heading").
117
+ function walkSectionForEntities(
118
+ type: string,
119
+ block: { content?: unknown; options?: unknown },
120
+ prefix: string,
121
+ report: (path: string, entities: string[]) => void,
122
+ ): void {
123
+ const { content, options } = block;
124
+ if (typeof content === "object" && content !== null) {
125
+ const rich = new Set(getRichTextFields(type));
126
+ for (const [key, value] of Object.entries(content as Record<string, unknown>)) {
127
+ if (rich.has(key)) continue;
128
+ if (key === "children" && Array.isArray(value)) {
129
+ value.forEach((child, i) => {
130
+ const c = child as { type?: unknown } | null;
131
+ if (c && typeof c === "object" && typeof c.type === "string") {
132
+ walkSectionForEntities(c.type, c as { content?: unknown; options?: unknown }, `${prefix}content.children[${i}].`, report);
133
+ }
134
+ });
135
+ continue;
136
+ }
137
+ visitPlainLeaf(value, `${prefix}content.${key}`, report);
138
+ }
139
+ }
140
+ if (typeof options === "object" && options !== null) {
141
+ visitPlainLeaf(options, `${prefix}options`, report);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The write path's sentence for a section check's error, shared with
147
+ * validate_site so a dry run words a failure exactly as the save it predicts.
148
+ */
149
+ export function sectionCheckMessage(sectionId: string, type: string, code: SectionCheckError["code"]): string {
150
+ switch (code) {
151
+ case "schema":
152
+ return `Validation failed: section "${sectionId}" (${type})`;
153
+ case "layout":
154
+ return `Validation failed: section "${sectionId}" has an invalid layout envelope`;
155
+ case "depth":
156
+ return `Validation failed: section "${sectionId}" exceeds max nesting depth (${MAX_BLOCK_DEPTH})`;
157
+ case "reference-in-container":
158
+ return `Validation failed: section "${sectionId}" holds a Reference, which can only sit at the top level of a page, not inside a container`;
159
+ }
160
+ }
161
+
162
+ /** The write path's warning for one entity finding, shared with validate_site. */
163
+ export function entityWarning(sectionId: string, { path, entities }: EntityFinding): string {
164
+ const noun = entities.length > 1 ? "entities" : "entity";
165
+ return `section "${sectionId}": "${path}" contains HTML ${noun} ${entities.join(", ")} — this is a plain-text field, so entities render literally; if a character was intended, use the character itself.`;
166
+ }
@@ -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
+ }
@@ -0,0 +1,221 @@
1
+ import type { ZodType } from "zod";
2
+ import { NavEntrySchema, NavFileSchema, type NavEntry } from "../schemas/nav";
3
+ import { TagEntrySchema, TagsFileSchema, type TagEntry } from "../schemas/tags";
4
+ import type { SiteIndex } from "../schemas/site-config";
5
+ import { reconcileNavReport } from "./nav-tree";
6
+ import { mergeTags, reconcileTagsReport } from "./tags";
7
+ import type { SidecarDiagnostic } from "./sidecar-diagnostics";
8
+
9
+ export type { SidecarDiagnostic };
10
+
11
+ /**
12
+ * One definition per Sidecar — a content file kept beside index.json rather than
13
+ * on it, so a writer that predates it cannot drop it (CONTEXT.md). Every rule a
14
+ * caller would otherwise re-implement per file lives behind these: lenient
15
+ * parse, lifting the wire key off an index before IndexSchema strips it, and the
16
+ * two places the Sidecars genuinely differ — how a list is repaired against the
17
+ * index, and how concurrent edits rebase. Pure and browser-safe: the editor uses
18
+ * it during hydration, and callers do their own I/O.
19
+ */
20
+ export interface SidecarDefinition<E> {
21
+ /** Also the key the Sidecar travels under on the wire (`index.nav`, `index.tags`). */
22
+ name: "nav" | "tags";
23
+ path: string;
24
+ /** The key holding the entry list inside the file. */
25
+ fileKey: "entries" | "tags";
26
+ entrySchema: ZodType<E>;
27
+ fileSchema: ZodType<unknown>;
28
+ /** Total, pure and idempotent; also reports what it repaired. */
29
+ reconcile(entries: E[], index: SiteIndex): { entries: E[]; diagnostics: SidecarDiagnostic[] };
30
+ /** The list after a collab rebase; `undefined` when no side carries one. */
31
+ rebase(args: { base: E[] | undefined; local: E[] | undefined; remote: E[] | undefined }): E[] | undefined;
32
+ /** Words for a declined write's warning. */
33
+ words: {
34
+ /** What the file holds: "navigation entries". */
35
+ entries: string;
36
+ /** What did not land: "Page grouping changes". */
37
+ changes: string;
38
+ /** What a valid payload is: "a valid navigation tree". */
39
+ valid: string;
40
+ /** The expected wire shape, for an agent that sent the wrong one. */
41
+ shape: string;
42
+ };
43
+ }
44
+
45
+ export const NAV: SidecarDefinition<NavEntry> = {
46
+ name: "nav",
47
+ path: "src/content/nav.json",
48
+ fileKey: "entries",
49
+ entrySchema: NavEntrySchema,
50
+ fileSchema: NavFileSchema,
51
+ reconcile: (entries, index) => reconcileNavReport(entries, index.pages),
52
+ // Grouping is edited rarely and has no dirty tracking, so persisted remote
53
+ // state beats unsaved local edits. A remote without the Sidecar (an older
54
+ // server) keeps ours rather than flattening it.
55
+ rebase: ({ local, remote }) => (remote !== undefined ? remote : local),
56
+ words: {
57
+ entries: "navigation entries",
58
+ changes: "Page grouping changes",
59
+ valid: "a valid navigation tree",
60
+ shape: 'an array of {kind:"page",pageId} / {kind:"group",id,label,startCollapsed,pageIds} entries',
61
+ },
62
+ };
63
+
64
+ export const TAGS: SidecarDefinition<TagEntry> = {
65
+ name: "tags",
66
+ path: "src/content/tags.json",
67
+ fileKey: "tags",
68
+ entrySchema: TagEntrySchema,
69
+ fileSchema: TagsFileSchema,
70
+ reconcile: (entries, index) => reconcileTagsReport(entries, index.sections),
71
+ // Tagging is edited from every section's chrome, so remote-wins would drop
72
+ // unsaved tagging whenever a peer saved anything. See mergeTags.
73
+ rebase: mergeTags,
74
+ words: {
75
+ entries: "tag entries",
76
+ changes: "Tag changes",
77
+ valid: "a valid tag list",
78
+ shape: "an array of {id,label,sectionIds} entries",
79
+ },
80
+ };
81
+
82
+ // Widened once here: the definitions are invariant in their entry type, so a
83
+ // loop over both needs the common type.
84
+ export const SIDECARS: readonly SidecarDefinition<unknown>[] = [NAV, TAGS] as readonly SidecarDefinition<unknown>[];
85
+
86
+ /**
87
+ * A Sidecar as read. `degraded` means the file was present but held something
88
+ * this package version could not represent, so `entries` is missing it: the read
89
+ * is a Degraded read, never to be cached, posted back, or written over the file.
90
+ */
91
+ export type SidecarRead<E> = { entries: E[]; degraded: boolean };
92
+
93
+ /**
94
+ * Tolerance is per entry: one unknown entry kind (a newer package's addition, an
95
+ * authoring typo) drops that entry alone, not every group or tag on the site.
96
+ */
97
+ function readEntries<E>(def: SidecarDefinition<E>, raw: unknown): SidecarRead<E> {
98
+ if (!Array.isArray(raw)) {
99
+ console.warn(`[sidecars] ${def.name}: entry list is not a list; reading it as empty and degraded`);
100
+ return { entries: [], degraded: true };
101
+ }
102
+ const entries: E[] = [];
103
+ let dropped = 0;
104
+ for (const item of raw) {
105
+ const parsed = def.entrySchema.safeParse(item);
106
+ if (parsed.success) entries.push(parsed.data);
107
+ else dropped++;
108
+ }
109
+ if (dropped > 0) {
110
+ console.warn(`[sidecars] ${def.name}: dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} this package version cannot represent; the read is degraded`);
111
+ }
112
+ return { entries, degraded: dropped > 0 };
113
+ }
114
+
115
+ /** Read a parsed Sidecar file. Absent (null/undefined) is an empty list, not degraded. */
116
+ export function readSidecar<E>(def: SidecarDefinition<E>, file: unknown): SidecarRead<E> {
117
+ if (file === undefined || file === null) return { entries: [], degraded: false };
118
+ return readEntries(def, (file as Record<string, unknown>)[def.fileKey]);
119
+ }
120
+
121
+ /**
122
+ * Read a Sidecar file's raw text. Present-but-unparseable JSON is degraded, not
123
+ * absent: a site whose nav.json is corrupt still has groups, and treating it as
124
+ * "never had any" would let a writer replace them.
125
+ */
126
+ export function readSidecarText<E>(def: SidecarDefinition<E>, text: string | undefined): SidecarRead<E> {
127
+ if (text === undefined) return { entries: [], degraded: false };
128
+ let file: unknown;
129
+ try {
130
+ file = JSON.parse(text);
131
+ } catch {
132
+ console.warn(`[sidecars] ${def.name}: ${def.path} is not valid JSON; the read is degraded`);
133
+ return { entries: [], degraded: true };
134
+ }
135
+ return readSidecar(def, file);
136
+ }
137
+
138
+ /**
139
+ * Lift the Sidecars off an index as it arrives on the wire, BEFORE anything
140
+ * parses it with IndexSchema — the parse strips unknown keys, and an index
141
+ * round-tripped through it has lost its Sidecars. A key the index does not carry
142
+ * is `undefined` (omitted: writers leave the file alone); a carried key is read
143
+ * with the file's per-entry leniency, into fresh objects that never alias the
144
+ * caller's.
145
+ */
146
+ export function liftSidecars(rawIndex: unknown): { nav?: SidecarRead<NavEntry>; tags?: SidecarRead<TagEntry> } {
147
+ const raw = rawIndex !== null && typeof rawIndex === "object" ? (rawIndex as Record<string, unknown>) : {};
148
+ return {
149
+ ...(raw.nav !== undefined ? { nav: readEntries(NAV, raw.nav) } : {}),
150
+ ...(raw.tags !== undefined ? { tags: readEntries(TAGS, raw.tags) } : {}),
151
+ };
152
+ }
153
+
154
+ /** The Sidecar file on the branch a write lands on, as the writer read it. */
155
+ export type SidecarFileState =
156
+ | { kind: "absent" }
157
+ | { kind: "present"; text: string }
158
+ /** The read itself failed, so nothing is known about the file. */
159
+ | { kind: "unreadable" };
160
+
161
+ export type SidecarWritePlan =
162
+ | { action: "untouched" }
163
+ | { action: "write"; path: string; content: string }
164
+ /** Declined. The rest of the write still lands, so the warning is what tells the caller. */
165
+ | { action: "skip"; warning: string };
166
+
167
+ /**
168
+ * What a write does to one Sidecar file, given what was posted (from
169
+ * liftSidecars) and the file on the branch. Every Sidecar write REPLACES the
170
+ * file, and the posted list was built from what this package version could
171
+ * READ — so a file holding anything this version cannot fully parse (an entry
172
+ * from a newer package, or a corrupt file) is read-only: replacing it would
173
+ * destroy those entries with a clean write. The same holds for a posted
174
+ * Degraded read.
175
+ *
176
+ * Emptiness is judged on the POSTED list, before reconcile: an empty list never
177
+ * creates a file (every editor save carries the Sidecars, and nav's reconcile
178
+ * would turn [] into the page list), while an empty list over an existing file
179
+ * replaces it wholesale. A written list is reconciled against the index being
180
+ * written, so it never names a page or section that write removed.
181
+ */
182
+ export function planSidecarWrite<E>(
183
+ def: SidecarDefinition<E>,
184
+ posted: SidecarRead<E> | undefined,
185
+ branch: SidecarFileState,
186
+ index: SiteIndex,
187
+ ): SidecarWritePlan {
188
+ if (posted === undefined) return { action: "untouched" };
189
+ const skip = (reason: string): SidecarWritePlan => ({
190
+ action: "skip",
191
+ warning: `${def.path} was left unchanged: ${reason} ${def.words.changes} from this save did not land. Upgrade the site's @drawnagency packages (or repair ${def.path} directly in the repo), then retry.`,
192
+ });
193
+
194
+ if (posted.degraded) {
195
+ // Leaving the file alone is the right action; saying nothing is not — an
196
+ // agent that posted a malformed list would otherwise believe it landed.
197
+ return {
198
+ action: "skip",
199
+ warning: `${def.name} was not ${def.words.valid}, so ${def.path} was left unchanged (it held entries this package version cannot read). Expected ${def.words.shape}.`,
200
+ };
201
+ }
202
+ if (branch.kind === "unreadable") {
203
+ return skip("the current file could not be read to confirm the replacement loses nothing.");
204
+ }
205
+ if (branch.kind === "present") {
206
+ let existing: unknown;
207
+ try {
208
+ existing = JSON.parse(branch.text);
209
+ } catch {
210
+ return skip("the current file is not valid JSON, so this version cannot prove a replacement keeps its entries.");
211
+ }
212
+ if (!def.fileSchema.safeParse(existing).success) {
213
+ return skip(`the current file holds ${def.words.entries} this package version cannot represent (likely written by a newer version).`);
214
+ }
215
+ } else if (posted.entries.length === 0) {
216
+ return { action: "untouched" };
217
+ }
218
+
219
+ const { entries } = def.reconcile(posted.entries, index);
220
+ return { action: "write", path: def.path, content: JSON.stringify({ [def.fileKey]: entries }, null, 2) };
221
+ }