@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.
@@ -0,0 +1,303 @@
1
+ /**
2
+ * @blogmcp/nav-core — resolve (auto-build + override merge)
3
+ *
4
+ * The heart of the "one resolved tree, never two" design (navigation-model.md §2/§4):
5
+ *
6
+ * resolved = applyOverrides( buildDefaultTree(currentContent), curatedOverrides )
7
+ *
8
+ * `buildDefaultTree` is recomputed from the CURRENT published content on every
9
+ * resolve, so it is never stale: a new article appears automatically, a deleted
10
+ * one disappears, and an override that targets a now-absent node is simply
11
+ * skipped (non-fatal). The curated overrides (`doc` in `kb_navigation`) store
12
+ * only deviations — never the flattened tree.
13
+ *
14
+ * Pure: no I/O. The consumer (KB API service) supplies the content index from SQL
15
+ * and persists the overrides; this module just transforms data → tree.
16
+ *
17
+ * v1 auto-build policy: group published articles by their primary tag (first tag
18
+ * by slug); untagged articles land in an "Unfiled" group. `frontmatter`-based
19
+ * parent/order is intentionally NOT used (that column is legacy/empty in the KB).
20
+ */
21
+
22
+ import { findNode, sortTree } from '../traverse/index.js';
23
+ import { insertNode, moveNode, removeNode } from '../mutate/index.js';
24
+ import type {
25
+ ArticleRefNode,
26
+ GroupNode,
27
+ Locale,
28
+ LocaleLabels,
29
+ NavData,
30
+ NavNode,
31
+ NavTree,
32
+ } from '../types/index.js';
33
+ import type { BadgeSpec } from '../types/index.js';
34
+
35
+ /* -------------------------------------------------------------------------- */
36
+ /* content index (auto-build input) */
37
+ /* -------------------------------------------------------------------------- */
38
+
39
+ export interface ContentLocaleMeta {
40
+ readonly title: string;
41
+ readonly description?: string;
42
+ }
43
+
44
+ /** One published article, as needed to auto-build the default tree. */
45
+ export interface ContentItem {
46
+ /** kb_articles.id */
47
+ readonly id: string;
48
+ /** Public URL handle (filename without extension). */
49
+ readonly slug?: string;
50
+ /** Tag slugs. */
51
+ readonly tags: readonly string[];
52
+ /** Per-locale title/description. */
53
+ readonly locales: Readonly<Record<Locale, ContentLocaleMeta>>;
54
+ /** Source language code (fallback locale for this item). */
55
+ readonly sourceLocale: Locale;
56
+ /** ISO timestamp, used as a secondary sort key. */
57
+ readonly updatedAt?: string;
58
+ }
59
+
60
+ export interface BuildOptions {
61
+ readonly nodeIdPrefix?: string;
62
+ readonly groupIdPrefix?: string;
63
+ readonly unfiledGroupId?: string;
64
+ readonly unfiledLabel?: string;
65
+ /** Per-tag display labels (e.g. from kb_tags). Falls back to the tag slug. */
66
+ readonly tagLabels?: Readonly<Record<string, LocaleLabels>>;
67
+ /** Choose an item's primary tag. Default: first tag by slug ascending. */
68
+ readonly primaryTag?: (item: ContentItem) => string | undefined;
69
+ }
70
+
71
+ const DEFAULTS = {
72
+ nodeIdPrefix: 'auto:',
73
+ groupIdPrefix: 'auto:group:',
74
+ unfiledLabel: 'Unfiled',
75
+ } as const;
76
+
77
+ /**
78
+ * Build the default navigation tree from the current published content.
79
+ * Deterministic: groups sorted by tag slug (Unfiled last), articles sorted by
80
+ * source-locale title then updatedAt. Labels are baked from content so the tree
81
+ * is self-contained; overrides may relabel.
82
+ */
83
+ export function buildDefaultTree(items: readonly ContentItem[], options: BuildOptions = {}): NavNode[] {
84
+ const nodeIdPrefix = options.nodeIdPrefix ?? DEFAULTS.nodeIdPrefix;
85
+ const groupIdPrefix = options.groupIdPrefix ?? DEFAULTS.groupIdPrefix;
86
+ const unfiledGroupId = options.unfiledGroupId ?? `${groupIdPrefix}unfiled`;
87
+ const unfiledLabel = options.unfiledLabel ?? DEFAULTS.unfiledLabel;
88
+ const pickPrimary = options.primaryTag ?? defaultPrimaryTag;
89
+
90
+ const allLocales = collectLocales(items);
91
+
92
+ // bucket items by primary tag ('' = unfiled)
93
+ const buckets = new Map<string, ContentItem[]>();
94
+ for (const item of items) {
95
+ const key = pickPrimary(item) ?? '';
96
+ const bucket = buckets.get(key);
97
+ if (bucket) bucket.push(item);
98
+ else buckets.set(key, [item]);
99
+ }
100
+
101
+ const tagKeys = [...buckets.keys()].filter((k) => k !== '').sort();
102
+ const orderedKeys = buckets.has('') ? [...tagKeys, ''] : tagKeys;
103
+
104
+ return orderedKeys.map((key, groupIndex): GroupNode => {
105
+ const isUnfiled = key === '';
106
+ const groupId = isUnfiled ? unfiledGroupId : `${groupIdPrefix}${key}`;
107
+ const labels = isUnfiled
108
+ ? labelsForAll(allLocales, unfiledLabel)
109
+ : (options.tagLabels?.[key] ?? labelsForAll(allLocales, key));
110
+
111
+ const children = sortItems(buckets.get(key)!).map((item, i): ArticleRefNode => ({
112
+ id: `${nodeIdPrefix}${item.id}`,
113
+ type: 'article-ref',
114
+ ref: item.id,
115
+ slug: item.slug,
116
+ owned: true,
117
+ order: i,
118
+ labels: articleLabels(item),
119
+ }));
120
+
121
+ return {
122
+ id: groupId,
123
+ type: 'group',
124
+ order: groupIndex,
125
+ labels,
126
+ collapsible: true,
127
+ data: { auto: true, tag: isUnfiled ? null : key },
128
+ children,
129
+ };
130
+ });
131
+ }
132
+
133
+ /* -------------------------------------------------------------------------- */
134
+ /* overrides */
135
+ /* -------------------------------------------------------------------------- */
136
+
137
+ /** A per-node deviation from the auto-built tree. */
138
+ export interface NodePatch {
139
+ /** Move under a new parent (null = root). */
140
+ readonly parentId?: string | null;
141
+ readonly order?: number;
142
+ readonly hidden?: boolean;
143
+ /** Merged over the node's existing labels. */
144
+ readonly labels?: LocaleLabels;
145
+ readonly badge?: BadgeSpec;
146
+ readonly icon?: string;
147
+ /** group only. */
148
+ readonly collapsed?: boolean;
149
+ /** article-ref only. */
150
+ readonly owned?: boolean;
151
+ /** Merged over the node's existing data bag. */
152
+ readonly data?: NavData;
153
+ /** Drop this node (and its subtree) from the menu. */
154
+ readonly removed?: boolean;
155
+ }
156
+
157
+ /** A hand-authored node not derivable from content (external link, custom group, separator…). */
158
+ export interface PlacedNode {
159
+ readonly node: NavNode;
160
+ readonly parentId?: string | null;
161
+ readonly index?: number;
162
+ }
163
+
164
+ /** The curated overrides document persisted in `kb_navigation.doc`. */
165
+ export interface NavOverrides {
166
+ readonly patches?: Readonly<Record<string, NodePatch>>;
167
+ readonly extra?: readonly PlacedNode[];
168
+ }
169
+
170
+ /**
171
+ * Apply curated overrides to a freshly auto-built tree. Order of operations:
172
+ * extra inserts → field patches + removals → moves → sort. Targets that no
173
+ * longer exist (e.g. an unpublished article) are skipped — the resolved tree
174
+ * always reflects current content.
175
+ *
176
+ * Extra (hand-authored) nodes are inserted FIRST so that field patches and
177
+ * removals below apply to them uniformly: an external link or custom group can
178
+ * be relabeled, hidden, reordered, or removed via the same `patches` mechanism
179
+ * as an auto-built node. (Previously inserts ran after patching, so any patch
180
+ * targeting a placed node — including `removed` — was silently a no-op.)
181
+ */
182
+ export function applyOverrides(autoTree: NavTree, overrides: NavOverrides = {}): NavNode[] {
183
+ const patches = overrides.patches ?? {};
184
+ const extra = overrides.extra ?? [];
185
+
186
+ // 1. extra inserts. A placed `article-ref` (a cross-listed internal link) is
187
+ // skipped when its target article is no longer a live auto node — deleted,
188
+ // unpublished, or not visible at this access tier — so cross-listed links
189
+ // never dangle, the same "never stale" guarantee the auto nodes get.
190
+ const liveRefs = collectArticleRefs(autoTree);
191
+ let tree: NavNode[] = [...autoTree];
192
+ for (const placed of extra) {
193
+ if (placed.node.type === 'article-ref' && !liveRefs.has(placed.node.ref)) continue;
194
+ if (findNode(tree, placed.node.id)) continue;
195
+ if (placed.parentId != null && !findNode(tree, placed.parentId)) continue;
196
+ tree = insertNode(tree, placed.node, { parentId: placed.parentId ?? null, index: placed.index });
197
+ }
198
+
199
+ // 2. field patches + removals (recursive, immutable) over the combined tree
200
+ const patchNode = (node: NavNode): NavNode | null => {
201
+ const patch = patches[node.id];
202
+ if (patch?.removed) return null;
203
+ let next: NavNode = patch ? applyFieldPatch(node, patch) : node;
204
+ if (next.children && next.children.length > 0) {
205
+ const kids = next.children
206
+ .map(patchNode)
207
+ .filter((n): n is NavNode => n !== null);
208
+ next = { ...next, children: kids } as NavNode;
209
+ }
210
+ return next;
211
+ };
212
+ tree = tree.map(patchNode).filter((n): n is NavNode => n !== null);
213
+
214
+ // 3. moves (parentId in a patch). Skip absent targets/parents; ignore cycles.
215
+ for (const [id, patch] of Object.entries(patches)) {
216
+ if (patch.parentId === undefined) continue;
217
+ if (!findNode(tree, id)) continue;
218
+ if (patch.parentId !== null && !findNode(tree, patch.parentId)) continue;
219
+ try {
220
+ tree = moveNode(tree, id, { parentId: patch.parentId });
221
+ } catch {
222
+ // cycle / invalid move → leave node where it is (non-fatal)
223
+ }
224
+ }
225
+
226
+ // 4. canonical display order
227
+ return sortTree(tree);
228
+ }
229
+
230
+ /** Convenience: build + apply in one call. */
231
+ export function resolve(
232
+ items: readonly ContentItem[],
233
+ overrides: NavOverrides = {},
234
+ buildOptions: BuildOptions = {},
235
+ ): NavNode[] {
236
+ return applyOverrides(buildDefaultTree(items, buildOptions), overrides);
237
+ }
238
+
239
+ /* -------------------------------------------------------------------------- */
240
+ /* helpers */
241
+ /* -------------------------------------------------------------------------- */
242
+
243
+ function defaultPrimaryTag(item: ContentItem): string | undefined {
244
+ if (item.tags.length === 0) return undefined;
245
+ return [...item.tags].sort()[0];
246
+ }
247
+
248
+ /** Article ids that have a live node in the auto-built tree (for prune checks). */
249
+ function collectArticleRefs(tree: NavTree): Set<string> {
250
+ const refs = new Set<string>();
251
+ const walk = (nodes: readonly NavNode[]): void => {
252
+ for (const n of nodes) {
253
+ if (n.type === 'article-ref') refs.add(n.ref);
254
+ if (n.children) walk(n.children);
255
+ }
256
+ };
257
+ walk(tree);
258
+ return refs;
259
+ }
260
+
261
+ /** Sorted union of locales present across the content index. */
262
+ export function collectLocales(items: readonly ContentItem[]): Locale[] {
263
+ const set = new Set<Locale>();
264
+ for (const item of items) {
265
+ for (const locale of Object.keys(item.locales)) set.add(locale);
266
+ }
267
+ return [...set].sort();
268
+ }
269
+
270
+ function labelsForAll(locales: readonly Locale[], text: string): LocaleLabels {
271
+ return Object.fromEntries(locales.map((l) => [l, text]));
272
+ }
273
+
274
+ function articleLabels(item: ContentItem): LocaleLabels {
275
+ const out: Record<Locale, string> = {};
276
+ for (const [locale, meta] of Object.entries(item.locales)) {
277
+ out[locale] = meta.title;
278
+ }
279
+ return out;
280
+ }
281
+
282
+ function sortItems(items: ContentItem[]): ContentItem[] {
283
+ return [...items].sort((a, b) => {
284
+ const ta = a.locales[a.sourceLocale]?.title ?? '';
285
+ const tb = b.locales[b.sourceLocale]?.title ?? '';
286
+ const byTitle = ta.localeCompare(tb);
287
+ if (byTitle !== 0) return byTitle;
288
+ return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
289
+ });
290
+ }
291
+
292
+ function applyFieldPatch(node: NavNode, patch: NodePatch): NavNode {
293
+ const next: Record<string, unknown> = { ...node };
294
+ if (patch.order !== undefined) next.order = patch.order;
295
+ if (patch.hidden !== undefined) next.hidden = patch.hidden;
296
+ if (patch.labels) next.labels = { ...(node.labels ?? {}), ...patch.labels };
297
+ if (patch.badge !== undefined) next.badge = patch.badge;
298
+ if (patch.icon !== undefined) next.icon = patch.icon;
299
+ if (patch.data) next.data = { ...(node.data ?? {}), ...patch.data };
300
+ if (patch.collapsed !== undefined && node.type === 'group') next.collapsed = patch.collapsed;
301
+ if (patch.owned !== undefined && node.type === 'article-ref') next.owned = patch.owned;
302
+ return next as unknown as NavNode;
303
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * @blogmcp/nav-core — traverse
3
+ *
4
+ * Pure read helpers over a resolved tree. Two consumers with opposite needs use
5
+ * the same functions: the SDK runs them in-memory over a cached tree (zero
6
+ * round-trips), and the server exposes them as MCP tools (token-efficient).
7
+ *
8
+ * All functions are non-mutating. Functions that depend on display order sort
9
+ * siblings by `order` internally (stable: explicit order ascending, then
10
+ * undefined-order nodes in their original sequence).
11
+ */
12
+
13
+ import type {
14
+ FlatNode,
15
+ LocalizedNode,
16
+ LocalizedTree,
17
+ Locale,
18
+ NavNode,
19
+ NavTree,
20
+ } from '../types/index.js';
21
+
22
+ /* -------------------------------------------------------------------------- */
23
+ /* ordering */
24
+ /* -------------------------------------------------------------------------- */
25
+
26
+ /** Stable sort of siblings by `order` (undefined sorts last, ties keep input order). */
27
+ export function sortSiblings(nodes: readonly NavNode[]): NavNode[] {
28
+ return [...nodes].sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
29
+ }
30
+
31
+ /** Return a new tree with every level's children sorted by `order`. */
32
+ export function sortTree(tree: NavTree): NavNode[] {
33
+ return sortSiblings(tree).map((node) =>
34
+ node.children && node.children.length > 0
35
+ ? ({ ...node, children: sortTree(node.children) } as NavNode)
36
+ : node,
37
+ );
38
+ }
39
+
40
+ /* -------------------------------------------------------------------------- */
41
+ /* lookup */
42
+ /* -------------------------------------------------------------------------- */
43
+
44
+ /** Depth-first pre-order walk. Return `false` from the visitor to stop early. */
45
+ export function walk(
46
+ tree: NavTree,
47
+ visitor: (node: NavNode, parent: NavNode | null, depth: number) => void | boolean,
48
+ ): void {
49
+ const recur = (nodes: readonly NavNode[], parent: NavNode | null, depth: number): boolean => {
50
+ for (const node of nodes) {
51
+ if (visitor(node, parent, depth) === false) return false;
52
+ if (node.children && !recur(node.children, node, depth + 1)) return false;
53
+ }
54
+ return true;
55
+ };
56
+ recur(tree, null, 0);
57
+ }
58
+
59
+ /** Find a node by id, or `undefined`. */
60
+ export function findNode(tree: NavTree, id: string): NavNode | undefined {
61
+ let found: NavNode | undefined;
62
+ walk(tree, (node) => {
63
+ if (node.id === id) {
64
+ found = node;
65
+ return false;
66
+ }
67
+ });
68
+ return found;
69
+ }
70
+
71
+ /** Find a node's parent, or `null` if it is a root (or `undefined` if not found). */
72
+ export function getParent(tree: NavTree, id: string): NavNode | null | undefined {
73
+ let result: NavNode | null | undefined;
74
+ walk(tree, (node, parent) => {
75
+ if (node.id === id) {
76
+ result = parent;
77
+ return false;
78
+ }
79
+ });
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * Ancestor path from root to the node, inclusive of the node itself.
85
+ * Empty array if not found. This is the basis for breadcrumbs.
86
+ */
87
+ export function getPath(tree: NavTree, id: string): NavNode[] {
88
+ const path: NavNode[] = [];
89
+ let found = false;
90
+ const recur = (nodes: readonly NavNode[]): boolean => {
91
+ for (const node of nodes) {
92
+ path.push(node);
93
+ if (node.id === id) {
94
+ found = true;
95
+ return true;
96
+ }
97
+ if (node.children && recur(node.children)) return true;
98
+ path.pop();
99
+ }
100
+ return false;
101
+ };
102
+ recur(tree);
103
+ return found ? path : [];
104
+ }
105
+
106
+ /** Ancestors of a node, root → parent (excludes the node itself). */
107
+ export function breadcrumbs(tree: NavTree, id: string): NavNode[] {
108
+ const path = getPath(tree, id);
109
+ return path.slice(0, -1);
110
+ }
111
+
112
+ /** Siblings of a node (same parent), excluding the node itself, in display order. */
113
+ export function getSiblings(tree: NavTree, id: string): NavNode[] {
114
+ const parent = getParent(tree, id);
115
+ if (parent === undefined) return [];
116
+ const siblings = parent === null ? tree : (parent.children ?? []);
117
+ return sortSiblings(siblings).filter((n) => n.id !== id);
118
+ }
119
+
120
+ /** The node together with its descendants, or `undefined`. */
121
+ export function getSubtree(tree: NavTree, id: string): NavNode | undefined {
122
+ return findNode(tree, id);
123
+ }
124
+
125
+ /* -------------------------------------------------------------------------- */
126
+ /* sequential navigation (prev / next) */
127
+ /* -------------------------------------------------------------------------- */
128
+
129
+ /** Default "navigable" predicate: visible article references only. */
130
+ export function isNavigable(node: NavNode): boolean {
131
+ return node.type === 'article-ref' && node.hidden !== true;
132
+ }
133
+
134
+ export interface NextPrev {
135
+ readonly prev: NavNode | null;
136
+ readonly next: NavNode | null;
137
+ }
138
+
139
+ /**
140
+ * Previous/next navigable node in depth-first display order. By default only
141
+ * visible `article-ref` nodes participate; pass a predicate to change that.
142
+ */
143
+ export function getNextPrev(
144
+ tree: NavTree,
145
+ id: string,
146
+ predicate: (node: NavNode) => boolean = isNavigable,
147
+ ): NextPrev {
148
+ const sequence: NavNode[] = [];
149
+ walk(sortTree(tree), (node) => {
150
+ if (predicate(node)) sequence.push(node);
151
+ });
152
+ const idx = sequence.findIndex((n) => n.id === id);
153
+ if (idx === -1) return { prev: null, next: null };
154
+ return {
155
+ prev: idx > 0 ? sequence[idx - 1]! : null,
156
+ next: idx < sequence.length - 1 ? sequence[idx + 1]! : null,
157
+ };
158
+ }
159
+
160
+ /* -------------------------------------------------------------------------- */
161
+ /* projections of shape */
162
+ /* -------------------------------------------------------------------------- */
163
+
164
+ /** Flatten to FLAT form (parent pointer + index + depth), in display order. */
165
+ export function flatten(tree: NavTree): FlatNode[] {
166
+ const out: FlatNode[] = [];
167
+ const recur = (nodes: readonly NavNode[], parentId: string | null, depth: number): void => {
168
+ sortSiblings(nodes).forEach((node, index) => {
169
+ out.push({ id: node.id, parentId, type: node.type, index, depth });
170
+ if (node.children) recur(node.children, node.id, depth + 1);
171
+ });
172
+ };
173
+ recur(tree, null, 0);
174
+ return out;
175
+ }
176
+
177
+ /* -------------------------------------------------------------------------- */
178
+ /* access-tier visibility */
179
+ /* -------------------------------------------------------------------------- */
180
+
181
+ /** Default access-tier ranking (higher = more access). */
182
+ export const DEFAULT_TIER_RANK: Readonly<Record<string, number>> = {
183
+ public: 0,
184
+ premium: 1,
185
+ partners: 2,
186
+ internal: 3,
187
+ };
188
+
189
+ /**
190
+ * Whether a node is visible to a viewer tier. A node with no `visibility.tiers`
191
+ * is public. Otherwise the viewer must meet (rank ≥) at least one declared tier.
192
+ * Pure helper; pair with {@link prune} to filter a tree. (Content-level access —
193
+ * which articles exist at all — is enforced upstream in the data query; this
194
+ * gates override-declared per-node visibility.)
195
+ */
196
+ export function visibleToTier(
197
+ node: NavNode,
198
+ viewerTier: string,
199
+ ranks: Readonly<Record<string, number>> = DEFAULT_TIER_RANK,
200
+ ): boolean {
201
+ const tiers = node.visibility?.tiers;
202
+ if (!tiers || tiers.length === 0) return true;
203
+ const viewerRank = ranks[viewerTier] ?? 0;
204
+ return tiers.some((t) => viewerRank >= (ranks[t] ?? 0));
205
+ }
206
+
207
+ /** Remove every node (and its subtree) for which `keep` returns false. */
208
+ export function prune(tree: NavTree, keep: (node: NavNode) => boolean): NavNode[] {
209
+ return tree
210
+ .filter(keep)
211
+ .map((node) =>
212
+ node.children && node.children.length > 0
213
+ ? ({ ...node, children: prune(node.children, keep) } as NavNode)
214
+ : node,
215
+ );
216
+ }
217
+
218
+ /* -------------------------------------------------------------------------- */
219
+ /* localization */
220
+ /* -------------------------------------------------------------------------- */
221
+
222
+ export interface LocalizeOptions {
223
+ readonly locale: Locale;
224
+ readonly fallbackLocale?: Locale;
225
+ /**
226
+ * Supplies a default label when `labels` has none for the locale — typically
227
+ * the referenced article's title. nav-core does not know article content, so
228
+ * the consumer provides this.
229
+ */
230
+ readonly labelResolver?: (node: NavNode, locale: Locale) => string | undefined;
231
+ }
232
+
233
+ /** Resolve `labels` to a single locale, producing a {@link LocalizedTree}. */
234
+ export function localize(tree: NavTree, options: LocalizeOptions): LocalizedTree {
235
+ const { locale, fallbackLocale, labelResolver } = options;
236
+
237
+ const resolveLabel = (node: NavNode): string | undefined => {
238
+ return (
239
+ node.labels?.[locale] ??
240
+ (fallbackLocale ? node.labels?.[fallbackLocale] : undefined) ??
241
+ labelResolver?.(node, locale) ??
242
+ (fallbackLocale ? labelResolver?.(node, fallbackLocale) : undefined)
243
+ );
244
+ };
245
+
246
+ const localizeNode = (node: NavNode): LocalizedNode => {
247
+ const base: LocalizedNode = {
248
+ id: node.id,
249
+ type: node.type,
250
+ label: resolveLabel(node),
251
+ order: node.order,
252
+ badge: node.badge,
253
+ icon: node.icon,
254
+ hidden: node.hidden,
255
+ visibility: node.visibility,
256
+ data: node.data,
257
+ children: node.children ? sortSiblings(node.children).map(localizeNode) : [],
258
+ };
259
+ switch (node.type) {
260
+ case 'article-ref':
261
+ return { ...base, ref: node.ref, owned: node.owned, slug: node.slug };
262
+ case 'external-link':
263
+ return { ...base, href: node.href };
264
+ case 'generated-section':
265
+ return { ...base, slug: node.slug };
266
+ case 'group':
267
+ return { ...base, link: node.link, collapsed: node.collapsed, collapsible: node.collapsible };
268
+ case 'separator':
269
+ return base;
270
+ }
271
+ };
272
+
273
+ return sortSiblings(tree).map(localizeNode);
274
+ }