@dogsbay/docs-layout 0.2.0-beta.9 → 0.2.0-beta.90

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.
@@ -28,10 +28,26 @@ import type { TaxonomyDisplay } from "@dogsbay/types";
28
28
 
29
29
  interface Props {
30
30
  /**
31
- * Path prefix where Pagefind's index lives. Default: "/pagefind/".
32
- * Override when a site uses a non-root base path (e.g. "/docs/pagefind/").
31
+ * Path where Pagefind's index lives, e.g. `/pagefind/` or
32
+ * `/<repo>/pagefind/` for subpath-mounted deploys. NO DEFAULT
33
+ * a host-root default would silently 404 on subpath deploys
34
+ * (GH Pages project pages, multi-mount Cloudflare). The
35
+ * format-astro emitter passes the combined-prefix-aware URL;
36
+ * manual instantiations must do the same. When undefined, the
37
+ * search dialog throws on first open with a clear console error.
33
38
  */
34
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;
35
51
  /** Placeholder text for the search input */
36
52
  placeholder?: string;
37
53
  /**
@@ -44,7 +60,8 @@ interface Props {
44
60
  }
45
61
 
46
62
  const {
47
- pagefindUrl = "/pagefind/",
63
+ pagefindUrl,
64
+ navUrl,
48
65
  placeholder = "Search docs...",
49
66
  taxonomyDisplay,
50
67
  } = Astro.props;
@@ -53,6 +70,7 @@ const {
53
70
  <dialog
54
71
  data-search-dialog
55
72
  data-pagefind-url={pagefindUrl}
73
+ data-nav-url={navUrl}
56
74
  data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
57
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"
58
76
  >
@@ -156,13 +174,19 @@ const {
156
174
  shapeFacets,
157
175
  resolveFacetLabel,
158
176
  resolveFacetTitle,
177
+ sortFacetNames,
159
178
  filterStateToUrlParams,
160
179
  parseFiltersFromUrl,
161
180
  filterStateToPagefindFilters,
162
181
  toggleFilter,
163
182
  countActiveFilters,
183
+ buildFacetTree,
184
+ computeTreeState,
185
+ toggleTreeNode,
164
186
  type FacetMap,
187
+ type FacetTreeNode,
165
188
  type FilterState,
189
+ type NavLike,
166
190
  type TaxonomyDisplayMap,
167
191
  } from "./search-facets.js";
168
192
 
@@ -177,10 +201,15 @@ const {
177
201
  meta: { title?: string };
178
202
  sub_results?: Array<{ title: string; url: string; excerpt: string }>;
179
203
  };
204
+ // Filter values match what filterStateToPagefindFilters emits:
205
+ // each facet wrapped in `{any: [...]}` for OR-within-facet semantics.
206
+ // Pagefind also accepts other operator shapes (`all`/`none`/`not`,
207
+ // bare strings, bare arrays) but we only emit the `any` form.
208
+ type PagefindFilterValue = { any: string[] };
180
209
  type PagefindModule = {
181
210
  search(
182
211
  query: string,
183
- options?: { filters?: Record<string, string[]> },
212
+ options?: { filters?: Record<string, PagefindFilterValue> },
184
213
  ): Promise<{ results: PagefindResult[] }>;
185
214
  filters(): Promise<Record<string, Record<string, number>>>;
186
215
  };
@@ -204,6 +233,15 @@ const {
204
233
  let filters: FilterState = {};
205
234
  let availableFacets: FacetMap = {};
206
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
+
207
245
  // Display config baked into a data attribute by the Astro
208
246
  // template — parsed lazily.
209
247
  const taxonomyDisplay: TaxonomyDisplayMap = (() => {
@@ -218,7 +256,22 @@ const {
218
256
  async function ensurePagefindLoaded() {
219
257
  if (pagefind) return;
220
258
  if (loadingPagefind) return loadingPagefind;
221
- const url = (dialog!.dataset.pagefindUrl ?? "/pagefind/") + "pagefind.js";
259
+ // pagefindUrl is required a "/pagefind/" fallback would
260
+ // silently 404 on subpath-mounted deploys (GH Pages project
261
+ // pages, multi-mount Cloudflare). The emitter always passes
262
+ // a combined-prefix-aware value via data-pagefind-url. If it's
263
+ // missing the page wasn't built through format-astro and the
264
+ // caller forgot to pass it.
265
+ const dataUrl = dialog!.dataset.pagefindUrl;
266
+ if (!dataUrl) {
267
+ console.error(
268
+ "[dogsbay] SearchDialog: pagefindUrl prop missing. " +
269
+ "Pass the combined-prefix path (e.g. '/<base>/pagefind/') " +
270
+ "from your DocsLayout instantiation.",
271
+ );
272
+ return;
273
+ }
274
+ const url = dataUrl + "pagefind.js";
222
275
  loadingPagefind = (async () => {
223
276
  try {
224
277
  const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
@@ -237,6 +290,49 @@ const {
237
290
  return loadingPagefind;
238
291
  }
239
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
+
240
336
  function escapeHtml(s: string): string {
241
337
  return s
242
338
  .replace(/&/g, "&amp;")
@@ -313,54 +409,171 @@ const {
313
409
  resultsBox!.innerHTML = html;
314
410
  }
315
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
+
316
490
  /**
317
491
  * Build the facets sidebar. Runs once after Pagefind discovers
318
492
  * filters, then again whenever filter state changes (so checkbox
319
493
  * `checked` reflects current selections). When the corpus has no
320
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.
321
502
  */
322
503
  function renderFacets() {
323
- const facetNames = Object.keys(availableFacets);
504
+ const facetNames = sortFacetNames(
505
+ Object.keys(availableFacets),
506
+ taxonomyDisplay,
507
+ );
324
508
  if (facetNames.length === 0) {
325
509
  facetsBox!.classList.add("hidden");
326
510
  return;
327
511
  }
328
512
  facetsBox!.classList.remove("hidden");
329
513
 
514
+ facetTrees.clear();
515
+ facetNodesByValue.clear();
516
+
330
517
  const activeCount = countActiveFilters(filters);
331
518
  const clearAll = activeCount > 0
332
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>`
333
520
  : "";
334
521
 
522
+ let needsNav = false;
335
523
  const groups = facetNames
336
524
  .map((name) => {
337
525
  const entries = availableFacets[name];
338
526
  const title = escapeHtml(resolveFacetTitle(name));
339
- const items = entries
340
- .map((entry) => {
341
- const checked = (filters[name] ?? []).includes(entry.value);
342
- const label = escapeHtml(
343
- resolveFacetLabel(name, entry.value, taxonomyDisplay),
344
- );
345
- const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
346
- return `
347
- <li>
348
- <label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
349
- <input
350
- type="checkbox"
351
- id="${id}"
352
- data-facet-name="${escapeHtml(name)}"
353
- data-facet-value="${escapeHtml(entry.value)}"
354
- ${checked ? "checked" : ""}
355
- class="size-3.5 rounded border-border accent-primary"
356
- />
357
- <span class="flex-1 truncate">${label}</span>
358
- <span class="text-xs text-muted-foreground">${entry.count}</span>
359
- </label>
360
- </li>
361
- `;
362
- })
363
- .join("");
527
+ const hierarchical = isHierarchicalFacet(name);
528
+
529
+ let items: string;
530
+ if (hierarchical) {
531
+ // Every hierarchical facet wants nav.json — slash-encoded
532
+ // for sort order at each depth (so docs follow nav rather
533
+ // than count-desc), segment-encoded for the tree structure
534
+ // itself. Without nav, slash-encoded still renders a tree
535
+ // (using values alone) but in count-desc order, which is
536
+ // wrong on corpora where one branch dwarfs the rest
537
+ // (openshift's rest_api section). Fire ensureNavLoaded()
538
+ // any time a hierarchical facet renders with navData still
539
+ // null — when it resolves, renderFacets re-runs and
540
+ // upgrades the sort.
541
+ if (navData === null) needsNav = true;
542
+ const tree = buildFacetTree(name, entries, {
543
+ display: taxonomyDisplay,
544
+ nav: navData ?? undefined,
545
+ });
546
+ facetTrees.set(name, tree);
547
+ facetNodesByValue.set(name, indexTree(tree));
548
+ items = tree.map((n) => renderTreeNode(name, n)).join("");
549
+ } else {
550
+ items = entries
551
+ .map((entry) => {
552
+ const checked = (filters[name] ?? []).includes(entry.value);
553
+ const label = escapeHtml(
554
+ resolveFacetLabel(name, entry.value, taxonomyDisplay),
555
+ );
556
+ const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
557
+ return `
558
+ <li>
559
+ <label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
560
+ <input
561
+ type="checkbox"
562
+ id="${id}"
563
+ data-facet-name="${escapeHtml(name)}"
564
+ data-facet-value="${escapeHtml(entry.value)}"
565
+ ${checked ? "checked" : ""}
566
+ class="size-3.5 rounded border-border accent-primary"
567
+ />
568
+ <span class="flex-1 truncate">${label}</span>
569
+ <span class="text-xs text-muted-foreground">${entry.count}</span>
570
+ </label>
571
+ </li>
572
+ `;
573
+ })
574
+ .join("");
575
+ }
576
+
364
577
  return `
365
578
  <fieldset class="mb-3">
366
579
  <legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
@@ -371,6 +584,20 @@ const {
371
584
  .join("");
372
585
 
373
586
  facetsBox!.innerHTML = clearAll + groups;
587
+
588
+ // `indeterminate` is a JS property, not an HTML attribute —
589
+ // can't set via innerHTML. Walk new checkboxes and set it
590
+ // post-paint so the visual tri-state matches state.
591
+ const indet = facetsBox!.querySelectorAll<HTMLInputElement>(
592
+ 'input[type="checkbox"][data-tree-indeterminate="true"]',
593
+ );
594
+ for (const el of Array.from(indet)) el.indeterminate = true;
595
+
596
+ // Lazy nav fetch — fired only when at least one hierarchical
597
+ // segment-encoded facet rendered with the flat fallback. The
598
+ // fetch's resolution handler calls renderFacets() again so the
599
+ // facet upgrades to its real tree shape without user action.
600
+ if (needsNav) void ensureNavLoaded();
374
601
  }
375
602
 
376
603
  async function runSearch(query: string) {
@@ -475,13 +702,26 @@ const {
475
702
  });
476
703
 
477
704
  // Facet checkbox toggling — event delegation on the sidebar.
705
+ // Tree nodes route through toggleTreeNode (parent click expands
706
+ // to all descendants); flat checkboxes use toggleFilter as before.
707
+ // We compute state from the pre-click filter, NOT from the
708
+ // checkbox's post-click `checked` value — the browser has already
709
+ // flipped it by the time `change` fires, so reading it would
710
+ // invert our toggle direction for indeterminate parents.
478
711
  facetsBox.addEventListener("change", (e) => {
479
712
  const target = e.target as HTMLInputElement;
480
713
  if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
481
714
  const name = target.dataset.facetName;
482
715
  const value = target.dataset.facetValue;
483
716
  if (!name || !value) return;
484
- filters = toggleFilter(filters, name, value);
717
+ if (target.dataset.treeNode === "true") {
718
+ const node = facetNodesByValue.get(name)?.get(value);
719
+ if (!node) return;
720
+ const currentState = computeTreeState(node, name, filters);
721
+ filters = toggleTreeNode(filters, name, node, currentState);
722
+ } else {
723
+ filters = toggleFilter(filters, name, value);
724
+ }
485
725
  renderFacets();
486
726
  runSearch(input!.value);
487
727
  syncUrl();
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Client-side sidebar nav hydration.
3
+ *
4
+ * Fetches `/_dogsbay/nav.json` once per session, renders the tree DOM
5
+ * to mirror `@dogsbay/ui/sidebar/SidebarNavTree.astro`'s structure,
6
+ * and re-highlights the current page on each Astro view-transition
7
+ * page-load.
8
+ *
9
+ * Design constraints:
10
+ * - DOM output matches SidebarNavTree exactly (same tags, classes,
11
+ * data-attributes) so Tailwind's compiled CSS styles us correctly
12
+ * and any sidebar-system selectors (e.g. `data-sidebar="nav-tree"`
13
+ * hooks) keep working.
14
+ * - Filter by `version` / `locale` matches `nav-filter.ts`'s SSR
15
+ * filter so a multi-axis site renders the same nav as `ssr-full`
16
+ * mode (without the multiplicative HTML cost).
17
+ * - One fetch per session. The `nav.json` URL is served with
18
+ * `Cache-Control: immutable` by Astro's static build (it lives
19
+ * under `public/_dogsbay/`), so the browser cache holds it across
20
+ * navigations.
21
+ *
22
+ * See plans/client-rendered-nav.md.
23
+ */
24
+ import { filterNavByAxis } from "./nav-filter.js";
25
+
26
+ interface NavItem {
27
+ label: string;
28
+ href?: string;
29
+ icon?: string;
30
+ children?: NavItem[];
31
+ }
32
+
33
+ /**
34
+ * Module-level cache so multiple page loads in the same SPA session
35
+ * share the same fetched nav data. Cleared by re-loads (full page
36
+ * navigations) but Astro's view transitions re-execute the script
37
+ * module without reloading the page — so on view-transition the
38
+ * cached promise is reused and no extra fetch fires.
39
+ */
40
+ let navPromise: Promise<NavItem[]> | null = null;
41
+
42
+ function fetchNav(url: string): Promise<NavItem[]> {
43
+ if (!navPromise) {
44
+ navPromise = fetch(url, { credentials: "same-origin" }).then((r) => {
45
+ if (!r.ok) throw new Error(`nav.json fetch failed: ${r.status}`);
46
+ return r.json() as Promise<NavItem[]>;
47
+ });
48
+ }
49
+ return navPromise;
50
+ }
51
+
52
+ function normalize(path: string): string {
53
+ return path.replace(/\/$/, "") || "/";
54
+ }
55
+
56
+ function hasActiveDescendant(item: NavItem, current: string): boolean {
57
+ if (item.href && normalize(item.href) === current) return true;
58
+ return item.children?.some((c) => hasActiveDescendant(c, current)) ?? false;
59
+ }
60
+
61
+ /**
62
+ * Render a single nav item into a `<li>` DOM node. Matches
63
+ * SidebarNavTree's per-level class set exactly so styling stays in
64
+ * sync. Padding is computed from `level` the same way (`8 + level*12`
65
+ * pixels) so indentation lines up across the same render.
66
+ */
67
+ function renderItem(item: NavItem, current: string, level: number): HTMLLIElement {
68
+ const li = document.createElement("li");
69
+ li.dataset.sidebar = "nav-tree-item";
70
+
71
+ const active = item.href ? normalize(item.href) === current : false;
72
+ const hasChildren = !!item.children && item.children.length > 0;
73
+ const padLeft = `${8 + level * 12}px`;
74
+ const heightClass = level === 0 ? "h-8" : "h-7";
75
+
76
+ if (hasChildren) {
77
+ const details = document.createElement("details");
78
+ if (active || hasActiveDescendant(item, current)) details.open = true;
79
+
80
+ const summary = document.createElement("summary");
81
+ summary.className = [
82
+ "flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none [list-style:none] ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&::-webkit-details-marker]:hidden",
83
+ heightClass,
84
+ active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
85
+ ]
86
+ .filter(Boolean)
87
+ .join(" ");
88
+ summary.style.paddingLeft = padLeft;
89
+ if (item.href) summary.dataset.navHref = item.href;
90
+ if (active) summary.dataset.active = "true";
91
+
92
+ // Chevron SVG — matches SidebarNavTree's rotation-on-open via CSS
93
+ // (`details[open] > summary [data-chevron] { transform: rotate(90deg); }`).
94
+ summary.innerHTML =
95
+ '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
96
+
97
+ if (item.icon) {
98
+ const iconSpan = document.createElement("span");
99
+ iconSpan.className = "shrink-0 [&>svg]:size-4";
100
+ iconSpan.innerHTML = item.icon;
101
+ summary.appendChild(iconSpan);
102
+ }
103
+
104
+ const label = document.createElement("span");
105
+ label.className = "truncate";
106
+ label.textContent = item.label;
107
+ summary.appendChild(label);
108
+
109
+ details.appendChild(summary);
110
+
111
+ // Recurse — nested `<ul>` mirrors SidebarNavTree's `<Astro.self>`.
112
+ const childUl = document.createElement("ul");
113
+ childUl.className = "flex min-w-0 flex-col";
114
+ childUl.dataset.sidebar = "nav-tree";
115
+ childUl.dataset.level = String(level + 1);
116
+ for (const child of item.children!) {
117
+ childUl.appendChild(renderItem(child, current, level + 1));
118
+ }
119
+ details.appendChild(childUl);
120
+
121
+ li.appendChild(details);
122
+ } else {
123
+ const a = document.createElement("a");
124
+ a.href = item.href || "#";
125
+ a.className = [
126
+ "flex w-full min-w-0 items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2",
127
+ heightClass,
128
+ active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
129
+ ]
130
+ .filter(Boolean)
131
+ .join(" ");
132
+ a.style.paddingLeft = padLeft;
133
+ if (active) a.dataset.active = "true";
134
+ if (item.href) a.dataset.navHref = item.href;
135
+
136
+ // Spacer to align leaf text with branch text (which has a chevron).
137
+ const spacer = document.createElement("span");
138
+ spacer.className = "size-4 shrink-0";
139
+ a.appendChild(spacer);
140
+
141
+ if (item.icon) {
142
+ const iconSpan = document.createElement("span");
143
+ iconSpan.className = "shrink-0 [&>svg]:size-4";
144
+ iconSpan.innerHTML = item.icon;
145
+ a.appendChild(iconSpan);
146
+ }
147
+
148
+ const label = document.createElement("span");
149
+ label.className = "truncate";
150
+ label.textContent = item.label;
151
+ a.appendChild(label);
152
+
153
+ li.appendChild(a);
154
+ }
155
+
156
+ return li;
157
+ }
158
+
159
+ function renderTree(items: NavItem[], current: string, root: HTMLElement): void {
160
+ const ul = document.createElement("ul");
161
+ ul.className =
162
+ "flex min-w-0 flex-col w-full group-data-[collapsible=icon]:hidden";
163
+ ul.dataset.sidebar = "nav-tree";
164
+ ul.dataset.level = "0";
165
+ for (const item of items) {
166
+ ul.appendChild(renderItem(item, current, 0));
167
+ }
168
+ // Replace skeleton in one DOM op so there's no flash of partial state.
169
+ root.replaceChildren(ul);
170
+ root.removeAttribute("aria-busy");
171
+ }
172
+
173
+ /**
174
+ * Re-highlight the active item without rebuilding the whole tree.
175
+ * Used on `astro:page-load` when view transitions land on a new
176
+ * route — we re-toggle the `data-active` attribute and re-apply the
177
+ * active classes, then expand the new active branch's ancestors.
178
+ *
179
+ * Cheaper than a full re-render (no fetch, no DOM rebuild). For
180
+ * the wider hydration loop we still re-render when a brand-new nav
181
+ * structure is needed (e.g. switching versions), but path-only
182
+ * navigation just re-highlights.
183
+ */
184
+ function rehighlight(root: HTMLElement, current: string): void {
185
+ const ACTIVE_CLASSES = [
186
+ "bg-sidebar-accent",
187
+ "font-medium",
188
+ "text-sidebar-accent-foreground",
189
+ ];
190
+ const items = root.querySelectorAll<HTMLElement>("[data-nav-href]");
191
+ for (const el of Array.from(items)) {
192
+ const href = el.dataset.navHref;
193
+ const active = !!href && normalize(href) === current;
194
+ if (active) {
195
+ el.dataset.active = "true";
196
+ el.classList.add(...ACTIVE_CLASSES);
197
+ } else {
198
+ delete el.dataset.active;
199
+ el.classList.remove(...ACTIVE_CLASSES);
200
+ }
201
+ }
202
+ // Expand ancestors of the new active item.
203
+ const active = root.querySelector<HTMLElement>('[data-active="true"]');
204
+ if (active) {
205
+ let parent: HTMLElement | null = active.parentElement;
206
+ while (parent) {
207
+ if (parent.tagName === "DETAILS") {
208
+ (parent as HTMLDetailsElement).open = true;
209
+ }
210
+ parent = parent.parentElement;
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Public entry point. Called once from `<DocsNavClient />`'s inline
217
+ * script tag. Idempotent — subsequent calls (e.g. from view
218
+ * transitions) are no-ops once the tree has been rendered; they just
219
+ * re-highlight against the new pathname.
220
+ */
221
+ export async function hydrateDocsNav(): Promise<void> {
222
+ const root = document.getElementById("docs-nav-root");
223
+ if (!root) return;
224
+
225
+ const navUrl = root.dataset.navUrl;
226
+ if (!navUrl) {
227
+ console.warn("docs-nav: missing data-nav-url");
228
+ return;
229
+ }
230
+ const current = normalize(root.dataset.currentPath || location.pathname);
231
+ const basePath = root.dataset.basePath || "";
232
+ const version = root.dataset.version || undefined;
233
+ const locale = root.dataset.locale || undefined;
234
+
235
+ try {
236
+ const nav = await fetchNav(navUrl);
237
+ const filtered = filterNavByAxis(nav, {
238
+ basePath: basePath || "/docs",
239
+ version: version || undefined,
240
+ locale: locale || undefined,
241
+ });
242
+ renderTree(filtered, current, root);
243
+ } catch (err) {
244
+ console.error("docs-nav: hydration failed", err);
245
+ root.setAttribute("aria-busy", "false");
246
+ // Keep skeleton so layout doesn't collapse on failure; a real
247
+ // user will reload or follow the noscript link.
248
+ }
249
+ }
250
+
251
+ // Re-run on view transitions. astro:page-load fires both on initial
252
+ // load and after each ClientRouter transition; the module-level
253
+ // `navPromise` cache makes the post-transition path a fast
254
+ // highlight-only pass without re-fetching.
255
+ document.addEventListener("astro:page-load", () => {
256
+ const root = document.getElementById("docs-nav-root");
257
+ if (!root) return;
258
+ // If tree is already rendered (no aria-busy), just re-highlight.
259
+ if (root.getAttribute("aria-busy") === null) {
260
+ const current = normalize(root.dataset.currentPath || location.pathname);
261
+ rehighlight(root, current);
262
+ } else {
263
+ void hydrateDocsNav();
264
+ }
265
+ });