@drawnagency/primitives 0.1.95 → 0.1.97

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 (86) hide show
  1. package/dist/{chunk-ZKGMLLUQ.js → chunk-5BFPJWIO.js} +5 -5
  2. package/dist/chunk-EUSWEVOA.js +29 -0
  3. package/dist/{chunk-CI624HRS.js → chunk-F2337B35.js} +1 -1
  4. package/dist/{chunk-QQBBYQYD.js → chunk-FOEZNEF5.js} +9 -9
  5. package/dist/chunk-HJSE6SK5.js +27 -0
  6. package/dist/{chunk-YUUUCDXN.js → chunk-II3X7GLJ.js} +1 -1
  7. package/dist/{chunk-DJAJRBPD.js → chunk-KPP2CFW6.js} +2 -2
  8. package/dist/{chunk-ABV7OZB3.js → chunk-QEHPGA5N.js} +26 -8
  9. package/dist/{chunk-SOILPZ7U.js → chunk-S4L3ZFKM.js} +1 -1
  10. package/dist/{chunk-RQPHPAT2.js → chunk-YDXBJR74.js} +2 -2
  11. package/dist/chunk-ZD6XGRQM.js +85 -0
  12. package/dist/collab/rebase.d.ts +20 -0
  13. package/dist/collab/rebase.d.ts.map +1 -1
  14. package/dist/collab/rebase.js +17 -0
  15. package/dist/components/editor/DropEdgeIndicator.d.ts +6 -1
  16. package/dist/components/editor/DropEdgeIndicator.d.ts.map +1 -1
  17. package/dist/components/editor/NavGroupRow.d.ts +21 -0
  18. package/dist/components/editor/NavGroupRow.d.ts.map +1 -0
  19. package/dist/components/editor/PagesModal.d.ts +7 -1
  20. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  21. package/dist/components/editor/index.js +4 -4
  22. package/dist/components/primitives/index.js +7 -7
  23. package/dist/components/sections/brand-guide.js +6 -6
  24. package/dist/components/sections/register-schemas.js +10 -10
  25. package/dist/components/sections/register.js +10 -10
  26. package/dist/components/shared/Navigation.d.ts.map +1 -1
  27. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  28. package/dist/hooks/useSortableNavRow.d.ts +27 -0
  29. package/dist/hooks/useSortableNavRow.d.ts.map +1 -0
  30. package/dist/index.js +60 -38
  31. package/dist/lib/dexie.d.ts.map +1 -1
  32. package/dist/lib/dexie.js +3 -1
  33. package/dist/lib/index-ops.d.ts +77 -0
  34. package/dist/lib/index-ops.d.ts.map +1 -0
  35. package/dist/lib/index-ops.js +223 -0
  36. package/dist/lib/index.d.ts +1 -0
  37. package/dist/lib/index.d.ts.map +1 -1
  38. package/dist/lib/index.js +17 -6
  39. package/dist/lib/loader.d.ts +2 -2
  40. package/dist/lib/loader.d.ts.map +1 -1
  41. package/dist/lib/nav-tree.d.ts +63 -0
  42. package/dist/lib/nav-tree.d.ts.map +1 -0
  43. package/dist/lib/nav.d.ts +13 -2
  44. package/dist/lib/nav.d.ts.map +1 -1
  45. package/dist/lib/pages.d.ts +0 -1
  46. package/dist/lib/pages.d.ts.map +1 -1
  47. package/dist/lib/reorder.d.ts +24 -0
  48. package/dist/lib/reorder.d.ts.map +1 -1
  49. package/dist/media/index.js +2 -2
  50. package/dist/schemas/index.d.ts +1 -0
  51. package/dist/schemas/index.d.ts.map +1 -1
  52. package/dist/schemas/index.js +8 -2
  53. package/dist/schemas/nav.d.ts +40 -0
  54. package/dist/schemas/nav.d.ts.map +1 -0
  55. package/dist/schemas/site-config.d.ts +11 -1
  56. package/dist/schemas/site-config.d.ts.map +1 -1
  57. package/dist/storage/index.d.ts +1 -1
  58. package/dist/storage/index.d.ts.map +1 -1
  59. package/dist/storage/index.js +9 -3
  60. package/dist/storage/types.d.ts +44 -0
  61. package/dist/storage/types.d.ts.map +1 -1
  62. package/package.json +5 -1
  63. package/src/collab/rebase.ts +35 -0
  64. package/src/components/editor/DropEdgeIndicator.tsx +15 -8
  65. package/src/components/editor/NavGroupRow.tsx +126 -0
  66. package/src/components/editor/PagesModal.tsx +150 -29
  67. package/src/components/shared/Navigation.tsx +51 -13
  68. package/src/components/shell/EditorShell.tsx +40 -10
  69. package/src/hooks/useSortableNavRow.ts +139 -0
  70. package/src/lib/dexie.ts +6 -1
  71. package/src/lib/index-ops.ts +309 -0
  72. package/src/lib/index.ts +1 -0
  73. package/src/lib/loader.ts +31 -4
  74. package/src/lib/nav-tree.ts +192 -0
  75. package/src/lib/nav.ts +43 -10
  76. package/src/lib/pages.ts +14 -11
  77. package/src/lib/reorder.ts +56 -0
  78. package/src/schemas/index.ts +1 -0
  79. package/src/schemas/nav.ts +36 -0
  80. package/src/schemas/site-config.ts +9 -1
  81. package/src/storage/index.ts +1 -1
  82. package/src/storage/types.ts +55 -0
  83. package/dist/chunk-VAOW4WXQ.js +0 -15
  84. package/dist/chunk-YBVK254P.js +0 -14
  85. /package/dist/{chunk-53DKVAB6.js → chunk-72CG6AA2.js} +0 -0
  86. /package/dist/{chunk-3HJDMFTB.js → chunk-XYCCXKVC.js} +0 -0
