@drawnagency/primitives 0.1.97 → 0.1.99

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 (67) hide show
  1. package/dist/{chunk-F2337B35.js → chunk-26ALJO4K.js} +1 -1
  2. package/dist/{chunk-S4L3ZFKM.js → chunk-3COSXTOY.js} +1 -1
  3. package/dist/{chunk-RHWWQV3J.js → chunk-BGHWUJFV.js} +1 -0
  4. package/dist/{chunk-QEHPGA5N.js → chunk-CZLYECHC.js} +20 -9
  5. package/dist/{chunk-YDXBJR74.js → chunk-EWNDBKYK.js} +1 -1
  6. package/dist/{chunk-EUSWEVOA.js → chunk-FAZFADJK.js} +9 -0
  7. package/dist/{chunk-XYCCXKVC.js → chunk-FOY5TF7D.js} +1 -1
  8. package/dist/{chunk-II3X7GLJ.js → chunk-GYCBWGVW.js} +1 -1
  9. package/dist/{chunk-FOEZNEF5.js → chunk-HKHEAH5F.js} +6 -6
  10. package/dist/{chunk-5BFPJWIO.js → chunk-KP4DWTPR.js} +3 -3
  11. package/dist/{chunk-KPP2CFW6.js → chunk-MUTHF2GT.js} +2 -2
  12. package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
  13. package/dist/chunk-TGNS7D4F.js +200 -0
  14. package/dist/collab/rebase.d.ts.map +1 -1
  15. package/dist/collab/rebase.js +4 -2
  16. package/dist/components/editor/index.js +5 -5
  17. package/dist/components/primitives/index.js +6 -6
  18. package/dist/components/sections/brand-guide.js +6 -6
  19. package/dist/components/sections/register-schemas.js +9 -9
  20. package/dist/components/sections/register.js +9 -9
  21. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  22. package/dist/hooks/useEditorPersistence.d.ts +7 -0
  23. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  24. package/dist/hooks/useEditorPublish.d.ts +6 -1
  25. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  26. package/dist/index.js +16 -12
  27. package/dist/lib/block-move.d.ts.map +1 -1
  28. package/dist/lib/dexie.d.ts.map +1 -1
  29. package/dist/lib/dexie.js +6 -1
  30. package/dist/lib/index-ops.d.ts +139 -2
  31. package/dist/lib/index-ops.d.ts.map +1 -1
  32. package/dist/lib/index-ops.js +189 -9
  33. package/dist/lib/index.js +5 -5
  34. package/dist/lib/loader.d.ts +6 -0
  35. package/dist/lib/loader.d.ts.map +1 -1
  36. package/dist/lib/pages.d.ts +18 -0
  37. package/dist/lib/pages.d.ts.map +1 -1
  38. package/dist/media/index.js +3 -3
  39. package/dist/media/provider.d.ts +0 -1
  40. package/dist/media/provider.d.ts.map +1 -1
  41. package/dist/schemas/index.js +5 -3
  42. package/dist/schemas/site-config.d.ts +1 -0
  43. package/dist/schemas/site-config.d.ts.map +1 -1
  44. package/dist/storage/index.d.ts +1 -1
  45. package/dist/storage/index.d.ts.map +1 -1
  46. package/dist/storage/index.js +3 -1
  47. package/dist/storage/types.d.ts +22 -5
  48. package/dist/storage/types.d.ts.map +1 -1
  49. package/dist/types/database.d.ts +14 -3
  50. package/dist/types/database.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/collab/rebase.ts +15 -0
  53. package/src/components/shell/EditorShell.tsx +70 -11
  54. package/src/hooks/useEditorPersistence.ts +14 -0
  55. package/src/hooks/useEditorPublish.ts +17 -3
  56. package/src/lib/block-move.ts +15 -4
  57. package/src/lib/dexie.ts +6 -1
  58. package/src/lib/index-ops.ts +277 -7
  59. package/src/lib/loader.ts +32 -11
  60. package/src/lib/pages.ts +48 -0
  61. package/src/media/provider.ts +0 -1
  62. package/src/schemas/nav.ts +3 -3
  63. package/src/schemas/site-config.ts +3 -1
  64. package/src/storage/index.ts +1 -1
  65. package/src/storage/types.ts +25 -5
  66. package/src/types/database.ts +14 -3
  67. package/dist/chunk-ZD6XGRQM.js +0 -85
