@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.
- package/dist/{chunk-CI624HRS.js → chunk-26ALJO4K.js} +1 -1
- package/dist/{chunk-SOILPZ7U.js → chunk-3COSXTOY.js} +1 -1
- package/dist/{chunk-RHWWQV3J.js → chunk-BGHWUJFV.js} +1 -0
- package/dist/{chunk-RQPHPAT2.js → chunk-EWNDBKYK.js} +2 -2
- package/dist/{chunk-3HJDMFTB.js → chunk-FOY5TF7D.js} +1 -1
- package/dist/{chunk-YUUUCDXN.js → chunk-GYCBWGVW.js} +1 -1
- package/dist/chunk-HJSE6SK5.js +27 -0
- package/dist/{chunk-CSVGCAIH.js → chunk-HKHEAH5F.js} +7 -7
- package/dist/{chunk-IFCQWC3G.js → chunk-KP4DWTPR.js} +3 -3
- package/dist/{chunk-DJAJRBPD.js → chunk-MUTHF2GT.js} +2 -2
- package/dist/{chunk-CS2ECR3U.js → chunk-NA5EJM6Z.js} +24 -6
- package/dist/chunk-TGNS7D4F.js +200 -0
- package/dist/collab/rebase.d.ts +20 -0
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +17 -0
- package/dist/components/editor/DropEdgeIndicator.d.ts +6 -1
- package/dist/components/editor/DropEdgeIndicator.d.ts.map +1 -1
- package/dist/components/editor/NavGroupRow.d.ts +21 -0
- package/dist/components/editor/NavGroupRow.d.ts.map +1 -0
- package/dist/components/editor/PagesModal.d.ts +7 -1
- package/dist/components/editor/PagesModal.d.ts.map +1 -1
- package/dist/components/editor/index.js +5 -5
- package/dist/components/primitives/index.js +7 -7
- package/dist/components/sections/brand-guide.js +6 -6
- package/dist/components/sections/register-schemas.js +10 -10
- package/dist/components/sections/register.js +10 -10
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/useEditorPersistence.d.ts +7 -0
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useSortableNavRow.d.ts +27 -0
- package/dist/hooks/useSortableNavRow.d.ts.map +1 -0
- package/dist/index.js +26 -8
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +9 -2
- package/dist/lib/index-ops.d.ts +138 -2
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +176 -8
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +17 -6
- package/dist/lib/loader.d.ts +2 -2
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/nav-tree.d.ts +63 -0
- package/dist/lib/nav-tree.d.ts.map +1 -0
- package/dist/lib/nav.d.ts +13 -2
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +0 -1
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/reorder.d.ts +24 -0
- package/dist/lib/reorder.d.ts.map +1 -1
- package/dist/media/index.js +3 -3
- package/dist/media/provider.d.ts +0 -1
- package/dist/media/provider.d.ts.map +1 -1
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +10 -2
- package/dist/schemas/nav.d.ts +40 -0
- package/dist/schemas/nav.d.ts.map +1 -0
- package/dist/schemas/site-config.d.ts +12 -1
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/types/database.d.ts +0 -3
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/collab/rebase.ts +35 -0
- package/src/components/editor/DropEdgeIndicator.tsx +15 -8
- package/src/components/editor/NavGroupRow.tsx +126 -0
- package/src/components/editor/PagesModal.tsx +150 -29
- package/src/components/shared/Navigation.tsx +51 -13
- package/src/components/shell/EditorShell.tsx +67 -18
- package/src/hooks/useEditorPersistence.ts +14 -0
- package/src/hooks/useSortableNavRow.ts +139 -0
- package/src/lib/dexie.ts +12 -2
- package/src/lib/index-ops.ts +261 -7
- package/src/lib/index.ts +1 -0
- package/src/lib/loader.ts +31 -4
- package/src/lib/nav-tree.ts +192 -0
- package/src/lib/nav.ts +43 -10
- package/src/lib/pages.ts +14 -11
- package/src/lib/reorder.ts +56 -0
- package/src/media/provider.ts +0 -1
- package/src/schemas/index.ts +1 -0
- package/src/schemas/nav.ts +36 -0
- package/src/schemas/site-config.ts +12 -2
- package/src/types/database.ts +0 -3
- package/dist/chunk-YBVK254P.js +0 -14
- /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
|
|
97
|
+
export interface GroupNavItem {
|
|
98
|
+
kind: "group";
|
|
99
|
+
id: string;
|
|
100
|
+
label: string;
|
|
101
|
+
startCollapsed: boolean;
|
|
97
102
|
pages: PageNavItem[];
|
|
98
|
-
|
|
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
|
-
|
|
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 {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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: {
|
|
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
|
}
|
package/src/lib/reorder.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/media/provider.ts
CHANGED
|
@@ -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
|
}
|
package/src/schemas/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/types/database.ts
CHANGED
|
@@ -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
|
}
|
package/dist/chunk-YBVK254P.js
DELETED
|
@@ -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
|