@@ -0,0 +1,139 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { computeNavDropTarget } from "../lib/reorder";
3
+ import type { NavLocation, NavRow } from "../lib/nav-tree";
4
+
5
+ interface Options {
6
+ /** This row's identity — supplies the source location and the drop container. */
7
+ row: NavRow;
8
+ /** The row's index in the TOP-LEVEL entry list (a member row: its group's index). */
9
+ entryIndex: number;
10
+ kind: "page" | "group";
11
+ onMove: (from: NavLocation, to: NavLocation) => void;
12
+ enabled?: boolean;
13
+ }
14
+
15
+ const DRAG_TYPE = "nav-tree-row";
16
+
17
+ /**
18
+ * Closest-edge drag wiring for one Pages-modal nav row. A sibling of
19
+ * useSortableRow that speaks NavLocation instead of a flat index; the section
20
+ * modal keeps using the original, so this cannot regress it.
21
+ *
22
+ * Loads the atlaskit adapter lazily, matching useSortableRow, so it stays out
23
+ * of the SSR and initial bundles.
24
+ */
25
+ export function useSortableNavRow<R extends HTMLElement, H extends HTMLElement>({
26
+ row, entryIndex, kind, onMove, enabled = true,
27
+ }: Options) {
28
+ const rowRef = useRef<R>(null);
29
+ const handleRef = useRef<H>(null);
30
+ const [isDragging, setIsDragging] = useState(false);
31
+ const [closestEdge, setClosestEdge] = useState<"top" | "bottom" | null>(null);
32
+ const [isDropTarget, setIsDropTarget] = useState(false);
33
+
34
+ // Read through a ref so the atlaskit effect doesn't re-subscribe every render.
35
+ const latest = useRef({ row, entryIndex, kind, onMove });
36
+ latest.current = { row, entryIndex, kind, onMove };
37
+
38
+ // MUST be a pure helper reading from `latest.current`, never a render-scope
39
+ // closure. The effect deliberately does not re-subscribe when the row's
40
+ // position changes, so a closure captured at mount would make every drag
41
+ // AFTER the first reorder report a stale `from` — and moveNavEntry would
42
+ // splice the wrong slot. Silent wrong-item moves, no error anywhere.
43
+ // (`useSortableRow` avoids this by re-subscribing on `[index, …]`; this hook
44
+ // cannot, because its identity is a NavLocation rather than a single number.)
45
+ const locationOf = (r: NavRow, ei: number): NavLocation =>
46
+ r.kind === "group"
47
+ ? { groupId: null, index: ei }
48
+ : r.kind === "page"
49
+ ? { groupId: r.groupId, index: r.index }
50
+ : r.kind === "empty-group"
51
+ ? { groupId: r.groupId, index: 0 }
52
+ : { groupId: null, index: ei };
53
+
54
+ useEffect(() => {
55
+ if (!enabled) return;
56
+ const element = rowRef.current;
57
+ const handle = handleRef.current;
58
+ if (!element) return;
59
+ // Only draggable rows need a handle; empty-group and tail rows are drop-only.
60
+ const dropOnly = row.kind === "empty-group" || row.kind === "tail";
61
+ if (!dropOnly && !handle) return;
62
+
63
+ let cleanup: (() => void) | undefined;
64
+ let cancelled = false;
65
+
66
+ Promise.all([
67
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
68
+ import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
69
+ ]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
70
+ if (cancelled) return;
71
+ const cleanups: (() => void)[] = [];
72
+
73
+ if (!dropOnly && handle) {
74
+ cleanups.push(draggable({
75
+ element, dragHandle: handle,
76
+ getInitialData: () => ({
77
+ dragType: DRAG_TYPE,
78
+ kind,
79
+ from: locationOf(latest.current.row, latest.current.entryIndex),
80
+ }),
81
+ onGenerateDragPreview: () => {
82
+ element.style.opacity = "0.4";
83
+ requestAnimationFrame(() => { element.style.opacity = ""; });
84
+ },
85
+ onDragStart: () => setIsDragging(true),
86
+ onDrop: () => setIsDragging(false),
87
+ }));
88
+ }
89
+
90
+ const resolve = (source: Record<string, unknown>, edge: "top" | "bottom" | null) =>
91
+ computeNavDropTarget({
92
+ from: source.from as NavLocation,
93
+ fromKind: source.kind as "page" | "group",
94
+ over: latest.current.row,
95
+ overEntryIndex: latest.current.entryIndex,
96
+ edge,
97
+ });
98
+
99
+ cleanups.push(dropTargetForElements({
100
+ element,
101
+ canDrop: ({ source }) => source.data.dragType === DRAG_TYPE,
102
+ getData: ({ input, element: el }) =>
103
+ attachClosestEdge({}, { input, element: el, allowedEdges: ["top", "bottom"] }),
104
+ onDragEnter: ({ self, source }) => {
105
+ const raw = extractClosestEdge(self.data);
106
+ const edge = raw === "top" || raw === "bottom" ? raw : null;
107
+ // Show an indicator only where the drop would actually land, so a
108
+ // rejected target reads as inert rather than as a silent failure.
109
+ const target = resolve(source.data, edge);
110
+ setClosestEdge(target ? edge : null);
111
+ setIsDropTarget(!!target);
112
+ },
113
+ onDrag: ({ self, source }) => {
114
+ const raw = extractClosestEdge(self.data);
115
+ const edge = raw === "top" || raw === "bottom" ? raw : null;
116
+ const target = resolve(source.data, edge);
117
+ setClosestEdge(target ? edge : null);
118
+ setIsDropTarget(!!target);
119
+ },
120
+ onDragLeave: () => { setClosestEdge(null); setIsDropTarget(false); },
121
+ onDrop: ({ self, source }) => {
122
+ setClosestEdge(null);
123
+ setIsDropTarget(false);
124
+ const raw = extractClosestEdge(self.data);
125
+ const edge = raw === "top" || raw === "bottom" ? raw : null;
126
+ const target = resolve(source.data, edge);
127
+ if (target) latest.current.onMove(source.data.from as NavLocation, target);
128
+ },
129
+ }));
130
+
131
+ cleanup = () => cleanups.forEach((c) => c());
132
+ });
133
+
134
+ return () => { cancelled = true; cleanup?.(); };
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ }, [enabled, row.kind, kind]);
137
+
138
+ return { rowRef, handleRef, isDragging, closestEdge, isDropTarget };
139
+ }
package/src/lib/dexie.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import Dexie from "dexie";
2
2
  import type { SectionContent } from "../schemas/sections";
