@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,192 @@
1
+ import type { NavEntry, NavGroupEntry } from "../schemas/nav";
2
+ import type { Page, SiteIndex } from "../schemas/site-config";
3
+
4
+ /**
5
+ * Repair an authored nav against the current page set.
6
+ *
7
+ * Total, pure, and idempotent: every possible input maps to a valid nav in
8
+ * which each page appears exactly once. This is what lets the sidecar tolerate
9
+ * an older writer editing index.json without knowing about nav.json — the worst
10
+ * case is a page drifting to the bottom of the nav, never a hard failure. It is
11
+ * deliberately NOT a schema refinement: parse-rejecting content leaves viewer
12
+ * and editor both refusing to load, with no in-product repair path.
13
+ *
14
+ * "Total" is meant literally, so the shape guards below are load-bearing rather
15
+ * than paranoia: callers include agent-authored JSON and live editor state, and
16
+ * `pages` is not id-unique (IndexSchema checks slug uniqueness, not page ids).
17
+ * Every entry is cloned on the way out — the editor holds this in React state,
18
+ * where a returned-by-reference entry would let a later mutation silently edit
19
+ * prior state and defeat identity-based re-render checks.
20
+ */
21
+ export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
22
+ const entryList = Array.isArray(entries) ? entries : [];
23
+ const pageList = Array.isArray(pages) ? pages : [];
24
+
25
+ const validIds = new Set<string>();
26
+ for (const p of pageList) {
27
+ if (p?.id) validIds.add(p.id);
28
+ }
29
+
30
+ const placed = new Set<string>();
31
+ const seenGroupIds = new Set<string>();
32
+ const result: NavEntry[] = [];
33
+
34
+ for (const entry of entryList) {
35
+ if (entry?.kind === "page") {
36
+ if (!validIds.has(entry.pageId) || placed.has(entry.pageId)) continue;
37
+ placed.add(entry.pageId);
38
+ result.push({ ...entry });
39
+ continue;
40
+ }
41
+
42
+ if (entry?.kind !== "group") continue;
43
+ if (seenGroupIds.has(entry.id)) continue;
44
+ seenGroupIds.add(entry.id);
45
+
46
+ const claimed = Array.isArray(entry.pageIds) ? entry.pageIds : [];
47
+ const pageIds = claimed.filter((id) => {
48
+ if (!validIds.has(id) || placed.has(id)) return false;
49
+ placed.add(id);
50
+ return true;
51
+ });
52
+ const group: NavGroupEntry = { ...entry, pageIds };
53
+ result.push(group);
54
+ }
55
+
56
+ for (const p of pageList) {
57
+ if (!p?.id || placed.has(p.id)) continue;
58
+ placed.add(p.id);
59
+ result.push({ kind: "page", pageId: p.id });
60
+ }
61
+
62
+ return result;
63
+ }
64
+
65
+ /**
66
+ * The ONLY sanctioned way to read the nav. Always reconciles, so callers never
67
+ * have to reason about whether index.nav is present, stale, or authored by an
68
+ * older writer. Cheap and idempotent — call it freely.
69
+ */
70
+ export function navEntries(index: SiteIndex): NavEntry[] {
71
+ return reconcileNav(index.nav ?? [], index.pages);
72
+ }
73
+
74
+ /** Pages in authored nav order. Use this anywhere user-visible sequence matters. */
75
+ export function pagesInNavOrder(index: SiteIndex): Page[] {
76
+ const byId = new Map(index.pages.map((p) => [p.id, p]));
77
+ const out: Page[] = [];
78
+ for (const entry of navEntries(index)) {
79
+ if (entry.kind === "page") {
80
+ const p = byId.get(entry.pageId);
81
+ if (p) out.push(p);
82
+ } else {
83
+ for (const id of entry.pageIds) {
84
+ const p = byId.get(id);
85
+ if (p) out.push(p);
86
+ }
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ export function groupOfPage(index: SiteIndex, pageId: string): NavGroupEntry | null {
93
+ for (const entry of navEntries(index)) {
94
+ if (entry.kind === "group" && entry.pageIds.includes(pageId)) return entry;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /** Display fallback for blank group labels — mirrors pageDisplayTitle in lib/pages.ts. */
100
+ export function groupDisplayLabel(label: string): string {
101
+ return label || "Untitled group";
102
+ }
103
+
104
+ /** A position in the nav: inside a group, or at the top level when groupId is null. */
105
+ export interface NavLocation {
106
+ groupId: string | null;
107
+ index: number;
108
+ }
109
+
110
+ export type NavRow =
111
+ | { kind: "page"; pageId: string; groupId: string | null; index: number }
112
+ | { kind: "group"; groupId: string; index: number }
113
+ | { kind: "empty-group"; groupId: string }
114
+ // The trailing drop target. Without it a page can never land AFTER a group
115
+ // that sits last in the nav: the group's header-bottom edge and every member
116
+ // edge all resolve to positions INSIDE it.
117
+ | { kind: "tail" };
118
+
119
+ function withNav(index: SiteIndex, entries: NavEntry[]): SiteIndex {
120
+ return { ...index, nav: entries };
121
+ }
122
+
123
+ export function addGroup(index: SiteIndex, groupId: string): SiteIndex {
124
+ return withNav(index, [
125
+ ...navEntries(index),
126
+ { kind: "group", id: groupId, label: "", startCollapsed: false, pageIds: [] },
127
+ ]);
128
+ }
129
+
130
+ export function renameGroup(index: SiteIndex, groupId: string, label: string): SiteIndex {
131
+ return withNav(index, navEntries(index).map((e) =>
132
+ e.kind === "group" && e.id === groupId ? { ...e, label } : e,
133
+ ));
134
+ }
135
+
136
+ export function setGroupStartCollapsed(index: SiteIndex, groupId: string, startCollapsed: boolean): SiteIndex {
137
+ return withNav(index, navEntries(index).map((e) =>
138
+ e.kind === "group" && e.id === groupId ? { ...e, startCollapsed } : e,
139
+ ));
140
+ }
141
+
142
+ /** Removes the group; its members become top-level entries at the group's slot. */
143
+ export function deleteGroup(index: SiteIndex, groupId: string): SiteIndex {
144
+ const out: NavEntry[] = [];
145
+ for (const entry of navEntries(index)) {
146
+ if (entry.kind === "group" && entry.id === groupId) {
147
+ for (const pageId of entry.pageIds) out.push({ kind: "page", pageId });
148
+ } else {
149
+ out.push(entry);
150
+ }
151
+ }
152
+ return withNav(index, out);
153
+ }
154
+
155
+ /**
156
+ * The single reorder+reparent primitive. `from` and `to` are both NavLocations,
157
+ * so moving within a container, between containers, and promoting/demoting a
158
+ * page are all the same operation.
159
+ */
160
+ export function moveNavEntry(index: SiteIndex, from: NavLocation, to: NavLocation): SiteIndex {
161
+ const entries = navEntries(index).map((e) => (e.kind === "group" ? { ...e, pageIds: [...e.pageIds] } : { ...e }));
162
+
163
+ // Lift the moved item out of its source container.
164
+ let moved: NavEntry | string | undefined;
165
+ if (from.groupId === null) {
166
+ if (from.index < 0 || from.index >= entries.length) return index;
167
+ [moved] = entries.splice(from.index, 1);
168
+ } else {
169
+ const group = entries.find((e) => e.kind === "group" && e.id === from.groupId);
170
+ if (!group || group.kind !== "group") return index;
171
+ if (from.index < 0 || from.index >= group.pageIds.length) return index;
172
+ [moved] = group.pageIds.splice(from.index, 1);
173
+ }
174
+ if (moved === undefined) return index;
175
+
176
+ // A group can only ever live at the top level — one level of nesting, enforced.
177
+ const movedIsGroup = typeof moved !== "string" && moved.kind === "group";
178
+ if (movedIsGroup && to.groupId !== null) return index;
179
+
180
+ if (to.groupId === null) {
181
+ const entry: NavEntry = typeof moved === "string" ? { kind: "page", pageId: moved } : moved;
182
+ entries.splice(Math.max(0, Math.min(to.index, entries.length)), 0, entry);
183
+ } else {
184
+ const group = entries.find((e) => e.kind === "group" && e.id === to.groupId);
185
+ if (!group || group.kind !== "group") return index;
186
+ const pageId = typeof moved === "string" ? moved : moved.kind === "page" ? moved.pageId : null;
187
+ if (pageId === null) return index;
188
+ group.pageIds.splice(Math.max(0, Math.min(to.index, group.pageIds.length)), 0, pageId);
189
+ }
190
+
191
+ return withNav(index, entries);
192
+ }
package/src/lib/nav.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { LoadedSection } from "./loader";
2
2
  import { getSection, type SectionRegistry } from "./registry";
3
3
  import { audiencePasses, homePage, pageDisplayTitle } from "./pages";
4
+ import { navEntries } from "./nav-tree";
4
5
  import type { SiteIndex } from "../schemas/site-config";
5
6
 
6
7
  export interface NavItem {
@@ -93,9 +94,20 @@ export interface PageNavItem {
93
94
  headings: NavItem[];
94
95
  }
95
96
 
96
- export interface SiteNav {
97
+ export interface GroupNavItem {
98
+ kind: "group";
99
+ id: string;
100
+ label: string;
101
+ startCollapsed: boolean;
97
102
  pages: PageNavItem[];
98
- /** Live pages with showInNav off — editor-only group, like the archive. */
103
+ }
104
+
105
+ export type SiteNavEntry = ({ kind: "page" } & PageNavItem) | GroupNavItem;
106
+
107
+ export interface SiteNav {
108
+ /** The authored tree: loose pages and groups, in nav order. */
109
+ entries: SiteNavEntry[];
110
+ /** Live pages with showInNav off — editor-only flat bucket, like the archive. */
99
111
  hidden: PageNavItem[];
100
112
  archive: PageNavItem[];
101
113
  }
@@ -114,6 +126,7 @@ export function generateSiteNav(opts: {
114
126
  }): SiteNav {
115
127
  const { index, activeSections, activePageId, mode, audience, registry } = opts;
116
128
  const active = activePageId ?? homePage(index).id;
129
+ const byId = new Map(index.pages.map((p) => [p.id, p]));
117
130
 
118
131
  const toNavItem = (p: (typeof index.pages)[number]): PageNavItem => ({
119
132
  id: p.id,
@@ -124,19 +137,39 @@ export function generateSiteNav(opts: {
124
137
  headings: p.id === active ? generateNavLinks(activeSections, registry) : [],
125
138
  });
126
139
 
140
+ // Tree membership: live + shown in nav, and for viewers also audience-passing.
141
+ // Hidden and archived pages keep their nav slot in the data but render in the
142
+ // flat editor buckets instead, so a group never appears in two places.
143
+ const inTree = (p: (typeof index.pages)[number]) =>
144
+ p.status === "live" && p.showInNav && (mode === "editor" || audiencePasses(p.access, audience));
145
+
146
+ const entries: SiteNavEntry[] = [];
147
+ for (const entry of navEntries(index)) {
148
+ if (entry.kind === "page") {
149
+ const p = byId.get(entry.pageId);
150
+ if (p && inTree(p)) entries.push({ kind: "page", ...toNavItem(p) });
151
+ continue;
152
+ }
153
+ const pages = entry.pageIds
154
+ .map((id) => byId.get(id))
155
+ .filter((p): p is NonNullable<typeof p> => !!p && inTree(p))
156
+ .map(toNavItem);
157
+ // A group with nothing visible is noise for viewers; editors keep it so an
158
+ // empty group they just created doesn't disappear out from under them.
159
+ if (pages.length === 0 && mode === "viewer") continue;
160
+ entries.push({
161
+ kind: "group", id: entry.id, label: entry.label,
162
+ startCollapsed: entry.startCollapsed, pages,
163
+ });
164
+ }
165
+
127
166
  if (mode === "editor") {
128
167
  return {
129
- pages: index.pages.filter((p) => p.status === "live" && p.showInNav).map(toNavItem),
168
+ entries,
130
169
  hidden: index.pages.filter((p) => p.status === "live" && !p.showInNav).map(toNavItem),
131
170
  archive: index.pages.filter((p) => p.status === "archived").map(toNavItem),
132
171
  };
133
172
  }
134
173
 
135
- return {
136
- pages: index.pages
137
- .filter((p) => p.status === "live" && p.showInNav && audiencePasses(p.access, audience))
138
- .map(toNavItem),
139
- hidden: [],
140
- archive: [],
141
- };
174
+ return { entries, hidden: [], archive: [] };
142
175
  }
package/src/lib/pages.ts CHANGED
@@ -4,6 +4,7 @@ import { RESERVED_SLUGS } from "../schemas/site-config";
4
4
  // is allowed to reach into its engine to write the page-level document options
5
5
  // block (mirrors the same sanctioned import in schemas/site-config.ts).
6
6
  import { documentOptionsSchema, type DocumentOptions } from "../document/engine/settings";
7
+ import { navEntries } from "./nav-tree";
7
8
 
8
9
  /**
9
10
  * Display fallback for blank page titles. addPage creates pages with an empty
@@ -59,16 +60,11 @@ export function addPage(index: SiteIndex, id: string): SiteIndex {
59
60
  const page: Page = {
60
61
  id, title: "", slug, isHome: false, showInNav: true, status: "live", access: [], order: [],
61
62
  };
62
- return { ...index, pages: [...index.pages, page] };
63
- }
64
-
65
- export function reorderPages(index: SiteIndex, fromIndex: number, toIndex: number): SiteIndex {
66
- const len = index.pages.length;
67
- if (fromIndex < 0 || fromIndex >= len || toIndex < 0 || toIndex >= len) return index;
68
- const pages = [...index.pages];
69
- const [moved] = pages.splice(fromIndex, 1);
70
- pages.splice(toIndex, 0, moved);
71
- return { ...index, pages };
63
+ return {
64
+ ...index,
65
+ pages: [...index.pages, page],
66
+ nav: [...navEntries(index), { kind: "page", pageId: id }],
67
+ };
72
68
  }
73
69
 
74
70
  export function setHomePage(index: SiteIndex, pageId: string): SiteIndex {
@@ -152,7 +148,14 @@ export function deletePage(
152
148
  const nextSections: Record<string, SectionMeta> = { ...index.sections };
153
149
  for (const id of removedSectionIds) delete nextSections[id];
154
150
  return {
155
- index: { ...index, pages: index.pages.filter((p) => p.id !== pageId), sections: nextSections },
151
+ index: {
152
+ ...index,
153
+ pages: index.pages.filter((p) => p.id !== pageId),
154
+ sections: nextSections,
155
+ nav: navEntries(index)
156
+ .filter((e) => !(e.kind === "page" && e.pageId === pageId))
157
+ .map((e) => (e.kind === "group" ? { ...e, pageIds: e.pageIds.filter((id) => id !== pageId) } : e)),
158
+ },
156
159
  removedSectionIds,
157
160
  };
158
161
  }
@@ -1,3 +1,5 @@
1
+ import type { NavLocation, NavRow } from "./nav-tree";
2
+
1
3
  /**
2
4
  * Compute the destination index for a closest-edge drag-to-reorder drop.
3
5
  *
@@ -18,3 +20,57 @@ export function computeReorderTarget(
18
20
  if (fromIndex < toIndex) toIndex--;
19
21
  return fromIndex === toIndex ? null : toIndex;
20
22
  }
23
+
24
+ /**
25
+ * Resolve a closest-edge drop over a nav row into a destination NavLocation.
26
+ *
27
+ * With one level of nesting a target's own identity determines its container,
28
+ * so page drags need no depth inference. Group drags DO need remapping: a group
29
+ * can only live at the top level, so hovering a member row is reinterpreted as
30
+ * "next to that member's group" rather than rejected — otherwise every member
31
+ * row of every expanded group is an inert dead zone during a group drag.
32
+ *
33
+ * `overEntryIndex` is the row's index in the TOP-LEVEL entry list: its own index
34
+ * for a top-level page or group row, its enclosing group's index for a member
35
+ * row, and the total entry count for the tail row. Group-drag remapping targets
36
+ * it directly.
37
+ *
38
+ * Returns null when the drop would not move anything, or is not permitted.
39
+ */
40
+ export function computeNavDropTarget(args: {
41
+ from: NavLocation;
42
+ fromKind: "page" | "group";
43
+ over: NavRow;
44
+ overEntryIndex: number;
45
+ edge: "top" | "bottom" | null;
46
+ }): NavLocation | null {
47
+ const { from, fromKind, over, overEntryIndex, edge } = args;
48
+ const after = edge === "bottom";
49
+
50
+ let to: NavLocation;
51
+ if (over.kind === "tail") {
52
+ // A container-level target for both kinds. `overEntryIndex` for the tail row
53
+ // is the top-level entry count, so this appends. The shared decrement below
54
+ // makes "drag the last top-level row onto the tail" a correct no-op.
55
+ to = { groupId: null, index: overEntryIndex };
56
+ } else if (fromKind === "group") {
57
+ // Groups are top-level only. Empty-group rows have no top-level meaning here.
58
+ if (over.kind === "empty-group") return null;
59
+ to = { groupId: null, index: overEntryIndex + (after ? 1 : 0) };
60
+ } else if (over.kind === "empty-group") {
61
+ to = { groupId: over.groupId, index: 0 };
62
+ } else if (over.kind === "group") {
63
+ // Top edge = before the group at the top level; bottom edge = first slot
64
+ // INSIDE it. The header's lower boundary is visually the group's interior.
65
+ to = after ? { groupId: over.groupId, index: 0 } : { groupId: null, index: overEntryIndex };
66
+ } else {
67
+ to = { groupId: over.groupId, index: over.index + (after ? 1 : 0) };
68
+ }
69
+
70
+ // Removing the dragged item before re-inserting shifts later slots in the
71
+ // SAME container only — mirrors computeReorderTarget's decrement.
72
+ if (to.groupId === from.groupId && from.index < to.index) to = { ...to, index: to.index - 1 };
73
+
74
+ if (to.groupId === from.groupId && to.index === from.index) return null;
75
+ return to;
76
+ }
@@ -1,6 +1,7 @@
1
1
  export * from "./shared";
2
2
  export * from "./sections";
3
3
  export * from "./site-config";
4
+ export * from "./nav";
4
5
  export * from "./media";
5
6
  export * from "./audience";
6
7
  export * from "./media-grid-options";
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * The site's authored navigation tree, persisted to `src/content/nav.json`.
5
+ *
6
+ * Deliberately NOT a key on index.json: `applyContentWrite` re-serializes the
7
+ * Zod-parsed index, so any writer running a package version that predates a new
8
+ * index key silently deletes it from disk. A sidecar is absent from an old
9
+ * writer's file list and survives untouched.
10
+ */
11
+ export const NavEntrySchema = z.discriminatedUnion("kind", [
12
+ z.object({
13
+ kind: z.literal("page"),
14
+ pageId: z.string().min(1),
15
+ }),
16
+ z.object({
17
+ kind: z.literal("group"),
18
+ id: z.string().min(1),
19
+ /** May be blank — agent-authored files can omit it; UI falls back to "Untitled group". */
20
+ label: z.string().default(""),
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.
25
+ pageIds: z.array(z.string().min(1)).default([]),
26
+ }),
27
+ ]);
28
+
29
+ export const NavFileSchema = z.object({
30
+ entries: z.array(NavEntrySchema),
31
+ });
32
+
33
+ export type NavEntry = z.infer<typeof NavEntrySchema>;
34
+ export type NavPageEntry = Extract<NavEntry, { kind: "page" }>;
35
+ export type NavGroupEntry = Extract<NavEntry, { kind: "group" }>;
36
+ export type NavFile = z.infer<typeof NavFileSchema>;
@@ -6,6 +6,7 @@ import { CORNER_RADIUS_OPTIONS } from "../lib/corner-radius";
6
6
  // site-config.ts is allowed to reach into its engine to wire the page-level
7
7
  // document options block onto PageSchema.
8
8
  import { documentOptionsSchema } from "../document/engine/settings";
9
+ import type { NavEntry } from "./nav";
9
10
 
10
11
  export const StatusSchema = z.enum(["draft", "live", "archived", "published"]).transform(
11
12
  (val) => val === "published" ? "live" as const : val,
@@ -167,7 +168,14 @@ const CanonicalIndexSchema = z
167
168
 
168
169
  export const IndexSchema = RawIndexSchema.transform(normalizeRawIndex).pipe(CanonicalIndexSchema);
169
170
 
170
- export type SiteIndex = z.infer<typeof CanonicalIndexSchema>;
171
+ /**
172
+ * `nav` is a RUNTIME field only: it is loaded from the src/content/nav.json
173
+ * sidecar and attached by the loader, never parsed from or written to
174
+ * index.json. It is optional because most SiteIndex values (tests, legacy
175
+ * content, partial writes) have no nav — reads go through navEntries(), which
176
+ * reconciles a missing nav into a flat one.
177
+ */
178
+ export type SiteIndex = z.infer<typeof CanonicalIndexSchema> & { nav?: NavEntry[] };
171
179
 
172
180
  /** Parse + normalize any raw index (legacy or canonical) to the canonical shape. */
173
181
  export function normalizeSiteIndex(raw: unknown): SiteIndex {
@@ -1,2 +1,2 @@
1
1
  export type { StorageProvider, FileWrite, AssetStore } from "./types";
2
- export { StorageConflictError, ASSET_CACHE_CONTROL } from "./types";
2
+ export { StorageConflictError, ASSET_CACHE_CONTROL, IDEMPOTENCY_TRAILER, IDEMPOTENCY_KEY_RE, withIdempotencyTrailer } from "./types";
@@ -31,6 +31,61 @@ export interface StorageProvider {
31
31
  getVersion(): Promise<string>;
32
32
 
33
33
  listDirectory(path: string, options?: { draft?: boolean }): Promise<string[]>;
34
+
35
+ /**
36
+ * Find a recent commit on the draft branch whose message carries the given
37
+ * `Idempotency-Key:` trailer, returning its sha (null if not found).
38
+ *
39
+ * This exists to make a retried write LEGIBLE, not to make it safe — safety is
40
+ * already `writeFiles`' baseVersion check, which a retry cannot get past once
41
+ * the original landed (the head moved, so the retry's baseVersion is stale).
42
+ * What the check cannot do is tell "your write failed" from "your write
43
+ * succeeded and you didn't hear back", and it reports both as a conflict. A
44
+ * caller that tagged its write can ask that question here.
45
+ *
46
+ * OPTIONAL: providers that cannot search history omit it, and callers fall
47
+ * back to surfacing the plain conflict.
48
+ *
49
+ * Best-effort by contract. It searches a BOUNDED window, so an old key reads
50
+ * as absent, and an implementation may answer "not found" when the history
51
+ * read itself fails — failing closed keeps the conflict rather than inventing
52
+ * a replay. So `null` means "could not confirm", NOT "your write did not
53
+ * land": a caller must not tell the user the earlier attempt failed.
54
+ */
55
+ findCommitByIdempotencyKey?(
56
+ key: string,
57
+ options?: { limit?: number },
58
+ ): Promise<string | null>;
59
+ }
60
+
61
+ /**
62
+ * Commit-message trailer carrying a write's idempotency key. The commit itself is
63
+ * the store — no table, and no TTL to tune: keys live exactly as long as the
64
+ * draft branch, and `promoteDraft` deletes it, which is the correct lifetime
65
+ * (after a publish there is no draft, so an old write must not replay).
66
+ */
67
+ export const IDEMPOTENCY_TRAILER = "Idempotency-Key";
68
+
69
+ /**
70
+ * Keys are interpolated into a commit message, so the charset is enforced HERE,
71
+ * at the interpolation point, and not only by the caller that happens to have a
72
+ * schema. A key containing a newline would let a caller append arbitrary further
73
+ * trailer lines to the commit.
74
+ */
75
+ export const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9._:-]{8,200}$/;
76
+
77
+ /**
78
+ * Build a commit message carrying `key` as a trailer, or return `subject`
79
+ * unchanged when no key is given. Throws on a malformed key: every caller
80
+ * reaches this through a write path that should have validated first, so an
81
+ * invalid key here is a bug, not user input to be tolerated.
82
+ */
83
+ export function withIdempotencyTrailer(subject: string, key?: string): string {
84
+ if (!key) return subject;
85
+ if (!IDEMPOTENCY_KEY_RE.test(key)) {
86
+ throw new Error("Invalid idempotency key: expected 8-200 chars of A-Za-z0-9._:-");
87
+ }
88
+ return `${subject}\n\n${IDEMPOTENCY_TRAILER}: ${key}`;
34
89
  }
35
90
 
36
91
  export type FileWrite = {
@@ -1,15 +0,0 @@
1
- // src/storage/types.ts
2
- var StorageConflictError = class extends Error {
3
- constructor(currentVersion) {
4
- super("Draft has been modified since it was loaded");
5
- this.currentVersion = currentVersion;
6
- this.name = "StorageConflictError";
7
- }
8
- currentVersion;
9
- };
10
- var ASSET_CACHE_CONTROL = "max-age=31536000, immutable";
11
-
12
- export {
13
- StorageConflictError,
14
- ASSET_CACHE_CONTROL
15
- };
@@ -1,14 +0,0 @@
1
- // src/lib/pages.ts
2
- function slugifyPageSlug(input) {
3
- return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4
- }
5
- function audiencePasses(access, audience) {
6
- if (access.length === 0) return true;
7
- if (!audience) return true;
8
- return access.includes(audience);
9
- }
10
-
11
- export {
12
- slugifyPageSlug,
13
- audiencePasses
14
- };
File without changes
File without changes