@drawnagency/primitives 0.1.96 → 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.
- package/dist/{chunk-IFCQWC3G.js → chunk-5BFPJWIO.js} +2 -2
- package/dist/{chunk-CI624HRS.js → chunk-F2337B35.js} +1 -1
- package/dist/{chunk-CSVGCAIH.js → chunk-FOEZNEF5.js} +6 -6
- package/dist/chunk-HJSE6SK5.js +27 -0
- package/dist/{chunk-YUUUCDXN.js → chunk-II3X7GLJ.js} +1 -1
- package/dist/{chunk-DJAJRBPD.js → chunk-KPP2CFW6.js} +2 -2
- package/dist/{chunk-CS2ECR3U.js → chunk-QEHPGA5N.js} +23 -5
- package/dist/{chunk-SOILPZ7U.js → chunk-S4L3ZFKM.js} +1 -1
- package/dist/{chunk-RQPHPAT2.js → chunk-YDXBJR74.js} +2 -2
- package/dist/chunk-ZD6XGRQM.js +85 -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 +4 -4
- package/dist/components/primitives/index.js +6 -6
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +9 -9
- package/dist/components/sections/register.js +9 -9
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.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 +22 -6
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +3 -1
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +15 -4
- 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 +2 -2
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +7 -1
- package/dist/schemas/nav.d.ts +40 -0
- package/dist/schemas/nav.d.ts.map +1 -0
- package/dist/schemas/site-config.d.ts +11 -1
- package/dist/schemas/site-config.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 +40 -10
- package/src/hooks/useSortableNavRow.ts +139 -0
- package/src/lib/dexie.ts +6 -1
- 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/schemas/index.ts +1 -0
- package/src/schemas/nav.ts +36 -0
- package/src/schemas/site-config.ts +9 -1
- package/dist/chunk-YBVK254P.js +0 -14
- /package/dist/{chunk-53DKVAB6.js → chunk-72CG6AA2.js} +0 -0
- /package/dist/{chunk-3HJDMFTB.js → chunk-XYCCXKVC.js} +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { computeNavDropTarget } from "../lib/reorder";
|
|
3
|
+
import type { NavLocation, NavRow } from "../lib/nav-tree";
|
|
4
|
+
|
|
5
|
+
interface Options {
|
|
6
|
+
/** This row's identity — supplies the source location and the drop container. */
|
|
7
|
+
row: NavRow;
|
|
8
|
+
/** The row's index in the TOP-LEVEL entry list (a member row: its group's index). */
|
|
9
|
+
entryIndex: number;
|
|
10
|
+
kind: "page" | "group";
|
|
11
|
+
onMove: (from: NavLocation, to: NavLocation) => void;
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DRAG_TYPE = "nav-tree-row";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Closest-edge drag wiring for one Pages-modal nav row. A sibling of
|
|
19
|
+
* useSortableRow that speaks NavLocation instead of a flat index; the section
|
|
20
|
+
* modal keeps using the original, so this cannot regress it.
|
|
21
|
+
*
|
|
22
|
+
* Loads the atlaskit adapter lazily, matching useSortableRow, so it stays out
|
|
23
|
+
* of the SSR and initial bundles.
|
|
24
|
+
*/
|
|
25
|
+
export function useSortableNavRow<R extends HTMLElement, H extends HTMLElement>({
|
|
26
|
+
row, entryIndex, kind, onMove, enabled = true,
|
|
27
|
+
}: Options) {
|
|
28
|
+
const rowRef = useRef<R>(null);
|
|
29
|
+
const handleRef = useRef<H>(null);
|
|
30
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
31
|
+
const [closestEdge, setClosestEdge] = useState<"top" | "bottom" | null>(null);
|
|
32
|
+
const [isDropTarget, setIsDropTarget] = useState(false);
|
|
33
|
+
|
|
34
|
+
// Read through a ref so the atlaskit effect doesn't re-subscribe every render.
|
|
35
|
+
const latest = useRef({ row, entryIndex, kind, onMove });
|
|
36
|
+
latest.current = { row, entryIndex, kind, onMove };
|
|
37
|
+
|
|
38
|
+
// MUST be a pure helper reading from `latest.current`, never a render-scope
|
|
39
|
+
// closure. The effect deliberately does not re-subscribe when the row's
|
|
40
|
+
// position changes, so a closure captured at mount would make every drag
|
|
41
|
+
// AFTER the first reorder report a stale `from` — and moveNavEntry would
|
|
42
|
+
// splice the wrong slot. Silent wrong-item moves, no error anywhere.
|
|
43
|
+
// (`useSortableRow` avoids this by re-subscribing on `[index, …]`; this hook
|
|
44
|
+
// cannot, because its identity is a NavLocation rather than a single number.)
|
|
45
|
+
const locationOf = (r: NavRow, ei: number): NavLocation =>
|
|
46
|
+
r.kind === "group"
|
|
47
|
+
? { groupId: null, index: ei }
|
|
48
|
+
: r.kind === "page"
|
|
49
|
+
? { groupId: r.groupId, index: r.index }
|
|
50
|
+
: r.kind === "empty-group"
|
|
51
|
+
? { groupId: r.groupId, index: 0 }
|
|
52
|
+
: { groupId: null, index: ei };
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!enabled) return;
|
|
56
|
+
const element = rowRef.current;
|
|
57
|
+
const handle = handleRef.current;
|
|
58
|
+
if (!element) return;
|
|
59
|
+
// Only draggable rows need a handle; empty-group and tail rows are drop-only.
|
|
60
|
+
const dropOnly = row.kind === "empty-group" || row.kind === "tail";
|
|
61
|
+
if (!dropOnly && !handle) return;
|
|
62
|
+
|
|
63
|
+
let cleanup: (() => void) | undefined;
|
|
64
|
+
let cancelled = false;
|
|
65
|
+
|
|
66
|
+
Promise.all([
|
|
67
|
+
import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
|
|
68
|
+
import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
|
|
69
|
+
]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
|
|
70
|
+
if (cancelled) return;
|
|
71
|
+
const cleanups: (() => void)[] = [];
|
|
72
|
+
|
|
73
|
+
if (!dropOnly && handle) {
|
|
74
|
+
cleanups.push(draggable({
|
|
75
|
+
element, dragHandle: handle,
|
|
76
|
+
getInitialData: () => ({
|
|
77
|
+
dragType: DRAG_TYPE,
|
|
78
|
+
kind,
|
|
79
|
+
from: locationOf(latest.current.row, latest.current.entryIndex),
|
|
80
|
+
}),
|
|
81
|
+
onGenerateDragPreview: () => {
|
|
82
|
+
element.style.opacity = "0.4";
|
|
83
|
+
requestAnimationFrame(() => { element.style.opacity = ""; });
|
|
84
|
+
},
|
|
85
|
+
onDragStart: () => setIsDragging(true),
|
|
86
|
+
onDrop: () => setIsDragging(false),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resolve = (source: Record<string, unknown>, edge: "top" | "bottom" | null) =>
|
|
91
|
+
computeNavDropTarget({
|
|
92
|
+
from: source.from as NavLocation,
|
|
93
|
+
fromKind: source.kind as "page" | "group",
|
|
94
|
+
over: latest.current.row,
|
|
95
|
+
overEntryIndex: latest.current.entryIndex,
|
|
96
|
+
edge,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
cleanups.push(dropTargetForElements({
|
|
100
|
+
element,
|
|
101
|
+
canDrop: ({ source }) => source.data.dragType === DRAG_TYPE,
|
|
102
|
+
getData: ({ input, element: el }) =>
|
|
103
|
+
attachClosestEdge({}, { input, element: el, allowedEdges: ["top", "bottom"] }),
|
|
104
|
+
onDragEnter: ({ self, source }) => {
|
|
105
|
+
const raw = extractClosestEdge(self.data);
|
|
106
|
+
const edge = raw === "top" || raw === "bottom" ? raw : null;
|
|
107
|
+
// Show an indicator only where the drop would actually land, so a
|
|
108
|
+
// rejected target reads as inert rather than as a silent failure.
|
|
109
|
+
const target = resolve(source.data, edge);
|
|
110
|
+
setClosestEdge(target ? edge : null);
|
|
111
|
+
setIsDropTarget(!!target);
|
|
112
|
+
},
|
|
113
|
+
onDrag: ({ self, source }) => {
|
|
114
|
+
const raw = extractClosestEdge(self.data);
|
|
115
|
+
const edge = raw === "top" || raw === "bottom" ? raw : null;
|
|
116
|
+
const target = resolve(source.data, edge);
|
|
117
|
+
setClosestEdge(target ? edge : null);
|
|
118
|
+
setIsDropTarget(!!target);
|
|
119
|
+
},
|
|
120
|
+
onDragLeave: () => { setClosestEdge(null); setIsDropTarget(false); },
|
|
121
|
+
onDrop: ({ self, source }) => {
|
|
122
|
+
setClosestEdge(null);
|
|
123
|
+
setIsDropTarget(false);
|
|
124
|
+
const raw = extractClosestEdge(self.data);
|
|
125
|
+
const edge = raw === "top" || raw === "bottom" ? raw : null;
|
|
126
|
+
const target = resolve(source.data, edge);
|
|
127
|
+
if (target) latest.current.onMove(source.data.from as NavLocation, target);
|
|
128
|
+
},
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
cleanup = () => cleanups.forEach((c) => c());
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return () => { cancelled = true; cleanup?.(); };
|
|
135
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
136
|
+
}, [enabled, row.kind, kind]);
|
|
137
|
+
|
|
138
|
+
return { rowRef, handleRef, isDragging, closestEdge, isDropTarget };
|
|
139
|
+
}
|
package/src/lib/dexie.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Dexie from "dexie";
|
|
2
2
|
import type { SectionContent } from "../schemas/sections";
|
|
3
3
|
import type { SiteIndex, SectionMeta, SiteConfig, Page } from "../schemas/site-config";
|
|
4
|
+
import type { NavEntry } from "../schemas/nav";
|
|
4
5
|
import type { LoadedSection } from "./loader";
|
|
5
6
|
import type { MediaManifest, MediaItem } from "../media/types";
|
|
6
7
|
|
|
@@ -15,6 +16,8 @@ interface SiteIndexRow {
|
|
|
15
16
|
key: string;
|
|
16
17
|
pages: Page[];
|
|
17
18
|
sections: Record<string, SectionMeta>;
|
|
19
|
+
/** Optional: rows written before page groups shipped have no nav. */
|
|
20
|
+
nav?: NavEntry[];
|
|
18
21
|
deletedSections: string[];
|
|
19
22
|
updatedAt: string;
|
|
20
23
|
}
|
|
@@ -237,7 +240,7 @@ export async function restoreLocalChanges(): Promise<{
|
|
|
237
240
|
const siteId = metaRow?.siteId ?? "";
|
|
238
241
|
return {
|
|
239
242
|
sections,
|
|
240
|
-
siteIndex: { siteId, pages: indexRow.pages, sections: indexRow.sections },
|
|
243
|
+
siteIndex: { siteId, pages: indexRow.pages, sections: indexRow.sections, nav: indexRow.nav },
|
|
241
244
|
siteConfig: configRow?.config,
|
|
242
245
|
deletedSections: indexRow.deletedSections ?? [],
|
|
243
246
|
};
|
|
@@ -266,6 +269,7 @@ export async function persistSiteIndex(index: SiteIndex, deletedSections: string
|
|
|
266
269
|
key: "current",
|
|
267
270
|
pages: index.pages,
|
|
268
271
|
sections: index.sections,
|
|
272
|
+
nav: index.nav,
|
|
269
273
|
deletedSections,
|
|
270
274
|
updatedAt: now,
|
|
271
275
|
});
|
|
@@ -389,6 +393,7 @@ export async function persistAll(
|
|
|
389
393
|
key: "current",
|
|
390
394
|
pages: siteIndex.pages,
|
|
391
395
|
sections: siteIndex.sections,
|
|
396
|
+
nav: siteIndex.nav,
|
|
392
397
|
deletedSections: [],
|
|
393
398
|
updatedAt: now,
|
|
394
399
|
});
|
package/src/lib/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { cn } from "./cn";
|
|
|
3
3
|
export { audiencePasses } from "./pages";
|
|
4
4
|
export { resolveChatbotName, resolveThinkingPhrases, DEFAULT_CHATBOT_NAME } from "./chatbot";
|
|
5
5
|
export { generateNavLinks, toSectionId, type NavItem } from "./nav";
|
|
6
|
+
export { navEntries, pagesInNavOrder, groupOfPage, groupDisplayLabel, reconcileNav } from "./nav-tree";
|
|
6
7
|
export {
|
|
7
8
|
deriveContrast,
|
|
8
9
|
contrastColor,
|
package/src/lib/loader.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { IndexSchema, type SiteIndex, type SectionMeta } from "../schemas/site-c
|
|
|
3
3
|
import { getAllSchemas } from "./registry";
|
|
4
4
|
import { blockDepth, MAX_BLOCK_DEPTH } from "../schemas/block";
|
|
5
5
|
import { upgradeLegacySection } from "./migrate-sections-transform";
|
|
6
|
+
import { NavFileSchema, type NavEntry } from "../schemas/nav";
|
|
6
7
|
|
|
7
8
|
export interface LoadedSection {
|
|
8
9
|
section: Section;
|
|
@@ -14,6 +15,21 @@ export interface SiteContent {
|
|
|
14
15
|
index: SiteIndex;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Parse the nav.json sidecar defensively. A malformed sidecar must degrade to a
|
|
20
|
+
* flat nav, never brick the site — nav.json is optional content, and the whole
|
|
21
|
+
* point of the sidecar is that it tolerates writers that don't understand it.
|
|
22
|
+
*/
|
|
23
|
+
function parseNavFile(navFile: unknown): NavEntry[] {
|
|
24
|
+
if (!navFile) return [];
|
|
25
|
+
const result = NavFileSchema.safeParse(navFile);
|
|
26
|
+
if (!result.success) {
|
|
27
|
+
console.warn("Invalid nav.json, falling back to a flat navigation");
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
return result.data.entries;
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
/**
|
|
18
34
|
* Merge a validated index with raw section file contents.
|
|
19
35
|
* Validates each section file against its Zod schema.
|
|
@@ -21,7 +37,8 @@ export interface SiteContent {
|
|
|
21
37
|
*/
|
|
22
38
|
export function mergeSiteContent(
|
|
23
39
|
index: SiteIndex,
|
|
24
|
-
sectionFiles: Record<string, unknown
|
|
40
|
+
sectionFiles: Record<string, unknown>,
|
|
41
|
+
navFile?: unknown,
|
|
25
42
|
): SiteContent {
|
|
26
43
|
const sections: LoadedSection[] = [];
|
|
27
44
|
|
|
@@ -66,7 +83,7 @@ export function mergeSiteContent(
|
|
|
66
83
|
}
|
|
67
84
|
}
|
|
68
85
|
|
|
69
|
-
return { sections, index };
|
|
86
|
+
return { sections, index: { ...index, nav: parseNavFile(navFile) } };
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
/**
|
|
@@ -77,6 +94,7 @@ export function mergeSiteContent(
|
|
|
77
94
|
export function loadStaticSiteContent(
|
|
78
95
|
index: SiteIndex,
|
|
79
96
|
sectionGlob: Record<string, unknown>,
|
|
97
|
+
navFile?: unknown,
|
|
80
98
|
): SiteContent {
|
|
81
99
|
const sectionFiles: Record<string, unknown> = {};
|
|
82
100
|
for (const [path, data] of Object.entries(sectionGlob)) {
|
|
@@ -84,7 +102,7 @@ export function loadStaticSiteContent(
|
|
|
84
102
|
const id = filename.replace(".json", "");
|
|
85
103
|
sectionFiles[id] = data;
|
|
86
104
|
}
|
|
87
|
-
return mergeSiteContent(index, sectionFiles);
|
|
105
|
+
return mergeSiteContent(index, sectionFiles, navFile);
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
/**
|
|
@@ -105,5 +123,14 @@ export async function loadSiteContent(contentDir: string): Promise<SiteContent>
|
|
|
105
123
|
sectionFiles[id] = JSON.parse(await fs.readFile(sectionPath, "utf-8"));
|
|
106
124
|
}
|
|
107
125
|
|
|
108
|
-
|
|
126
|
+
// nav.json is an optional sidecar — absent (every portal predating page
|
|
127
|
+
// groups) or unreadable must degrade to a flat nav, never fail the load.
|
|
128
|
+
let navFile: unknown;
|
|
129
|
+
try {
|
|
130
|
+
navFile = JSON.parse(await fs.readFile(path.join(contentDir, "nav.json"), "utf-8"));
|
|
131
|
+
} catch {
|
|
132
|
+
navFile = undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return mergeSiteContent(index, sectionFiles, navFile);
|
|
109
136
|
}
|
|
@@ -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
|
+
}
|