3
3
  import type { SiteIndex, SectionMeta, SiteConfig, Page } from "../schemas/site-config";
4
+ import type { NavEntry } from "../schemas/nav";
4
5
  import type { LoadedSection } from "./loader";
5
6
  import type { MediaManifest, MediaItem } from "../media/types";
6
7
 
@@ -15,6 +16,8 @@ interface SiteIndexRow {
15
16
  key: string;
16
17
  pages: Page[];
17
18
  sections: Record<string, SectionMeta>;
19
+ /** Optional: rows written before page groups shipped have no nav. */
20
+ nav?: NavEntry[];
18
21
  deletedSections: string[];
19
22
  updatedAt: string;
20
23
  }
@@ -237,7 +240,7 @@ export async function restoreLocalChanges(): Promise<{
237
240
  const siteId = metaRow?.siteId ?? "";
238
241
  return {
239
242
  sections,
240
- siteIndex: { siteId, pages: indexRow.pages, sections: indexRow.sections },
243
+ siteIndex: { siteId, pages: indexRow.pages, sections: indexRow.sections, nav: indexRow.nav },
241
244
  siteConfig: configRow?.config,
242
245
  deletedSections: indexRow.deletedSections ?? [],
243
246
  };
@@ -266,6 +269,7 @@ export async function persistSiteIndex(index: SiteIndex, deletedSections: string
266
269
  key: "current",
267
270
  pages: index.pages,
268
271
  sections: index.sections,
272
+ nav: index.nav,
269
273
  deletedSections,
270
274
  updatedAt: now,
271
275
  });
