@dogsbay/docs-layout 0.2.0-beta.8 → 0.2.0-beta.81

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.
@@ -16,6 +16,33 @@ import type { PrefixDisplay } from "./tag-list-data.js";
16
16
  export interface TaxonomyDisplay {
17
17
  prefixes?: Record<string, PrefixDisplay>;
18
18
  labels?: Record<string, string>;
19
+ /**
20
+ * Sort weight for the facet column in the search dialog. Lower
21
+ * numbers appear first. Facets without an `order` sort
22
+ * alphabetically *after* any pinned ones — so `{ type: { order: 1 } }`
23
+ * promotes "Type" to the top while everything else stays alpha.
24
+ */
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[];
19
46
  }
20
47
 
21
48
  /** Map of taxonomy name → display config. */
@@ -120,6 +147,40 @@ export function resolveFacetTitle(facetName: string): string {
120
147
  .replace(/\b\w/g, (c) => c.toUpperCase());
121
148
  }
122
149
 
150
+ /**
151
+ * Return facet names in sidebar render order.
152
+ *
153
+ * Pagefind's `filters()` map iterates in index-discovery order
154
+ * (effectively the first page Pagefind happened to read), so without
155
+ * sorting the column order is arbitrary and shifts between builds.
156
+ * Order rule:
157
+ * 1. Facets with a numeric `order` in their `TaxonomyDisplay`
158
+ * come first, ascending — use this to pin "Type" or "Audience"
159
+ * to the top regardless of name.
160
+ * 2. Everything else sorts alphabetically by facet name.
161
+ * Stable within each tier so ties don't shuffle.
162
+ */
163
+ export function sortFacetNames(
164
+ names: string[],
165
+ display?: TaxonomyDisplayMap,
166
+ ): string[] {
167
+ const withOrder = (name: string): number | undefined => {
168
+ const o = display?.[name]?.order;
169
+ return typeof o === "number" ? o : undefined;
170
+ };
171
+ return [...names].sort((a, b) => {
172
+ const oa = withOrder(a);
173
+ const ob = withOrder(b);
174
+ if (oa !== undefined && ob !== undefined) {
175
+ if (oa !== ob) return oa - ob;
176
+ return a.localeCompare(b);
177
+ }
178
+ if (oa !== undefined) return -1;
179
+ if (ob !== undefined) return 1;
180
+ return a.localeCompare(b);
181
+ });
182
+ }
183
+
123
184
  // ── URL persistence ──────────────────────────────────────────────
124
185
 
125
186
  /**
@@ -175,21 +236,32 @@ export function parseFiltersFromUrl(
175
236
  }
176
237
 
177
238
  /**
178
- * Pagefind's `search()` accepts filters keyed by facet name with
179
- * either a single value, an array (OR), or a nested operator
180
- * object. We always pass the array form so multi-select works
181
- * within a facet without special-casing single-select.
239
+ * Build Pagefind's `filters` argument from internal facet state.
240
+ *
241
+ * Pagefind's array shape (`{ tag: ["a", "b"] }`) is AND by default —
242
+ * "page must have BOTH tags" — per
243
+ * https://pagefind.app/docs/js-api-filtering/ ("All filtering
244
+ * defaults to AND filtering"). That's the wrong UX for faceted
245
+ * search: clicking two checkboxes in the same group should widen
246
+ * the result set, not collapse it to zero. We wrap each facet in
247
+ * `{ any: [...] }` so multi-select within a facet is OR. Across
248
+ * different facets Pagefind already ANDs the keys, which matches
249
+ * the standard "narrow with each additional dimension" UX.
250
+ *
251
+ * Single-value selections also go through `{ any: ["x"] }` —
252
+ * Pagefind treats a one-element `any` identically to a bare string,
253
+ * so there's no behavioural delta and the code stays branch-free.
182
254
  *
183
255
  * Empty filter state → `{}` (Pagefind returns the unfiltered
184
- * result set). A facet with an empty array also drops out so we
185
- * don't accidentally narrow to "must equal nothing".
256
+ * result set). A facet with an empty array drops out so we don't
257
+ * accidentally narrow to "must equal nothing".
186
258
  */
187
259
  export function filterStateToPagefindFilters(
188
260
  filters: FilterState,
189
- ): Record<string, string[]> {
190
- const out: Record<string, string[]> = {};
261
+ ): Record<string, { any: string[] }> {
262
+ const out: Record<string, { any: string[] }> = {};
191
263
  for (const [name, values] of Object.entries(filters)) {
192
- if (values.length > 0) out[name] = [...values];
264
+ if (values.length > 0) out[name] = { any: [...values] };
193
265
  }
194
266
  return out;
195
267
  }
