@drawnagency/primitives 0.1.96 → 0.1.98

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 (87) hide show
  1. package/dist/{chunk-CI624HRS.js → chunk-26ALJO4K.js} +1 -1
  2. package/dist/{chunk-SOILPZ7U.js → chunk-3COSXTOY.js} +1 -1
  3. package/dist/{chunk-RHWWQV3J.js → chunk-BGHWUJFV.js} +1 -0
  4. package/dist/{chunk-RQPHPAT2.js → chunk-EWNDBKYK.js} +2 -2
  5. package/dist/{chunk-3HJDMFTB.js → chunk-FOY5TF7D.js} +1 -1
  6. package/dist/{chunk-YUUUCDXN.js → chunk-GYCBWGVW.js} +1 -1
  7. package/dist/chunk-HJSE6SK5.js +27 -0
  8. package/dist/{chunk-CSVGCAIH.js → chunk-HKHEAH5F.js} +7 -7
  9. package/dist/{chunk-IFCQWC3G.js → chunk-KP4DWTPR.js} +3 -3
  10. package/dist/{chunk-DJAJRBPD.js → chunk-MUTHF2GT.js} +2 -2
  11. package/dist/{chunk-CS2ECR3U.js → chunk-NA5EJM6Z.js} +24 -6
  12. package/dist/chunk-TGNS7D4F.js +200 -0
  13. package/dist/collab/rebase.d.ts +20 -0
  14. package/dist/collab/rebase.d.ts.map +1 -1
  15. package/dist/collab/rebase.js +17 -0
  16. package/dist/components/editor/DropEdgeIndicator.d.ts +6 -1
  17. package/dist/components/editor/DropEdgeIndicator.d.ts.map +1 -1
  18. package/dist/components/editor/NavGroupRow.d.ts +21 -0
  19. package/dist/components/editor/NavGroupRow.d.ts.map +1 -0
  20. package/dist/components/editor/PagesModal.d.ts +7 -1
  21. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  22. package/dist/components/editor/index.js +5 -5
  23. package/dist/components/primitives/index.js +7 -7
  24. package/dist/components/sections/brand-guide.js +6 -6
  25. package/dist/components/sections/register-schemas.js +10 -10
  26. package/dist/components/sections/register.js +10 -10
  27. package/dist/components/shared/Navigation.d.ts.map +1 -1
  28. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  29. package/dist/hooks/useEditorPersistence.d.ts +7 -0
  30. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  31. package/dist/hooks/useSortableNavRow.d.ts +27 -0
  32. package/dist/hooks/useSortableNavRow.d.ts.map +1 -0
  33. package/dist/index.js +26 -8
  34. package/dist/lib/dexie.d.ts.map +1 -1
  35. package/dist/lib/dexie.js +9 -2
  36. package/dist/lib/index-ops.d.ts +138 -2
  37. package/dist/lib/index-ops.d.ts.map +1 -1
  38. package/dist/lib/index-ops.js +176 -8
  39. package/dist/lib/index.d.ts +1 -0
  40. package/dist/lib/index.d.ts.map +1 -1
  41. package/dist/lib/index.js +17 -6
  42. package/dist/lib/loader.d.ts +2 -2
  43. package/dist/lib/loader.d.ts.map +1 -1
  44. package/dist/lib/nav-tree.d.ts +63 -0
  45. package/dist/lib/nav-tree.d.ts.map +1 -0
  46. package/dist/lib/nav.d.ts +13 -2
  47. package/dist/lib/nav.d.ts.map +1 -1
  48. package/dist/lib/pages.d.ts +0 -1
  49. package/dist/lib/pages.d.ts.map +1 -1
  50. package/dist/lib/reorder.d.ts +24 -0
  51. package/dist/lib/reorder.d.ts.map +1 -1
  52. package/dist/media/index.js +3 -3
  53. package/dist/media/provider.d.ts +0 -1
  54. package/dist/media/provider.d.ts.map +1 -1
  55. package/dist/schemas/index.d.ts +1 -0
  56. package/dist/schemas/index.d.ts.map +1 -1
  57. package/dist/schemas/index.js +10 -2
  58. package/dist/schemas/nav.d.ts +40 -0
  59. package/dist/schemas/nav.d.ts.map +1 -0
  60. package/dist/schemas/site-config.d.ts +12 -1
  61. package/dist/schemas/site-config.d.ts.map +1 -1
  62. package/dist/types/database.d.ts +0 -3
  63. package/dist/types/database.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/collab/rebase.ts +35 -0
  66. package/src/components/editor/DropEdgeIndicator.tsx +15 -8
  67. package/src/components/editor/NavGroupRow.tsx +126 -0
  68. package/src/components/editor/PagesModal.tsx +150 -29
  69. package/src/components/shared/Navigation.tsx +51 -13
  70. package/src/components/shell/EditorShell.tsx +67 -18
  71. package/src/hooks/useEditorPersistence.ts +14 -0
  72. package/src/hooks/useSortableNavRow.ts +139 -0
  73. package/src/lib/dexie.ts +12 -2
  74. package/src/lib/index-ops.ts +261 -7
  75. package/src/lib/index.ts +1 -0
  76. package/src/lib/loader.ts +31 -4
  77. package/src/lib/nav-tree.ts +192 -0
  78. package/src/lib/nav.ts +43 -10
  79. package/src/lib/pages.ts +14 -11
  80. package/src/lib/reorder.ts +56 -0
  81. package/src/media/provider.ts +0 -1
  82. package/src/schemas/index.ts +1 -0
  83. package/src/schemas/nav.ts +36 -0
  84. package/src/schemas/site-config.ts +12 -2
  85. package/src/types/database.ts +0 -3
  86. package/dist/chunk-YBVK254P.js +0 -14
  87. /package/dist/{chunk-53DKVAB6.js → chunk-72CG6AA2.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,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
  }
