@dogsbay/docs-layout 0.2.0-beta.72 → 0.2.0-beta.74
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/package.json +3 -3
- package/src/DocsLayout.astro +1 -0
- package/src/SearchDialog.astro +236 -26
- package/src/search-facets.ts +372 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/docs-layout",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.74",
|
|
4
4
|
"description": "Standard documentation layout components for Dogsbay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"./json-ld": "./src/json-ld.ts"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@dogsbay/ui": "0.2.0-beta.
|
|
33
|
-
"@dogsbay/primitives": "0.2.0-beta.
|
|
32
|
+
"@dogsbay/ui": "0.2.0-beta.74",
|
|
33
|
+
"@dogsbay/primitives": "0.2.0-beta.74"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"vitest": "^3.0.0"
|
package/src/DocsLayout.astro
CHANGED
|
@@ -792,6 +792,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
792
792
|
{!hideSearch && (
|
|
793
793
|
<SearchDialog
|
|
794
794
|
pagefindUrl={pagefindUrl}
|
|
795
|
+
navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
|
|
795
796
|
taxonomyDisplay={taxonomyDisplay}
|
|
796
797
|
/>
|
|
797
798
|
)}
|
package/src/SearchDialog.astro
CHANGED
|
@@ -37,6 +37,17 @@ interface Props {
|
|
|
37
37
|
* search dialog throws on first open with a clear console error.
|
|
38
38
|
*/
|
|
39
39
|
pagefindUrl?: string;
|
|
40
|
+
/**
|
|
41
|
+
* URL of the site's `nav.json` (typically `${basePath}/_dogsbay/nav.json`).
|
|
42
|
+
* Used to render hierarchical facets in document order: when the
|
|
43
|
+
* `category` facet (or any taxonomy flagged `hierarchical: true`)
|
|
44
|
+
* is segment-encoded — i.e. its values are individual path segments
|
|
45
|
+
* not slash-joined — the tree shape is derived from nav. Without
|
|
46
|
+
* `navUrl`, hierarchical segment-encoded facets fall back to a flat
|
|
47
|
+
* list (still functional, just no tree structure). Slash-encoded
|
|
48
|
+
* taxonomies don't need nav and build trees from their values directly.
|
|
49
|
+
*/
|
|
50
|
+
navUrl?: string;
|
|
40
51
|
/** Placeholder text for the search input */
|
|
41
52
|
placeholder?: string;
|
|
42
53
|
/**
|
|
@@ -50,6 +61,7 @@ interface Props {
|
|
|
50
61
|
|
|
51
62
|
const {
|
|
52
63
|
pagefindUrl,
|
|
64
|
+
navUrl,
|
|
53
65
|
placeholder = "Search docs...",
|
|
54
66
|
taxonomyDisplay,
|
|
55
67
|
} = Astro.props;
|
|
@@ -58,6 +70,7 @@ const {
|
|
|
58
70
|
<dialog
|
|
59
71
|
data-search-dialog
|
|
60
72
|
data-pagefind-url={pagefindUrl}
|
|
73
|
+
data-nav-url={navUrl}
|
|
61
74
|
data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
|
|
62
75
|
class="fixed left-1/2 top-[10vh] z-50 w-[calc(100vw-2rem)] max-w-4xl -translate-x-1/2 rounded-xl border border-border bg-popover p-0 text-popover-foreground shadow-2xl backdrop:bg-black/40 backdrop:backdrop-blur-sm"
|
|
63
76
|
>
|
|
@@ -167,8 +180,13 @@ const {
|
|
|
167
180
|
filterStateToPagefindFilters,
|
|
168
181
|
toggleFilter,
|
|
169
182
|
countActiveFilters,
|
|
183
|
+
buildFacetTree,
|
|
184
|
+
computeTreeState,
|
|
185
|
+
toggleTreeNode,
|
|
170
186
|
type FacetMap,
|
|
187
|
+
type FacetTreeNode,
|
|
171
188
|
type FilterState,
|
|
189
|
+
type NavLike,
|
|
172
190
|
type TaxonomyDisplayMap,
|
|
173
191
|
} from "./search-facets.js";
|
|
174
192
|
|
|
@@ -215,6 +233,15 @@ const {
|
|
|
215
233
|
let filters: FilterState = {};
|
|
216
234
|
let availableFacets: FacetMap = {};
|
|
217
235
|
|
|
236
|
+
// Nav data cached after first fetch — used to derive the
|
|
237
|
+
// segment-encoded hierarchical-facet tree (the auto-`category`
|
|
238
|
+
// case). Null while pending or absent. The fetch fires lazily
|
|
239
|
+
// the first time renderFacets() encounters a hierarchical facet,
|
|
240
|
+
// not eagerly on dialog open, so sites without hierarchical
|
|
241
|
+
// facets never pay for it.
|
|
242
|
+
let navData: NavLike[] | null = null;
|
|
243
|
+
let loadingNav: Promise<void> | null = null;
|
|
244
|
+
|
|
218
245
|
// Display config baked into a data attribute by the Astro
|
|
219
246
|
// template — parsed lazily.
|
|
220
247
|
const taxonomyDisplay: TaxonomyDisplayMap = (() => {
|
|
@@ -263,6 +290,49 @@ const {
|
|
|
263
290
|
return loadingPagefind;
|
|
264
291
|
}
|
|
265
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Lazy nav.json fetch, kicked off the first time a hierarchical
|
|
295
|
+
* facet is rendered. Mirrors the pattern in `docs-nav-client.ts` —
|
|
296
|
+
* one fetch per session, `same-origin` credentials so cookied
|
|
297
|
+
* mounts work, module-level cache.
|
|
298
|
+
*
|
|
299
|
+
* When the fetch resolves we re-call `renderFacets()` so the
|
|
300
|
+
* previously-flat segment-encoded facet upgrades to its tree
|
|
301
|
+
* shape — without this, a slow nav fetch would leave the facets
|
|
302
|
+
* stuck on the flat fallback until the user toggled a filter.
|
|
303
|
+
*
|
|
304
|
+
* Network errors are logged and swallowed; the helper's flat
|
|
305
|
+
* fallback keeps the dialog functional.
|
|
306
|
+
*/
|
|
307
|
+
async function ensureNavLoaded() {
|
|
308
|
+
if (navData !== null) return;
|
|
309
|
+
if (loadingNav) return loadingNav;
|
|
310
|
+
const url = dialog!.dataset.navUrl;
|
|
311
|
+
if (!url) return; // navUrl not provided — flat fallback stays
|
|
312
|
+
loadingNav = (async () => {
|
|
313
|
+
try {
|
|
314
|
+
const res = await fetch(url, { credentials: "same-origin" });
|
|
315
|
+
if (!res.ok) throw new Error(`nav.json fetch failed: ${res.status}`);
|
|
316
|
+
const data = (await res.json()) as unknown;
|
|
317
|
+
navData = Array.isArray(data) ? (data as NavLike[]) : [];
|
|
318
|
+
// Re-render so hierarchical segment-encoded facets pick up
|
|
319
|
+
// the nav shape. Cheap — no Pagefind round-trip, just a
|
|
320
|
+
// DOM rebuild from `availableFacets`.
|
|
321
|
+
if (Object.keys(availableFacets).length > 0) {
|
|
322
|
+
renderFacets();
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
console.warn(
|
|
326
|
+
"[dogsbay] failed to load nav.json (hierarchical facets fall back to flat list):",
|
|
327
|
+
err,
|
|
328
|
+
);
|
|
329
|
+
// Mark as loaded-with-empty so we don't keep retrying.
|
|
330
|
+
navData = [];
|
|
331
|
+
}
|
|
332
|
+
})();
|
|
333
|
+
return loadingNav;
|
|
334
|
+
}
|
|
335
|
+
|
|
266
336
|
function escapeHtml(s: string): string {
|
|
267
337
|
return s
|
|
268
338
|
.replace(/&/g, "&")
|
|
@@ -339,11 +409,96 @@ const {
|
|
|
339
409
|
resultsBox!.innerHTML = html;
|
|
340
410
|
}
|
|
341
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Hierarchical-facet caches, rebuilt on every renderFacets() so
|
|
414
|
+
* they stay aligned with the current filter state + available
|
|
415
|
+
* facets. The trees are the source for `value → node` lookups
|
|
416
|
+
* during click handling (parent click expands selection to all
|
|
417
|
+
* descendants — needs the node to know what to add).
|
|
418
|
+
*/
|
|
419
|
+
const facetTrees = new Map<string, FacetTreeNode[]>();
|
|
420
|
+
const facetNodesByValue = new Map<string, Map<string, FacetTreeNode>>();
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Decide whether a facet renders as a tree. `category` defaults
|
|
424
|
+
* to hierarchical (auto-derived path segments are the canonical
|
|
425
|
+
* use case); explicit override via `taxonomyDisplay[name].hierarchical`
|
|
426
|
+
* wins both ways.
|
|
427
|
+
*/
|
|
428
|
+
function isHierarchicalFacet(name: string): boolean {
|
|
429
|
+
const flag = taxonomyDisplay[name]?.hierarchical;
|
|
430
|
+
if (typeof flag === "boolean") return flag;
|
|
431
|
+
return name === "category";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Flatten a tree into a `value → node` map so the click handler
|
|
436
|
+
* can look up the clicked node without re-walking the tree.
|
|
437
|
+
*/
|
|
438
|
+
function indexTree(nodes: FacetTreeNode[]): Map<string, FacetTreeNode> {
|
|
439
|
+
const out = new Map<string, FacetTreeNode>();
|
|
440
|
+
const visit = (n: FacetTreeNode): void => {
|
|
441
|
+
out.set(n.value, n);
|
|
442
|
+
for (const c of n.children) visit(c);
|
|
443
|
+
};
|
|
444
|
+
for (const r of nodes) visit(r);
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Recursive HTML for one tree node. Synthetic parents (`hasValue:
|
|
450
|
+
* false`) render as a clickable group header — checking them
|
|
451
|
+
* still cascades to descendants via toggleTreeNode. Indent scales
|
|
452
|
+
* with `node.depth`.
|
|
453
|
+
*/
|
|
454
|
+
function renderTreeNode(name: string, node: FacetTreeNode): string {
|
|
455
|
+
const state = computeTreeState(node, name, filters);
|
|
456
|
+
const id = `facet-${name}-${node.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
457
|
+
const indent = node.depth * 12;
|
|
458
|
+
const label = escapeHtml(node.label);
|
|
459
|
+
const countText = node.hasValue ? `${node.count}` : "";
|
|
460
|
+
const stateAttr =
|
|
461
|
+
state === "checked" ? "checked" : state === "indeterminate" ? 'data-tree-indeterminate="true"' : "";
|
|
462
|
+
const childrenHtml = node.children
|
|
463
|
+
.map((c) => renderTreeNode(name, c))
|
|
464
|
+
.join("");
|
|
465
|
+
return `
|
|
466
|
+
<li>
|
|
467
|
+
<label
|
|
468
|
+
for="${id}"
|
|
469
|
+
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent"
|
|
470
|
+
style="padding-left: ${0.5 + indent / 16}rem"
|
|
471
|
+
>
|
|
472
|
+
<input
|
|
473
|
+
type="checkbox"
|
|
474
|
+
id="${id}"
|
|
475
|
+
data-facet-name="${escapeHtml(name)}"
|
|
476
|
+
data-facet-value="${escapeHtml(node.value)}"
|
|
477
|
+
data-tree-node="true"
|
|
478
|
+
${state === "checked" ? "checked" : ""}
|
|
479
|
+
${state === "indeterminate" ? 'data-tree-indeterminate="true"' : ""}
|
|
480
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
481
|
+
/>
|
|
482
|
+
<span class="flex-1 truncate">${label}</span>
|
|
483
|
+
<span class="text-xs text-muted-foreground">${countText}</span>
|
|
484
|
+
</label>
|
|
485
|
+
${childrenHtml ? `<ul class="space-y-0.5">${childrenHtml}</ul>` : ""}
|
|
486
|
+
</li>
|
|
487
|
+
`;
|
|
488
|
+
}
|
|
489
|
+
|
|
342
490
|
/**
|
|
343
491
|
* Build the facets sidebar. Runs once after Pagefind discovers
|
|
344
492
|
* filters, then again whenever filter state changes (so checkbox
|
|
345
493
|
* `checked` reflects current selections). When the corpus has no
|
|
346
494
|
* filters, the sidebar stays hidden — single-column layout.
|
|
495
|
+
*
|
|
496
|
+
* Branches per facet: hierarchical taxonomies (and the built-in
|
|
497
|
+
* `category` default) render as a tree; flat taxonomies keep the
|
|
498
|
+
* original checkbox-list shape. The hierarchical path falls back
|
|
499
|
+
* to a flat list automatically when segment-encoded values lack a
|
|
500
|
+
* `nav` source — preserves render-ability until Phase 3 wires the
|
|
501
|
+
* nav.json fetch.
|
|
347
502
|
*/
|
|
348
503
|
function renderFacets() {
|
|
349
504
|
const facetNames = sortFacetNames(
|
|
@@ -356,40 +511,68 @@ const {
|
|
|
356
511
|
}
|
|
357
512
|
facetsBox!.classList.remove("hidden");
|
|
358
513
|
|
|
514
|
+
facetTrees.clear();
|
|
515
|
+
facetNodesByValue.clear();
|
|
516
|
+
|
|
359
517
|
const activeCount = countActiveFilters(filters);
|
|
360
518
|
const clearAll = activeCount > 0
|
|
361
519
|
? `<button type="button" data-clear-filters class="mb-3 w-full rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">Clear all (${activeCount})</button>`
|
|
362
520
|
: "";
|
|
363
521
|
|
|
522
|
+
let needsNav = false;
|
|
364
523
|
const groups = facetNames
|
|
365
524
|
.map((name) => {
|
|
366
525
|
const entries = availableFacets[name];
|
|
367
526
|
const title = escapeHtml(resolveFacetTitle(name));
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
527
|
+
const hierarchical = isHierarchicalFacet(name);
|
|
528
|
+
|
|
529
|
+
let items: string;
|
|
530
|
+
if (hierarchical) {
|
|
531
|
+
// Slash-encoded values build a tree directly from their
|
|
532
|
+
// values; segment-encoded values need nav.json for
|
|
533
|
+
// structure. The helper detects which and falls back to
|
|
534
|
+
// a flat list when segment-encoded + no nav yet — phase 3
|
|
535
|
+
// upgrades to the real tree as soon as ensureNavLoaded
|
|
536
|
+
// resolves and re-renders.
|
|
537
|
+
const segmentEncoded = !entries.some((e) =>
|
|
538
|
+
e.value.includes("/"),
|
|
539
|
+
);
|
|
540
|
+
if (segmentEncoded && navData === null) needsNav = true;
|
|
541
|
+
const tree = buildFacetTree(name, entries, {
|
|
542
|
+
display: taxonomyDisplay,
|
|
543
|
+
nav: navData ?? undefined,
|
|
544
|
+
});
|
|
545
|
+
facetTrees.set(name, tree);
|
|
546
|
+
facetNodesByValue.set(name, indexTree(tree));
|
|
547
|
+
items = tree.map((n) => renderTreeNode(name, n)).join("");
|
|
548
|
+
} else {
|
|
549
|
+
items = entries
|
|
550
|
+
.map((entry) => {
|
|
551
|
+
const checked = (filters[name] ?? []).includes(entry.value);
|
|
552
|
+
const label = escapeHtml(
|
|
553
|
+
resolveFacetLabel(name, entry.value, taxonomyDisplay),
|
|
554
|
+
);
|
|
555
|
+
const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
556
|
+
return `
|
|
557
|
+
<li>
|
|
558
|
+
<label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
|
|
559
|
+
<input
|
|
560
|
+
type="checkbox"
|
|
561
|
+
id="${id}"
|
|
562
|
+
data-facet-name="${escapeHtml(name)}"
|
|
563
|
+
data-facet-value="${escapeHtml(entry.value)}"
|
|
564
|
+
${checked ? "checked" : ""}
|
|
565
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
566
|
+
/>
|
|
567
|
+
<span class="flex-1 truncate">${label}</span>
|
|
568
|
+
<span class="text-xs text-muted-foreground">${entry.count}</span>
|
|
569
|
+
</label>
|
|
570
|
+
</li>
|
|
571
|
+
`;
|
|
572
|
+
})
|
|
573
|
+
.join("");
|
|
574
|
+
}
|
|
575
|
+
|
|
393
576
|
return `
|
|
394
577
|
<fieldset class="mb-3">
|
|
395
578
|
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
|
|
@@ -400,6 +583,20 @@ const {
|
|
|
400
583
|
.join("");
|
|
401
584
|
|
|
402
585
|
facetsBox!.innerHTML = clearAll + groups;
|
|
586
|
+
|
|
587
|
+
// `indeterminate` is a JS property, not an HTML attribute —
|
|
588
|
+
// can't set via innerHTML. Walk new checkboxes and set it
|
|
589
|
+
// post-paint so the visual tri-state matches state.
|
|
590
|
+
const indet = facetsBox!.querySelectorAll<HTMLInputElement>(
|
|
591
|
+
'input[type="checkbox"][data-tree-indeterminate="true"]',
|
|
592
|
+
);
|
|
593
|
+
for (const el of Array.from(indet)) el.indeterminate = true;
|
|
594
|
+
|
|
595
|
+
// Lazy nav fetch — fired only when at least one hierarchical
|
|
596
|
+
// segment-encoded facet rendered with the flat fallback. The
|
|
597
|
+
// fetch's resolution handler calls renderFacets() again so the
|
|
598
|
+
// facet upgrades to its real tree shape without user action.
|
|
599
|
+
if (needsNav) void ensureNavLoaded();
|
|
403
600
|
}
|
|
404
601
|
|
|
405
602
|
async function runSearch(query: string) {
|
|
@@ -504,13 +701,26 @@ const {
|
|
|
504
701
|
});
|
|
505
702
|
|
|
506
703
|
// Facet checkbox toggling — event delegation on the sidebar.
|
|
704
|
+
// Tree nodes route through toggleTreeNode (parent click expands
|
|
705
|
+
// to all descendants); flat checkboxes use toggleFilter as before.
|
|
706
|
+
// We compute state from the pre-click filter, NOT from the
|
|
707
|
+
// checkbox's post-click `checked` value — the browser has already
|
|
708
|
+
// flipped it by the time `change` fires, so reading it would
|
|
709
|
+
// invert our toggle direction for indeterminate parents.
|
|
507
710
|
facetsBox.addEventListener("change", (e) => {
|
|
508
711
|
const target = e.target as HTMLInputElement;
|
|
509
712
|
if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
|
|
510
713
|
const name = target.dataset.facetName;
|
|
511
714
|
const value = target.dataset.facetValue;
|
|
512
715
|
if (!name || !value) return;
|
|
513
|
-
|
|
716
|
+
if (target.dataset.treeNode === "true") {
|
|
717
|
+
const node = facetNodesByValue.get(name)?.get(value);
|
|
718
|
+
if (!node) return;
|
|
719
|
+
const currentState = computeTreeState(node, name, filters);
|
|
720
|
+
filters = toggleTreeNode(filters, name, node, currentState);
|
|
721
|
+
} else {
|
|
722
|
+
filters = toggleFilter(filters, name, value);
|
|
723
|
+
}
|
|
514
724
|
renderFacets();
|
|
515
725
|
runSearch(input!.value);
|
|
516
726
|
syncUrl();
|
package/src/search-facets.ts
CHANGED
|
@@ -23,6 +23,26 @@ export interface TaxonomyDisplay {
|
|
|
23
23
|
* promotes "Type" to the top while everything else stays alpha.
|
|
24
24
|
*/
|
|
25
25
|
order?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Render this facet as a tree instead of a flat checkbox list.
|
|
28
|
+
* See `buildFacetTree` for the two derivation strategies (slash-
|
|
29
|
+
* encoded vs nav-driven segment-encoded) and the canonical
|
|
30
|
+
* @dogsbay/types definition for full semantics.
|
|
31
|
+
*/
|
|
32
|
+
hierarchical?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Minimal nav item shape — mirrors `@dogsbay/types`' `NavItem`
|
|
37
|
+
* locally so this module has no runtime dependency on the types
|
|
38
|
+
* package. Used as input to `buildFacetTree` for segment-encoded
|
|
39
|
+
* facets (the auto-`category` case), where the document tree is
|
|
40
|
+
* the source of structure and order.
|
|
41
|
+
*/
|
|
42
|
+
export interface NavLike {
|
|
43
|
+
label: string;
|
|
44
|
+
href?: string;
|
|
45
|
+
children?: NavLike[];
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
/** Map of taxonomy name → display config. */
|
|
@@ -282,3 +302,355 @@ export function countActiveFilters(filters: FilterState): number {
|
|
|
282
302
|
for (const values of Object.values(filters)) n += values.length;
|
|
283
303
|
return n;
|
|
284
304
|
}
|
|
305
|
+
|
|
306
|
+
// ── Hierarchical facets ──────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* One node in a hierarchical facet tree (see `buildFacetTree`).
|
|
310
|
+
*
|
|
311
|
+
* `value` is the canonical Pagefind filter value for this node —
|
|
312
|
+
* for slash-encoded facets the full slash-joined path
|
|
313
|
+
* (`concept/a11y`); for segment-encoded facets a single segment
|
|
314
|
+
* (`installing_aws`). `value` matches Pagefind's value key 1:1
|
|
315
|
+
* for "real" entries; synthetic parents (intermediate trie nodes
|
|
316
|
+
* that don't correspond to a Pagefind entry) reuse the same
|
|
317
|
+
* derived value but carry `hasValue: false` and `count: 0`.
|
|
318
|
+
*/
|
|
319
|
+
export interface FacetTreeNode {
|
|
320
|
+
value: string;
|
|
321
|
+
label: string;
|
|
322
|
+
count: number;
|
|
323
|
+
depth: number;
|
|
324
|
+
/** True if Pagefind reported this exact value; false for synthetic parents. */
|
|
325
|
+
hasValue: boolean;
|
|
326
|
+
children: FacetTreeNode[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build a hierarchical facet tree from Pagefind's flat entry list.
|
|
331
|
+
*
|
|
332
|
+
* Two strategies depending on the values' shape:
|
|
333
|
+
*
|
|
334
|
+
* - **Slash-encoded.** When at least one entry value contains `/`,
|
|
335
|
+
* each value is treated as a path through the tree (`concept/a11y`
|
|
336
|
+
* → `concept → a11y`). Splits on `/`, inserts into a trie.
|
|
337
|
+
* Parent entries that don't have a Pagefind value of their own
|
|
338
|
+
* (e.g. `concept` standalone is unindexed but `concept/a11y` is)
|
|
339
|
+
* appear as synthetic intermediate nodes with `hasValue: false`.
|
|
340
|
+
* Within a parent, children sort by count desc then alpha — the
|
|
341
|
+
* same rule as flat facets.
|
|
342
|
+
*
|
|
343
|
+
* - **Segment-encoded.** When NO entry contains `/`, each value is
|
|
344
|
+
* a single path segment. Hierarchy comes from `nav` — every nav
|
|
345
|
+
* leaf's href segments become a chain in the trie. Counts come
|
|
346
|
+
* from Pagefind's entry list, attached to nodes whose segment
|
|
347
|
+
* name matches an entry value. **Caveat:** Pagefind reports one
|
|
348
|
+
* count per distinct segment name regardless of where in the
|
|
349
|
+
* tree it lives, so a segment that appears at multiple positions
|
|
350
|
+
* (`installing/.../ipi`, `installing_azure/.../ipi`) gets the
|
|
351
|
+
* same total count at every position — clicking selects the
|
|
352
|
+
* shared value, returning pages from every position. This
|
|
353
|
+
* matches the underlying Pagefind filter behaviour and is
|
|
354
|
+
* documented in plans/hierarchical-facets.md.
|
|
355
|
+
*
|
|
356
|
+
* Order is nav-document order, NOT count desc. Pagefind entries
|
|
357
|
+
* not represented in nav are appended as orphans at the root in
|
|
358
|
+
* alpha order.
|
|
359
|
+
*
|
|
360
|
+
* When `nav` is undefined for the segment-encoded case, falls
|
|
361
|
+
* back to a flat list (every entry a root-level node with no
|
|
362
|
+
* children, in count-desc order) — preserves render-ability
|
|
363
|
+
* when nav.json hasn't loaded yet.
|
|
364
|
+
*
|
|
365
|
+
* Returns the top-level nodes. Empty `entries` → empty array.
|
|
366
|
+
*/
|
|
367
|
+
export function buildFacetTree(
|
|
368
|
+
facetName: string,
|
|
369
|
+
entries: FacetEntry[],
|
|
370
|
+
options: {
|
|
371
|
+
nav?: NavLike[];
|
|
372
|
+
display?: TaxonomyDisplayMap;
|
|
373
|
+
} = {},
|
|
374
|
+
): FacetTreeNode[] {
|
|
375
|
+
if (entries.length === 0) return [];
|
|
376
|
+
|
|
377
|
+
const slashEncoded = entries.some((e) => e.value.includes("/"));
|
|
378
|
+
if (slashEncoded) {
|
|
379
|
+
return buildSlashTree(facetName, entries, options.display);
|
|
380
|
+
}
|
|
381
|
+
return buildSegmentTree(facetName, entries, options.nav, options.display);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Slash-encoded tree builder. Each entry value is a slash-joined
|
|
386
|
+
* path; split, walk a trie, attach Pagefind counts to terminal
|
|
387
|
+
* nodes. Synthetic intermediate nodes (a path component used by a
|
|
388
|
+
* descendant but absent from `entries`) get `hasValue: false` and
|
|
389
|
+
* `count: 0` so they render but don't contribute to selection.
|
|
390
|
+
*/
|
|
391
|
+
function buildSlashTree(
|
|
392
|
+
facetName: string,
|
|
393
|
+
entries: FacetEntry[],
|
|
394
|
+
display?: TaxonomyDisplayMap,
|
|
395
|
+
): FacetTreeNode[] {
|
|
396
|
+
// Trie keyed by full slash-path. Children are tracked on each
|
|
397
|
+
// node's own `children` array (built unsorted in pass 1, sorted
|
|
398
|
+
// recursively in pass 2).
|
|
399
|
+
const byPath = new Map<string, FacetTreeNode>();
|
|
400
|
+
const rootValues: string[] = [];
|
|
401
|
+
|
|
402
|
+
for (const entry of entries) {
|
|
403
|
+
const segments = entry.value.split("/").filter((s) => s.length > 0);
|
|
404
|
+
if (segments.length === 0) continue;
|
|
405
|
+
let pathSoFar = "";
|
|
406
|
+
let parent: FacetTreeNode | null = null;
|
|
407
|
+
for (let i = 0; i < segments.length; i++) {
|
|
408
|
+
pathSoFar = pathSoFar ? `${pathSoFar}/${segments[i]}` : segments[i];
|
|
409
|
+
let node = byPath.get(pathSoFar);
|
|
410
|
+
if (!node) {
|
|
411
|
+
node = {
|
|
412
|
+
value: pathSoFar,
|
|
413
|
+
label: resolveFacetLabel(facetName, pathSoFar, display),
|
|
414
|
+
count: 0,
|
|
415
|
+
depth: i,
|
|
416
|
+
hasValue: false,
|
|
417
|
+
children: [],
|
|
418
|
+
};
|
|
419
|
+
byPath.set(pathSoFar, node);
|
|
420
|
+
if (parent) parent.children.push(node);
|
|
421
|
+
else rootValues.push(pathSoFar);
|
|
422
|
+
}
|
|
423
|
+
if (i === segments.length - 1) {
|
|
424
|
+
node.count = entry.count;
|
|
425
|
+
node.hasValue = true;
|
|
426
|
+
}
|
|
427
|
+
parent = node;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Pass 2: sort children at every depth. Mirrors sortFacetEntries
|
|
432
|
+
// (count desc, alpha tiebreaker).
|
|
433
|
+
const sortChildren = (node: FacetTreeNode): void => {
|
|
434
|
+
if (node.children.length === 0) return;
|
|
435
|
+
node.children.sort((a, b) => {
|
|
436
|
+
if (a.count !== b.count) return b.count - a.count;
|
|
437
|
+
return a.value.localeCompare(b.value);
|
|
438
|
+
});
|
|
439
|
+
for (const c of node.children) sortChildren(c);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const rootList = rootValues.map((v) => byPath.get(v)!);
|
|
443
|
+
rootList.sort((a, b) => {
|
|
444
|
+
if (a.count !== b.count) return b.count - a.count;
|
|
445
|
+
return a.value.localeCompare(b.value);
|
|
446
|
+
});
|
|
447
|
+
for (const r of rootList) sortChildren(r);
|
|
448
|
+
return rootList;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Segment-encoded tree builder. Walks `nav` to discover the
|
|
453
|
+
* canonical document hierarchy, then attaches Pagefind counts to
|
|
454
|
+
* any node whose segment name matches an entry. Pagefind entries
|
|
455
|
+
* not in nav are appended as alpha-sorted root orphans so they
|
|
456
|
+
* don't disappear.
|
|
457
|
+
*/
|
|
458
|
+
function buildSegmentTree(
|
|
459
|
+
facetName: string,
|
|
460
|
+
entries: FacetEntry[],
|
|
461
|
+
nav: NavLike[] | undefined,
|
|
462
|
+
display?: TaxonomyDisplayMap,
|
|
463
|
+
): FacetTreeNode[] {
|
|
464
|
+
const byValue = new Map<string, FacetEntry>();
|
|
465
|
+
for (const e of entries) byValue.set(e.value, e);
|
|
466
|
+
|
|
467
|
+
if (!nav || nav.length === 0) {
|
|
468
|
+
// Flat fallback in count-desc order — preserves render-ability
|
|
469
|
+
// when nav hasn't loaded.
|
|
470
|
+
return entries.map((e) => ({
|
|
471
|
+
value: e.value,
|
|
472
|
+
label: resolveFacetLabel(facetName, e.value, display),
|
|
473
|
+
count: e.count,
|
|
474
|
+
depth: 0,
|
|
475
|
+
hasValue: true,
|
|
476
|
+
children: [],
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const seenValues = new Set<string>();
|
|
481
|
+
const roots: FacetTreeNode[] = [];
|
|
482
|
+
const rootIndex = new Map<string, FacetTreeNode>();
|
|
483
|
+
|
|
484
|
+
/** Insert a path's parent segments (excluding the leaf filename) into the trie. */
|
|
485
|
+
const insertPath = (segments: string[]): void => {
|
|
486
|
+
if (segments.length === 0) return;
|
|
487
|
+
let parentList: FacetTreeNode[] = roots;
|
|
488
|
+
let parentIndex: Map<string, FacetTreeNode> = rootIndex;
|
|
489
|
+
for (let i = 0; i < segments.length; i++) {
|
|
490
|
+
const seg = segments[i];
|
|
491
|
+
let node = parentIndex.get(seg);
|
|
492
|
+
if (!node) {
|
|
493
|
+
const entry = byValue.get(seg);
|
|
494
|
+
node = {
|
|
495
|
+
value: seg,
|
|
496
|
+
label: resolveFacetLabel(facetName, seg, display),
|
|
497
|
+
count: entry ? entry.count : 0,
|
|
498
|
+
depth: i,
|
|
499
|
+
hasValue: entry !== undefined,
|
|
500
|
+
children: [],
|
|
501
|
+
};
|
|
502
|
+
parentList.push(node);
|
|
503
|
+
parentIndex.set(seg, node);
|
|
504
|
+
if (entry) seenValues.add(seg);
|
|
505
|
+
}
|
|
506
|
+
// Walk into the child list for the next iteration. Each
|
|
507
|
+
// node carries its own child index lazily via a WeakMap-like
|
|
508
|
+
// closure; here we use a regular Map keyed off the node.
|
|
509
|
+
let nextIndex = childIndex.get(node);
|
|
510
|
+
if (!nextIndex) {
|
|
511
|
+
nextIndex = new Map();
|
|
512
|
+
childIndex.set(node, nextIndex);
|
|
513
|
+
}
|
|
514
|
+
parentList = node.children;
|
|
515
|
+
parentIndex = nextIndex;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const childIndex = new Map<FacetTreeNode, Map<string, FacetTreeNode>>();
|
|
520
|
+
|
|
521
|
+
// Walk every nav leaf. Use href segments (excluding the leaf
|
|
522
|
+
// filename) — those are the auto-derived `category` values
|
|
523
|
+
// emitted by parseMeta's deriveCategoryFromSlug.
|
|
524
|
+
const walkNav = (items: NavLike[]): void => {
|
|
525
|
+
for (const item of items) {
|
|
526
|
+
if (item.href) {
|
|
527
|
+
const parents = hrefToCategorySegments(item.href);
|
|
528
|
+
insertPath(parents);
|
|
529
|
+
}
|
|
530
|
+
if (item.children && item.children.length > 0) walkNav(item.children);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
walkNav(nav);
|
|
534
|
+
|
|
535
|
+
// Pagefind values not represented in nav → root-level orphans in
|
|
536
|
+
// alpha order. Without this they vanish from the UI silently.
|
|
537
|
+
const orphans: FacetTreeNode[] = [];
|
|
538
|
+
for (const e of entries) {
|
|
539
|
+
if (seenValues.has(e.value)) continue;
|
|
540
|
+
orphans.push({
|
|
541
|
+
value: e.value,
|
|
542
|
+
label: resolveFacetLabel(facetName, e.value, display),
|
|
543
|
+
count: e.count,
|
|
544
|
+
depth: 0,
|
|
545
|
+
hasValue: true,
|
|
546
|
+
children: [],
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
orphans.sort((a, b) => a.value.localeCompare(b.value));
|
|
550
|
+
return [...roots, ...orphans];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Strip an href down to its parent path segments — the `category`
|
|
555
|
+
* derivation logic from `parseMeta.deriveCategoryFromSlug`. Drops
|
|
556
|
+
* leading/trailing slashes, drops the leaf filename segment.
|
|
557
|
+
*/
|
|
558
|
+
function hrefToCategorySegments(href: string): string[] {
|
|
559
|
+
// Drop scheme/host if absolute (we only treat path segments)
|
|
560
|
+
let path = href;
|
|
561
|
+
try {
|
|
562
|
+
if (/^[a-z]+:\/\//i.test(href)) path = new URL(href).pathname;
|
|
563
|
+
} catch {
|
|
564
|
+
// not a URL — treat as-is
|
|
565
|
+
}
|
|
566
|
+
// Drop hash + query — facets care about the URL path only
|
|
567
|
+
path = path.split("#")[0].split("?")[0];
|
|
568
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
569
|
+
// Match deriveCategoryFromSlug: parents = everything except the leaf
|
|
570
|
+
if (segments.length < 2) return [];
|
|
571
|
+
return segments.slice(0, -1);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Flat list of every "real" Pagefind value at or under `node`
|
|
576
|
+
* (i.e. every node where `hasValue === true`). Synthetic parents
|
|
577
|
+
* are excluded since adding them to the filter would be a no-op
|
|
578
|
+
* (Pagefind has no pages tagged with the synthetic value).
|
|
579
|
+
*
|
|
580
|
+
* Used by `toggleTreeNode` when the user ticks a parent — we add
|
|
581
|
+
* every real descendant value to the selection in one shot.
|
|
582
|
+
*/
|
|
583
|
+
export function collectDescendantValues(node: FacetTreeNode): string[] {
|
|
584
|
+
const out: string[] = [];
|
|
585
|
+
const visit = (n: FacetTreeNode): void => {
|
|
586
|
+
if (n.hasValue) out.push(n.value);
|
|
587
|
+
for (const c of n.children) visit(c);
|
|
588
|
+
};
|
|
589
|
+
visit(node);
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Tri-state of a tree node given the current filter state.
|
|
595
|
+
*
|
|
596
|
+
* - `"checked"` — every real descendant value (and self if
|
|
597
|
+
* self has a value) is in `filters[facetName]`.
|
|
598
|
+
* - `"unchecked"` — none of them are.
|
|
599
|
+
* - `"indeterminate"`— at least one is, but not all.
|
|
600
|
+
*
|
|
601
|
+
* Synthetic parents with no real-value descendants resolve to
|
|
602
|
+
* "unchecked" (defensive default — shouldn't occur in valid trees).
|
|
603
|
+
*/
|
|
604
|
+
export function computeTreeState(
|
|
605
|
+
node: FacetTreeNode,
|
|
606
|
+
facetName: string,
|
|
607
|
+
filters: FilterState,
|
|
608
|
+
): "checked" | "unchecked" | "indeterminate" {
|
|
609
|
+
const descendants = collectDescendantValues(node);
|
|
610
|
+
if (descendants.length === 0) return "unchecked";
|
|
611
|
+
const selected = new Set(filters[facetName] ?? []);
|
|
612
|
+
let hits = 0;
|
|
613
|
+
for (const v of descendants) if (selected.has(v)) hits++;
|
|
614
|
+
if (hits === 0) return "unchecked";
|
|
615
|
+
if (hits === descendants.length) return "checked";
|
|
616
|
+
return "indeterminate";
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Toggle a tree node's selection.
|
|
621
|
+
*
|
|
622
|
+
* - If currently checked → remove self + all descendant values
|
|
623
|
+
* from `filters[facetName]`.
|
|
624
|
+
* - If unchecked or indeterminate → add self + all descendant
|
|
625
|
+
* values (auto-select). Matches the decided UX: parent click
|
|
626
|
+
* fans out to children.
|
|
627
|
+
*
|
|
628
|
+
* Returns a new `FilterState` — input is not mutated. Same
|
|
629
|
+
* immutability contract as `toggleFilter`.
|
|
630
|
+
*/
|
|
631
|
+
export function toggleTreeNode(
|
|
632
|
+
filters: FilterState,
|
|
633
|
+
facetName: string,
|
|
634
|
+
node: FacetTreeNode,
|
|
635
|
+
currentState: "checked" | "unchecked" | "indeterminate",
|
|
636
|
+
): FilterState {
|
|
637
|
+
const descendants = collectDescendantValues(node);
|
|
638
|
+
if (descendants.length === 0) return filters;
|
|
639
|
+
const current = filters[facetName] ?? [];
|
|
640
|
+
let nextValues: string[];
|
|
641
|
+
if (currentState === "checked") {
|
|
642
|
+
const drop = new Set(descendants);
|
|
643
|
+
nextValues = current.filter((v) => !drop.has(v));
|
|
644
|
+
} else {
|
|
645
|
+
const merged = new Set(current);
|
|
646
|
+
for (const v of descendants) merged.add(v);
|
|
647
|
+
nextValues = Array.from(merged);
|
|
648
|
+
}
|
|
649
|
+
const next: FilterState = { ...filters };
|
|
650
|
+
if (nextValues.length === 0) {
|
|
651
|
+
delete next[facetName];
|
|
652
|
+
} else {
|
|
653
|
+
next[facetName] = nextValues;
|
|
654
|
+
}
|
|
655
|
+
return next;
|
|
656
|
+
}
|