@dogsbay/docs-layout 0.2.0-beta.7 → 0.2.0-beta.72

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.7",
3
+ "version": "0.2.0-beta.72",
4
4
  "description": "Standard documentation layout components for Dogsbay",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,14 +29,14 @@
29
29
  "./json-ld": "./src/json-ld.ts"
30
30
  },
31
31
  "dependencies": {
32
- "@dogsbay/ui": "0.2.0-beta.7",
33
- "@dogsbay/primitives": "0.2.0-beta.7"
32
+ "@dogsbay/ui": "0.2.0-beta.72",
33
+ "@dogsbay/primitives": "0.2.0-beta.72"
34
34
  },
35
35
  "devDependencies": {
36
36
  "vitest": "^3.0.0"
37
37
  },
38
38
  "peerDependencies": {
39
- "astro": "^5.0.0 || ^6.0.0"
39
+ "astro": "^5.0.0 || ^6.0.0 || ^7.0.0"
40
40
  },
41
41
  "files": [
42
42
  "src",
@@ -31,6 +31,7 @@ import SidebarMenuItem from "@dogsbay/ui/sidebar/SidebarMenuItem.astro";
31
31
  import SidebarMenuButton from "@dogsbay/ui/sidebar/SidebarMenuButton.astro";
32
32
  import SidebarSeparator from "@dogsbay/ui/sidebar/SidebarSeparator.astro";
33
33
  import SidebarNavTree from "@dogsbay/ui/sidebar/SidebarNavTree.astro";
34
+ import DocsNavClient from "./DocsNavClient.astro";
34
35
  import Separator from "@dogsbay/ui/separator/Separator.astro";
35
36
  import ThemeToggle from "@dogsbay/ui/theme-toggle/ThemeToggle.astro";
36
37
  import DocsToc from "./DocsToc.astro";
@@ -44,7 +45,7 @@ import VersionSwitcher from "./VersionSwitcher.astro";
44
45
  import LocaleSwitcher from "./LocaleSwitcher.astro";
45
46
  import { filterNavByAxis } from "./nav-filter.js";
46
47
  import type { LlmProviderName } from "./llm-actions.js";
47
- import { jsonLdTypeFor, normalizeCustomJsonLd } from "./json-ld.js";
48
+ import { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd } from "./json-ld.js";
48
49
  import { resolveTagKeywords } from "./tag-list-data.js";
49
50
 
50
51
  interface NavItem {
@@ -112,7 +113,13 @@ interface Props {
112
113
  siteDescription?: string;
113
114
  /** Copyright text (HTML allowed) */
114
115
  copyright?: string;
115
- /** Favicon path (default: "/favicon.ico"). Set to false to disable. */
116
+ /**
117
+ * Favicon path. No host-root default — that 404s on subpath
118
+ * deploys. The format-astro emitter passes a combined-prefix
119
+ * path (e.g. `/<repo>/favicon.ico`) computed from site.url +
120
+ * basePath. Pass an empty string or `false` to disable. When
121
+ * undefined, no `<link rel="icon">` is emitted.
122
+ */
116
123
  favicon?: string | false;
117
124
  /** Per-page OG image URL. Overrides defaultOgImage. */
118
125
  ogImage?: string;
@@ -127,16 +134,25 @@ interface Props {
127
134
  /** Theme color hint for browsers (hex string) */
128
135
  themeColor?: string;
129
136
  /**
130
- * Emit `<meta name="robots" content="noindex, nofollow">` when
137
+ * Emit `noindex` in the `<meta name="robots">` directive when
131
138
  * true. Tells external search engines (Google, Bing) to skip
132
- * this page. Has NO effect on in-site Pagefind search — for
133
- * that, use `excludeFromSearch`. The two are independent: a
134
- * page can be excluded from external SEs but still appear in
135
- * Pagefind (e.g. duplicate / old content readers might still
136
- * want to find when they're already on the site), or vice
137
- * versa.
139
+ * indexing this page. Independent of `nofollow` common pattern
140
+ * for tag / index pages is noindex + follow (don't list this
141
+ * page in results, but do crawl through to the real content).
142
+ *
143
+ * Has NO effect on in-site Pagefind search for that, use
144
+ * `excludeFromSearch`. The two are independent: a page can be
145
+ * excluded from external SEs but still appear in Pagefind (e.g.
146
+ * duplicate / old content readers might still want to find when
147
+ * they're already on the site), or vice versa.
138
148
  */
139
149
  noindex?: boolean;
150
+ /**
151
+ * Emit `nofollow` in the `<meta name="robots">` directive when
152
+ * true. Tells crawlers not to follow outbound links from this
153
+ * page. Independent of `noindex`. Default false.
154
+ */
155
+ nofollow?: boolean;
140
156
  /**
141
157
  * Exclude this page from in-site Pagefind search results.
142
158
  *
@@ -256,6 +272,23 @@ interface Props {
256
272
  * elements in `<body>`.
257
273
  */
258
274
  category?: string[];
275
+ /**
276
+ * Custom-taxonomy values from `meta.taxonomies` — anything declared
277
+ * in `taxonomies:` config that isn't one of the five hardcoded
278
+ * built-ins (`tags`, `category`, `audience`, `type`, `status`).
279
+ *
280
+ * Each entry becomes one `<div data-pagefind-filter="<name>:<value>">`
281
+ * inside the indexed body, so the search dialog grows a checkbox
282
+ * group for every custom taxonomy automatically. No extra config
283
+ * needed beyond declaring the taxonomy in `dogsbay.config.yml` —
284
+ * the search dialog discovers facets at index time and renders
285
+ * whatever Pagefind reports.
286
+ *
287
+ * Display labels for the checkboxes flow through
288
+ * `taxonomyDisplay[<name>]` (same prefix/label config that drives
289
+ * chip rendering elsewhere). When unset, raw slugs are shown.
290
+ */
291
+ taxonomies?: Record<string, string[]>;
259
292
  /**
260
293
  * Map of taxonomy name → index path for declared taxonomies.
261
294
  * Used to wire links from built-in field badges (TypeBadge,
@@ -324,6 +357,19 @@ interface Props {
324
357
  * (`<basePath>/<version>/`). Defaults to "/docs".
325
358
  */
326
359
  basePath?: string;
360
+ /**
361
+ * Sidebar navigation render mode.
362
+ *
363
+ * - `"client"` (default): emit the small `<DocsNavClient />` placeholder;
364
+ * the tree is hydrated from `/_dogsbay/nav.json` once per session.
365
+ * Page HTML shrinks dramatically at scale (~50 KB vs ~1.2 MB on a
366
+ * 2k-page site). No-JS users see a sitemap fallback link.
367
+ * - `"ssr-full"`: render the full nav tree into every page's HTML.
368
+ * Best for very small sites or strict no-JS / SEO contexts.
369
+ *
370
+ * See plans/client-rendered-nav.md.
371
+ */
372
+ navMode?: "client" | "ssr-full";
327
373
  /**
328
374
  * Per-page LLM action UI. When set and `enabled !== false`, renders
329
375
  * the PageActions cluster (Copy markdown + Open in Claude/ChatGPT/
@@ -376,7 +422,7 @@ const {
376
422
  editUrl,
377
423
  lastUpdated,
378
424
  copyright,
379
- favicon = "/favicon.ico",
425
+ favicon,
380
426
  ogImage,
381
427
  defaultOgImage,
382
428
  ogType = "article",
@@ -384,11 +430,18 @@ const {
384
430
  twitterHandle,
385
431
  themeColor,
386
432
  noindex,
433
+ nofollow,
387
434
  excludeFromSearch,
388
435
  plausibleDomain,
389
436
  plausibleScriptUrl,
390
437
  hideSearch = false,
391
- pagefindUrl = "/pagefind/",
438
+ // No default for pagefindUrl host-root absolute paths break on
439
+ // subpath-mounted deploys (GH Pages project pages, multi-mount
440
+ // Cloudflare). format-astro's emitter always passes the
441
+ // combined-prefix-aware URL; manual instantiation must too.
442
+ // Undefined here propagates to SearchDialog where the JS loader
443
+ // throws on first open instead of silently 404'ing the bundle.
444
+ pagefindUrl,
392
445
  mdMirror = false,
393
446
  tags,
394
447
  tagsIndexPath = "/tags",
@@ -398,6 +451,7 @@ const {
398
451
  pageType,
399
452
  audience,
400
453
  category,
454
+ taxonomies,
401
455
  taxonomyIndexPaths,
402
456
  taxonomyDisplay,
403
457
  autoH1,
@@ -407,6 +461,7 @@ const {
407
461
  multiSource,
408
462
  switcherMap,
409
463
  basePath,
464
+ navMode = "client",
410
465
  wideLayout = false,
411
466
  class: className,
412
467
  } = Astro.props;
@@ -428,6 +483,16 @@ const showLlmActionsInline =
428
483
  llmActionsEnabled
429
484
  && (llmActionsPlacement === "inline" || llmActionsPlacement === "both");
430
485
  const llmFooterLink = !!llmActions && llmActions.footerLink !== false;
486
+ // Per-mount llms.txt URL — Dogsbay emits `<basePath>/llms.txt`
487
+ // (sitemap-index pattern), so the footer link must be basePath-
488
+ // prefixed too. Falls back to `/llms.txt` when basePath is empty
489
+ // or unset, matching the platform's host-root single-site case.
490
+ const llmsLinkHrefResolved = (() => {
491
+ const bp = (basePath ?? "").replace(/\/+$/, "");
492
+ if (!bp) return "/llms.txt";
493
+ const prefix = bp.startsWith("/") ? bp : `/${bp}`;
494
+ return `${prefix}/llms.txt`;
495
+ })();
431
496
 
432
497
  // Compute href targets for the type / status badges. A field is
433
498
  // linkable only when (a) the user declared a `taxonomies.<field>`
@@ -453,9 +518,25 @@ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
453
518
  const metaDescription = description ?? siteDescription;
454
519
  const metaOgImage = ogImage ?? defaultOgImage;
455
520
  const isAbsoluteSiteUrl = /^https?:\/\//.test(siteUrl);
521
+ // Compose canonical from ORIGIN + pathname. siteUrl may carry a
522
+ // path component (the urlBase that drives Astro's `base` — see
523
+ // plans/astro-base-from-site-url.md), and Astro.url.pathname
524
+ // already includes that prefix. Naively concatenating siteUrl +
525
+ // pathname double-counts the urlBase (e.g. .../repo/repo/page).
526
+ // Strip path off siteUrl by reparsing as a URL.
527
+ let canonicalOrigin: string | undefined;
528
+ if (isAbsoluteSiteUrl) {
529
+ try {
530
+ const u = new URL(siteUrl);
531
+ canonicalOrigin = `${u.protocol}//${u.host}`;
532
+ } catch {
533
+ // Malformed siteUrl — fall back to the original (no path) behavior.
534
+ canonicalOrigin = siteUrl.replace(/\/$/, "");
535
+ }
536
+ }
456
537
  const computedCanonical = canonicalUrl
457
- ?? (isAbsoluteSiteUrl
458
- ? siteUrl.replace(/\/$/, "") + Astro.url.pathname
538
+ ?? (canonicalOrigin
539
+ ? canonicalOrigin + Astro.url.pathname
459
540
  : undefined);
460
541
 
461
542
  // Markdown mirror — append `.md` to the current path for the alternate link
@@ -482,15 +563,16 @@ const tagKeywords = resolveTagKeywords(tags, tagLabels);
482
563
  // search engines key off for educational / tutorial / reference
483
564
  // SERP rendering. See `json-ld.ts` for the full mapping table.
484
565
  const articleJsonLd = (ogType === "article" && tagKeywords.length > 0)
485
- ? {
486
- "@context": "https://schema.org",
487
- "@type": jsonLdTypeFor(pageType),
488
- headline: title,
489
- keywords: tagKeywords.join(", "),
490
- ...(metaDescription ? { description: metaDescription } : {}),
491
- ...(metaOgImage ? { image: metaOgImage } : {}),
492
- ...(computedCanonical ? { url: computedCanonical } : {}),
493
- }
566
+ ? buildArticleJsonLd({
567
+ type: jsonLdTypeFor(pageType),
568
+ title,
569
+ siteName,
570
+ keywords: tagKeywords,
571
+ description: metaDescription,
572
+ image: metaOgImage,
573
+ url: computedCanonical,
574
+ headings,
575
+ })
494
576
  : undefined;
495
577
 
496
578
  // `customJsonLd` accepts either a single object or an array.
@@ -510,11 +592,22 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
510
592
  <title>{title} | {siteName}</title>
511
593
 
512
594
  {metaDescription && <meta name="description" content={metaDescription} />}
513
- {favicon !== false && <link rel="icon" href={favicon} />}
595
+ {favicon && <link rel="icon" href={favicon} />}
514
596
  {themeColor && <meta name="theme-color" content={themeColor} />}
515
597
  {/* External search engine directive — orthogonal to in-site
516
- Pagefind exclusion. */}
517
- {noindex && <meta name="robots" content="noindex, nofollow" />}
598
+ Pagefind exclusion. `noindex` + `nofollow` are independent
599
+ bits per the meta-robots spec; emit only the directives that
600
+ are set. Combining them when both are set keeps the tag
601
+ compact (`<meta name="robots" content="noindex, nofollow">`)
602
+ instead of emitting two tags. */}
603
+ {(noindex || nofollow) && (
604
+ <meta
605
+ name="robots"
606
+ content={[noindex && "noindex", nofollow && "nofollow"]
607
+ .filter(Boolean)
608
+ .join(", ")}
609
+ />
610
+ )}
518
611
 
519
612
  {/* In-site Pagefind exclusion is wired via two coordinated
520
613
  attributes on <body> and <main> below — see the prop docs
@@ -529,6 +622,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
529
622
 
530
623
  {computedCanonical && <link rel="canonical" href={computedCanonical} />}
531
624
  {mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
625
+ {/* Programmatic llms.txt discovery — agents that follow head
626
+ link rels get the per-mount llms.txt without parsing HTML.
627
+ Mirrors the existing _headers Link rel="describedby" used by
628
+ Cloudflare Pages / Workers. */}
629
+ <link rel="alternate" type="text/plain" title="llms.txt" href={llmsLinkHrefResolved} />
630
+ <link rel="describedby" type="text/plain" href={llmsLinkHrefResolved} />
532
631
 
533
632
  {/* Open Graph */}
534
633
  <meta property="og:type" content={ogType} />
@@ -592,7 +691,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
592
691
  <SidebarHeader>
593
692
  <SidebarMenu>
594
693
  <SidebarMenuItem>
595
- <SidebarMenuButton size="lg" href={siteUrl} isActive={currentPath === siteUrl || currentPath === "/"}>
694
+ <SidebarMenuButton size="lg" href={basePath || "/"} isActive={currentPath === basePath || currentPath === "/"}>
596
695
  <div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
597
696
  <Fragment set:html={siteIcon} />
598
697
  </div>
@@ -608,7 +707,24 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
608
707
  <SidebarSeparator />
609
708
 
610
709
  <SidebarContent>
611
- {navGroups ? (
710
+ {navMode === "client" ? (
711
+ // Client-render mode: emit one DocsNavClient placeholder
712
+ // inside a single SidebarGroup, regardless of navGroups.
713
+ // The group-label shape doesn't apply when the tree is
714
+ // hydrated by JS — multi-group nav is reconstructed
715
+ // client-side from the same /_dogsbay/nav.json shape.
716
+ // See plans/client-rendered-nav.md.
717
+ <SidebarGroup>
718
+ <SidebarGroupContent>
719
+ <DocsNavClient
720
+ currentPath={currentPath}
721
+ basePath={basePath ?? ""}
722
+ version={multiSource?.version}
723
+ locale={multiSource?.locale}
724
+ />
725
+ </SidebarGroupContent>
726
+ </SidebarGroup>
727
+ ) : navGroups ? (
612
728
  navGroups.map(group => (
613
729
  <SidebarGroup>
614
730
  <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
@@ -709,9 +825,30 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
709
825
  the actual page content. Filter elements live in here
710
826
  now to stay inside that scope.
711
827
  */}
712
- {Array.isArray(tags) && tags.map((tag) => (
713
- <div hidden data-pagefind-filter={`tag:${tag}`}></div>
714
- ))}
828
+ {/*
829
+ Slash-nested tags whose prefix is declared in
830
+ `taxonomies.tags.prefixes` emit as per-prefix filter
831
+ divs so each prefix becomes its own Pagefind facet
832
+ column (Difficulty, Topic, Persona, …) instead of
833
+ pooling under a single "Tag" column.
834
+
835
+ Plain tags and tags whose prefix isn't declared fall
836
+ back to the pooled `tag:` filter — backward-compatible
837
+ for sites that haven't declared prefixes.
838
+
839
+ See plans/per-prefix-search-facets.md.
840
+ */}
841
+ {Array.isArray(tags) && tags.map((tag) => {
842
+ const slash = tag.indexOf("/");
843
+ if (slash > 0) {
844
+ const prefix = tag.slice(0, slash);
845
+ const leaf = tag.slice(slash + 1);
846
+ if (tagPrefixes && tagPrefixes[prefix]) {
847
+ return <div hidden data-pagefind-filter={`${prefix}:${leaf}`}></div>;
848
+ }
849
+ }
850
+ return <div hidden data-pagefind-filter={`tag:${tag}`}></div>;
851
+ })}
715
852
  {Array.isArray(audience) && audience.map((value) => (
716
853
  <div hidden data-pagefind-filter={`audience:${value}`}></div>
717
854
  ))}
@@ -720,6 +857,20 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
720
857
  ))}
721
858
  {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
722
859
  {pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
860
+ {/*
861
+ Custom-taxonomy filters. Any taxonomy declared in
862
+ `dogsbay.config.yml` that isn't one of the five built-ins
863
+ flows through here, so `difficulty: intermediate` (etc.)
864
+ becomes a real Pagefind facet checkbox automatically.
865
+ See plans/beta-launch-followups.md for context.
866
+ */}
867
+ {taxonomies && Object.entries(taxonomies).flatMap(([name, values]) =>
868
+ Array.isArray(values)
869
+ ? values.map((value) => (
870
+ <div hidden data-pagefind-filter={`${name}:${value}`}></div>
871
+ ))
872
+ : []
873
+ )}
723
874
 
724
875
  <div class:list={["mx-auto", wideLayout ? "max-w-7xl" : "max-w-3xl"]}>
725
876
  {/*
@@ -802,6 +953,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
802
953
  next={next}
803
954
  copyright={copyright}
804
955
  llmsLink={llmFooterLink}
956
+ llmsLinkHref={llmsLinkHrefResolved}
805
957
  />
806
958
  </div>
807
959
  </main>
@@ -0,0 +1,89 @@
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.xml` link covers no-JS navigation.
21
+ * - First paint waits for the JS bundle + the JSON fetch. On a 4G
22
+ * connection that's typically <200 ms; the skeleton fills the
23
+ * space until then.
24
+ *
25
+ * See plans/client-rendered-nav.md.
26
+ */
27
+ interface Props {
28
+ /** Current page's URL pathname; the script uses it to mark the active item. */
29
+ currentPath: string;
30
+ /**
31
+ * URL prefix the host serves under (combined `urlBase` + `basePath`).
32
+ * The script joins this with `/_dogsbay/nav.json` to locate the
33
+ * fetchable nav tree; also threaded to the version/locale filter
34
+ * so multi-axis sites work the same as SSR.
35
+ */
36
+ basePath?: string;
37
+ /** Current source's version axis value, if multi-version site. */
38
+ version?: string;
39
+ /** Current source's locale axis value, if multi-locale site. */
40
+ locale?: string;
41
+ }
42
+
43
+ const { currentPath, basePath = "", version, locale } = Astro.props;
44
+ const navUrl = `${basePath}/_dogsbay/nav.json`;
45
+ ---
46
+
47
+ <div
48
+ id="docs-nav-root"
49
+ data-nav-url={navUrl}
50
+ data-current-path={currentPath}
51
+ data-base-path={basePath}
52
+ data-version={version ?? ""}
53
+ data-locale={locale ?? ""}
54
+ aria-busy="true"
55
+ aria-label="Documentation navigation"
56
+ >
57
+ <ul class="flex min-w-0 flex-col" data-sidebar="nav-tree" data-level="0">
58
+ {[0, 1, 2, 3, 4, 5].map((i) => (
59
+ <li>
60
+ <div
61
+ class="flex h-8 w-full items-center gap-2 rounded-md px-2"
62
+ aria-hidden="true"
63
+ >
64
+ <div class="h-2 w-2 rounded-full bg-sidebar-foreground/10" />
65
+ <div
66
+ class="h-2 rounded bg-sidebar-foreground/10"
67
+ style={`width: ${50 + ((i * 17) % 35)}%;`}
68
+ />
69
+ </div>
70
+ </li>
71
+ ))}
72
+ </ul>
73
+ <noscript>
74
+ <p class="px-2 py-1.5 text-sm text-sidebar-foreground/70">
75
+ JavaScript is required to render the sidebar. Use the
76
+ <a href={`${basePath}/sitemap.xml`} class="underline">sitemap</a>
77
+ to browse all pages.
78
+ </p>
79
+ </noscript>
80
+ </div>
81
+
82
+ <script>
83
+ // Single import — Astro/Vite bundles this into one shared chunk
84
+ // referenced from every page that uses DocsNavClient. Browsers
85
+ // cache the chunk, so the nav script ships once per session
86
+ // regardless of how many pages the user visits.
87
+ import { hydrateDocsNav } from "./docs-nav-client.ts";
88
+ hydrateDocsNav();
89
+ </script>
@@ -28,8 +28,13 @@ 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;
35
40
  /** Placeholder text for the search input */
@@ -44,7 +49,7 @@ interface Props {
44
49
  }
45
50
 
46
51
  const {
47
- pagefindUrl = "/pagefind/",
52
+ pagefindUrl,
48
53
  placeholder = "Search docs...",
49
54
  taxonomyDisplay,
50
55
  } = Astro.props;
@@ -156,6 +161,7 @@ const {
156
161
  shapeFacets,
157
162
  resolveFacetLabel,
158
163
  resolveFacetTitle,
164
+ sortFacetNames,
159
165
  filterStateToUrlParams,
160
166
  parseFiltersFromUrl,
161
167
  filterStateToPagefindFilters,
@@ -177,10 +183,15 @@ const {
177
183
  meta: { title?: string };
178
184
  sub_results?: Array<{ title: string; url: string; excerpt: string }>;
179
185
  };
186
+ // Filter values match what filterStateToPagefindFilters emits:
187
+ // each facet wrapped in `{any: [...]}` for OR-within-facet semantics.
188
+ // Pagefind also accepts other operator shapes (`all`/`none`/`not`,
189
+ // bare strings, bare arrays) but we only emit the `any` form.
190
+ type PagefindFilterValue = { any: string[] };
180
191
  type PagefindModule = {
181
192
  search(
182
193
  query: string,
183
- options?: { filters?: Record<string, string[]> },
194
+ options?: { filters?: Record<string, PagefindFilterValue> },
184
195
  ): Promise<{ results: PagefindResult[] }>;
185
196
  filters(): Promise<Record<string, Record<string, number>>>;
186
197
  };
@@ -218,7 +229,22 @@ const {
218
229
  async function ensurePagefindLoaded() {
219
230
  if (pagefind) return;
220
231
  if (loadingPagefind) return loadingPagefind;
221
- const url = (dialog!.dataset.pagefindUrl ?? "/pagefind/") + "pagefind.js";
232
+ // pagefindUrl is required a "/pagefind/" fallback would
233
+ // silently 404 on subpath-mounted deploys (GH Pages project
234
+ // pages, multi-mount Cloudflare). The emitter always passes
235
+ // a combined-prefix-aware value via data-pagefind-url. If it's
236
+ // missing the page wasn't built through format-astro and the
237
+ // caller forgot to pass it.
238
+ const dataUrl = dialog!.dataset.pagefindUrl;
239
+ if (!dataUrl) {
240
+ console.error(
241
+ "[dogsbay] SearchDialog: pagefindUrl prop missing. " +
242
+ "Pass the combined-prefix path (e.g. '/<base>/pagefind/') " +
243
+ "from your DocsLayout instantiation.",
244
+ );
245
+ return;
246
+ }
247
+ const url = dataUrl + "pagefind.js";
222
248
  loadingPagefind = (async () => {
223
249
  try {
224
250
  const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
@@ -320,7 +346,10 @@ const {
320
346
  * filters, the sidebar stays hidden — single-column layout.
321
347
  */
322
348
  function renderFacets() {
323
- const facetNames = Object.keys(availableFacets);
349
+ const facetNames = sortFacetNames(
350
+ Object.keys(availableFacets),
351
+ taxonomyDisplay,
352
+ );
324
353
  if (facetNames.length === 0) {
325
354
  facetsBox!.classList.add("hidden");
326
355
  return;
@@ -93,15 +93,22 @@ function segmentLabel(depth: number): string {
93
93
 
94
94
  const titleSegments = term.fullPath.map((_, i) => segmentLabel(i));
95
95
 
96
- // Breadcrumb segments — clickable parent paths.
96
+ // Breadcrumb segments — only link to prefixes that have their own
97
+ // term page. When a taxonomy is non-hierarchical, only full-path
98
+ // terms exist in `data.terms`; linking to `/tags/<prefix>/` for an
99
+ // intermediate segment would 404. Render those as plain text instead.
97
100
  function breadcrumbs() {
98
- const out: { label: string; href: string }[] = [
101
+ const termKeys = new Set(data.terms.map((t) => t.fullPath.join("/")));
102
+ const out: { label: string; href?: string }[] = [
99
103
  { label: displayName, href: `${data.indexPath}/` },
100
104
  ];
101
105
  for (let i = 0; i < term.fullPath.length - 1; i++) {
106
+ const prefix = term.fullPath.slice(0, i + 1).join("/");
102
107
  out.push({
103
108
  label: segmentLabel(i),
104
- href: `${data.indexPath}/${term.fullPath.slice(0, i + 1).join("/")}/`,
109
+ href: termKeys.has(prefix)
110
+ ? `${data.indexPath}/${prefix}/`
111
+ : undefined,
105
112
  });
106
113
  }
107
114
  return out;
@@ -120,7 +127,11 @@ const crumbs = breadcrumbs();
120
127
  <nav class="mb-3 text-sm text-muted-foreground" aria-label="Breadcrumbs">
121
128
  {crumbs.map((c, i) => (
122
129
  <span>
123
- <a href={c.href} class="hover:text-foreground hover:underline">{c.label}</a>
130
+ {c.href ? (
131
+ <a href={c.href} class="hover:text-foreground hover:underline">{c.label}</a>
132
+ ) : (
133
+ <span>{c.label}</span>
134
+ )}
124
135
  {i < crumbs.length - 1 && <span class="mx-1.5">/</span>}
125
136
  </span>
126
137
  ))}
@@ -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
+ });
package/src/json-ld.ts CHANGED
@@ -53,3 +53,80 @@ export function normalizeCustomJsonLd(
53
53
  if (Array.isArray(raw)) return raw;
54
54
  return [raw];
55
55
  }
56
+
57
+ export interface BuildArticleJsonLdOptions {
58
+ /** Already-resolved Schema.org `@type` (output of jsonLdTypeFor). */
59
+ type: string;
60
+ title: string;
61
+ /** Site name — used as the `provider` Organization for Course. */
62
+ siteName: string;
63
+ keywords: string[];
64
+ description?: string;
65
+ image?: string;
66
+ url?: string;
67
+ /**
68
+ * Page headings — used to synthesize `step[]` for HowTo. Each
69
+ * H2 (or H3 if no H2s exist) becomes a HowToStep with a deep
70
+ * link to the heading id.
71
+ */
72
+ headings?: ReadonlyArray<{ depth: number; slug: string; text: string }>;
73
+ }
74
+
75
+ /**
76
+ * Build the JSON-LD payload for the page's primary structured-data
77
+ * block, shaped per `@type`. The earlier implementation emitted
78
+ * Article-shaped fields (`headline`) regardless of @type, which
79
+ * meant HowTo / Course pages failed Google's Rich Results
80
+ * requirements (HowTo needs `name` + `step`, Course needs `name` +
81
+ * `description` + `provider`). See
82
+ * `packages/cli/src/audit/rules/seo/json-ld-required-fields.ts`
83
+ * for the validator that catches this.
84
+ *
85
+ * Schema.org's `name` is a Thing-level field accepted by every
86
+ * @type, so we always emit it. `headline` is added on top for
87
+ * Article-family types (Article, TechArticle) because Google's
88
+ * Rich Results validator specifically requires it there. HowTo
89
+ * gets a synthesized `step[]` from the page's H2 headings (each
90
+ * H2 = one procedure step); Course gets a `provider` Organization
91
+ * built from `siteName`.
92
+ */
93
+ export function buildArticleJsonLd(
94
+ opts: BuildArticleJsonLdOptions,
95
+ ): Record<string, unknown> {
96
+ const { type, title, siteName, keywords, description, image, url, headings } =
97
+ opts;
98
+
99
+ const block: Record<string, unknown> = {
100
+ "@context": "https://schema.org",
101
+ "@type": type,
102
+ name: title,
103
+ keywords: keywords.join(", "),
104
+ };
105
+
106
+ if (type === "Article" || type === "TechArticle") {
107
+ block.headline = title;
108
+ }
109
+
110
+ if (description) block.description = description;
111
+ if (image) block.image = image;
112
+ if (url) block.url = url;
113
+
114
+ if (type === "HowTo") {
115
+ let stepHeadings = (headings ?? []).filter((h) => h.depth === 2);
116
+ if (stepHeadings.length === 0) {
117
+ stepHeadings = (headings ?? []).filter((h) => h.depth === 3);
118
+ }
119
+ block.step = stepHeadings.map((h) => ({
120
+ "@type": "HowToStep",
121
+ name: h.text,
122
+ url: url ? `${url}#${h.slug}` : `#${h.slug}`,
123
+ }));
124
+ }
125
+
126
+ if (type === "Course") {
127
+ block.provider = { "@type": "Organization", name: siteName };
128
+ if (!block.description) block.description = title;
129
+ }
130
+
131
+ return block;
132
+ }
@@ -16,6 +16,13 @@ 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;
19
26
  }
20
27
 
21
28
  /** Map of taxonomy name → display config. */
@@ -120,6 +127,40 @@ export function resolveFacetTitle(facetName: string): string {
120
127
  .replace(/\b\w/g, (c) => c.toUpperCase());
121
128
  }
122
129
 
130
+ /**
131
+ * Return facet names in sidebar render order.
132
+ *
133
+ * Pagefind's `filters()` map iterates in index-discovery order
134
+ * (effectively the first page Pagefind happened to read), so without
135
+ * sorting the column order is arbitrary and shifts between builds.
136
+ * Order rule:
137
+ * 1. Facets with a numeric `order` in their `TaxonomyDisplay`
138
+ * come first, ascending — use this to pin "Type" or "Audience"
139
+ * to the top regardless of name.
140
+ * 2. Everything else sorts alphabetically by facet name.
141
+ * Stable within each tier so ties don't shuffle.
142
+ */
143
+ export function sortFacetNames(
144
+ names: string[],
145
+ display?: TaxonomyDisplayMap,
146
+ ): string[] {
147
+ const withOrder = (name: string): number | undefined => {
148
+ const o = display?.[name]?.order;
149
+ return typeof o === "number" ? o : undefined;
150
+ };
151
+ return [...names].sort((a, b) => {
152
+ const oa = withOrder(a);
153
+ const ob = withOrder(b);
154
+ if (oa !== undefined && ob !== undefined) {
155
+ if (oa !== ob) return oa - ob;
156
+ return a.localeCompare(b);
157
+ }
158
+ if (oa !== undefined) return -1;
159
+ if (ob !== undefined) return 1;
160
+ return a.localeCompare(b);
161
+ });
162
+ }
163
+
123
164
  // ── URL persistence ──────────────────────────────────────────────
124
165
 
125
166
  /**
@@ -175,21 +216,32 @@ export function parseFiltersFromUrl(
175
216
  }
176
217
 
177
218
  /**
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.
219
+ * Build Pagefind's `filters` argument from internal facet state.
220
+ *
221
+ * Pagefind's array shape (`{ tag: ["a", "b"] }`) is AND by default —
222
+ * "page must have BOTH tags" — per
223
+ * https://pagefind.app/docs/js-api-filtering/ ("All filtering
224
+ * defaults to AND filtering"). That's the wrong UX for faceted
225
+ * search: clicking two checkboxes in the same group should widen
226
+ * the result set, not collapse it to zero. We wrap each facet in
227
+ * `{ any: [...] }` so multi-select within a facet is OR. Across
228
+ * different facets Pagefind already ANDs the keys, which matches
229
+ * the standard "narrow with each additional dimension" UX.
230
+ *
231
+ * Single-value selections also go through `{ any: ["x"] }` —
232
+ * Pagefind treats a one-element `any` identically to a bare string,
233
+ * so there's no behavioural delta and the code stays branch-free.
182
234
  *
183
235
  * 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".
236
+ * result set). A facet with an empty array drops out so we don't
237
+ * accidentally narrow to "must equal nothing".
186
238
  */
187
239
  export function filterStateToPagefindFilters(
188
240
  filters: FilterState,
189
- ): Record<string, string[]> {
190
- const out: Record<string, string[]> = {};
241
+ ): Record<string, { any: string[] }> {
242
+ const out: Record<string, { any: string[] }> = {};
191
243
  for (const [name, values] of Object.entries(filters)) {
192
- if (values.length > 0) out[name] = [...values];
244
+ if (values.length > 0) out[name] = { any: [...values] };
193
245
  }
194
246
  return out;
195
247
  }
@@ -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 ?? []);