@@ -389,6 +393,7 @@ export async function persistAll(
389
393
  key: "current",
390
394
  pages: siteIndex.pages,
391
395
  sections: siteIndex.sections,
396
+ nav: siteIndex.nav,
392
397
  deletedSections: [],
393
398
  updatedAt: now,
394
399
  });
@@ -0,0 +1,309 @@
1
+ import { z } from "zod";
2
+ import { IndexSchema, RESERVED_SLUGS, type SiteIndex, type SectionMeta } from "../schemas/site-config";
3
+
4
+ /**
5
+ * Server-applied index deltas for the MCP connector's save path. An agent
6
+ * cannot reliably hand-transcribe a real site's 40–51 KB index into a tool
7
+ * parameter (two consecutive attempts each introduced an id-level slip), so
8
+ * structural edits are expressed as ops and applied here against the index the
9
+ * write is concurrency-checked against.
10
+ *
11
+ * Pure by contract: no I/O, no storage, no octokit. The caller owns reading the
12
+ * base index at the right commit and committing the result.
13
+ */
14
+
15
+ // The three canonical statuses. Deliberately a plain enum rather than the
16
+ // schemas' StatusSchema (a ZodPipe with a "published" -> "live" transform):
17
+ // this shape is converted to JSON Schema for MCP clients, so it stays a flat
18
+ // enum, and reads never surface "published" anyway (they return normalized
19
+ // values).
20
+ const OpStatusSchema = z.enum(["draft", "live", "archived"]);
21
+
22
+ // Must match content-ops' SAFE_SECTION_ID: the id becomes a FILENAME
23
+ // (src/content/sections/<id>.json). Enforced here so a bad id is a named op
24
+ // error rather than a bare "[input] Invalid section ID" from deep in the write
25
+ // path, which names neither the op nor the id.
26
+ const SAFE_SECTION_ID = /^[a-zA-Z0-9_-]{1,64}$/;
27
+
28
+ const SectionInsertSchema = z.object({
29
+ id: z.string().regex(SAFE_SECTION_ID, "Section ids may only contain letters, numbers, dashes and underscores (max 64 chars) — they become filenames.").describe("The new section's id: letters, numbers, dashes, underscores; max 64 chars. Must not already exist in the index."),
30
+ status: OpStatusSchema.optional().describe('Defaults to "draft" — invisible to viewers until promoted to "live".'),
31
+ access: z.array(z.string()).optional().describe("Audience slugs from list_audiences. Defaults to [] (everyone)."),
32
+ });
33
+
34
+ export const IndexOpSchema = z.discriminatedUnion("op", [
35
+ z.object({
36
+ op: z.literal("insertAfter"),
37
+ pageId: z.string().describe("The page whose order receives the sections."),
38
+ afterId: z.string().nullable().describe("Insert directly after this section id, or null for the start of the page."),
39
+ sections: z.array(SectionInsertSchema).min(1).describe("Sections to create, in the order they should appear. Each needs its body in this call's `sections`."),
40
+ }),
41
+ z.object({
42
+ op: z.literal("insertAt"),
43
+ pageId: z.string().describe("The page whose order receives the sections."),
44
+ position: z.number().int().min(0).describe("0-based index in the page's order; equal to the order's length appends."),
45
+ sections: z.array(SectionInsertSchema).min(1).describe("Sections to create, in the order they should appear. Each needs its body in this call's `sections`."),
46
+ }),
47
+ z.object({
48
+ op: z.literal("move"),
49
+ pageId: z.string().describe("Destination page id (may be the section's current page)."),
50
+ id: z.string().describe("The existing section to move."),
51
+ afterId: z.string().nullable().describe("Place it directly after this section id in the destination page, or null for the start."),
52
+ }),
53
+ z.object({
54
+ op: z.literal("remove"),
55
+ ids: z.array(z.string()).min(1).describe("Sections to delete: removed from the page order AND from sections{}, and their content files are deleted."),
56
+ }),
57
+ z.object({
58
+ op: z.literal("setMeta"),
59
+ id: z.string().describe("An existing section id."),
60
+ status: OpStatusSchema.optional().describe('New status. "live" is what makes a section visible to viewers.'),
61
+ access: z.array(z.string()).optional().describe("New audience slugs from list_audiences; [] means everyone."),
62
+ }),
63
+ z.object({
64
+ op: z.literal("setPageMeta"),
65
+ pageId: z.string().describe("An existing page id."),
66
+ title: z.string().optional().describe("New page title."),
67
+ slug: z.string().optional().describe("New URL slug (lowercase words joined by dashes). Not settable on the home page, whose slug is always empty."),
68
+ showInNav: z.boolean().optional().describe("Whether the page appears in site navigation."),
69
+ access: z.array(z.string()).optional().describe("New audience slugs from list_audiences; [] means everyone."),
70
+ }),
71
+ ]);
72
+
73
+ export type IndexOp = z.infer<typeof IndexOpSchema>;
74
+
75
+ export type ApplyIndexOpsResult =
76
+ | { ok: true; index: SiteIndex; removedIds: string[]; warnings: string[] }
77
+ | { ok: false; error: string };
78
+
79
+ /** Section-body types keyed by id, for the ids an insert op creates. */
80
+ export function collectPostedTypes(sections: { id: string; content: unknown }[] | undefined): Record<string, string> {
81
+ const types: Record<string, string> = {};
82
+ for (const s of sections ?? []) {
83
+ const type = (s.content as { type?: unknown } | null | undefined)?.type;
84
+ if (typeof type === "string") types[s.id] = type;
85
+ }
86
+ return types;
87
+ }
88
+
89
+ /**
90
+ * Every section id the ops name, derivable from the ops ALONE (no index read).
91
+ * Callers need this before applying anything: the collab broadcast has to tell
92
+ * browser editors which rows moved, and the audit trail has to record that an
93
+ * ops-only save touched anything at all. `setPageMeta` names no section.
94
+ */
95
+ export function opTouchedSectionIds(ops: IndexOp[]): string[] {
96
+ const ids = new Set<string>();
97
+ for (const op of ops) {
98
+ if (op.op === "insertAfter" || op.op === "insertAt") for (const s of op.sections) ids.add(s.id);
99
+ else if (op.op === "move" || op.op === "setMeta") ids.add(op.id);
100
+ else if (op.op === "remove") for (const id of op.ids) ids.add(id);
101
+ }
102
+ return [...ids];
103
+ }
104
+
105
+ const fmt = (ids: string[]) => ids.map((id) => `"${id}"`).join(", ");
106
+
107
+ export function applyIndexOps(
108
+ rawIndex: unknown,
109
+ ops: IndexOp[],
110
+ newSectionTypes: Record<string, string>,
111
+ ): ApplyIndexOpsResult {
112
+ // Apply to the CANONICAL shape, not the raw one: a legacy index
113
+ // (`order`/`sections` with no `pages`) normalizes into a synthetic home page
114
+ // carrying those ids, and ops against the raw shape would find no pages at
115
+ // all and silently miss every id.
116
+ const base = IndexSchema.safeParse(rawIndex);
117
+ if (!base.success) {
118
+ return {
119
+ ok: false,
120
+ error:
121
+ "the site's current index could not be parsed, so indexOps cannot be applied — read it with get_site_index and post a repaired full `index` instead. " +
122
+ base.error.issues.map((i) => i.message).join("; "),
123
+ };
124
+ }
125
+
126
+ // Own copy: the caller's index is also its knownSiteTypes source and may be
127
+ // logged, so nothing here mutates it.
128
+ const index: SiteIndex = {
129
+ ...base.data,
130
+ pages: base.data.pages.map((p) => ({ ...p, order: [...p.order] })),
131
+ sections: Object.fromEntries(Object.entries(base.data.sections).map(([k, v]) => [k, { ...v }])),
132
+ };
133
+
134
+ const removed = new Set<string>();
135
+ const warnings: string[] = [];
136
+ const fail = (error: string): ApplyIndexOpsResult => ({ ok: false, error });
137
+
138
+ for (const [i, op] of ops.entries()) {
139
+ const at = `indexOps[${i}] ${op.op}`;
140
+
141
+ if (op.op === "insertAfter" || op.op === "insertAt") {
142
+ const page = index.pages.find((p) => p.id === op.pageId);
143
+ if (!page) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
144
+
145
+ let position: number;
146
+ if (op.op === "insertAfter") {
147
+ if (op.afterId === null) position = 0;
148
+ else {
149
+ const idx = page.order.indexOf(op.afterId);
150
+ if (idx === -1) return fail(`${at}: afterId "${op.afterId}" is not in page "${op.pageId}"'s order.`);
151
+ position = idx + 1;
152
+ }
153
+ } else {
154
+ if (op.position > page.order.length) {
155
+ return fail(`${at}: position ${op.position} is out of range for page "${op.pageId}" (valid: 0..${page.order.length}).`);
156
+ }
157
+ position = op.position;
158
+ }
159
+
160
+ const metas: [string, SectionMeta][] = [];
161
+ // Ids in THIS op are not in `index.sections` yet, so the existence check
162
+ // below cannot see them — without a local seen-set a repeated id splices
163
+ // twice and only fails at the final re-validation, as "appears in more
164
+ // than one page order" with no op index and a misleading description.
165
+ const seen = new Set<string>();
166
+ for (const s of op.sections) {
167
+ if (seen.has(s.id)) return fail(`${at}: section "${s.id}" is listed twice in this op's \`sections\`.`);
168
+ seen.add(s.id);
169
+ if (index.sections[s.id]) {
170
+ return fail(`${at}: section "${s.id}" already exists in the index — use move to relocate it, or setMeta to change its status/access.`);
171
+ }
172
+ if (!SAFE_SECTION_ID.test(s.id)) {
173
+ return fail(`${at}: section id "${s.id}" is not usable — ids become filenames, so they may only contain letters, numbers, dashes and underscores (max 64 chars).`);
174
+ }
175
+ const type = newSectionTypes[s.id];
176
+ if (!type) {
177
+ // Covers both "no body posted" and "body posted but its `type` is
178
+ // missing or not a string" — collectPostedTypes only records usable
179
+ // string types, so this cannot tell them apart, and the old wording
180
+ // ("has no body") sent an agent hunting for a body that was right there.
181
+ return fail(`${at}: section "${s.id}" has no usable body in this call — post its full body ({ "type": "...", "content": {...} }) in \`sections\` in the same save; an inserted section's type comes from the body.`);
182
+ }
183
+ const meta: SectionMeta = { type, status: s.status ?? "draft", access: s.access ?? [] };
184
+ // Same advisory as `move`: an inserted section whose audiences the
185
+ // destination page does not allow is invisible to every audienced
186
+ // viewer, and saying nothing would make that look like a successful save.
187
+ const stranded = page.access.length > 0 ? meta.access.filter((a) => !page.access.includes(a)) : [];
188
+ if (stranded.length > 0) {
189
+ warnings.push(
190
+ `${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.`,
191
+ );
192
+ }
193
+ metas.push([s.id, meta]);
194
+ }
195
+ page.order.splice(position, 0, ...metas.map(([id]) => id));
196
+ for (const [id, meta] of metas) index.sections[id] = meta;
197
+ continue;
198
+ }
199
+
200
+ if (op.op === "move") {
201
+ const meta = index.sections[op.id];
202
+ if (!meta) return fail(`${at}: section "${op.id}" is not in the index.`);
203
+ const dest = index.pages.find((p) => p.id === op.pageId);
204
+ if (!dest) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
205
+ if (op.afterId === op.id) return fail(`${at}: cannot move section "${op.id}" after itself.`);
206
+
207
+ for (const p of index.pages) {
208
+ const idx = p.order.indexOf(op.id);
209
+ if (idx !== -1) p.order.splice(idx, 1);
210
+ }
211
+ let position: number;
212
+ if (op.afterId === null) position = 0;
213
+ else {
214
+ const idx = dest.order.indexOf(op.afterId);
215
+ if (idx === -1) return fail(`${at}: afterId "${op.afterId}" is not in destination page "${op.pageId}"'s order.`);
216
+ position = idx + 1;
217
+ }
218
+ dest.order.splice(position, 0, op.id);
219
+
220
+ // Advisory, never a silent rewrite: the editor's moveSection resets an
221
+ // incompatible section access, but an API doing that invisibly would
222
+ // change who can see content without saying so. "no audienced viewer",
223
+ // not "everyone" — audiencePasses returns true for a viewer with no
224
+ // audience at all (lib/pages.ts).
225
+ const stranded = dest.access.length > 0 ? meta.access.filter((a) => !dest.access.includes(a)) : [];
226
+ if (stranded.length > 0) {
227
+ warnings.push(
228
+ `${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.`,
229
+ );
230
+ }
231
+ continue;
232
+ }
233
+
234
+ if (op.op === "remove") {
235
+ for (const id of op.ids) {
236
+ if (!index.sections[id]) return fail(`${at}: section "${id}" is not in the index.`);
237
+ delete index.sections[id];
238
+ for (const p of index.pages) {
239
+ const idx = p.order.indexOf(id);
240
+ if (idx !== -1) p.order.splice(idx, 1);
241
+ }
242
+ removed.add(id);
243
+ }
244
+ continue;
245
+ }
246
+
247
+ if (op.op === "setMeta") {
248
+ if (op.status === undefined && op.access === undefined) {
249
+ return fail(`${at}: nothing to set — provide status and/or access.`);
250
+ }
251
+ const meta = index.sections[op.id];
252
+ if (!meta) return fail(`${at}: section "${op.id}" is not in the index.`);
253
+ if (op.status !== undefined) meta.status = op.status;
254
+ if (op.access !== undefined) meta.access = op.access;
255
+ continue;
256
+ }
257
+
258
+ // setPageMeta
259
+ if (op.title === undefined && op.slug === undefined && op.showInNav === undefined && op.access === undefined) {
260
+ return fail(`${at}: nothing to set — provide title, slug, showInNav and/or access.`);
261
+ }
262
+ const page = index.pages.find((p) => p.id === op.pageId);
263
+ if (!page) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
264
+ if (op.slug !== undefined && page.isHome) {
265
+ return fail(`${at}: the home page's slug is always empty and cannot be set. Promote a different page to home in the editor if you need to change which page is the root.`);
266
+ }
267
+ // IndexSchema hard-rejects only the five slugs reserved before client sites
268
+ // existed (PARSE_REJECTED_SLUGS) — the newer ones ("present", "print") are
269
+ // enforced at AUTHORING time on purpose, so a grandfathered index still
270
+ // loads (see schemas/site-config.ts). This is an authoring surface, so it
271
+ // owns that enforcement; without it a page silently disappears behind an
272
+ // injected platform route with no error anywhere.
273
+ if (op.slug !== undefined && (RESERVED_SLUGS as readonly string[]).includes(op.slug)) {
274
+ return fail(`${at}: slug "${op.slug}" is reserved by a platform route and would shadow this page. Pick another slug.`);
275
+ }
276
+ if (op.title !== undefined) page.title = op.title;
277
+ if (op.slug !== undefined) page.slug = op.slug;
278
+ if (op.showInNav !== undefined) page.showInNav = op.showInNav;
279
+ if (op.access !== undefined) {
280
+ const dropped = page.access.filter((a) => !op.access!.includes(a));
281
+ page.access = op.access;
282
+ if (dropped.length > 0 && op.access.length > 0) {
283
+ const stranded = page.order.filter((id) => index.sections[id]?.access.some((a) => dropped.includes(a)));
284
+ if (stranded.length > 0) {
285
+ warnings.push(
286
+ `${at}: section(s) ${fmt(stranded)} still carry audience(s) ${fmt(dropped)} that page "${op.pageId}" no longer allows, so no audienced viewer will see them — clear them with setMeta.`,
287
+ );
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ // A remove followed by a re-insert of the same id is a net no-op for files:
294
+ // reporting it for deletion would delete the file the resulting index
295
+ // references, and content-ops' stillReferenced guard would reject the write.
296
+ const removedIds = [...removed].filter((id) => !index.sections[id]);
297
+
298
+ // Ops cannot break the order/sections partition by construction, but they can
299
+ // break page-level invariants the ops above do not check themselves: a
300
+ // duplicate slug, an invalid slug shape, one of the five parse-rejected legacy
301
+ // slugs. Re-run the canonical validation so the caller gets those named here
302
+ // rather than as an opaque ZodError from the write path. (The NEWER reserved
303
+ // slugs are not in that set — setPageMeta checks them itself, above.)
304
+ const validated = IndexSchema.safeParse(index);
305
+ if (!validated.success) {
306
+ return fail(`applying indexOps produced an invalid index: ${validated.error.issues.map((issue) => issue.message).join("; ")}`);
307
+ }
308
+ return { ok: true, index: validated.data, removedIds, warnings };
309
+ }
package/src/lib/index.ts CHANGED
@@ -3,6 +3,7 @@ export { cn } from "./cn";
3
3
  export { audiencePasses } from "./pages";
4
4
  export { resolveChatbotName, resolveThinkingPhrases, DEFAULT_CHATBOT_NAME } from "./chatbot";
5
5
  export { generateNavLinks, toSectionId, type NavItem } from "./nav";
6
+ export { navEntries, pagesInNavOrder, groupOfPage, groupDisplayLabel, reconcileNav } from "./nav-tree";
6
7
  export {
7
8
  deriveContrast,
8
9
  contrastColor,
package/src/lib/loader.ts CHANGED
@@ -3,6 +3,7 @@ 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 { NavFileSchema, type NavEntry } from "../schemas/nav";
6
7
 
7
8
  export interface LoadedSection {
8
9
  section: Section;
@@ -14,6 +15,21 @@ export interface SiteContent {
14
15
  index: SiteIndex;
15
16
  }
16
17
 
18
+ /**
19
+ * Parse the nav.json sidecar defensively. A malformed sidecar must degrade to a
20
+ * flat nav, never brick the site — nav.json is optional content, and the whole
21
+ * point of the sidecar is that it tolerates writers that don't understand it.
22
+ */
23
+ function parseNavFile(navFile: unknown): NavEntry[] {
24
+ if (!navFile) return [];
25
+ const result = NavFileSchema.safeParse(navFile);
26
+ if (!result.success) {
27
+ console.warn("Invalid nav.json, falling back to a flat navigation");
28
+ return [];
29
+ }
30
+ return result.data.entries;
31
+ }
32
+
17
33
  /**
18
34
  * Merge a validated index with raw section file contents.
19
35
  * Validates each section file against its Zod schema.
@@ -21,7 +37,8 @@ export interface SiteContent {
21
37
  */
22
38
  export function mergeSiteContent(
23
39
  index: SiteIndex,
24
- sectionFiles: Record<string, unknown>
40
+ sectionFiles: Record<string, unknown>,
41
+ navFile?: unknown,
25
42
  ): SiteContent {
26
43
  const sections: LoadedSection[] = [];
27
44
 
@@ -66,7 +83,7 @@ export function mergeSiteContent(
66
83
  }
67
84
  }
68
85
 
69
- return { sections, index };
86
+ return { sections, index: { ...index, nav: parseNavFile(navFile) } };
70
87
  }
71
88
 
72
89
  /**
@@ -77,6 +94,7 @@ export function mergeSiteContent(
77
94
  export function loadStaticSiteContent(
78
95
  index: SiteIndex,
79
96
  sectionGlob: Record<string, unknown>,
97
+ navFile?: unknown,
80
98
  ): SiteContent {
81
99
  const sectionFiles: Record<string, unknown> = {};
82
100
  for (const [path, data] of Object.entries(sectionGlob)) {
@@ -84,7 +102,7 @@ export function loadStaticSiteContent(
84
102
  const id = filename.replace(".json", "");
85
103
  sectionFiles[id] = data;
86
104
  }
87
- return mergeSiteContent(index, sectionFiles);
105
+ return mergeSiteContent(index, sectionFiles, navFile);
88
106
  }
89
107
 
90
108
  /**
@@ -105,5 +123,14 @@ export async function loadSiteContent(contentDir: string): Promise<SiteContent>
105
123
  sectionFiles[id] = JSON.parse(await fs.readFile(sectionPath, "utf-8"));
106
124
  }
107
125
 
108
- return mergeSiteContent(index, sectionFiles);
126
+ // nav.json is an optional sidecar — absent (every portal predating page
127
+ // groups) or unreadable must degrade to a flat nav, never fail the load.
128
+ let navFile: unknown;
129
+ try {
130
+ navFile = JSON.parse(await fs.readFile(path.join(contentDir, "nav.json"), "utf-8"));
131
+ } catch {
132
+ navFile = undefined;
133
+ }
134
+
135
+ return mergeSiteContent(index, sectionFiles, navFile);
109
136
  }