@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/docs-layout",
3
- "version": "0.2.0-beta.72",
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.72",
33
- "@dogsbay/primitives": "0.2.0-beta.72"
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"
@@ -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
  )}
@@ -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, "&amp;")
@@ -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 items = entries
369
- .map((entry) => {
370
- const checked = (filters[name] ?? []).includes(entry.value);
371
- const label = escapeHtml(
372
- resolveFacetLabel(name, entry.value, taxonomyDisplay),
373
- );
374
- const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
375
- return `
376
- <li>
377
- <label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
378
- <input
379
- type="checkbox"
380
- id="${id}"
381
- data-facet-name="${escapeHtml(name)}"
382
- data-facet-value="${escapeHtml(entry.value)}"
383
- ${checked ? "checked" : ""}
384
- class="size-3.5 rounded border-border accent-primary"
385
- />
386
- <span class="flex-1 truncate">${label}</span>
387
- <span class="text-xs text-muted-foreground">${entry.count}</span>
388
- </label>
389
- </li>
390
- `;
391
- })
392
- .join("");
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
- filters = toggleFilter(filters, name, value);
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();
@@ -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
+ }