@@ -24,7 +24,7 @@ function blobToBase64(blob: Blob): Promise<string> {
24
24
  * is preserved verbatim; non-JSON bodies (platform-level rejections) get a
25
25
  * status-coded fallback — or a size hint when the request body itself was
26
26
  * near Netlify's ~6MB limit (should be unreachable once saves are chunked). */
27
- async function postSave(payload: object): Promise<{ sha: string }> {
27
+ async function postSave(payload: object): Promise<{ sha: string; warnings?: string[] }> {
28
28
  const body = JSON.stringify(payload);
29
29
  let response: Response;
30
30
  try {
@@ -47,9 +47,9 @@ async function postSave(payload: object): Promise<{ sha: string }> {
47
47
  : `Save failed (HTTP ${response.status})`;
48
48
  throw Object.assign(new Error(serverMessage || fallback), { status: response.status });
49
49
  }
50
- const data = await response.json().catch(() => null) as { sha?: string } | null;
50
+ const data = await response.json().catch(() => null) as { sha?: string; warnings?: string[] } | null;
51
51
  if (!data?.sha) throw new Error("Save response missing SHA");
52
- return data as { sha: string };
52
+ return data as { sha: string; warnings?: string[] };
53
53
  }
54
54
 
55
55
  interface PublishDeps {
@@ -98,6 +98,11 @@ interface PublishDeps {
98
98
  * dies at the first save (discardSavedChanges clears it) and later 409
99
99
  * recoveries would silently merge local-wins. */
100
100
  onBaselineCommitted?: (args: { sha: string; sections: LoadedSection[]; index: SiteIndex; savedIds: string[] }) => Promise<void>;
101
+ /** Injected by EditorShell. Receives non-fatal server warnings from a
102
+ * successful save (e.g. the nav.json replacement was declined by the
103
+ * degraded-nav guard) so the shell can surface them instead of the save
104
+ * reading as fully landed. */
105
+ onSaveWarnings?: (warnings: string[]) => void;
101
106
  /** Test hook: overrides the per-request payload budget (default 4MB). */
102
107
  saveChunkBudget?: number;
103
108
  /** Injected by EditorShell. Mirrors this hook's in-flight-save state out to the
@@ -141,6 +146,7 @@ export function useEditorPublish({
141
146
  getBaseVersion,
142
147
  recoverConflict,
143
148
  onBaselineCommitted,
149
+ onSaveWarnings,
144
150
  saveChunkBudget,
145
151
  savingRef,
146
152
  getEditGeneration,
@@ -232,6 +238,7 @@ export function useEditorPublish({
232
238
  mediaManifest: MediaManifest;
233
239
  manifestDirty: boolean;
234
240
  saveChunkBudget?: number;
241
+ onSaveWarnings?: (warnings: string[]) => void;
235
242
  }): Promise<{
236
243
  sha: string;
237
244
  // sectionId+updatedAt for compare-and-clear cleanup, plus the SENT content —
@@ -337,6 +344,12 @@ export function useEditorPublish({
337
344
  } : {}),
338
345
  });
339
346
 
347
+ // Surface server-side warnings (e.g. the degraded-nav guard declining to
348
+ // replace nav.json) — the save succeeded, but part of it was refused, and
349
+ // the editor believing it landed is exactly the silence the guard exists
350
+ // to break.
351
+ if (responseData.warnings?.length) args.onSaveWarnings?.(responseData.warnings);
352
+
340
353
  return {
341
354
  sha: responseData.sha,
342
355
  savedSections: dirty.map(({ sectionId, updatedAt, content }) => ({ sectionId, updatedAt, content })),
@@ -401,6 +414,7 @@ export function useEditorPublish({
401
414
  // below sees recoverConflict's rebased manifest, not this render's copy.
402
415
  mediaManifest: mediaManifestRef?.current ?? mediaManifest,
403
416
  saveChunkBudget,
417
+ onSaveWarnings,
404
418
  });
405
419
  }
406
420
 
@@ -3,7 +3,16 @@ import type { Section } from "../schemas/sections";
3
3
  import type { SiteIndex, SectionMeta } from "../schemas/site-config";
4
4
  import { ROOT_CONTAINER_ID, canDropBlock } from "./block-dnd";
5
5
  import { getContainerChildren, reorderChild, insertChildAt, removeChildAt, placeBlockAtColumn, trimTrailingSpacers } from "./container-ops";
6
- import { reorderSectionInPage, addSectionToPage, removeSectionFromPages } from "./pages";
6
+ import { reorderVisibleSectionInPage, visibleInsertPosition, addSectionToPage, removeSectionFromPages } from "./pages";
7
+
8
+ /** The root list the canvas actually renders: page order filtered to loaded
9
+ * bodies. Drag indices refer to THIS list, not to `page.order` — a section
10
+ * whose body the loader dropped (unparseable file, schema miss) is invisible
11
+ * but still holds its order slot. */
12
+ function visibleRootIds(index: SiteIndex, rootPageId: string, sections: LoadedSection[]): string[] {
13
+ const bodyIds = new Set(sections.map((l) => l.section.id));
14
+ return index.pages.find((p) => p.id === rootPageId)?.order.filter((id) => bodyIds.has(id)) ?? [];
15
+ }
7
16
 
8
17
  export interface BlockMoveState {
9
18
  sections: LoadedSection[];
@@ -84,11 +93,11 @@ export function applyBlockMove(
84
93
 
85
94
  if (fromContainerId === toContainerId) {
86
95
  // toIndex is the raw insertion index in the original (pre-removal) array.
87
- // Both reorderSectionInPage and reorderChild use splice(from,1)+splice(to,0,moved),
96
+ // Both reorder paths use splice(from,1)+splice(to,0,moved),
88
97
  // so we must convert to the post-removal position for same-container moves.
89
98
  const adjusted = fromIndex < toIndex ? toIndex - 1 : toIndex;
90
99
  if (fromContainerId === ROOT_CONTAINER_ID) {
91
- const nextIndex = reorderSectionInPage(index, rootPageId, fromIndex, adjusted);
100
+ const nextIndex = reorderVisibleSectionInPage(index, rootPageId, visibleRootIds(index, rootPageId, sections), fromIndex, adjusted);
92
101
  if (nextIndex === index) return noop(state);
93
102
  return {
94
103
  sections,
@@ -133,7 +142,9 @@ export function applyBlockMove(
133
142
  if (toContainerId === ROOT_CONTAINER_ID) {
134
143
  const { layout: _dropLayout, ...rootBlock } = moving as Section & { layout?: unknown };
135
144
  const meta = DEFAULT_CHILD_META(rootBlock.type);
136
- nextIndex = addSectionToPage(nextIndex, rootPageId, rootBlock.id, meta, toIndex);
145
+ const rootOrder = nextIndex.pages.find((p) => p.id === rootPageId)?.order ?? [];
146
+ nextIndex = addSectionToPage(nextIndex, rootPageId, rootBlock.id, meta,
147
+ visibleInsertPosition(rootOrder, visibleRootIds(nextIndex, rootPageId, nextSections), toIndex));
137
148
  nextSections = [...nextSections, { section: rootBlock as Section, meta }];
138
149
  indexDirty = true;
139
150
  createSectionIds.push(rootBlock.id);
package/src/lib/dexie.ts CHANGED
@@ -394,7 +394,12 @@ export async function persistAll(
394
394
  pages: siteIndex.pages,
395
395
  sections: siteIndex.sections,
396
396
  nav: siteIndex.nav,
397
- deletedSections: [],
397
+ // Record the deletion INTENT, not just its effect on the order. The
398
+ // restore path reads this back; when it was hardcoded `[]` that path
399
+ // had to re-derive deletions by diffing this snapshot against a fresh
400
+ // remote read, which treats a third party's additions (MCP connector,
401
+ // peer editor) as this editor's deletions.
402
+ deletedSections: deletedSectionIds ?? [],
398
403
  updatedAt: now,
399
404
  });
400
405
  await database.meta.put({
@@ -1,5 +1,15 @@
1
1
  import { z } from "zod";
2
- import { IndexSchema, RESERVED_SLUGS, type SiteIndex, type SectionMeta } from "../schemas/site-config";
2
+ import { IndexSchema, PageStatusSchema, RESERVED_SLUGS, SLUG_RE, type SiteIndex, type SectionMeta } from "../schemas/site-config";
3
+ import type { NavEntry, NavGroupEntry } from "../schemas/nav";
4
+ import type { NavLocation } from "./nav-tree";
5
+ // Page and nav mutations are shared with the editor rather than reimplemented:
6
+ // both surfaces must agree on what "delete a page" or "remove a group" means, and
7
+ // a second implementation here is how they would drift.
8
+ import { addPage as addPageToIndex, deletePage, slugifyPageSlug, uniquePageSlug } from "./pages";
9
+ import {
10
+ addGroup, deleteGroup, groupOfPage, moveNavEntry, navEntries, reconcileNav,
11
+ renameGroup, setGroupStartCollapsed,
12
+ } from "./nav-tree";
3
13
 
4
14
  /**
5
15
  * Server-applied index deltas for the MCP connector's save path. An agent
@@ -25,6 +35,11 @@ const OpStatusSchema = z.enum(["draft", "live", "archived"]);
25
35
  // path, which names neither the op nor the id.
26
36
  const SAFE_SECTION_ID = /^[a-zA-Z0-9_-]{1,64}$/;
27
37
 
38
+ // Page and nav-group ids are not filenames, but they are dictionary keys an
39
+ // agent has to echo back, so they get the same tame character set. Keeping the
40
+ // shapes identical also means one remembered rule covers every id in this API.
41
+ const SAFE_ENTITY_ID = /^[a-zA-Z0-9_-]{1,64}$/;
42
+
28
43
  const SectionInsertSchema = z.object({
29
44
  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
45
  status: OpStatusSchema.optional().describe('Defaults to "draft" — invisible to viewers until promoted to "live".'),
@@ -67,10 +82,81 @@ export const IndexOpSchema = z.discriminatedUnion("op", [
67
82
  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
83
  showInNav: z.boolean().optional().describe("Whether the page appears in site navigation."),
69
84
  access: z.array(z.string()).optional().describe("New audience slugs from list_audiences; [] means everyone."),
85
+ status: PageStatusSchema.optional().describe('Page status. "archived" hides the page and its sections from viewers without deleting anything; the home page cannot be archived.'),
86
+ }),
87
+ z.object({
88
+ op: z.literal("addPage"),
89
+ id: z.string().regex(SAFE_ENTITY_ID, "Page ids may only contain letters, numbers, dashes and underscores (max 64 chars).").describe("The new page's id: letters, numbers, dashes, underscores; max 64 chars. Must not already exist."),
90
+ title: z.string().describe("Page title, shown in navigation."),
91
+ slug: z.string().optional().describe("URL slug (lowercase words joined by dashes). Defaults to a unique slug derived from the title."),
92
+ showInNav: z.boolean().optional().describe("Whether the page appears in site navigation. Defaults to true."),
93
+ access: z.array(z.string()).optional().describe("Audience slugs from list_audiences. Defaults to [] (everyone)."),
94
+ navGroupId: z.string().optional().describe("Put the new page inside this existing nav group instead of at the top level."),
95
+ }),
96
+ z.object({
97
+ op: z.literal("removePage"),
98
+ pageId: z.string().describe("The page to delete. The home page cannot be deleted."),
99
+ removeSections: z.boolean().optional().describe("Required `true` when the page still holds sections — they are deleted with it and their content files removed. Without it a non-empty page is refused, so a page is never emptied by accident."),
100
+ }),
101
+ z.object({
102
+ op: z.literal("addNavGroup"),
103
+ id: z.string().regex(SAFE_ENTITY_ID, "Nav group ids may only contain letters, numbers, dashes and underscores (max 64 chars).").describe("The new group's id: letters, numbers, dashes, underscores; max 64 chars."),
104
+ label: z.string().optional().describe('Group heading in the sidebar. Blank renders as "Untitled group".'),
105
+ startCollapsed: z.boolean().optional().describe("Whether the group renders collapsed on first viewer load. Defaults to false."),
106
+ pageIds: z.array(z.string()).optional().describe("Existing page ids to move into the group, in order. A page belongs to at most one group, so listing it here removes it from wherever it was."),
107
+ }),
108
+ z.object({
109
+ op: z.literal("removeNavGroup"),
110
+ groupId: z.string().describe("The group to remove. Its member pages are NOT deleted — they become top-level nav entries at the group's former position."),
111
+ }),
112
+ z.object({
113
+ op: z.literal("setNavGroupMeta"),
114
+ groupId: z.string().describe("An existing nav group id."),
115
+ label: z.string().optional().describe("New sidebar heading."),
116
+ startCollapsed: z.boolean().optional().describe("Whether the group renders collapsed on first viewer load."),
117
+ }),
118
+ z.object({
119
+ op: z.literal("movePageInNav"),
120
+ pageId: z.string().describe("The page to reposition in the sidebar."),
121
+ groupId: z.string().nullable().describe("Destination group id, or null to move the page out to the top level."),
122
+ position: z.number().int().min(0).optional().describe("0-based slot within the destination (inside the group, or among top-level nav entries). Defaults to the end."),
70
123
  }),
71
124
  ]);
72
125
 
73
- export type IndexOp = z.infer<typeof IndexOpSchema>;
126
+ /**
127
+ * Written out rather than `z.infer<typeof IndexOpSchema>`.
128
+ *
129
+ * The MCP SDK infers a tool's handler and JSON-Schema types from the Zod shape it
130
+ * is registered with, and at twelve union members each carrying several described
131
+ * optionals, re-deriving this union at that boundary overflowed tsc's stack — the
132
+ * `apps/mcp` typecheck segfaulted rather than failing with a diagnostic. Naming
133
+ * the type once caps that inference: consumers see this union directly instead of
134
+ * re-deriving it through the schema. `assertIndexOpTypesMatchSchema` in
135
+ * tests/lib/index-ops.test.ts pins the two together so they cannot drift.
136
+ */
137
+ export type IndexOp =
138
+ | { op: "insertAfter"; pageId: string; afterId: string | null; sections: SectionInsert[] }
139
+ | { op: "insertAt"; pageId: string; position: number; sections: SectionInsert[] }
140
+ | { op: "move"; pageId: string; id: string; afterId: string | null }
141
+ | { op: "remove"; ids: string[] }
142
+ | { op: "setMeta"; id: string; status?: OpStatus; access?: string[] }
143
+ | {
144
+ op: "setPageMeta"; pageId: string; title?: string; slug?: string;
145
+ showInNav?: boolean; access?: string[]; status?: PageStatus;
146
+ }
147
+ | {
148
+ op: "addPage"; id: string; title: string; slug?: string;
149
+ showInNav?: boolean; access?: string[]; navGroupId?: string;
150
+ }
151
+ | { op: "removePage"; pageId: string; removeSections?: boolean }
152
+ | { op: "addNavGroup"; id: string; label?: string; startCollapsed?: boolean; pageIds?: string[] }
153
+ | { op: "removeNavGroup"; groupId: string }
154
+ | { op: "setNavGroupMeta"; groupId: string; label?: string; startCollapsed?: boolean }
155
+ | { op: "movePageInNav"; pageId: string; groupId: string | null; position?: number };
156
+
157
+ type OpStatus = z.infer<typeof OpStatusSchema>;
158
+ type PageStatus = z.infer<typeof PageStatusSchema>;
159
+ type SectionInsert = z.infer<typeof SectionInsertSchema>;
74
160
 
75
161
  export type ApplyIndexOpsResult =
76
162
  | { ok: true; index: SiteIndex; removedIds: string[]; warnings: string[] }
@@ -102,8 +188,53 @@ export function opTouchedSectionIds(ops: IndexOp[]): string[] {
102
188
  return [...ids];
103
189
  }
104
190
 
191
+ /**
192
+ * The ops that materialize and write the nav sidecar (the ones that set
193
+ * `navTouched` in applyIndexOps — keep the two in sync; the "nav sidecar
194
+ * preservation" test asserts they agree). Callers must refuse to apply these
195
+ * over a DEGRADED nav read: the tree they'd commit was built from only the
196
+ * entries that version could parse, so writing it destroys the rest.
197
+ * Section-level ops are absent by design — they emit no `nav` key at all.
198
+ */
199
+ const NAV_TOUCHING_OPS = new Set<IndexOp["op"]>([
200
+ "addPage", "removePage", "addNavGroup", "removeNavGroup", "setNavGroupMeta", "movePageInNav",
201
+ ]);
202
+
203
+ export function opsTouchNav(ops: IndexOp[]): boolean {
204
+ return ops.some((op) => NAV_TOUCHING_OPS.has(op.op));
205
+ }
206
+
105
207
  const fmt = (ids: string[]) => ids.map((id) => `"${id}"`).join(", ");
106
208
 
209
+ /**
210
+ * Id-based reparent/reposition of a page in the nav, expressed over the editor's
211
+ * index-based `moveNavEntry` primitive. Agents address pages and groups by id;
212
+ * the drag-and-drop surface addresses slots — this is the adapter between them,
213
+ * so both still go through one move implementation.
214
+ *
215
+ * `position === undefined` appends. `moveNavEntry` lifts the page out of its
216
+ * source container BEFORE inserting, so an append index computed against the
217
+ * pre-removal length can overshoot by one; it clamps, so that is safe.
218
+ */
219
+ function moveIntoGroup(
220
+ index: SiteIndex,
221
+ pageId: string,
222
+ groupId: string | null,
223
+ position: number | undefined,
224
+ ): SiteIndex {
225
+ const entries = navEntries(index);
226
+ const currentGroup = groupOfPage(index, pageId);
227
+ // navEntries reconciles, so the page is guaranteed to appear exactly once —
228
+ // either as a top-level entry or inside exactly one group.
229
+ const from: NavLocation = currentGroup
230
+ ? { groupId: currentGroup.id, index: currentGroup.pageIds.indexOf(pageId) }
231
+ : { groupId: null, index: entries.findIndex((e) => e.kind === "page" && e.pageId === pageId) };
232
+ const destLength = groupId === null
233
+ ? entries.length
234
+ : entries.find((e): e is NavGroupEntry => e.kind === "group" && e.id === groupId)?.pageIds.length ?? 0;
235
+ return moveNavEntry(index, from, { groupId, index: position ?? destLength });
236
+ }
237
+
107
238
  export function applyIndexOps(
108
239
  rawIndex: unknown,
109
240
  ops: IndexOp[],
@@ -123,17 +254,30 @@ export function applyIndexOps(
123
254
  };
124
255
  }
125
256
 
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;
262
+
126
263
  // 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 = {
264
+ // logged, so nothing here mutates it. Reassigned by the page/nav ops, which
265
+ // are shared immutable helpers rather than in-place mutators.
266
+ let index: SiteIndex = {
129
267
  ...base.data,
130
268
  pages: base.data.pages.map((p) => ({ ...p, order: [...p.order] })),
131
269
  sections: Object.fromEntries(Object.entries(base.data.sections).map(([k, v]) => [k, { ...v }])),
270
+ ...(Array.isArray(rawNav) ? { nav: rawNav as NavEntry[] } : {}),
132
271
  };
133
272
 
134
273
  const removed = new Set<string>();
135
274
  const warnings: string[] = [];
136
275
  const fail = (error: string): ApplyIndexOpsResult => ({ ok: false, error });
276
+ // Whether any op in this batch changed the navigation sidecar. Section-level
277
+ // ops must leave `nav` OFF the result: content-ops writes nav.json only when
278
+ // the posted index carries a `nav`, so omitting it is what guarantees an
279
+ // ordinary indexOps write cannot disturb a site's page groups.
280
+ let navTouched = false;
137
281
 
138
282
  for (const [i, op] of ops.entries()) {
139
283
  const at = `indexOps[${i}] ${op.op}`;
@@ -255,12 +399,129 @@ export function applyIndexOps(
255
399
  continue;
256
400
  }
257
401
 
402
+ if (op.op === "addPage") {
403
+ if (index.pages.some((p) => p.id === op.id)) {
404
+ return fail(`${at}: a page with id "${op.id}" already exists — use setPageMeta to edit it.`);
405
+ }
406
+ let slug: string;
407
+ if (op.slug !== undefined) {
408
+ // An explicit slug is never silently rewritten: uniquePageSlug would
409
+ // quietly hand back "about-2" and the agent would keep addressing "about".
410
+ if (!SLUG_RE.test(op.slug)) {
411
+ return fail(`${at}: slug "${op.slug}" is not a valid URL slug — use lowercase words joined by dashes (e.g. "brand-guide").`);
412
+ }
413
+ if ((RESERVED_SLUGS as readonly string[]).includes(op.slug)) {
414
+ return fail(`${at}: slug "${op.slug}" is reserved by a platform route and would shadow this page. Pick another slug.`);
415
+ }
416
+ if (index.pages.some((p) => p.slug === op.slug)) {
417
+ return fail(`${at}: slug "${op.slug}" is already used by another page. Slugs must be unique.`);
418
+ }
419
+ slug = op.slug;
420
+ } else {
421
+ // Derived slugs MAY be de-duplicated — nothing is addressing them yet.
422
+ slug = uniquePageSlug(slugifyPageSlug(op.title), index.pages.map((p) => p.slug));
423
+ }
424
+ if (op.navGroupId !== undefined && !navEntries(index).some((e) => e.kind === "group" && e.id === op.navGroupId)) {
425
+ return fail(`${at}: no nav group with id "${op.navGroupId}" — create it with addNavGroup first, or omit navGroupId to place the page at the top level.`);
426
+ }
427
+ index = addPageToIndex(index, op.id);
428
+ const created = index.pages.find((p) => p.id === op.id)!;
429
+ created.title = op.title;
430
+ created.slug = slug;
431
+ if (op.showInNav !== undefined) created.showInNav = op.showInNav;
432
+ if (op.access !== undefined) created.access = op.access;
433
+ if (op.navGroupId !== undefined) {
434
+ // addPageToIndex appended a top-level nav entry; lift it into the group.
435
+ index = moveIntoGroup(index, op.id, op.navGroupId, undefined);
436
+ }
437
+ navTouched = true;
438
+ continue;
439
+ }
440
+
441
+ if (op.op === "removePage") {
442
+ const page = index.pages.find((p) => p.id === op.pageId);
443
+ if (!page) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
444
+ if (page.isHome) {
445
+ return fail(`${at}: the home page cannot be removed — it is the site's root. Promote a different page to home in the editor first.`);
446
+ }
447
+ if (page.order.length > 0 && op.removeSections !== true) {
448
+ return fail(
449
+ `${at}: page "${op.pageId}" still holds ${page.order.length} section(s) (${fmt(page.order.slice(0, 5))}${page.order.length > 5 ? ", …" : ""}). ` +
450
+ "Pass removeSections: true to delete them with the page, or move them elsewhere first with move ops.",
451
+ );
452
+ }
453
+ const result = deletePage(index, op.pageId);
454
+ index = result.index;
455
+ for (const id of result.removedSectionIds) removed.add(id);
456
+ navTouched = true;
457
+ continue;
458
+ }
459
+
460
+ if (op.op === "addNavGroup") {
461
+ const entries = navEntries(index);
462
+ if (entries.some((e) => e.kind === "group" && e.id === op.id)) {
463
+ return fail(`${at}: a nav group with id "${op.id}" already exists — use setNavGroupMeta to edit it.`);
464
+ }
465
+ const missing = (op.pageIds ?? []).filter((id) => !index.pages.some((p) => p.id === id));
466
+ if (missing.length > 0) {
467
+ return fail(`${at}: no page with id ${fmt(missing)} — page ids come from get_site_index.`);
468
+ }
469
+ index = addGroup(index, op.id);
470
+ if (op.label !== undefined) index = renameGroup(index, op.id, op.label);
471
+ if (op.startCollapsed !== undefined) index = setGroupStartCollapsed(index, op.id, op.startCollapsed);
472
+ // In the order given: each move appends, so listing pages here places them
473
+ // in that sequence regardless of their previous nav positions.
474
+ for (const pageId of op.pageIds ?? []) index = moveIntoGroup(index, pageId, op.id, undefined);
475
+ navTouched = true;
476
+ continue;
477
+ }
478
+
479
+ if (op.op === "removeNavGroup") {
480
+ if (!navEntries(index).some((e) => e.kind === "group" && e.id === op.groupId)) {
481
+ return fail(`${at}: no nav group with id "${op.groupId}" — group ids come from the \`nav\` on get_site_index.`);
482
+ }
483
+ index = deleteGroup(index, op.groupId);
484
+ navTouched = true;
485
+ continue;
486
+ }
487
+
488
+ if (op.op === "setNavGroupMeta") {
489
+ if (op.label === undefined && op.startCollapsed === undefined) {
490
+ return fail(`${at}: nothing to set — provide label and/or startCollapsed.`);
491
+ }
492
+ if (!navEntries(index).some((e) => e.kind === "group" && e.id === op.groupId)) {
493
+ return fail(`${at}: no nav group with id "${op.groupId}" — group ids come from the \`nav\` on get_site_index.`);
494
+ }
495
+ if (op.label !== undefined) index = renameGroup(index, op.groupId, op.label);
496
+ if (op.startCollapsed !== undefined) index = setGroupStartCollapsed(index, op.groupId, op.startCollapsed);
497
+ navTouched = true;
498
+ continue;
499
+ }
500
+
501
+ if (op.op === "movePageInNav") {
502
+ if (!index.pages.some((p) => p.id === op.pageId)) {
503
+ return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
504
+ }
505
+ if (op.groupId !== null && !navEntries(index).some((e) => e.kind === "group" && e.id === op.groupId)) {
506
+ return fail(`${at}: no nav group with id "${op.groupId}" — group ids come from the \`nav\` on get_site_index.`);
507
+ }
508
+ index = moveIntoGroup(index, op.pageId, op.groupId, op.position);
509
+ navTouched = true;
510
+ continue;
511
+ }
512
+
258
513
  // 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.`);
514
+ if (
515
+ op.title === undefined && op.slug === undefined && op.showInNav === undefined &&
516
+ op.access === undefined && op.status === undefined
517
+ ) {
518
+ return fail(`${at}: nothing to set — provide title, slug, showInNav, access and/or status.`);
261
519
  }
262
520
  const page = index.pages.find((p) => p.id === op.pageId);
263
521
  if (!page) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
522
+ if (op.status === "archived" && page.isHome) {
523
+ return fail(`${at}: the home page cannot be archived — it is the site's root. Promote a different page to home in the editor first.`);
524
+ }
264
525
  if (op.slug !== undefined && page.isHome) {
265
526
  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
527
  }
@@ -276,6 +537,7 @@ export function applyIndexOps(
276
537
  if (op.title !== undefined) page.title = op.title;
277
538
  if (op.slug !== undefined) page.slug = op.slug;
278
539
  if (op.showInNav !== undefined) page.showInNav = op.showInNav;
540
+ if (op.status !== undefined) page.status = op.status;
279
541
  if (op.access !== undefined) {
280
542
  const dropped = page.access.filter((a) => !op.access!.includes(a));
281
543
  page.access = op.access;
@@ -305,5 +567,13 @@ export function applyIndexOps(
305
567
  if (!validated.success) {
306
568
  return fail(`applying indexOps produced an invalid index: ${validated.error.issues.map((issue) => issue.message).join("; ")}`);
307
569
  }
308
- return { ok: true, index: validated.data, removedIds, warnings };
570
+ // The parse above stripped `nav`. Reattach it ONLY when an op actually changed
571
+ // the navigation, reconciled against the resulting page set so a batch that
572
+ // added or removed pages emits a complete tree. When no nav op ran the result
573
+ // carries no `nav` at all, so content-ops leaves nav.json untouched and page
574
+ // groups survive untouched — the invariant every section-level op relies on.
575
+ const result: SiteIndex = navTouched
576
+ ? { ...validated.data, nav: reconcileNav(index.nav ?? [], validated.data.pages) }
577
+ : validated.data;
578
+ return { ok: true, index: result, removedIds, warnings };
309
579
  }
package/src/lib/loader.ts CHANGED
@@ -3,7 +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
+ import { NavEntrySchema, type NavEntry } from "../schemas/nav";
7
7
 
8
8
  export interface LoadedSection {
9
9
  section: Section;
@@ -13,21 +13,41 @@ export interface LoadedSection {
13
13
  export interface SiteContent {
14
14
  sections: LoadedSection[];
15
15
  index: SiteIndex;
16
+ /** True when nav.json was present but held entries this package version could
17
+ * not represent (unknown kinds, invalid shapes) — the returned nav is missing
18
+ * them. Writers must treat a degraded nav as read-only: wholesale-replacing
19
+ * nav.json from a degraded read permanently destroys the entries this
20
+ * version dropped. */
21
+ navDegraded: boolean;
16
22
  }
17
23
 
18
24
  /**
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.
25
+ * Parse the nav.json sidecar defensively, entry by entry. A malformed sidecar
26
+ * must degrade, never brick the site — nav.json is optional content, and the
27
+ * whole point of the sidecar is that it tolerates writers that don't understand
28
+ * it. Tolerance is PER ENTRY: one unknown entry kind (a newer package version's
29
+ * addition, or an authoring typo) drops that entry alone, not every group on
30
+ * the site. Any drop is reported as `degraded` so write paths can refuse to
31
+ * persist the lossy tree back over the original.
22
32
  */
23
- function parseNavFile(navFile: unknown): NavEntry[] {
24
- if (!navFile) return [];
25
- const result = NavFileSchema.safeParse(navFile);
26
- if (!result.success) {
33
+ function parseNavFile(navFile: unknown): { entries: NavEntry[]; degraded: boolean } {
34
+ if (!navFile) return { entries: [], degraded: false };
35
+ const rawEntries = (navFile as { entries?: unknown }).entries;
36
+ if (!Array.isArray(rawEntries)) {
27
37
  console.warn("Invalid nav.json, falling back to a flat navigation");
28
- return [];
38
+ return { entries: [], degraded: true };
29
39
  }
30
- return result.data.entries;
40
+ const entries: NavEntry[] = [];
41
+ let dropped = 0;
42
+ for (const raw of rawEntries) {
43
+ const result = NavEntrySchema.safeParse(raw);
44
+ if (result.success) entries.push(result.data);
45
+ else dropped++;
46
+ }
47
+ if (dropped > 0) {
48
+ console.warn(`nav.json: dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} this package version cannot represent; navigation is degraded`);
49
+ }
50
+ return { entries, degraded: dropped > 0 };
31
51
  }
32
52
 
33
53
  /**
@@ -83,7 +103,8 @@ export function mergeSiteContent(
83
103
  }
84
104
  }
85
105
 
86
- return { sections, index: { ...index, nav: parseNavFile(navFile) } };
106
+ const nav = parseNavFile(navFile);
107
+ return { sections, index: { ...index, nav: nav.entries }, navDegraded: nav.degraded };
87
108
  }
88
109
 
89
110
  /**
package/src/lib/pages.ts CHANGED
@@ -203,6 +203,54 @@ export function reorderSectionInPage(
203
203
  return { ...index, pages };
204
204
  }
205
205
 
206
+ /**
207
+ * Translate an insertion index expressed over the VISIBLE section list (what the
208
+ * editor renders: `page.order` filtered to loadable bodies) into a `page.order`
209
+ * position. The two disagree whenever a section body was dropped by the loader
210
+ * (unparseable file, schema miss, site-local type) — splicing a display index
211
+ * straight into `order` then moves the WRONG section. `displayIndex` may be
212
+ * `visibleIds.length` ("after the last visible entry"), which lands right after
213
+ * the last visible id so hidden trailing sections keep their place.
214
+ */
215
+ export function visibleInsertPosition(order: string[], visibleIds: string[], displayIndex: number): number {
216
+ if (displayIndex < visibleIds.length) {
217
+ const pos = order.indexOf(visibleIds[displayIndex]);
218
+ if (pos >= 0) return pos;
219
+ }
220
+ const lastVisible = visibleIds[visibleIds.length - 1];
221
+ const lastPos = lastVisible !== undefined ? order.indexOf(lastVisible) : -1;
222
+ return lastPos >= 0 ? lastPos + 1 : order.length;
223
+ }
224
+
225
+ /**
226
+ * Reorder a section using DISPLAY indices over the visible list (same semantics
227
+ * as `reorderSectionInPage`: `toIndex` is the post-removal position). Resolves
228
+ * both endpoints to ids before touching `page.order`, so filtered-out sections
229
+ * can neither be moved by mistake nor shift the splice onto a different entry.
230
+ * Falls back to a no-op when the endpoints don't resolve.
231
+ */
232
+ export function reorderVisibleSectionInPage(
233
+ index: SiteIndex,
234
+ pageId: string,
235
+ visibleIds: string[],
236
+ fromIndex: number,
237
+ toIndex: number,
238
+ ): SiteIndex {
239
+ const fromId = visibleIds[fromIndex];
240
+ if (fromId === undefined) return index;
241
+ const withoutFrom = visibleIds.filter((id) => id !== fromId);
242
+ const pages = index.pages.map((p) => {
243
+ if (p.id !== pageId) return p;
244
+ const fromPos = p.order.indexOf(fromId);
245
+ if (fromPos < 0) return p;
246
+ const order = [...p.order];
247
+ order.splice(fromPos, 1);
248
+ order.splice(visibleInsertPosition(order, withoutFrom, toIndex), 0, fromId);
249
+ return { ...p, order };
250
+ });
251
+ return { ...index, pages };
252
+ }
253
+
206
254
  export function moveSection(
207
255
  index: SiteIndex,
208
256
  sectionId: string,
@@ -1,7 +1,6 @@
1
1
  import type { MediaManifest, MediaItem, ResolvedMedia } from "./types";
2
2
 
3
3
  export interface MediaProvider {
4
- getManifest(): Promise<MediaManifest>;
5
4
  resolve(item: MediaItem, sizes: number[]): ResolvedMedia | null;
6
5
  exists(hash: string, manifest: MediaManifest): boolean;
7
6
  }
@@ -19,9 +19,9 @@ export const NavEntrySchema = z.discriminatedUnion("kind", [
19
19
  /** May be blank — agent-authored files can omit it; UI falls back to "Untitled group". */
20
20
  label: z.string().default(""),
21
21
  startCollapsed: z.boolean().default(false),
22
- // Everything but `id` defaults: NavFileSchema is all-or-nothing, and the
23
- // loader degrades a failed parse to a flat nav — so one agent-authored group
24
- // missing a key would silently discard every group on the site.
22
+ // Everything but `id` defaults: the loader parses entries individually and
23
+ // drops the ones that fail — defaulting here keeps an agent-authored group
24
+ // missing a key from being dropped (and its drop marking the nav degraded).
25
25
  pageIds: z.array(z.string().min(1)).default([]),
26
26
  }),
27
27
  ]);
@@ -53,7 +53,9 @@ export const PageSchema = z.object({
53
53
 
54
54
  export type Page = z.infer<typeof PageSchema>;
55
55
 
56
- const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
56
+ // Exported so authoring surfaces (indexOps' addPage) can reject a bad slug with a
57
+ // named error instead of letting it surface as an opaque index-parse failure.
58
+ export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
57
59
 
58
60
  // Raw input: accepts legacy (order[]) OR canonical (pages[]).
59
61
  const RawIndexSchema = z.object({
@@ -1,2 +1,2 @@
1
1
  export type { StorageProvider, FileWrite, AssetStore } from "./types";
2
- export { StorageConflictError, ASSET_CACHE_CONTROL, IDEMPOTENCY_TRAILER, IDEMPOTENCY_KEY_RE, withIdempotencyTrailer } from "./types";
2
+ export { StorageConflictError, IdempotencyLookupUnavailableError, ASSET_CACHE_CONTROL, IDEMPOTENCY_TRAILER, IDEMPOTENCY_KEY_RE, withIdempotencyTrailer } from "./types";