@blogmcp/nav-core 0.1.0
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/README.md +41 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/mutate/index.d.ts +41 -0
- package/dist/mutate/index.js +123 -0
- package/dist/mutate/index.js.map +1 -0
- package/dist/resolve/index.d.ts +105 -0
- package/dist/resolve/index.js +217 -0
- package/dist/resolve/index.js.map +1 -0
- package/dist/traverse/index.d.ts +70 -0
- package/dist/traverse/index.js +213 -0
- package/dist/traverse/index.js.map +1 -0
- package/dist/types/index.d.ts +165 -0
- package/dist/types/index.js +29 -0
- package/dist/types/index.js.map +1 -0
- package/dist/validate/index.d.ts +44 -0
- package/dist/validate/index.js +175 -0
- package/dist/validate/index.js.map +1 -0
- package/package.json +62 -0
- package/src/index.ts +18 -0
- package/src/mutate/index.ts +162 -0
- package/src/resolve/index.ts +303 -0
- package/src/traverse/index.ts +274 -0
- package/src/types/index.ts +206 -0
- package/src/validate/index.ts +233 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blogmcp/nav-core — validate
|
|
3
|
+
*
|
|
4
|
+
* Structural validation of a navigation tree. Server-authoritative: the API
|
|
5
|
+
* layer re-runs this on every mutation (clients can't be trusted not to create
|
|
6
|
+
* a cycle / oversized data bag). nav-core never *interprets* the `data` bag — it
|
|
7
|
+
* only enforces size/depth caps so the JSONB-source-of-truth stays a small,
|
|
8
|
+
* one-row-read, cacheable artifact (see navigation-model.md §2, §4).
|
|
9
|
+
*/
|
|
10
|
+
import { CONTAINER_NODE_TYPES, NAV_NODE_TYPES, } from '../types/index.js';
|
|
11
|
+
/** Default caps. Override per call via {@link ValidateOptions}. */
|
|
12
|
+
export const DEFAULT_MAX_DEPTH = 6;
|
|
13
|
+
export const DEFAULT_MAX_DATA_BYTES = 4096;
|
|
14
|
+
export const DEFAULT_MAX_DATA_DEPTH = 8;
|
|
15
|
+
/**
|
|
16
|
+
* Validate a navigation tree. Returns all errors found (does not throw); callers
|
|
17
|
+
* that prefer to throw can use {@link assertValid}.
|
|
18
|
+
*/
|
|
19
|
+
export function validate(tree, options = {}) {
|
|
20
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
21
|
+
const maxDataBytes = options.maxDataBytes ?? DEFAULT_MAX_DATA_BYTES;
|
|
22
|
+
const maxDataDepth = options.maxDataDepth ?? DEFAULT_MAX_DATA_DEPTH;
|
|
23
|
+
const errors = [];
|
|
24
|
+
const seenIds = new Set();
|
|
25
|
+
/** ref → count of owned:true nodes, to enforce at most one owner per article. */
|
|
26
|
+
const ownerCounts = new Map();
|
|
27
|
+
const visit = (node, depth) => {
|
|
28
|
+
// id present + unique
|
|
29
|
+
if (!node.id) {
|
|
30
|
+
errors.push({ code: 'MISSING_ID', message: `Node of type "${node.type}" is missing an id.` });
|
|
31
|
+
}
|
|
32
|
+
else if (seenIds.has(node.id)) {
|
|
33
|
+
errors.push({ code: 'DUPLICATE_ID', message: `Duplicate node id "${node.id}".`, nodeId: node.id });
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
seenIds.add(node.id);
|
|
37
|
+
}
|
|
38
|
+
// known type
|
|
39
|
+
if (!NAV_NODE_TYPES.includes(node.type)) {
|
|
40
|
+
errors.push({ code: 'UNKNOWN_TYPE', message: `Unknown node type "${node.type}".`, nodeId: node.id });
|
|
41
|
+
}
|
|
42
|
+
// depth
|
|
43
|
+
if (depth > maxDepth) {
|
|
44
|
+
errors.push({
|
|
45
|
+
code: 'MAX_DEPTH_EXCEEDED',
|
|
46
|
+
message: `Node "${node.id}" at depth ${depth} exceeds max depth ${maxDepth}.`,
|
|
47
|
+
nodeId: node.id,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// order must be a finite number when present
|
|
51
|
+
if (node.order !== undefined && !Number.isFinite(node.order)) {
|
|
52
|
+
errors.push({ code: 'INVALID_ORDER', message: `Node "${node.id}" has a non-finite order.`, nodeId: node.id });
|
|
53
|
+
}
|
|
54
|
+
// type-specific required fields
|
|
55
|
+
validateRequiredFields(node, errors);
|
|
56
|
+
// children only on container types
|
|
57
|
+
const children = node.children ?? [];
|
|
58
|
+
if (children.length > 0 && !CONTAINER_NODE_TYPES.includes(node.type)) {
|
|
59
|
+
errors.push({
|
|
60
|
+
code: 'CHILDREN_NOT_ALLOWED',
|
|
61
|
+
message: `Node "${node.id}" of type "${node.type}" cannot have children.`,
|
|
62
|
+
nodeId: node.id,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// owner accounting
|
|
66
|
+
if (node.type === 'article-ref' && node.owned === true) {
|
|
67
|
+
ownerCounts.set(node.ref, (ownerCounts.get(node.ref) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
// data bag caps
|
|
70
|
+
if (node.data !== undefined) {
|
|
71
|
+
validateDataBag(node, maxDataBytes, maxDataDepth, errors);
|
|
72
|
+
}
|
|
73
|
+
for (const child of children) {
|
|
74
|
+
visit(child, depth + 1);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
for (const root of tree) {
|
|
78
|
+
visit(root, 0);
|
|
79
|
+
}
|
|
80
|
+
for (const [ref, count] of ownerCounts) {
|
|
81
|
+
if (count > 1) {
|
|
82
|
+
errors.push({
|
|
83
|
+
code: 'MULTIPLE_OWNERS',
|
|
84
|
+
message: `Article "${ref}" is marked owned by ${count} nodes; at most one location may own an article.`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { valid: errors.length === 0, errors };
|
|
89
|
+
}
|
|
90
|
+
/** Throwing variant. */
|
|
91
|
+
export function assertValid(tree, options) {
|
|
92
|
+
const result = validate(tree, options);
|
|
93
|
+
if (!result.valid) {
|
|
94
|
+
const summary = result.errors.map((e) => `${e.code}: ${e.message}`).join('; ');
|
|
95
|
+
throw new NavValidationError(summary, result.errors);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Error thrown by {@link assertValid}. */
|
|
99
|
+
export class NavValidationError extends Error {
|
|
100
|
+
errors;
|
|
101
|
+
constructor(message, errors) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.errors = errors;
|
|
104
|
+
this.name = 'NavValidationError';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function validateRequiredFields(node, errors) {
|
|
108
|
+
switch (node.type) {
|
|
109
|
+
case 'article-ref':
|
|
110
|
+
if (!node.ref) {
|
|
111
|
+
errors.push({ code: 'MISSING_REQUIRED_FIELD', message: `article-ref "${node.id}" is missing "ref".`, nodeId: node.id });
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
case 'external-link':
|
|
115
|
+
if (!node.href) {
|
|
116
|
+
errors.push({ code: 'MISSING_REQUIRED_FIELD', message: `external-link "${node.id}" is missing "href".`, nodeId: node.id });
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
case 'generated-section':
|
|
120
|
+
if (!node.slug) {
|
|
121
|
+
errors.push({ code: 'MISSING_REQUIRED_FIELD', message: `generated-section "${node.id}" is missing "slug".`, nodeId: node.id });
|
|
122
|
+
}
|
|
123
|
+
if (!node.query) {
|
|
124
|
+
errors.push({ code: 'MISSING_REQUIRED_FIELD', message: `generated-section "${node.id}" is missing "query".`, nodeId: node.id });
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
case 'group':
|
|
128
|
+
case 'separator':
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function validateDataBag(node, maxBytes, maxDepth, errors) {
|
|
133
|
+
const data = node.data;
|
|
134
|
+
// byte size (UTF-8) of the serialized bag
|
|
135
|
+
let serialized;
|
|
136
|
+
try {
|
|
137
|
+
serialized = JSON.stringify(data) ?? '';
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
serialized = '';
|
|
141
|
+
}
|
|
142
|
+
const bytes = utf8ByteLength(serialized);
|
|
143
|
+
if (bytes > maxBytes) {
|
|
144
|
+
errors.push({
|
|
145
|
+
code: 'DATA_TOO_LARGE',
|
|
146
|
+
message: `Node "${node.id}" data bag is ${bytes} bytes, exceeding the ${maxBytes}-byte cap. Large payloads belong on the article, not the nav node.`,
|
|
147
|
+
nodeId: node.id,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const depth = jsonDepth(data);
|
|
151
|
+
if (depth > maxDepth) {
|
|
152
|
+
errors.push({
|
|
153
|
+
code: 'DATA_TOO_DEEP',
|
|
154
|
+
message: `Node "${node.id}" data bag nests ${depth} levels, exceeding the ${maxDepth}-level cap.`,
|
|
155
|
+
nodeId: node.id,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function utf8ByteLength(s) {
|
|
160
|
+
// Avoid a hard dependency on Node's Buffer for portability (SDK/browser reuse).
|
|
161
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
162
|
+
return new TextEncoder().encode(s).length;
|
|
163
|
+
}
|
|
164
|
+
return s.length;
|
|
165
|
+
}
|
|
166
|
+
function jsonDepth(value, current = 1) {
|
|
167
|
+
if (value === null || typeof value !== 'object')
|
|
168
|
+
return current;
|
|
169
|
+
let max = current;
|
|
170
|
+
for (const v of Object.values(value)) {
|
|
171
|
+
max = Math.max(max, jsonDepth(v, current + 1));
|
|
172
|
+
}
|
|
173
|
+
return max;
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/validate/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blogmcp/nav-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic navigation tree core — types, validation, traversal, and immutable mutations for the KB information-architecture (menu) layer. Zero framework dependencies; consumed by the API service, MCP tools, and the blog SDK.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./types": {
|
|
18
|
+
"types": "./src/types/index.ts",
|
|
19
|
+
"import": "./dist/types/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./validate": {
|
|
22
|
+
"types": "./src/validate/index.ts",
|
|
23
|
+
"import": "./dist/validate/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./traverse": {
|
|
26
|
+
"types": "./src/traverse/index.ts",
|
|
27
|
+
"import": "./dist/traverse/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./mutate": {
|
|
30
|
+
"types": "./src/mutate/index.ts",
|
|
31
|
+
"import": "./dist/mutate/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./resolve": {
|
|
34
|
+
"types": "./src/resolve/index.ts",
|
|
35
|
+
"import": "./dist/resolve/index.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.build.json",
|
|
44
|
+
"dev": "tsc -p tsconfig.build.json --watch",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^20.10.0",
|
|
51
|
+
"typescript": "^5.3.3",
|
|
52
|
+
"vitest": "^3.0.0"
|
|
53
|
+
},
|
|
54
|
+
"keywords": [
|
|
55
|
+
"navigation",
|
|
56
|
+
"menu",
|
|
57
|
+
"tree",
|
|
58
|
+
"information-architecture",
|
|
59
|
+
"blog"
|
|
60
|
+
],
|
|
61
|
+
"license": "MIT"
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blogmcp/nav-core
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic core for the KB navigation (menu) tree: the node-model
|
|
5
|
+
* contract, structural validation, pure traversal, and immutable mutations.
|
|
6
|
+
* Pure TypeScript — no I/O, no framework. Consumed by the KB API service, the
|
|
7
|
+
* MCP nav tools, and the blog SDK.
|
|
8
|
+
*
|
|
9
|
+
* What lives here: types, validate, traverse, mutate, and resolve (the
|
|
10
|
+
* content→default-tree auto-builder + the auto-build⊕overrides merge). See
|
|
11
|
+
* competitor-monitor/docs/internal/navigation-model.md §2, §4, §9.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export * from './types/index.js';
|
|
15
|
+
export * from './validate/index.js';
|
|
16
|
+
export * from './traverse/index.js';
|
|
17
|
+
export * from './mutate/index.js';
|
|
18
|
+
export * from './resolve/index.js';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blogmcp/nav-core — mutate
|
|
3
|
+
*
|
|
4
|
+
* Immutable tree mutations. Every function returns a NEW tree and never edits
|
|
5
|
+
* its input. These back both the optimistic client-side apply (SDK) and the
|
|
6
|
+
* server-authoritative MCP/API tools — the server always re-runs `validate()`
|
|
7
|
+
* after a mutation (see navigation-model.md §5).
|
|
8
|
+
*
|
|
9
|
+
* Invalid operations throw {@link NavMutationError} (e.g. unknown id, or moving
|
|
10
|
+
* a node into its own descendant).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { findNode, walk } from '../traverse/index.js';
|
|
14
|
+
import type { NavNode, NavTree } from '../types/index.js';
|
|
15
|
+
|
|
16
|
+
export class NavMutationError extends Error {
|
|
17
|
+
constructor(message: string, public readonly code: NavMutationCode) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'NavMutationError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type NavMutationCode =
|
|
24
|
+
| 'NODE_NOT_FOUND'
|
|
25
|
+
| 'PARENT_NOT_FOUND'
|
|
26
|
+
| 'DUPLICATE_ID'
|
|
27
|
+
| 'CYCLE'
|
|
28
|
+
| 'NOT_A_CONTAINER';
|
|
29
|
+
|
|
30
|
+
export interface InsertOptions {
|
|
31
|
+
/** Parent id, or null/undefined to insert at the root level. */
|
|
32
|
+
readonly parentId?: string | null;
|
|
33
|
+
/** Position among target siblings; clamped to [0, len]. Defaults to end. */
|
|
34
|
+
readonly index?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Insert a (sub)tree. Throws if the id already exists or the parent is missing. */
|
|
38
|
+
export function insertNode(tree: NavTree, node: NavNode, options: InsertOptions = {}): NavNode[] {
|
|
39
|
+
if (findNode(tree, node.id)) {
|
|
40
|
+
throw new NavMutationError(`Node "${node.id}" already exists.`, 'DUPLICATE_ID');
|
|
41
|
+
}
|
|
42
|
+
const { parentId = null, index } = options;
|
|
43
|
+
|
|
44
|
+
if (parentId === null) {
|
|
45
|
+
return insertAt([...tree], node, index);
|
|
46
|
+
}
|
|
47
|
+
if (!findNode(tree, parentId)) {
|
|
48
|
+
throw new NavMutationError(`Parent "${parentId}" not found.`, 'PARENT_NOT_FOUND');
|
|
49
|
+
}
|
|
50
|
+
return mapInto(tree, parentId, (children) => insertAt([...children], node, index));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Remove a node and its subtree. Throws if the node is not found. */
|
|
54
|
+
export function removeNode(tree: NavTree, id: string): NavNode[] {
|
|
55
|
+
if (!findNode(tree, id)) {
|
|
56
|
+
throw new NavMutationError(`Node "${id}" not found.`, 'NODE_NOT_FOUND');
|
|
57
|
+
}
|
|
58
|
+
const recur = (nodes: readonly NavNode[]): NavNode[] =>
|
|
59
|
+
nodes
|
|
60
|
+
.filter((n) => n.id !== id)
|
|
61
|
+
.map((n) => (n.children ? ({ ...n, children: recur(n.children) } as NavNode) : n));
|
|
62
|
+
return recur(tree);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MoveOptions {
|
|
66
|
+
/** New parent id, or null for the root level. */
|
|
67
|
+
readonly parentId?: string | null;
|
|
68
|
+
/** Position among the new siblings; clamped. Defaults to end. */
|
|
69
|
+
readonly index?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Move a node (with its subtree) under a new parent. Throws on cycles. */
|
|
73
|
+
export function moveNode(tree: NavTree, id: string, options: MoveOptions = {}): NavNode[] {
|
|
74
|
+
const node = findNode(tree, id);
|
|
75
|
+
if (!node) {
|
|
76
|
+
throw new NavMutationError(`Node "${id}" not found.`, 'NODE_NOT_FOUND');
|
|
77
|
+
}
|
|
78
|
+
const { parentId = null, index } = options;
|
|
79
|
+
|
|
80
|
+
if (parentId !== null) {
|
|
81
|
+
if (parentId === id) {
|
|
82
|
+
throw new NavMutationError(`Cannot move node "${id}" into itself.`, 'CYCLE');
|
|
83
|
+
}
|
|
84
|
+
if (descendantIds(node).has(parentId)) {
|
|
85
|
+
throw new NavMutationError(`Cannot move node "${id}" into its own descendant "${parentId}".`, 'CYCLE');
|
|
86
|
+
}
|
|
87
|
+
if (!findNode(tree, parentId)) {
|
|
88
|
+
throw new NavMutationError(`Parent "${parentId}" not found.`, 'PARENT_NOT_FOUND');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const detached = removeNode(tree, id);
|
|
93
|
+
return insertNode(detached, node, { parentId, index });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reorder a parent's children to match `orderedIds`, reassigning each child's
|
|
98
|
+
* `order` to its new index. Ids present in the tree but absent from `orderedIds`
|
|
99
|
+
* keep their relative order at the end. Throws on unknown ids or missing parent.
|
|
100
|
+
*/
|
|
101
|
+
export function reorderChildren(tree: NavTree, parentId: string | null, orderedIds: readonly string[]): NavNode[] {
|
|
102
|
+
const apply = (children: readonly NavNode[]): NavNode[] => {
|
|
103
|
+
const byId = new Map(children.map((c) => [c.id, c]));
|
|
104
|
+
for (const oid of orderedIds) {
|
|
105
|
+
if (!byId.has(oid)) {
|
|
106
|
+
throw new NavMutationError(`Cannot reorder: "${oid}" is not a child of "${parentId ?? '<root>'}".`, 'NODE_NOT_FOUND');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const ordered = [
|
|
110
|
+
...orderedIds.map((oid) => byId.get(oid)!),
|
|
111
|
+
...children.filter((c) => !orderedIds.includes(c.id)),
|
|
112
|
+
];
|
|
113
|
+
return ordered.map((child, i) => ({ ...child, order: i }) as NavNode);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (parentId === null) {
|
|
117
|
+
return apply(tree);
|
|
118
|
+
}
|
|
119
|
+
if (!findNode(tree, parentId)) {
|
|
120
|
+
throw new NavMutationError(`Parent "${parentId}" not found.`, 'PARENT_NOT_FOUND');
|
|
121
|
+
}
|
|
122
|
+
return mapInto(tree, parentId, apply);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* -------------------------------------------------------------------------- */
|
|
126
|
+
/* helpers */
|
|
127
|
+
/* -------------------------------------------------------------------------- */
|
|
128
|
+
|
|
129
|
+
function insertAt(siblings: NavNode[], node: NavNode, index?: number): NavNode[] {
|
|
130
|
+
const at = index === undefined ? siblings.length : clamp(index, 0, siblings.length);
|
|
131
|
+
siblings.splice(at, 0, node);
|
|
132
|
+
return siblings;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Rebuild the tree, replacing the children array of the node with id === targetId. */
|
|
136
|
+
function mapInto(
|
|
137
|
+
tree: NavTree,
|
|
138
|
+
targetId: string,
|
|
139
|
+
transform: (children: readonly NavNode[]) => NavNode[],
|
|
140
|
+
): NavNode[] {
|
|
141
|
+
return tree.map((node) => {
|
|
142
|
+
if (node.id === targetId) {
|
|
143
|
+
return { ...node, children: transform(node.children ?? []) } as NavNode;
|
|
144
|
+
}
|
|
145
|
+
if (node.children) {
|
|
146
|
+
return { ...node, children: mapInto(node.children, targetId, transform) } as NavNode;
|
|
147
|
+
}
|
|
148
|
+
return node;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function descendantIds(node: NavNode): Set<string> {
|
|
153
|
+
const ids = new Set<string>();
|
|
154
|
+
walk(node.children ?? [], (n) => {
|
|
155
|
+
ids.add(n.id);
|
|
156
|
+
});
|
|
157
|
+
return ids;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function clamp(n: number, lo: number, hi: number): number {
|
|
161
|
+
return Math.max(lo, Math.min(hi, n));
|
|
162
|
+
}
|