-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathnav.mjs
More file actions
318 lines (267 loc) · 10.7 KB
/
Copy pathnav.mjs
File metadata and controls
318 lines (267 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
// Phase 2 nav substeps: nav-path, nav-integrity-check, nav-tree,
// nav-levels, breadcrumbs, children. Six Ruby plugins share one set of
// intermediate structures (titled set, byTitle / byParentTitle maps,
// sorted top-level list, ordered-children map); collecting them in one
// JS module lets the substeps reuse that state instead of rebuilding it
// six times the way each Jekyll Generator did in isolation.
//
// See builder/PLAN-2.md §5.1-§5.6 + §6 for the full spec.
// Ports: _plugins/nav-path.rb, nav-integrity-check.rb,
// nav-tree-precompute.rb, nav-levels-precompute.rb,
// breadcrumbs-precompute.rb, children-precompute.rb.
const NAV_TREE_MAX_DEPTH = 16;
const BREADCRUMBS_MAX_DEPTH = 8;
const REVERSE_FLAGS = new Set(["desc", "reversed"]);
export function computeNav(pages, config) {
computeNavPaths(pages);
validateNavIntegrity(pages);
const state = buildSharedNavState(pages, config);
const navTree = buildNavTree(state);
computeNavLevels(pages, state);
computeBreadcrumbs(pages, state);
computeChildren(pages, state);
return { navTree };
}
// ---------- §5.1 nav-path ---------------------------------------------------
function computeNavPaths(pages) {
for (const page of pages) {
const title = page.frontmatter.title;
if (title == null || title === "") continue;
const parts = [];
if (page.frontmatter.grand_parent) parts.push(String(page.frontmatter.grand_parent));
if (page.frontmatter.parent) parts.push(String(page.frontmatter.parent));
parts.push(String(title));
page.navPath = parts.join("/");
}
}
// ---------- §5.2 nav-integrity-check ---------------------------------------
function validateNavIntegrity(pages) {
const titled = pages.filter(p => isNonEmpty(p.frontmatter.title));
const navVisible = titled.filter(p => !p.frontmatter.nav_exclude);
const byTitle = groupBy(navVisible, p => String(p.frontmatter.title));
const ambiguous = [];
const orphaned = [];
for (const page of navVisible) {
const parentTitle = page.frontmatter.parent;
if (parentTitle == null || parentTitle === "") continue;
const candidates = byTitle.get(String(parentTitle));
if (!candidates || candidates.length === 0) {
orphaned.push({ page, reason: `no page titled "${parentTitle}" exists` });
continue;
}
if (candidates.length === 1) continue;
const gp = page.frontmatter.grand_parent;
if (gp == null) {
ambiguous.push({
page,
reason: `${candidates.length} pages are titled "${parentTitle}" and no grand_parent is declared to disambiguate`,
});
continue;
}
const filtered = candidates.filter(c => c.frontmatter.parent === gp);
if (filtered.length > 1) {
ambiguous.push({
page,
reason: `${filtered.length} pages titled "${parentTitle}" share parent "${gp}" - grand_parent does not disambiguate`,
});
} else if (filtered.length === 0) {
orphaned.push({
page,
reason: `grand_parent "${gp}" does not match any page titled "${parentTitle}"`,
});
}
}
if (ambiguous.length === 0 && orphaned.length === 0) return;
const lines = [];
if (ambiguous.length > 0) {
lines.push(`Nav-parent ambiguity detected in ${ambiguous.length} page(s):`);
for (const e of ambiguous) lines.push(` ${e.page.srcRel}: ${e.reason}`);
}
if (orphaned.length > 0) {
lines.push(`Nav-parent orphan detected in ${orphaned.length} page(s):`);
for (const e of orphaned) lines.push(` ${e.page.srcRel}: ${e.reason}`);
}
throw new Error(lines.join("\n"));
}
// ---------- §6.1 shared state ----------------------------------------------
function buildSharedNavState(pages, config) {
const titled = pages.filter(p => isNonEmpty(p.frontmatter.title));
const byTitle = groupBy(titled, p => String(p.frontmatter.title));
const byParentTitle = groupBy(
titled,
p => isNonEmpty(p.frontmatter.parent) ? String(p.frontmatter.parent) : "",
);
const caseInsensitive = config?.nav_sort === "case_insensitive";
const topLevel = sortPages(
(byParentTitle.get("") || []).filter(p => !p.frontmatter.nav_exclude),
caseInsensitive,
);
const orderedChildren = new Map();
for (const parent of titled) {
orderedChildren.set(
parent.permalink,
orderedChildrenFor(parent, byParentTitle, caseInsensitive),
);
}
return { titled, byTitle, byParentTitle, caseInsensitive, topLevel, orderedChildren };
}
// Mirrors the upstream nav/children.html filter: drop nav_exclude pages,
// drop pages whose grand_parent contradicts the candidate parent's own
// parent, then apply child_nav_order reversal so positions match render
// order. Shared by nav-tree and nav-levels.
function orderedChildrenFor(parent, byParentTitle, caseInsensitive) {
const parentTitle = String(parent.frontmatter.title);
const candidates = byParentTitle.get(parentTitle) || [];
const filtered = candidates.filter(c => {
if (c.frontmatter.nav_exclude) return false;
const gp = c.frontmatter.grand_parent;
return gp == null || gp === parent.frontmatter.parent;
});
const sorted = sortPages(filtered, caseInsensitive);
if (REVERSE_FLAGS.has(String(parent.frontmatter.child_nav_order || ""))) {
sorted.reverse();
}
return sorted;
}
// ---------- §6.2 four-bucket sort ------------------------------------------
function sortPages(pages, caseInsensitive) {
const navNum = [], navStr = [], titleNum = [], titleStr = [];
for (const p of pages) {
if (p.frontmatter.nav_order != null) {
(typeof p.frontmatter.nav_order === "number" ? navNum : navStr).push(p);
} else {
(typeof p.frontmatter.title === "number" ? titleNum : titleStr).push(p);
}
}
navNum.sort((a, b) => a.frontmatter.nav_order - b.frontmatter.nav_order);
navStr.sort((a, b) => cmp(sortKey(a.frontmatter.nav_order, caseInsensitive),
sortKey(b.frontmatter.nav_order, caseInsensitive)));
titleNum.sort((a, b) => a.frontmatter.title - b.frontmatter.title);
titleStr.sort((a, b) => cmp(sortKey(a.frontmatter.title, caseInsensitive),
sortKey(b.frontmatter.title, caseInsensitive)));
return [...navNum, ...navStr, ...titleNum, ...titleStr];
}
function sortKey(value, caseInsensitive) {
const s = String(value);
return caseInsensitive ? s.toLowerCase() : s;
}
function cmp(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
// ---------- §5.3 nav-tree ---------------------------------------------------
function buildNavTree(state) {
return state.topLevel.map(top => buildNavNode(top, [top], state.orderedChildren, 0));
}
function buildNavNode(page, chain, orderedChildren, depth) {
const rawChildren = orderedChildren.get(page.permalink) || [];
const children = rawChildren.filter(child => !chain.some(c => c.permalink === child.permalink));
const childNodes = depth < NAV_TREE_MAX_DEPTH
? children.map(c => buildNavNode(c, [...chain, c], orderedChildren, depth + 1))
: [];
return {
title: page.frontmatter.title,
url: page.permalink,
children: childNodes,
};
}
// ---------- §5.4 nav-levels ------------------------------------------------
function computeNavLevels(pages, state) {
const topIndex = new Map();
state.topLevel.forEach((p, i) => topIndex.set(p.permalink, i + 1));
const childIndex = new Map();
for (const [parentUrl, list] of state.orderedChildren) {
const m = new Map();
list.forEach((c, i) => m.set(c.permalink, i + 1));
childIndex.set(parentUrl, m);
}
const paths = new Map();
for (const top of state.topLevel) {
walkNavSubtree(top, [top], paths, state.orderedChildren, 0);
}
for (const page of state.titled) {
page.navLevels = levelsFromPath(paths.get(page.permalink), topIndex, childIndex);
}
}
function walkNavSubtree(node, chain, paths, orderedChildren, depth) {
if (depth > NAV_TREE_MAX_DEPTH) return;
if (!paths.has(node.permalink)) paths.set(node.permalink, chain);
const children = orderedChildren.get(node.permalink) || [];
for (const child of children) {
if (chain.some(p => p.permalink === child.permalink)) continue;
walkNavSubtree(child, [...chain, child], paths, orderedChildren, depth + 1);
}
}
function levelsFromPath(chain, topIndex, childIndex) {
if (!chain) return undefined;
const topIdx = topIndex.get(chain[0].permalink);
if (topIdx == null) return undefined;
const levels = [1, topIdx];
for (let i = 1; i < chain.length; i++) {
const map = childIndex.get(chain[i - 1].permalink);
const idx = map?.get(chain[i].permalink);
if (idx == null) return undefined;
levels.push(idx);
}
return levels;
}
// ---------- §5.5 breadcrumbs -----------------------------------------------
function computeBreadcrumbs(pages, state) {
for (const page of state.titled) {
page.breadcrumbs = breadcrumbChainFor(page, state.byTitle);
}
}
function breadcrumbChainFor(page, byTitle) {
const chain = [];
let current = page;
for (let depth = 0; depth < BREADCRUMBS_MAX_DEPTH; depth++) {
const parentTitle = current.frontmatter.parent;
if (parentTitle == null || String(parentTitle) === "") break;
const parent = resolveParent(String(parentTitle), current.frontmatter.grand_parent, byTitle);
if (!parent) break;
chain.unshift({ title: parent.frontmatter.title, url: parent.permalink });
current = parent;
}
return chain;
}
function resolveParent(parentTitle, grandParentTitle, byTitle) {
const candidates = byTitle.get(parentTitle);
if (!candidates || candidates.length === 0) return null;
if (grandParentTitle != null) {
const narrowed = candidates.find(c => c.frontmatter.parent === grandParentTitle);
if (narrowed) return narrowed;
}
return candidates[0];
}
// ---------- §5.6 children ---------------------------------------------------
function computeChildren(pages, state) {
for (const page of state.titled) {
const candidates = state.byParentTitle.get(String(page.frontmatter.title)) || [];
const filtered = candidates.filter(c => {
const gp = c.frontmatter.grand_parent;
return gp == null || gp === page.frontmatter.parent;
});
const sorted = sortPages(filtered, state.caseInsensitive);
if (REVERSE_FLAGS.has(String(page.frontmatter.child_nav_order || ""))) {
sorted.reverse();
}
page.children = sorted.map(c => ({
title: c.frontmatter.title,
url: c.permalink,
summary: c.frontmatter.summary,
}));
}
}
// ---------- utilities ------------------------------------------------------
function isNonEmpty(value) {
if (value == null) return false;
if (typeof value === "string") return value.length > 0;
return true;
}
function groupBy(items, keyFn) {
const out = new Map();
for (const item of items) {
const key = keyFn(item);
let arr = out.get(key);
if (!arr) { arr = []; out.set(key, arr); }
arr.push(item);
}
return out;
}