@@ -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,
@@ -52,7 +53,9 @@ export const PageSchema = z.object({
52
53
 
53
54
  export type Page = z.infer<typeof PageSchema>;
54
55
 
55
- 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]+)*$/;
56
59
 
57
60
  // Raw input: accepts legacy (order[]) OR canonical (pages[]).
58
61
  const RawIndexSchema = z.object({
@@ -167,7 +170,14 @@ const CanonicalIndexSchema = z
167
170
 
168
171
  export const IndexSchema = RawIndexSchema.transform(normalizeRawIndex).pipe(CanonicalIndexSchema);
169
172
 
170
- export type SiteIndex = z.infer<typeof CanonicalIndexSchema>;
173
+ /**
174
+ * `nav` is a RUNTIME field only: it is loaded from the src/content/nav.json
175
+ * sidecar and attached by the loader, never parsed from or written to
176
+ * index.json. It is optional because most SiteIndex values (tests, legacy
177
+ * content, partial writes) have no nav — reads go through navEntries(), which
178
+ * reconciles a missing nav into a flat one.
179
+ */
180
+ export type SiteIndex = z.infer<typeof CanonicalIndexSchema> & { nav?: NavEntry[] };
171
181
 
172
182
  /** Parse + normalize any raw index (legacy or canonical) to the canonical shape. */
173
183
  export function normalizeSiteIndex(raw: unknown): SiteIndex {
@@ -384,7 +384,6 @@ export type Database = {
384
384
  client_id: string
385
385
  client_id_issued_at: number | null
386
386
  client_metadata: Json
387
- client_secret_hash: string | null
388
387
  created_at: string
389
388
  redirect_uris: string[]
390
389
  }
@@ -392,7 +391,6 @@ export type Database = {
392
391
  client_id: string
393
392
  client_id_issued_at?: number | null
394
393
  client_metadata?: Json
395
- client_secret_hash?: string | null
396
394
  created_at?: string
397
395
  redirect_uris: string[]
398
396
  }
@@ -400,7 +398,6 @@ export type Database = {
400
398
  client_id?: string
401
399
  client_id_issued_at?: number | null
402
400
  client_metadata?: Json
403
- client_secret_hash?: string | null
404
401
  created_at?: string
405
402
  redirect_uris?: string[]
406
403
  }
@@ -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