@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,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,7 +393,13 @@ export async function persistAll(
|
|
|
389
393
|
key: "current",
|
|
390
394
|
pages: siteIndex.pages,
|
|
391
395
|
sections: siteIndex.sections,
|
|
392
|
-
|
|
396
|
+
nav: siteIndex.nav,
|
|
397
|
+
// Record the deletion INTENT, not just its effect on the order. The
|
|
398
|
+
// restore path reads this back; when it was hardcoded `[]` that path
|
|
399
|
+
// had to re-derive deletions by diffing this snapshot against a fresh
|
|
400
|
+
// remote read, which treats a third party's additions (MCP connector,
|
|
401
|
+
// peer editor) as this editor's deletions.
|
|
402
|
+
deletedSections: deletedSectionIds ?? [],
|
|
393
403
|
updatedAt: now,
|
|
394
404
|
});
|
|
395
405
|
await database.meta.put({
|
package/src/lib/index-ops.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { IndexSchema, RESERVED_SLUGS, type SiteIndex, type SectionMeta } from "../schemas/site-config";
|
|
2
|
+
import { IndexSchema, PageStatusSchema, RESERVED_SLUGS, SLUG_RE, type SiteIndex, type SectionMeta } from "../schemas/site-config";
|
|
3
|
+
import type { NavEntry, NavGroupEntry } from "../schemas/nav";
|
|
4
|
+
import type { NavLocation } from "./nav-tree";
|
|
5
|
+
// Page and nav mutations are shared with the editor rather than reimplemented:
|
|
6
|
+
// both surfaces must agree on what "delete a page" or "remove a group" means, and
|
|
7
|
+
// a second implementation here is how they would drift.
|
|
8
|
+
import { addPage as addPageToIndex, deletePage, slugifyPageSlug, uniquePageSlug } from "./pages";
|
|
9
|
+
import {
|
|
10
|
+
addGroup, deleteGroup, groupOfPage, moveNavEntry, navEntries, reconcileNav,
|
|
11
|
+
renameGroup, setGroupStartCollapsed,
|
|
12
|
+
} from "./nav-tree";
|
|
3
13
|
|
|
4
14
|
/**
|
|
5
15
|
* Server-applied index deltas for the MCP connector's save path. An agent
|
|
@@ -25,6 +35,11 @@ const OpStatusSchema = z.enum(["draft", "live", "archived"]);
|
|
|
25
35
|
// path, which names neither the op nor the id.
|
|
26
36
|
const SAFE_SECTION_ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
27
37
|
|
|
38
|
+
// Page and nav-group ids are not filenames, but they are dictionary keys an
|
|
39
|
+
// agent has to echo back, so they get the same tame character set. Keeping the
|
|
40
|
+
// shapes identical also means one remembered rule covers every id in this API.
|
|
41
|
+
const SAFE_ENTITY_ID = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
42
|
+
|
|
28
43
|
const SectionInsertSchema = z.object({
|
|
29
44
|
id: z.string().regex(SAFE_SECTION_ID, "Section ids may only contain letters, numbers, dashes and underscores (max 64 chars) — they become filenames.").describe("The new section's id: letters, numbers, dashes, underscores; max 64 chars. Must not already exist in the index."),
|
|
30
45
|
status: OpStatusSchema.optional().describe('Defaults to "draft" — invisible to viewers until promoted to "live".'),
|
|
@@ -67,10 +82,81 @@ export const IndexOpSchema = z.discriminatedUnion("op", [
|
|
|
67
82
|
slug: z.string().optional().describe("New URL slug (lowercase words joined by dashes). Not settable on the home page, whose slug is always empty."),
|
|
68
83
|
showInNav: z.boolean().optional().describe("Whether the page appears in site navigation."),
|
|
69
84
|
access: z.array(z.string()).optional().describe("New audience slugs from list_audiences; [] means everyone."),
|
|
85
|
+
status: PageStatusSchema.optional().describe('Page status. "archived" hides the page and its sections from viewers without deleting anything; the home page cannot be archived.'),
|
|
86
|
+
}),
|
|
87
|
+
z.object({
|
|
88
|
+
op: z.literal("addPage"),
|
|
89
|
+
id: z.string().regex(SAFE_ENTITY_ID, "Page ids may only contain letters, numbers, dashes and underscores (max 64 chars).").describe("The new page's id: letters, numbers, dashes, underscores; max 64 chars. Must not already exist."),
|
|
90
|
+
title: z.string().describe("Page title, shown in navigation."),
|
|
91
|
+
slug: z.string().optional().describe("URL slug (lowercase words joined by dashes). Defaults to a unique slug derived from the title."),
|
|
92
|
+
showInNav: z.boolean().optional().describe("Whether the page appears in site navigation. Defaults to true."),
|
|
93
|
+
access: z.array(z.string()).optional().describe("Audience slugs from list_audiences. Defaults to [] (everyone)."),
|
|
94
|
+
navGroupId: z.string().optional().describe("Put the new page inside this existing nav group instead of at the top level."),
|
|
95
|
+
}),
|
|
96
|
+
z.object({
|
|
97
|
+
op: z.literal("removePage"),
|
|
98
|
+
pageId: z.string().describe("The page to delete. The home page cannot be deleted."),
|
|
99
|
+
removeSections: z.boolean().optional().describe("Required `true` when the page still holds sections — they are deleted with it and their content files removed. Without it a non-empty page is refused, so a page is never emptied by accident."),
|
|
100
|
+
}),
|
|
101
|
+
z.object({
|
|
102
|
+
op: z.literal("addNavGroup"),
|
|
103
|
+
id: z.string().regex(SAFE_ENTITY_ID, "Nav group ids may only contain letters, numbers, dashes and underscores (max 64 chars).").describe("The new group's id: letters, numbers, dashes, underscores; max 64 chars."),
|
|
104
|
+
label: z.string().optional().describe('Group heading in the sidebar. Blank renders as "Untitled group".'),
|
|
105
|
+
startCollapsed: z.boolean().optional().describe("Whether the group renders collapsed on first viewer load. Defaults to false."),
|
|
106
|
+
pageIds: z.array(z.string()).optional().describe("Existing page ids to move into the group, in order. A page belongs to at most one group, so listing it here removes it from wherever it was."),
|
|
107
|
+
}),
|
|
108
|
+
z.object({
|
|
109
|
+
op: z.literal("removeNavGroup"),
|
|
110
|
+
groupId: z.string().describe("The group to remove. Its member pages are NOT deleted — they become top-level nav entries at the group's former position."),
|
|
111
|
+
}),
|
|
112
|
+
z.object({
|
|
113
|
+
op: z.literal("setNavGroupMeta"),
|
|
114
|
+
groupId: z.string().describe("An existing nav group id."),
|
|
115
|
+
label: z.string().optional().describe("New sidebar heading."),
|
|
116
|
+
startCollapsed: z.boolean().optional().describe("Whether the group renders collapsed on first viewer load."),
|
|
117
|
+
}),
|
|
118
|
+
z.object({
|
|
119
|
+
op: z.literal("movePageInNav"),
|
|
120
|
+
pageId: z.string().describe("The page to reposition in the sidebar."),
|
|
121
|
+
groupId: z.string().nullable().describe("Destination group id, or null to move the page out to the top level."),
|
|
122
|
+
position: z.number().int().min(0).optional().describe("0-based slot within the destination (inside the group, or among top-level nav entries). Defaults to the end."),
|
|
70
123
|
}),
|
|
71
124
|
]);
|
|
72
125
|
|
|
73
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Written out rather than `z.infer<typeof IndexOpSchema>`.
|
|
128
|
+
*
|
|
129
|
+
* The MCP SDK infers a tool's handler and JSON-Schema types from the Zod shape it
|
|
130
|
+
* is registered with, and at twelve union members each carrying several described
|
|
131
|
+
* optionals, re-deriving this union at that boundary overflowed tsc's stack — the
|
|
132
|
+
* `apps/mcp` typecheck segfaulted rather than failing with a diagnostic. Naming
|
|
133
|
+
* the type once caps that inference: consumers see this union directly instead of
|
|
134
|
+
* re-deriving it through the schema. `assertIndexOpTypesMatchSchema` in
|
|
135
|
+
* tests/lib/index-ops.test.ts pins the two together so they cannot drift.
|
|
136
|
+
*/
|
|
137
|
+
export type IndexOp =
|
|
138
|
+
| { op: "insertAfter"; pageId: string; afterId: string | null; sections: SectionInsert[] }
|
|
139
|
+
| { op: "insertAt"; pageId: string; position: number; sections: SectionInsert[] }
|
|
140
|
+
| { op: "move"; pageId: string; id: string; afterId: string | null }
|
|
141
|
+
| { op: "remove"; ids: string[] }
|
|
142
|
+
| { op: "setMeta"; id: string; status?: OpStatus; access?: string[] }
|
|
143
|
+
| {
|
|
144
|
+
op: "setPageMeta"; pageId: string; title?: string; slug?: string;
|
|
145
|
+
showInNav?: boolean; access?: string[]; status?: PageStatus;
|
|
146
|
+
}
|
|
147
|
+
| {
|
|
148
|
+
op: "addPage"; id: string; title: string; slug?: string;
|
|
149
|
+
showInNav?: boolean; access?: string[]; navGroupId?: string;
|
|
150
|
+
}
|
|
151
|
+
| { op: "removePage"; pageId: string; removeSections?: boolean }
|
|
152
|
+
| { op: "addNavGroup"; id: string; label?: string; startCollapsed?: boolean; pageIds?: string[] }
|
|
153
|
+
| { op: "removeNavGroup"; groupId: string }
|
|
154
|
+
| { op: "setNavGroupMeta"; groupId: string; label?: string; startCollapsed?: boolean }
|
|
155
|
+
| { op: "movePageInNav"; pageId: string; groupId: string | null; position?: number };
|
|
156
|
+
|
|
157
|
+
type OpStatus = z.infer<typeof OpStatusSchema>;
|
|
158
|
+
type PageStatus = z.infer<typeof PageStatusSchema>;
|
|
159
|
+
type SectionInsert = z.infer<typeof SectionInsertSchema>;
|
|
74
160
|
|
|
75
161
|
export type ApplyIndexOpsResult =
|
|
76
162
|
| { ok: true; index: SiteIndex; removedIds: string[]; warnings: string[] }
|
|
@@ -104,6 +190,35 @@ export function opTouchedSectionIds(ops: IndexOp[]): string[] {
|
|
|
104
190
|
|
|
105
191
|
const fmt = (ids: string[]) => ids.map((id) => `"${id}"`).join(", ");
|
|
106
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Id-based reparent/reposition of a page in the nav, expressed over the editor's
|
|
195
|
+
* index-based `moveNavEntry` primitive. Agents address pages and groups by id;
|
|
196
|
+
* the drag-and-drop surface addresses slots — this is the adapter between them,
|
|
197
|
+
* so both still go through one move implementation.
|
|
198
|
+
*
|
|
199
|
+
* `position === undefined` appends. `moveNavEntry` lifts the page out of its
|
|
200
|
+
* source container BEFORE inserting, so an append index computed against the
|
|
201
|
+
* pre-removal length can overshoot by one; it clamps, so that is safe.
|
|
202
|
+
*/
|
|
203
|
+
function moveIntoGroup(
|
|
204
|
+
index: SiteIndex,
|
|
205
|
+
pageId: string,
|
|
206
|
+
groupId: string | null,
|
|
207
|
+
position: number | undefined,
|
|
208
|
+
): SiteIndex {
|
|
209
|
+
const entries = navEntries(index);
|
|
210
|
+
const currentGroup = groupOfPage(index, pageId);
|
|
211
|
+
// navEntries reconciles, so the page is guaranteed to appear exactly once —
|
|
212
|
+
// either as a top-level entry or inside exactly one group.
|
|
213
|
+
const from: NavLocation = currentGroup
|
|
214
|
+
? { groupId: currentGroup.id, index: currentGroup.pageIds.indexOf(pageId) }
|
|
215
|
+
: { groupId: null, index: entries.findIndex((e) => e.kind === "page" && e.pageId === pageId) };
|
|
216
|
+
const destLength = groupId === null
|
|
217
|
+
? entries.length
|
|
218
|
+
: entries.find((e): e is NavGroupEntry => e.kind === "group" && e.id === groupId)?.pageIds.length ?? 0;
|
|
219
|
+
return moveNavEntry(index, from, { groupId, index: position ?? destLength });
|
|
220
|
+
}
|
|
221
|
+
|
|
107
222
|
export function applyIndexOps(
|
|
108
223
|
rawIndex: unknown,
|
|
109
224
|
ops: IndexOp[],
|
|
@@ -123,17 +238,30 @@ export function applyIndexOps(
|
|
|
123
238
|
};
|
|
124
239
|
}
|
|
125
240
|
|
|
241
|
+
// IndexSchema strips `nav` (that strip is what keeps the sidecar out of
|
|
242
|
+
// index.json), so the authored nav has to be lifted off the RAW input to be
|
|
243
|
+
// available to the nav ops below. reconcileNav is total, so an unparseable
|
|
244
|
+
// shape here degrades to a flat nav rather than failing.
|
|
245
|
+
const rawNav = (rawIndex as { nav?: unknown } | null | undefined)?.nav;
|
|
246
|
+
|
|
126
247
|
// Own copy: the caller's index is also its knownSiteTypes source and may be
|
|
127
|
-
// logged, so nothing here mutates it.
|
|
128
|
-
|
|
248
|
+
// logged, so nothing here mutates it. Reassigned by the page/nav ops, which
|
|
249
|
+
// are shared immutable helpers rather than in-place mutators.
|
|
250
|
+
let index: SiteIndex = {
|
|
129
251
|
...base.data,
|
|
130
252
|
pages: base.data.pages.map((p) => ({ ...p, order: [...p.order] })),
|
|
131
253
|
sections: Object.fromEntries(Object.entries(base.data.sections).map(([k, v]) => [k, { ...v }])),
|
|
254
|
+
...(Array.isArray(rawNav) ? { nav: rawNav as NavEntry[] } : {}),
|
|
132
255
|
};
|
|
133
256
|
|
|
134
257
|
const removed = new Set<string>();
|
|
135
258
|
const warnings: string[] = [];
|
|
136
259
|
const fail = (error: string): ApplyIndexOpsResult => ({ ok: false, error });
|
|
260
|
+
// Whether any op in this batch changed the navigation sidecar. Section-level
|
|
261
|
+
// ops must leave `nav` OFF the result: content-ops writes nav.json only when
|
|
262
|
+
// the posted index carries a `nav`, so omitting it is what guarantees an
|
|
263
|
+
// ordinary indexOps write cannot disturb a site's page groups.
|
|
264
|
+
let navTouched = false;
|
|
137
265
|
|
|
138
266
|
for (const [i, op] of ops.entries()) {
|
|
139
267
|
const at = `indexOps[${i}] ${op.op}`;
|
|
@@ -255,12 +383,129 @@ export function applyIndexOps(
|
|
|
255
383
|
continue;
|
|
256
384
|
}
|
|
257
385
|
|
|
386
|
+
if (op.op === "addPage") {
|
|
387
|
+
if (index.pages.some((p) => p.id === op.id)) {
|
|
388
|
+
return fail(`${at}: a page with id "${op.id}" already exists — use setPageMeta to edit it.`);
|
|
389
|
+
}
|
|
390
|
+
let slug: string;
|
|
391
|
+
if (op.slug !== undefined) {
|
|
392
|
+
// An explicit slug is never silently rewritten: uniquePageSlug would
|
|
393
|
+
// quietly hand back "about-2" and the agent would keep addressing "about".
|
|
394
|
+
if (!SLUG_RE.test(op.slug)) {
|
|
395
|
+
return fail(`${at}: slug "${op.slug}" is not a valid URL slug — use lowercase words joined by dashes (e.g. "brand-guide").`);
|
|
396
|
+
}
|
|
397
|
+
if ((RESERVED_SLUGS as readonly string[]).includes(op.slug)) {
|
|
398
|
+
return fail(`${at}: slug "${op.slug}" is reserved by a platform route and would shadow this page. Pick another slug.`);
|
|
399
|
+
}
|
|
400
|
+
if (index.pages.some((p) => p.slug === op.slug)) {
|
|
401
|
+
return fail(`${at}: slug "${op.slug}" is already used by another page. Slugs must be unique.`);
|
|
402
|
+
}
|
|
403
|
+
slug = op.slug;
|
|
404
|
+
} else {
|
|
405
|
+
// Derived slugs MAY be de-duplicated — nothing is addressing them yet.
|
|
406
|
+
slug = uniquePageSlug(slugifyPageSlug(op.title), index.pages.map((p) => p.slug));
|
|
407
|
+
}
|
|
408
|
+
if (op.navGroupId !== undefined && !navEntries(index).some((e) => e.kind === "group" && e.id === op.navGroupId)) {
|
|
409
|
+
return fail(`${at}: no nav group with id "${op.navGroupId}" — create it with addNavGroup first, or omit navGroupId to place the page at the top level.`);
|
|
410
|
+
}
|
|
411
|
+
index = addPageToIndex(index, op.id);
|
|
412
|
+
const created = index.pages.find((p) => p.id === op.id)!;
|
|
413
|
+
created.title = op.title;
|
|
414
|
+
created.slug = slug;
|
|
415
|
+
if (op.showInNav !== undefined) created.showInNav = op.showInNav;
|
|
416
|
+
if (op.access !== undefined) created.access = op.access;
|
|
417
|
+
if (op.navGroupId !== undefined) {
|
|
418
|
+
// addPageToIndex appended a top-level nav entry; lift it into the group.
|
|
419
|
+
index = moveIntoGroup(index, op.id, op.navGroupId, undefined);
|
|
420
|
+
}
|
|
421
|
+
navTouched = true;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (op.op === "removePage") {
|
|
426
|
+
const page = index.pages.find((p) => p.id === op.pageId);
|
|
427
|
+
if (!page) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
|
|
428
|
+
if (page.isHome) {
|
|
429
|
+
return fail(`${at}: the home page cannot be removed — it is the site's root. Promote a different page to home in the editor first.`);
|
|
430
|
+
}
|
|
431
|
+
if (page.order.length > 0 && op.removeSections !== true) {
|
|
432
|
+
return fail(
|
|
433
|
+
`${at}: page "${op.pageId}" still holds ${page.order.length} section(s) (${fmt(page.order.slice(0, 5))}${page.order.length > 5 ? ", …" : ""}). ` +
|
|
434
|
+
"Pass removeSections: true to delete them with the page, or move them elsewhere first with move ops.",
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
const result = deletePage(index, op.pageId);
|
|
438
|
+
index = result.index;
|
|
439
|
+
for (const id of result.removedSectionIds) removed.add(id);
|
|
440
|
+
navTouched = true;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (op.op === "addNavGroup") {
|
|
445
|
+
const entries = navEntries(index);
|
|
446
|
+
if (entries.some((e) => e.kind === "group" && e.id === op.id)) {
|
|
447
|
+
return fail(`${at}: a nav group with id "${op.id}" already exists — use setNavGroupMeta to edit it.`);
|
|
448
|
+
}
|
|
449
|
+
const missing = (op.pageIds ?? []).filter((id) => !index.pages.some((p) => p.id === id));
|
|
450
|
+
if (missing.length > 0) {
|
|
451
|
+
return fail(`${at}: no page with id ${fmt(missing)} — page ids come from get_site_index.`);
|
|
452
|
+
}
|
|
453
|
+
index = addGroup(index, op.id);
|
|
454
|
+
if (op.label !== undefined) index = renameGroup(index, op.id, op.label);
|
|
455
|
+
if (op.startCollapsed !== undefined) index = setGroupStartCollapsed(index, op.id, op.startCollapsed);
|
|
456
|
+
// In the order given: each move appends, so listing pages here places them
|
|
457
|
+
// in that sequence regardless of their previous nav positions.
|
|
458
|
+
for (const pageId of op.pageIds ?? []) index = moveIntoGroup(index, pageId, op.id, undefined);
|
|
459
|
+
navTouched = true;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (op.op === "removeNavGroup") {
|
|
464
|
+
if (!navEntries(index).some((e) => e.kind === "group" && e.id === op.groupId)) {
|
|
465
|
+
return fail(`${at}: no nav group with id "${op.groupId}" — group ids come from the \`nav\` on get_site_index.`);
|
|
466
|
+
}
|
|
467
|
+
index = deleteGroup(index, op.groupId);
|
|
468
|
+
navTouched = true;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (op.op === "setNavGroupMeta") {
|
|
473
|
+
if (op.label === undefined && op.startCollapsed === undefined) {
|
|
474
|
+
return fail(`${at}: nothing to set — provide label and/or startCollapsed.`);
|
|
475
|
+
}
|
|
476
|
+
if (!navEntries(index).some((e) => e.kind === "group" && e.id === op.groupId)) {
|
|
477
|
+
return fail(`${at}: no nav group with id "${op.groupId}" — group ids come from the \`nav\` on get_site_index.`);
|
|
478
|
+
}
|
|
479
|
+
if (op.label !== undefined) index = renameGroup(index, op.groupId, op.label);
|
|
480
|
+
if (op.startCollapsed !== undefined) index = setGroupStartCollapsed(index, op.groupId, op.startCollapsed);
|
|
481
|
+
navTouched = true;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (op.op === "movePageInNav") {
|
|
486
|
+
if (!index.pages.some((p) => p.id === op.pageId)) {
|
|
487
|
+
return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
|
|
488
|
+
}
|
|
489
|
+
if (op.groupId !== null && !navEntries(index).some((e) => e.kind === "group" && e.id === op.groupId)) {
|
|
490
|
+
return fail(`${at}: no nav group with id "${op.groupId}" — group ids come from the \`nav\` on get_site_index.`);
|
|
491
|
+
}
|
|
492
|
+
index = moveIntoGroup(index, op.pageId, op.groupId, op.position);
|
|
493
|
+
navTouched = true;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
|
|
258
497
|
// setPageMeta
|
|
259
|
-
if (
|
|
260
|
-
|
|
498
|
+
if (
|
|
499
|
+
op.title === undefined && op.slug === undefined && op.showInNav === undefined &&
|
|
500
|
+
op.access === undefined && op.status === undefined
|
|
501
|
+
) {
|
|
502
|
+
return fail(`${at}: nothing to set — provide title, slug, showInNav, access and/or status.`);
|
|
261
503
|
}
|
|
262
504
|
const page = index.pages.find((p) => p.id === op.pageId);
|
|
263
505
|
if (!page) return fail(`${at}: no page with id "${op.pageId}" — page ids come from get_site_index.`);
|
|
506
|
+
if (op.status === "archived" && page.isHome) {
|
|
507
|
+
return fail(`${at}: the home page cannot be archived — it is the site's root. Promote a different page to home in the editor first.`);
|
|
508
|
+
}
|
|
264
509
|
if (op.slug !== undefined && page.isHome) {
|
|
265
510
|
return fail(`${at}: the home page's slug is always empty and cannot be set. Promote a different page to home in the editor if you need to change which page is the root.`);
|
|
266
511
|
}
|
|
@@ -276,6 +521,7 @@ export function applyIndexOps(
|
|
|
276
521
|
if (op.title !== undefined) page.title = op.title;
|
|
277
522
|
if (op.slug !== undefined) page.slug = op.slug;
|
|
278
523
|
if (op.showInNav !== undefined) page.showInNav = op.showInNav;
|
|
524
|
+
if (op.status !== undefined) page.status = op.status;
|
|
279
525
|
if (op.access !== undefined) {
|
|
280
526
|
const dropped = page.access.filter((a) => !op.access!.includes(a));
|
|
281
527
|
page.access = op.access;
|
|
@@ -305,5 +551,13 @@ export function applyIndexOps(
|
|
|
305
551
|
if (!validated.success) {
|
|
306
552
|
return fail(`applying indexOps produced an invalid index: ${validated.error.issues.map((issue) => issue.message).join("; ")}`);
|
|
307
553
|
}
|
|
308
|
-
|
|
554
|
+
// The parse above stripped `nav`. Reattach it ONLY when an op actually changed
|
|
555
|
+
// the navigation, reconciled against the resulting page set so a batch that
|
|
556
|
+
// added or removed pages emits a complete tree. When no nav op ran the result
|
|
557
|
+
// carries no `nav` at all, so content-ops leaves nav.json untouched and page
|
|
558
|
+
// groups survive untouched — the invariant every section-level op relies on.
|
|
559
|
+
const result: SiteIndex = navTouched
|
|
560
|
+
? { ...validated.data, nav: reconcileNav(index.nav ?? [], validated.data.pages) }
|
|
561
|
+
: validated.data;
|
|
562
|
+
return { ok: true, index: result, removedIds, warnings };
|
|
309
563
|
}
|
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
|
}
|