@dogsbay/docs-layout 0.2.0-beta.14 → 0.2.0-beta.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ ---
2
+ /**
3
+ * Client-rendered drop-in replacement for SidebarNavTree.
4
+ *
5
+ * Emits an empty placeholder with the metadata the hydration script
6
+ * needs (nav-url to fetch, current-path to highlight, basePath for
7
+ * version/locale filtering). The actual tree DOM is rendered by
8
+ * `docs-nav-client.ts` after a single fetch of `nav.json` (cached
9
+ * once per session — subsequent navigations re-highlight without
10
+ * re-fetching, courtesy of view transitions).
11
+ *
12
+ * The placeholder shows a few skeleton rows so the layout doesn't
13
+ * shift when the real tree pops in. Skeleton uses the same width as
14
+ * the sidebar so visual jump is minimal even on a slow connection.
15
+ *
16
+ * Trade-offs vs SidebarNavTree:
17
+ * - HTML per page: ~200 bytes (this placeholder + a tiny script
18
+ * tag) vs ~600 KB+ for the SSR tree at scale.
19
+ * - No-JS users see only the skeleton + the `<noscript>` fallback
20
+ * link. A `sitemap-index.xml` link covers no-JS navigation —
21
+ * note `emitSitemapFiles` only runs when `site.url` is a valid
22
+ * http(s) URL, so a site without one has no sitemap for this
23
+ * fallback to reach.
24
+ * - First paint waits for the JS bundle + the JSON fetch. On a 4G
25
+ * connection that's typically <200 ms; the skeleton fills the
26
+ * space until then.
27
+ *
28
+ * See plans/client-rendered-nav.md.
29
+ */
30
+ interface Props {
31
+ /** Current page's URL pathname; the script uses it to mark the active item. */
32
+ currentPath: string;
33
+ /**
34
+ * URL prefix the host serves under (combined `urlBase` + `basePath`).
35
+ * The script joins this with `/_dogsbay/nav.json` to locate the
36
+ * fetchable nav tree; also threaded to the version/locale filter
37
+ * so multi-axis sites work the same as SSR.
38
+ */
39
+ basePath?: string;
40
+ /** Current source's product/namespace, if multi-product site. */
41
+ namespace?: string;
42
+ /** Current source's version axis value, if multi-version site. */
43
+ version?: string;
44
+ /** Current source's locale axis value, if multi-locale site. */
45
+ locale?: string;
46
+ /** The version served at the site root (`path: ""`), if any. */
47
+ rootVersion?: string;
48
+ /** The other versions' URL segments (left out of the root version's nav). */
49
+ versionSegments?: string[];
50
+ }
51
+
52
+ const { currentPath, basePath = "", namespace, version, locale, rootVersion, versionSegments } = Astro.props;
53
+ const navUrl = `${basePath}/_dogsbay/nav.json`;
54
+ ---
55
+
56
+ {/*
57
+ `role="navigation"` is REQUIRED here, not decoration: `aria-label` and
58
+ `aria-busy` are prohibited on a generic div (axe: aria-prohibited-attr,
59
+ serious) because a role-less element has no accessible name to label.
60
+ Giving the nav container its real role makes both attributes legal and
61
+ makes the landmark discoverable — it was previously neither.
62
+ */}
63
+ <div
64
+ id="docs-nav-root"
65
+ role="navigation"
66
+ data-nav-url={navUrl}
67
+ data-current-path={currentPath}
68
+ data-base-path={basePath}
69
+ data-namespace={namespace ?? ""}
70
+ data-version={version ?? ""}
71
+ data-locale={locale ?? ""}
72
+ data-root-version={rootVersion ?? ""}
73
+ data-version-segments={(versionSegments ?? []).join(" ")}
74
+ aria-busy="true"
75
+ aria-label="Documentation navigation"
76
+ >
77
+ <ul class="flex min-w-0 flex-col" data-sidebar="nav-tree" data-level="0">
78
+ {[0, 1, 2, 3, 4, 5].map((i) => (
79
+ <li>
80
+ <div
81
+ class="flex h-8 w-full items-center gap-2 rounded-md px-2"
82
+ aria-hidden="true"
83
+ >
84
+ <div class="h-2 w-2 rounded-full bg-sidebar-foreground/10" />
85
+ <div
86
+ class="h-2 rounded bg-sidebar-foreground/10"
87
+ style={`width: ${50 + ((i * 17) % 35)}%;`}
88
+ />
89
+ </div>
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ <noscript>
94
+ <p class="px-2 py-1.5 text-sm text-sidebar-foreground/70">
95
+ JavaScript is required to render the sidebar. Use the
96
+ {/* sitemap-index.xml, not sitemap.xml — Dogsbay emits the
97
+ sitemap-index / sitemap-0 pair directly (see emitSitemapFiles);
98
+ `sitemap.xml` has never existed, so this no-JS fallback link
99
+ 404'd on every site. */}
100
+ <a href={`${basePath}/sitemap-index.xml`} class="underline">sitemap</a>
101
+ to browse all pages.
102
+ </p>
103
+ </noscript>
104
+ </div>
105
+
106
+ <script>
107
+ // Single import — Astro/Vite bundles this into one shared chunk
108
+ // referenced from every page that uses DocsNavClient. Browsers
109
+ // cache the chunk, so the nav script ships once per session
110
+ // regardless of how many pages the user visits.
111
+ import { hydrateDocsNav } from "./docs-nav-client.ts";
112
+ hydrateDocsNav();
113
+ </script>
package/src/DocsToc.astro CHANGED
@@ -33,7 +33,7 @@ const filtered = headings.filter(h => h.depth >= minDepth && h.depth <= maxDepth
33
33
 
34
34
  {filtered.length > 0 && (
35
35
  <nav class:list={["text-sm", className]} aria-label="Table of contents">
36
- <div class="text-xs font-semibold uppercase text-muted-foreground">{title}</div>
36
+ {title && <div class="text-xs font-semibold uppercase text-muted-foreground">{title}</div>}
37
37
  <ul class="mt-2 space-y-1">
38
38
  {filtered.map(h => (
39
39
  <li>
@@ -28,10 +28,35 @@ 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
+ * The base Pagefind prefixes result URLs with. Pagefind's own default is
42
+ * the folder above the bundle, which is right for GitHub Pages project
43
+ * sites and doubles the prefix on Workers subpath mounts (their index
44
+ * already carries the mount). format-astro computes the right value per
45
+ * layout; when absent, Pagefind's default is left alone. See
46
+ * `pagefind-base-url.ts`.
47
+ */
48
+ pagefindBaseUrl?: string;
49
+ /**
50
+ * URL of the site's `nav.json` (typically `${basePath}/_dogsbay/nav.json`).
51
+ * Used to render hierarchical facets in document order: when the
52
+ * `category` facet (or any taxonomy flagged `hierarchical: true`)
53
+ * is segment-encoded — i.e. its values are individual path segments
54
+ * not slash-joined — the tree shape is derived from nav. Without
55
+ * `navUrl`, hierarchical segment-encoded facets fall back to a flat
56
+ * list (still functional, just no tree structure). Slash-encoded
57
+ * taxonomies don't need nav and build trees from their values directly.
58
+ */
59
+ navUrl?: string;
35
60
  /** Placeholder text for the search input */
36
61
  placeholder?: string;
37
62
  /**
@@ -41,19 +66,35 @@ interface Props {
41
66
  * back to slugs when undefined.
42
67
  */
43
68
  taxonomyDisplay?: Record<string, TaxonomyDisplay>;
69
+ /**
70
+ * Current page's product (namespace) and version. On a multi-product /
71
+ * versioned site, search opens PRE-SCOPED to these — a Calico 3.32 page
72
+ * searches Calico 3.32 by default. The scope is seeded as normal facet
73
+ * selections, so the reader can untick them to search wider.
74
+ */
75
+ scopeProduct?: string;
76
+ scopeVersion?: string;
44
77
  }
45
78
 
46
79
  const {
47
- pagefindUrl = "/pagefind/",
80
+ pagefindUrl,
81
+ pagefindBaseUrl,
82
+ navUrl,
48
83
  placeholder = "Search docs...",
49
84
  taxonomyDisplay,
85
+ scopeProduct,
86
+ scopeVersion,
50
87
  } = Astro.props;
51
88
  ---
52
89
 
53
90
  <dialog
54
91
  data-search-dialog
55
92
  data-pagefind-url={pagefindUrl}
93
+ data-pagefind-base-url={pagefindBaseUrl}
94
+ data-nav-url={navUrl}
56
95
  data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
96
+ data-scope-product={scopeProduct ?? ""}
97
+ data-scope-version={scopeVersion ?? ""}
57
98
  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
99
  >
59
100
  <form method="dialog" class="flex flex-col">
@@ -156,15 +197,22 @@ const {
156
197
  shapeFacets,
157
198
  resolveFacetLabel,
158
199
  resolveFacetTitle,
200
+ sortFacetNames,
159
201
  filterStateToUrlParams,
160
202
  parseFiltersFromUrl,
161
203
  filterStateToPagefindFilters,
162
204
  toggleFilter,
163
205
  countActiveFilters,
206
+ buildFacetTree,
207
+ computeTreeState,
208
+ toggleTreeNode,
164
209
  type FacetMap,
210
+ type FacetTreeNode,
165
211
  type FilterState,
212
+ type NavLike,
166
213
  type TaxonomyDisplayMap,
167
214
  } from "./search-facets.js";
215
+ import { applyPagefindBaseUrl } from "./pagefind-base-url.js";
168
216
 
169
217
  type PagefindResult = {
170
218
  id: string;
@@ -177,15 +225,24 @@ const {
177
225
  meta: { title?: string };
178
226
  sub_results?: Array<{ title: string; url: string; excerpt: string }>;
179
227
  };
228
+ // Filter values match what filterStateToPagefindFilters emits:
229
+ // each facet wrapped in `{any: [...]}` for OR-within-facet semantics.
230
+ // Pagefind also accepts other operator shapes (`all`/`none`/`not`,
231
+ // bare strings, bare arrays) but we only emit the `any` form.
232
+ type PagefindFilterValue = { any: string[] };
180
233
  type PagefindModule = {
181
234
  search(
182
235
  query: string,
183
- options?: { filters?: Record<string, string[]> },
236
+ options?: { filters?: Record<string, PagefindFilterValue> },
184
237
  ): Promise<{ results: PagefindResult[] }>;
185
238
  filters(): Promise<Record<string, Record<string, number>>>;
239
+ options?(opts: { baseUrl?: string }): Promise<void>;
186
240
  };
187
241
 
188
242
  const dialog = document.querySelector<HTMLDialogElement>("[data-search-dialog]");
243
+ // Current page's product/version — search opens pre-scoped to these.
244
+ const scopeProduct = dialog?.dataset.scopeProduct || "";
245
+ const scopeVersion = dialog?.dataset.scopeVersion || "";
189
246
  const trigger = document.querySelector<HTMLButtonElement>("[data-search-trigger]");
190
247
  const input = dialog?.querySelector<HTMLInputElement>("[data-search-input]");
191
248
  const resultsBox = dialog?.querySelector<HTMLDivElement>("[data-search-results]");
@@ -204,6 +261,15 @@ const {
204
261
  let filters: FilterState = {};
205
262
  let availableFacets: FacetMap = {};
206
263
 
264
+ // Nav data cached after first fetch — used to derive the
265
+ // segment-encoded hierarchical-facet tree (the auto-`category`
266
+ // case). Null while pending or absent. The fetch fires lazily
267
+ // the first time renderFacets() encounters a hierarchical facet,
268
+ // not eagerly on dialog open, so sites without hierarchical
269
+ // facets never pay for it.
270
+ let navData: NavLike[] | null = null;
271
+ let loadingNav: Promise<void> | null = null;
272
+
207
273
  // Display config baked into a data attribute by the Astro
208
274
  // template — parsed lazily.
209
275
  const taxonomyDisplay: TaxonomyDisplayMap = (() => {
@@ -218,10 +284,28 @@ const {
218
284
  async function ensurePagefindLoaded() {
219
285
  if (pagefind) return;
220
286
  if (loadingPagefind) return loadingPagefind;
221
- const url = (dialog!.dataset.pagefindUrl ?? "/pagefind/") + "pagefind.js";
287
+ // pagefindUrl is required — a "/pagefind/" fallback would
288
+ // silently 404 on subpath-mounted deploys (GH Pages project
289
+ // pages, multi-mount Cloudflare). The emitter always passes
290
+ // a combined-prefix-aware value via data-pagefind-url. If it's
291
+ // missing the page wasn't built through format-astro and the
292
+ // caller forgot to pass it.
293
+ const dataUrl = dialog!.dataset.pagefindUrl;
294
+ if (!dataUrl) {
295
+ console.error(
296
+ "[dogsbay] SearchDialog: pagefindUrl prop missing. " +
297
+ "Pass the combined-prefix path (e.g. '/<base>/pagefind/') " +
298
+ "from your DocsLayout instantiation.",
299
+ );
300
+ return;
301
+ }
302
+ const url = dataUrl + "pagefind.js";
222
303
  loadingPagefind = (async () => {
223
304
  try {
224
305
  const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
306
+ // Before the first search: result URLs are built with this base.
307
+ // Without it a Workers subpath mount links to /<mount>/<mount>/page/.
308
+ await applyPagefindBaseUrl(mod, dialog!.dataset.pagefindBaseUrl);
225
309
  pagefind = mod;
226
310
  // Discover facets once, on first load. Pagefind builds
227
311
  // this from the body-level data-pagefind-filter elements.
@@ -237,6 +321,49 @@ const {
237
321
  return loadingPagefind;
238
322
  }
239
323
 
324
+ /**
325
+ * Lazy nav.json fetch, kicked off the first time a hierarchical
326
+ * facet is rendered. Mirrors the pattern in `docs-nav-client.ts` —
327
+ * one fetch per session, `same-origin` credentials so cookied
328
+ * mounts work, module-level cache.
329
+ *
330
+ * When the fetch resolves we re-call `renderFacets()` so the
331
+ * previously-flat segment-encoded facet upgrades to its tree
332
+ * shape — without this, a slow nav fetch would leave the facets
333
+ * stuck on the flat fallback until the user toggled a filter.
334
+ *
335
+ * Network errors are logged and swallowed; the helper's flat
336
+ * fallback keeps the dialog functional.
337
+ */
338
+ async function ensureNavLoaded() {
339
+ if (navData !== null) return;
340
+ if (loadingNav) return loadingNav;
341
+ const url = dialog!.dataset.navUrl;
342
+ if (!url) return; // navUrl not provided — flat fallback stays
343
+ loadingNav = (async () => {
344
+ try {
345
+ const res = await fetch(url, { credentials: "same-origin" });
346
+ if (!res.ok) throw new Error(`nav.json fetch failed: ${res.status}`);
347
+ const data = (await res.json()) as unknown;
348
+ navData = Array.isArray(data) ? (data as NavLike[]) : [];
349
+ // Re-render so hierarchical segment-encoded facets pick up
350
+ // the nav shape. Cheap — no Pagefind round-trip, just a
351
+ // DOM rebuild from `availableFacets`.
352
+ if (Object.keys(availableFacets).length > 0) {
353
+ renderFacets();
354
+ }
355
+ } catch (err) {
356
+ console.warn(
357
+ "[dogsbay] failed to load nav.json (hierarchical facets fall back to flat list):",
358
+ err,
359
+ );
360
+ // Mark as loaded-with-empty so we don't keep retrying.
361
+ navData = [];
362
+ }
363
+ })();
364
+ return loadingNav;
365
+ }
366
+
240
367
  function escapeHtml(s: string): string {
241
368
  return s
242
369
  .replace(/&/g, "&amp;")
@@ -313,54 +440,171 @@ const {
313
440
  resultsBox!.innerHTML = html;
314
441
  }
315
442
 
443
+ /**
444
+ * Hierarchical-facet caches, rebuilt on every renderFacets() so
445
+ * they stay aligned with the current filter state + available
446
+ * facets. The trees are the source for `value → node` lookups
447
+ * during click handling (parent click expands selection to all
448
+ * descendants — needs the node to know what to add).
449
+ */
450
+ const facetTrees = new Map<string, FacetTreeNode[]>();
451
+ const facetNodesByValue = new Map<string, Map<string, FacetTreeNode>>();
452
+
453
+ /**
454
+ * Decide whether a facet renders as a tree. `category` defaults
455
+ * to hierarchical (auto-derived path segments are the canonical
456
+ * use case); explicit override via `taxonomyDisplay[name].hierarchical`
457
+ * wins both ways.
458
+ */
459
+ function isHierarchicalFacet(name: string): boolean {
460
+ const flag = taxonomyDisplay[name]?.hierarchical;
461
+ if (typeof flag === "boolean") return flag;
462
+ return name === "category";
463
+ }
464
+
465
+ /**
466
+ * Flatten a tree into a `value → node` map so the click handler
467
+ * can look up the clicked node without re-walking the tree.
468
+ */
469
+ function indexTree(nodes: FacetTreeNode[]): Map<string, FacetTreeNode> {
470
+ const out = new Map<string, FacetTreeNode>();
471
+ const visit = (n: FacetTreeNode): void => {
472
+ out.set(n.value, n);
473
+ for (const c of n.children) visit(c);
474
+ };
475
+ for (const r of nodes) visit(r);
476
+ return out;
477
+ }
478
+
479
+ /**
480
+ * Recursive HTML for one tree node. Synthetic parents (`hasValue:
481
+ * false`) render as a clickable group header — checking them
482
+ * still cascades to descendants via toggleTreeNode. Indent scales
483
+ * with `node.depth`.
484
+ */
485
+ function renderTreeNode(name: string, node: FacetTreeNode): string {
486
+ const state = computeTreeState(node, name, filters);
487
+ const id = `facet-${name}-${node.value}`.replace(/[^a-z0-9-]/gi, "-");
488
+ const indent = node.depth * 12;
489
+ const label = escapeHtml(node.label);
490
+ const countText = node.hasValue ? `${node.count}` : "";
491
+ const stateAttr =
492
+ state === "checked" ? "checked" : state === "indeterminate" ? 'data-tree-indeterminate="true"' : "";
493
+ const childrenHtml = node.children
494
+ .map((c) => renderTreeNode(name, c))
495
+ .join("");
496
+ return `
497
+ <li>
498
+ <label
499
+ for="${id}"
500
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent"
501
+ style="padding-left: ${0.5 + indent / 16}rem"
502
+ >
503
+ <input
504
+ type="checkbox"
505
+ id="${id}"
506
+ data-facet-name="${escapeHtml(name)}"
507
+ data-facet-value="${escapeHtml(node.value)}"
508
+ data-tree-node="true"
509
+ ${state === "checked" ? "checked" : ""}
510
+ ${state === "indeterminate" ? 'data-tree-indeterminate="true"' : ""}
511
+ class="size-3.5 rounded border-border accent-primary"
512
+ />
513
+ <span class="flex-1 truncate">${label}</span>
514
+ <span class="text-xs text-muted-foreground">${countText}</span>
515
+ </label>
516
+ ${childrenHtml ? `<ul class="space-y-0.5">${childrenHtml}</ul>` : ""}
517
+ </li>
518
+ `;
519
+ }
520
+
316
521
  /**
317
522
  * Build the facets sidebar. Runs once after Pagefind discovers
318
523
  * filters, then again whenever filter state changes (so checkbox
319
524
  * `checked` reflects current selections). When the corpus has no
320
525
  * filters, the sidebar stays hidden — single-column layout.
526
+ *
527
+ * Branches per facet: hierarchical taxonomies (and the built-in
528
+ * `category` default) render as a tree; flat taxonomies keep the
529
+ * original checkbox-list shape. The hierarchical path falls back
530
+ * to a flat list automatically when segment-encoded values lack a
531
+ * `nav` source — preserves render-ability until Phase 3 wires the
532
+ * nav.json fetch.
321
533
  */
322
534
  function renderFacets() {
323
- const facetNames = Object.keys(availableFacets);
535
+ const facetNames = sortFacetNames(
536
+ Object.keys(availableFacets),
537
+ taxonomyDisplay,
538
+ );
324
539
  if (facetNames.length === 0) {
325
540
  facetsBox!.classList.add("hidden");
326
541
  return;
327
542
  }
328
543
  facetsBox!.classList.remove("hidden");
329
544
 
545
+ facetTrees.clear();
546
+ facetNodesByValue.clear();
547
+
330
548
  const activeCount = countActiveFilters(filters);
331
549
  const clearAll = activeCount > 0
332
550
  ? `<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
551
  : "";
334
552
 
553
+ let needsNav = false;
335
554
  const groups = facetNames
336
555
  .map((name) => {
337
556
  const entries = availableFacets[name];
338
557
  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("");
558
+ const hierarchical = isHierarchicalFacet(name);
559
+
560
+ let items: string;
561
+ if (hierarchical) {
562
+ // Every hierarchical facet wants nav.json — slash-encoded
563
+ // for sort order at each depth (so docs follow nav rather
564
+ // than count-desc), segment-encoded for the tree structure
565
+ // itself. Without nav, slash-encoded still renders a tree
566
+ // (using values alone) but in count-desc order, which is
567
+ // wrong on corpora where one branch dwarfs the rest
568
+ // (openshift's rest_api section). Fire ensureNavLoaded()
569
+ // any time a hierarchical facet renders with navData still
570
+ // null — when it resolves, renderFacets re-runs and
571
+ // upgrades the sort.
572
+ if (navData === null) needsNav = true;
573
+ const tree = buildFacetTree(name, entries, {
574
+ display: taxonomyDisplay,
575
+ nav: navData ?? undefined,
576
+ });
577
+ facetTrees.set(name, tree);
578
+ facetNodesByValue.set(name, indexTree(tree));
579
+ items = tree.map((n) => renderTreeNode(name, n)).join("");
580
+ } else {
581
+ items = entries
582
+ .map((entry) => {
583
+ const checked = (filters[name] ?? []).includes(entry.value);
584
+ const label = escapeHtml(
585
+ resolveFacetLabel(name, entry.value, taxonomyDisplay),
586
+ );
587
+ const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
588
+ return `
589
+ <li>
590
+ <label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
591
+ <input
592
+ type="checkbox"
593
+ id="${id}"
594
+ data-facet-name="${escapeHtml(name)}"
595
+ data-facet-value="${escapeHtml(entry.value)}"
596
+ ${checked ? "checked" : ""}
597
+ class="size-3.5 rounded border-border accent-primary"
598
+ />
599
+ <span class="flex-1 truncate">${label}</span>
600
+ <span class="text-xs text-muted-foreground">${entry.count}</span>
601
+ </label>
602
+ </li>
603
+ `;
604
+ })
605
+ .join("");
606
+ }
607
+
364
608
  return `
365
609
  <fieldset class="mb-3">
366
610
  <legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
@@ -371,6 +615,20 @@ const {
371
615
  .join("");
372
616
 
373
617
  facetsBox!.innerHTML = clearAll + groups;
618
+
619
+ // `indeterminate` is a JS property, not an HTML attribute —
620
+ // can't set via innerHTML. Walk new checkboxes and set it
621
+ // post-paint so the visual tri-state matches state.
622
+ const indet = facetsBox!.querySelectorAll<HTMLInputElement>(
623
+ 'input[type="checkbox"][data-tree-indeterminate="true"]',
624
+ );
625
+ for (const el of Array.from(indet)) el.indeterminate = true;
626
+
627
+ // Lazy nav fetch — fired only when at least one hierarchical
628
+ // segment-encoded facet rendered with the flat fallback. The
629
+ // fetch's resolution handler calls renderFacets() again so the
630
+ // facet upgrades to its real tree shape without user action.
631
+ if (needsNav) void ensureNavLoaded();
374
632
  }
375
633
 
376
634
  async function runSearch(query: string) {
@@ -425,9 +683,22 @@ const {
425
683
  const fromUrl = parseFiltersFromUrl(new URLSearchParams(window.location.search));
426
684
  input!.value = fromUrl.query;
427
685
  filters = fromUrl.filters;
686
+ const hadUrlState = fromUrl.query.length > 0 || countActiveFilters(filters) > 0;
687
+ // Auto-scope: a FRESH open (no filters/query carried in the URL) on a
688
+ // multi-product/versioned site starts scoped to the CURRENT product +
689
+ // version. Seeded as ordinary facet selections, so the reader can
690
+ // untick "Product: calico" / "Version: 3.32" to search wider. A URL
691
+ // that already carries state wins (shared/roundtripped searches).
692
+ if (!hadUrlState) {
693
+ if (scopeProduct) filters.product = [scopeProduct];
694
+ if (scopeVersion) filters.version = [scopeVersion];
695
+ }
428
696
  renderFacets();
429
697
 
430
- const hasInitial = input!.value.length > 0 || countActiveFilters(filters) > 0;
698
+ // Run immediately only when there's a query or the state came from the
699
+ // URL. A fresh open shows the empty prompt with the scope pre-ticked —
700
+ // results appear (scoped) as soon as the reader types.
701
+ const hasInitial = hadUrlState;
431
702
  if (hasInitial) {
432
703
  runSearch(input!.value);
433
704
  } else {
@@ -475,13 +746,26 @@ const {
475
746
  });
476
747
 
477
748
  // Facet checkbox toggling — event delegation on the sidebar.
749
+ // Tree nodes route through toggleTreeNode (parent click expands
750
+ // to all descendants); flat checkboxes use toggleFilter as before.
751
+ // We compute state from the pre-click filter, NOT from the
752
+ // checkbox's post-click `checked` value — the browser has already
753
+ // flipped it by the time `change` fires, so reading it would
754
+ // invert our toggle direction for indeterminate parents.
478
755
  facetsBox.addEventListener("change", (e) => {
479
756
  const target = e.target as HTMLInputElement;
480
757
  if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
481
758
  const name = target.dataset.facetName;
482
759
  const value = target.dataset.facetValue;
483
760
  if (!name || !value) return;
484
- filters = toggleFilter(filters, name, value);
761
+ if (target.dataset.treeNode === "true") {
762
+ const node = facetNodesByValue.get(name)?.get(value);
763
+ if (!node) return;
764
+ const currentState = computeTreeState(node, name, filters);
765
+ filters = toggleTreeNode(filters, name, node, currentState);
766
+ } else {
767
+ filters = toggleFilter(filters, name, value);
768
+ }
485
769
  renderFacets();
486
770
  runSearch(input!.value);
487
771
  syncUrl();
package/src/TagList.astro CHANGED
@@ -48,6 +48,11 @@ interface Props {
48
48
  * text. URL still uses the slug.
49
49
  */
50
50
  labels?: Record<string, string>;
51
+ /**
52
+ * Give chips whose prefix has no color a stable palette color,
53
+ * hashed from the tag's first segment. Default false.
54
+ */
55
+ autoColor?: boolean;
51
56
  /** Optional aria-label override; default "Tags". */
52
57
  label?: string;
53
58
  class?: string;
@@ -58,11 +63,12 @@ const {
58
63
  indexPath = "/tags",
59
64
  prefixes,
60
65
  labels,
66
+ autoColor = false,
61
67
  label: ariaLabel = "Tags",
62
68
  class: className,
63
69
  } = Astro.props;
64
70
 
65
- const chips = buildChips(tags, { indexPath, prefixes, labels });
71
+ const chips = buildChips(tags, { indexPath, prefixes, labels, autoColor });
66
72
 
67
73
  /**
68
74
  * Closed palette → Tailwind class strings. Each entry is one
@@ -72,8 +78,13 @@ const chips = buildChips(tags, { indexPath, prefixes, labels });
72
78
  */
73
79
  const PALETTE: Record<TagPaletteName, string> = {
74
80
  blue: "border-blue-500/40 bg-blue-500/15 text-blue-700 dark:text-blue-300",
81
+ // amber-800, not -700, in light mode: amber-700 on amber-500/15 is
82
+ // 4.48:1 — under 4.5:1 by a hair, and failing regardless of the
83
+ // opacity bug below. -800 gives 6.32:1. Every other colour clears it
84
+ // at -700 (4.76–8.60:1). Dark mode is unaffected: text-*-300 on the
85
+ // same tint over a dark page is 9–12:1 across the palette.
75
86
  amber:
76
- "border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300",
87
+ "border-amber-500/40 bg-amber-500/15 text-amber-800 dark:text-amber-300",
77
88
  emerald:
78
89
  "border-emerald-500/40 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
79
90
  violet:
@@ -108,7 +119,17 @@ const DEFAULT_CLASS =
108
119
  >
109
120
  {chip.prefixLabel ? (
110
121
  <Fragment>
111
- <span class="opacity-70">{chip.prefixLabel}:</span>
122
+ {/*
123
+ No opacity here. `opacity-70` on the prefix blended the
124
+ text 30% toward the chip background and put EVERY colour
125
+ in the palette below 4.5:1 — measured 2.75–3.95:1, worst
126
+ amber, best slate. It is 12px text, so 4.5:1 applies.
127
+
128
+ The prefix/leaf distinction is carried by font-weight
129
+ instead: the leaf below is font-semibold against the
130
+ anchor's font-medium. Weight costs no contrast.
131
+ */}
132
+ <span>{chip.prefixLabel}:</span>
112
133
  <span class="font-semibold">{chip.leafLabel}</span>
113
134
  </Fragment>
114
135
  ) : (