@@ -230,3 +302,433 @@ export function countActiveFilters(filters: FilterState): number {
230
302
  for (const values of Object.values(filters)) n += values.length;
231
303
  return n;
232
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
+ // When nav is supplied, derive document-order. Otherwise fall
380
+ // back to count-desc + alpha. The map keys are full cumulative
381
+ // paths (matching Pagefind entry values 1:1).
382
+ const navOrder = options.nav
383
+ ? buildNavOrderMap(
384
+ options.nav,
385
+ new Set(entries.map((e) => e.value)),
386
+ )
387
+ : undefined;
388
+ return buildSlashTree(facetName, entries, options.display, navOrder);
389
+ }
390
+ return buildSegmentTree(facetName, entries, options.nav, options.display);
391
+ }
392
+
393
+ /**
394
+ * Slash-encoded tree builder. Each entry value is a slash-joined
395
+ * path; split, walk a trie, attach Pagefind counts to terminal
396
+ * nodes. Synthetic intermediate nodes (a path component used by a
397
+ * descendant but absent from `entries`) get `hasValue: false` and
398
+ * `count: 0` so they render but don't contribute to selection.
399
+ *
400
+ * Sort order at every depth:
401
+ * 1. `navOrder` index if present (document order from nav.json).
402
+ * Without this, deep API-reference subtrees with high page
403
+ * counts would dominate the top of the tree even though they
404
+ * sit at the BOTTOM of the rendered nav.
405
+ * 2. Count desc (more pages → higher).
406
+ * 3. Alpha (final tiebreak).
407
+ */
408
+ function buildSlashTree(
409
+ facetName: string,
410
+ entries: FacetEntry[],
411
+ display?: TaxonomyDisplayMap,
412
+ navOrder?: Map<string, number>,
413
+ ): FacetTreeNode[] {
414
+ // Trie keyed by full slash-path. Children are tracked on each
415
+ // node's own `children` array (built unsorted in pass 1, sorted
416
+ // recursively in pass 2).
417
+ const byPath = new Map<string, FacetTreeNode>();
418
+ const rootValues: string[] = [];
419
+
420
+ for (const entry of entries) {
421
+ const segments = entry.value.split("/").filter((s) => s.length > 0);
422
+ if (segments.length === 0) continue;
423
+ let pathSoFar = "";
424
+ let parent: FacetTreeNode | null = null;
425
+ for (let i = 0; i < segments.length; i++) {
426
+ pathSoFar = pathSoFar ? `${pathSoFar}/${segments[i]}` : segments[i];
427
+ let node = byPath.get(pathSoFar);
428
+ if (!node) {
429
+ node = {
430
+ value: pathSoFar,
431
+ label: resolveFacetLabel(facetName, pathSoFar, display),
432
+ count: 0,
433
+ depth: i,
434
+ hasValue: false,
435
+ children: [],
436
+ };
437
+ byPath.set(pathSoFar, node);
438
+ if (parent) parent.children.push(node);
439
+ else rootValues.push(pathSoFar);
440
+ }
441
+ if (i === segments.length - 1) {
442
+ node.count = entry.count;
443
+ node.hasValue = true;
444
+ }
445
+ parent = node;
446
+ }
447
+ }
448
+
449
+ // Pass 2: sort children at every depth.
450
+ const cmp = (a: FacetTreeNode, b: FacetTreeNode): number => {
451
+ if (navOrder) {
452
+ const oa = navOrder.get(a.value);
453
+ const ob = navOrder.get(b.value);
454
+ if (oa !== undefined && ob !== undefined && oa !== ob) return oa - ob;
455
+ // Nodes IN the nav sort before nodes not in nav
456
+ if (oa !== undefined && ob === undefined) return -1;
457
+ if (oa === undefined && ob !== undefined) return 1;
458
+ }
459
+ if (a.count !== b.count) return b.count - a.count;
460
+ return a.value.localeCompare(b.value);
461
+ };
462
+ const sortChildren = (node: FacetTreeNode): void => {
463
+ if (node.children.length === 0) return;
464
+ node.children.sort(cmp);
465
+ for (const c of node.children) sortChildren(c);
466
+ };
467
+
468
+ const rootList = rootValues.map((v) => byPath.get(v)!);
469
+ rootList.sort(cmp);
470
+ for (const r of rootList) sortChildren(r);
471
+ return rootList;
472
+ }
473
+
474
+ /**
475
+ * Walk `nav` and assign each cumulative slash-path (matching a
476
+ * Pagefind facet value in `knownValues`) a sequential index in
477
+ * first-encounter order. Used by `buildSlashTree` to sort children
478
+ * in document order rather than count-desc.
479
+ *
480
+ * Auto-detects and strips a leading basePath: for each nav leaf, we
481
+ * try each starting offset of the href's parent segments. The first
482
+ * offset whose initial cumulative path is in `knownValues` is the
483
+ * "real" content root; the dropped prefix is the basePath. This way
484
+ * the helper works on any site without needing the basePath threaded
485
+ * through explicitly.
486
+ *
487
+ * Hrefs whose parents don't match anything in `knownValues` at any
488
+ * offset are skipped (e.g. top-level pages with no auto-derived
489
+ * category, or pages outside the indexed corpus).
490
+ *
491
+ * Exported for use by `buildFacetTree`. Pure — no I/O.
492
+ */
493
+ export function buildNavOrderMap(
494
+ nav: NavLike[],
495
+ knownValues: Set<string>,
496
+ ): Map<string, number> {
497
+ const order = new Map<string, number>();
498
+ let n = 0;
499
+ const walk = (items: NavLike[]): void => {
500
+ for (const item of items) {
501
+ if (item.href) {
502
+ const parents = hrefToCategorySegments(item.href);
503
+ // Try each starting offset; first one whose initial cumulative
504
+ // path is in knownValues wins (auto-detects basePath).
505
+ for (let start = 0; start < parents.length; start++) {
506
+ const first = parents[start];
507
+ if (!knownValues.has(first)) continue;
508
+ // Record all cumulative paths starting from `start` that
509
+ // exist in knownValues. The check at line below filters out
510
+ // synthetic intermediates the corpus didn't tag (e.g. a
511
+ // user-supplied frontmatter category that skipped a level).
512
+ let cumulative = "";
513
+ for (let i = start; i < parents.length; i++) {
514
+ cumulative = cumulative ? `${cumulative}/${parents[i]}` : parents[i];
515
+ if (knownValues.has(cumulative) && !order.has(cumulative)) {
516
+ order.set(cumulative, n++);
517
+ }
518
+ }
519
+ break;
520
+ }
521
+ }
522
+ if (item.children && item.children.length > 0) walk(item.children);
523
+ }
524
+ };
525
+ walk(nav);
526
+ return order;
527
+ }
528
+
529
+ /**
530
+ * Segment-encoded tree builder. Walks `nav` to discover the
531
+ * canonical document hierarchy, then attaches Pagefind counts to
532
+ * any node whose segment name matches an entry. Pagefind entries
533
+ * not in nav are appended as alpha-sorted root orphans so they
534
+ * don't disappear.
535
+ */
536
+ function buildSegmentTree(
537
+ facetName: string,
538
+ entries: FacetEntry[],
539
+ nav: NavLike[] | undefined,
540
+ display?: TaxonomyDisplayMap,
541
+ ): FacetTreeNode[] {
542
+ const byValue = new Map<string, FacetEntry>();
543
+ for (const e of entries) byValue.set(e.value, e);
544
+
545
+ if (!nav || nav.length === 0) {
546
+ // Flat fallback in count-desc order — preserves render-ability
547
+ // when nav hasn't loaded.
548
+ return entries.map((e) => ({
549
+ value: e.value,
550
+ label: resolveFacetLabel(facetName, e.value, display),
551
+ count: e.count,
552
+ depth: 0,
553
+ hasValue: true,
554
+ children: [],
555
+ }));
556
+ }
557
+
558
+ const seenValues = new Set<string>();
559
+ const roots: FacetTreeNode[] = [];
560
+ const rootIndex = new Map<string, FacetTreeNode>();
561
+
562
+ /** Insert a path's parent segments (excluding the leaf filename) into the trie. */
563
+ const insertPath = (segments: string[]): void => {
564
+ if (segments.length === 0) return;
565
+ let parentList: FacetTreeNode[] = roots;
566
+ let parentIndex: Map<string, FacetTreeNode> = rootIndex;
567
+ for (let i = 0; i < segments.length; i++) {
568
+ const seg = segments[i];
569
+ let node = parentIndex.get(seg);
570
+ if (!node) {
571
+ const entry = byValue.get(seg);
572
+ node = {
573
+ value: seg,
574
+ label: resolveFacetLabel(facetName, seg, display),
575
+ count: entry ? entry.count : 0,
576
+ depth: i,
577
+ hasValue: entry !== undefined,
578
+ children: [],
579
+ };
580
+ parentList.push(node);
581
+ parentIndex.set(seg, node);
582
+ if (entry) seenValues.add(seg);
583
+ }
584
+ // Walk into the child list for the next iteration. Each
585
+ // node carries its own child index lazily via a WeakMap-like
586
+ // closure; here we use a regular Map keyed off the node.
587
+ let nextIndex = childIndex.get(node);
588
+ if (!nextIndex) {
589
+ nextIndex = new Map();
590
+ childIndex.set(node, nextIndex);
591
+ }
592
+ parentList = node.children;
593
+ parentIndex = nextIndex;
594
+ }
595
+ };
596
+
597
+ const childIndex = new Map<FacetTreeNode, Map<string, FacetTreeNode>>();
598
+
599
+ // Walk every nav leaf. Use href segments (excluding the leaf
600
+ // filename) — those are the auto-derived `category` values
601
+ // emitted by parseMeta's deriveCategoryFromSlug.
602
+ const walkNav = (items: NavLike[]): void => {
603
+ for (const item of items) {
604
+ if (item.href) {
605
+ const parents = hrefToCategorySegments(item.href);
606
+ insertPath(parents);
607
+ }
608
+ if (item.children && item.children.length > 0) walkNav(item.children);
609
+ }
610
+ };
611
+ walkNav(nav);
612
+
613
+ // Pagefind values not represented in nav → root-level orphans in
614
+ // alpha order. Without this they vanish from the UI silently.
615
+ const orphans: FacetTreeNode[] = [];
616
+ for (const e of entries) {
617
+ if (seenValues.has(e.value)) continue;
618
+ orphans.push({
619
+ value: e.value,
620
+ label: resolveFacetLabel(facetName, e.value, display),
621
+ count: e.count,
622
+ depth: 0,
623
+ hasValue: true,
624
+ children: [],
625
+ });
626
+ }
627
+ orphans.sort((a, b) => a.value.localeCompare(b.value));
628
+ return [...roots, ...orphans];
629
+ }
630
+
631
+ /**
632
+ * Strip an href down to its parent path segments — the `category`
633
+ * derivation logic from `parseMeta.deriveCategoryFromSlug`. Drops
634
+ * leading/trailing slashes, drops the leaf filename segment.
635
+ */
636
+ function hrefToCategorySegments(href: string): string[] {
637
+ // Drop scheme/host if absolute (we only treat path segments)
638
+ let path = href;
639
+ try {
640
+ if (/^[a-z]+:\/\//i.test(href)) path = new URL(href).pathname;
641
+ } catch {
642
+ // not a URL — treat as-is
643
+ }
644
+ // Drop hash + query — facets care about the URL path only
645
+ path = path.split("#")[0].split("?")[0];
646
+ const segments = path.split("/").filter((s) => s.length > 0);
647
+ // Match deriveCategoryFromSlug: parents = everything except the leaf
648
+ if (segments.length < 2) return [];
649
+ return segments.slice(0, -1);
650
+ }
651
+
652
+ /**
653
+ * Flat list of every "real" Pagefind value at or under `node`
654
+ * (i.e. every node where `hasValue === true`). Synthetic parents
655
+ * are excluded since adding them to the filter would be a no-op
656
+ * (Pagefind has no pages tagged with the synthetic value).
657
+ *
658
+ * Used by `toggleTreeNode` when the user ticks a parent — we add
659
+ * every real descendant value to the selection in one shot.
660
+ */
661
+ export function collectDescendantValues(node: FacetTreeNode): string[] {
662
+ const out: string[] = [];
663
+ const visit = (n: FacetTreeNode): void => {
664
+ if (n.hasValue) out.push(n.value);
665
+ for (const c of n.children) visit(c);
666
+ };
667
+ visit(node);
668
+ return out;
669
+ }
670
+
671
+ /**
672
+ * Tri-state of a tree node given the current filter state.
673
+ *
674
+ * - `"checked"` — every real descendant value (and self if
675
+ * self has a value) is in `filters[facetName]`.
676
+ * - `"unchecked"` — none of them are.
677
+ * - `"indeterminate"`— at least one is, but not all.
678
+ *
679
+ * Synthetic parents with no real-value descendants resolve to
680
+ * "unchecked" (defensive default — shouldn't occur in valid trees).
681
+ */
682
+ export function computeTreeState(
683
+ node: FacetTreeNode,
684
+ facetName: string,
685
+ filters: FilterState,
686
+ ): "checked" | "unchecked" | "indeterminate" {
687
+ const descendants = collectDescendantValues(node);
688
+ if (descendants.length === 0) return "unchecked";
689
+ const selected = new Set(filters[facetName] ?? []);
690
+ let hits = 0;
691
+ for (const v of descendants) if (selected.has(v)) hits++;
692
+ if (hits === 0) return "unchecked";
693
+ if (hits === descendants.length) return "checked";
694
+ return "indeterminate";
695
+ }
696
+
697
+ /**
698
+ * Toggle a tree node's selection.
699
+ *
700
+ * - If currently checked → remove self + all descendant values
701
+ * from `filters[facetName]`.
702
+ * - If unchecked or indeterminate → add self + all descendant
703
+ * values (auto-select). Matches the decided UX: parent click
704
+ * fans out to children.
705
+ *
706
+ * Returns a new `FilterState` — input is not mutated. Same
707
+ * immutability contract as `toggleFilter`.
708
+ */
709
+ export function toggleTreeNode(
710
+ filters: FilterState,
711
+ facetName: string,
712
+ node: FacetTreeNode,
713
+ currentState: "checked" | "unchecked" | "indeterminate",
714
+ ): FilterState {
715
+ const descendants = collectDescendantValues(node);
716
+ if (descendants.length === 0) return filters;
717
+ const current = filters[facetName] ?? [];
718
+ let nextValues: string[];
719
+ if (currentState === "checked") {
720
+ const drop = new Set(descendants);
721
+ nextValues = current.filter((v) => !drop.has(v));
722
+ } else {
723
+ const merged = new Set(current);
724
+ for (const v of descendants) merged.add(v);
725
+ nextValues = Array.from(merged);
726
+ }
727
+ const next: FilterState = { ...filters };
728
+ if (nextValues.length === 0) {
729
+ delete next[facetName];
730
+ } else {
731
+ next[facetName] = nextValues;
732
+ }
733
+ return next;
734
+ }
@@ -35,6 +35,20 @@ export interface AxisRedirectConfig {
35
35
  defaultLocale?: string;
36
36
  /** Full set of declared locale ids. Empty/undefined → axis inactive. */
37
37
  knownLocales?: string[];
38
+ /**
39
+ * First-segment names that aren't locale/version-axis-prefixable
40
+ * — e.g. taxonomy index paths like `tags`, `by-type`, `by-status`.
41
+ * Taxonomy routes emit a single global namespace shared across
42
+ * all locales / versions (one `/tags/` for the whole site, not
43
+ * one per locale), so the axis-redirect helper must skip them.
44
+ * Without this skip, chip hrefs to `/<basePath>/tags/...` would
45
+ * 302 to `/<basePath>/<defaultLocale>/tags/...` which 404s.
46
+ *
47
+ * Each entry is the first URL segment after basePath
48
+ * (no leading slash). Sourced from declared
49
+ * `taxonomies.<name>.indexPath` in `dogsbay.config.yml`.
50
+ */
51
+ globalPrefixes?: string[];
38
52
  }
39
53
 
40
54
  /**
@@ -88,6 +102,15 @@ export function shouldRedirectToDefaultVersion(
88
102
  // Skip Astro / Pagefind asset paths.
89
103
  if (segments[0].startsWith("_") || segments[0] === "pagefind") return null;
90
104
 
105
+ // Skip global-namespace prefixes (taxonomy index paths and similar
106
+ // routes that don't live under per-locale / per-version trees).
107
+ // Without this, chip hrefs to `/docs/tags/concept/rag/` get
108
+ // redirected to `/docs/<defaultLocale>/tags/concept/rag/` which
109
+ // 404s — the taxonomy routes are emitted once at the unprefixed
110
+ // path. See plans/beta-launch-followups.md.
111
+ const globalPrefixes = config.globalPrefixes ?? [];
112
+ if (globalPrefixes.includes(segments[0])) return null;
113
+
91
114
  // Greedy axis detection — locale outermost, version next.
92
115
  const knownLocales = new Set(config.knownLocales ?? []);
93
116
  const knownVersions = new Set(config.knownVersions ?? []);