@dogsbay/docs-layout 0.2.0-beta.93 → 0.2.0-beta.94

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.93",
3
+ "version": "0.2.0-beta.94",
4
4
  "description": "Standard documentation layout components for Dogsbay",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,7 @@
13
13
  "./TagList.astro": "./src/TagList.astro",
14
14
  "./StatusBadge.astro": "./src/StatusBadge.astro",
15
15
  "./TypeBadge.astro": "./src/TypeBadge.astro",
16
+ "./BlogIndex.astro": "./src/BlogIndex.astro",
16
17
  "./TaxonomyIndex.astro": "./src/TaxonomyIndex.astro",
17
18
  "./TaxonomyTerm.astro": "./src/TaxonomyTerm.astro",
18
19
  "./SearchDialog.astro": "./src/SearchDialog.astro",
@@ -29,12 +30,12 @@
29
30
  "./json-ld": "./src/json-ld.ts"
30
31
  },
31
32
  "dependencies": {
32
- "@dogsbay/ui": "0.2.0-beta.93",
33
- "@dogsbay/primitives": "0.2.0-beta.93"
33
+ "@dogsbay/primitives": "0.2.0-beta.94",
34
+ "@dogsbay/ui": "0.2.0-beta.94"
34
35
  },
35
36
  "devDependencies": {
36
- "happy-dom": "^20.8.9",
37
- "vitest": "^3.0.0"
37
+ "happy-dom": "^20.10.6",
38
+ "vitest": "^4.1.10"
38
39
  },
39
40
  "peerDependencies": {
40
41
  "astro": "^5.0.0 || ^6.0.0 || ^7.0.0"
@@ -0,0 +1,174 @@
1
+ ---
2
+ /**
3
+ * BlogIndex — reverse-chronological list of posts.
4
+ *
5
+ * Reads the slice its route file hands it from `src/data/blog.json`
6
+ * (emitted by `format-astro/src/blog.ts` during `dogsbay site build`)
7
+ * and renders one card per post plus page navigation.
8
+ *
9
+ * All layout lives here rather than in the generated route files, so a
10
+ * design fix propagates on the next build instead of being baked into
11
+ * every emitted page — the same reason `TaxonomyIndex` exists.
12
+ *
13
+ * Uses the shared theme tokens only. The blog is the same site as the
14
+ * docs, so there is no blog palette, no blog typography scale, and no
15
+ * second set of card styles to keep in sync.
16
+ */
17
+ interface BlogPostRef {
18
+ slug: string;
19
+ title: string;
20
+ url: string;
21
+ description?: string;
22
+ date?: string;
23
+ author?: string[];
24
+ tags?: string[];
25
+ heroImage?: string;
26
+ readingMinutes: number;
27
+ }
28
+
29
+ interface Props {
30
+ /** The posts for THIS page of the index, newest first. */
31
+ posts: BlogPostRef[];
32
+ /** URL-form index path, already basePath-prefixed. */
33
+ indexPath: string;
34
+ pageNo: number;
35
+ totalPages: number;
36
+ /** Page heading. Defaults to "Blog", or "Blog — page N" beyond page 1. */
37
+ heading?: string;
38
+ }
39
+
40
+ const {
41
+ posts,
42
+ indexPath,
43
+ pageNo,
44
+ totalPages,
45
+ heading = pageNo === 1 ? "Blog" : `Blog — page ${pageNo}`,
46
+ } = Astro.props;
47
+
48
+ /** Build time, so every reader sees the same string. See DocsLayout. */
49
+ function formatDate(iso: string): string {
50
+ const d = new Date(iso);
51
+ if (Number.isNaN(d.getTime())) return iso;
52
+ // timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
53
+ // 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
54
+ // negative-offset zone (the default on many CI runners) would render
55
+ // "31 December 2025". DocsFooter already carries this fix; these
56
+ // copies dropped it.
57
+ return d.toLocaleDateString("en-GB", {
58
+ year: "numeric",
59
+ month: "long",
60
+ day: "numeric",
61
+ timeZone: "UTC",
62
+ });
63
+ }
64
+
65
+ const base = indexPath === "/" ? "" : indexPath.replace(/\/+$/, "");
66
+ const hrefForPage = (n: number): string => (n === 1 ? `${base}/` : `${base}/page/${n}`);
67
+ ---
68
+
69
+ {/*
70
+ The index needs its own <h1>. Post titles are <h2>, so without one the
71
+ page has an h2 with no h1 above it — a heading-order violation axe-core
72
+ flags, and a missing page heading for anyone navigating by headings.
73
+ The sticky header's site name is chrome, not a page heading.
74
+ */}
75
+ <h1 class="text-3xl font-bold tracking-tight mb-8">{heading}</h1>
76
+
77
+ {posts.length === 0 && (
78
+ <p class="text-muted-foreground">No posts yet.</p>
79
+ )}
80
+
81
+ <ul class="not-prose flex flex-col gap-8" data-blog-index>
82
+ {posts.map((post) => (
83
+ <li class="group">
84
+ <article class="flex flex-col gap-2">
85
+ {post.heroImage && (
86
+ <a href={post.url} tabindex="-1" aria-hidden="true">
87
+ {/*
88
+ aspect-boxed for the same reason as the post hero: an
89
+ unsized image in a list shifts every card below it.
90
+ */}
91
+ <img
92
+ src={post.heroImage}
93
+ alt=""
94
+ class="mb-2 w-full rounded-lg border border-border object-cover aspect-[2/1]"
95
+ loading="lazy"
96
+ decoding="async"
97
+ />
98
+ </a>
99
+ )}
100
+
101
+ <h2 class="text-xl font-semibold tracking-tight">
102
+ <a
103
+ href={post.url}
104
+ class="text-foreground no-underline hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
105
+ >
106
+ {post.title}
107
+ </a>
108
+ </h2>
109
+
110
+ {/*
111
+ Byline mirrors the post page's, minus the reading estimate's
112
+ separator noise. data-pagefind-ignore because the same values
113
+ are indexed as structured filters on the post itself; without
114
+ it, every card's date leads its search excerpt.
115
+ */}
116
+ <div
117
+ class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"
118
+ data-pagefind-ignore
119
+ >
120
+ {post.author && post.author.length > 0 && (
121
+ <span class="font-medium text-foreground">{post.author.join(", ")}</span>
122
+ )}
123
+ {post.author && post.author.length > 0 && post.date && (
124
+ <span aria-hidden="true">&middot;</span>
125
+ )}
126
+ {post.date && <time datetime={post.date}>{formatDate(post.date)}</time>}
127
+ {/*
128
+ Guarded like the post page's byline. Unconditional, a post with
129
+ neither author nor date opened with a stray "· 5 min read", and
130
+ the card disagreed with the page it linked to.
131
+ */}
132
+ {(post.author?.length || post.date) && <span aria-hidden="true">&middot;</span>}
133
+ <span>{post.readingMinutes} min read</span>
134
+ </div>
135
+
136
+ {post.description && (
137
+ <p class="text-muted-foreground">{post.description}</p>
138
+ )}
139
+ </article>
140
+ </li>
141
+ ))}
142
+ </ul>
143
+
144
+ {totalPages > 1 && (
145
+ <nav class="not-prose mt-12 flex items-center justify-between" aria-label="Blog pages">
146
+ {pageNo > 1 ? (
147
+ <a
148
+ href={hrefForPage(pageNo - 1)}
149
+ class="text-sm text-foreground no-underline hover:underline"
150
+ rel="prev"
151
+ >
152
+ &larr; Newer posts
153
+ </a>
154
+ ) : (
155
+ <span></span>
156
+ )}
157
+
158
+ <span class="text-sm text-muted-foreground">
159
+ Page {pageNo} of {totalPages}
160
+ </span>
161
+
162
+ {pageNo < totalPages ? (
163
+ <a
164
+ href={hrefForPage(pageNo + 1)}
165
+ class="text-sm text-foreground no-underline hover:underline"
166
+ rel="next"
167
+ >
168
+ Older posts &rarr;
169
+ </a>
170
+ ) : (
171
+ <span></span>
172
+ )}
173
+ </nav>
174
+ )}
@@ -14,6 +14,14 @@ interface Props {
14
14
  lastUpdated?: string;
15
15
  prev?: PaginationLink;
16
16
  next?: PaginationLink;
17
+ /**
18
+ * Captions above the prev/next titles. Default "Previous" / "Next",
19
+ * which is right for docs, where adjacency is position in the sidebar.
20
+ * A blog's adjacency is TIME, so it passes "Newer" / "Older" — the
21
+ * link means something different there and the label should say so.
22
+ */
23
+ prevLabel?: string;
24
+ nextLabel?: string;
17
25
  copyright?: string;
18
26
  /**
19
27
  * When true, render a low-key footer link to `/llms.txt` so
@@ -32,6 +40,8 @@ const {
32
40
  lastUpdated,
33
41
  prev,
34
42
  next,
43
+ prevLabel = "Previous",
44
+ nextLabel = "Next",
35
45
  copyright,
36
46
  llmsLink = false,
37
47
  llmsLinkHref = "/llms.txt",
@@ -74,7 +84,7 @@ function formatDate(value: string): string {
74
84
  <a href={prev.href} class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent" rel="prev">
75
85
  <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"><polyline points="15 18 9 12 15 6"/></svg>
76
86
  <div class="text-left">
77
- <div class="text-xs text-muted-foreground">Previous</div>
87
+ <div class="text-xs text-muted-foreground">{prevLabel}</div>
78
88
  <div>{prev.label}</div>
79
89
  </div>
80
90
  </a>
@@ -82,7 +92,7 @@ function formatDate(value: string): string {
82
92
  {next ? (
83
93
  <a href={next.href} class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent" rel="next">
84
94
  <div class="text-right">
85
- <div class="text-xs text-muted-foreground">Next</div>
95
+ <div class="text-xs text-muted-foreground">{nextLabel}</div>
86
96
  <div>{next.label}</div>
87
97
  </div>
88
98
  <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"><polyline points="9 18 15 12 9 6"/></svg>
@@ -36,6 +36,7 @@ import Separator from "@dogsbay/ui/separator/Separator.astro";
36
36
  import ThemeToggle from "@dogsbay/ui/theme-toggle/ThemeToggle.astro";
37
37
  import DocsToc from "./DocsToc.astro";
38
38
  import { resolveTocPlacement, hasDisplayableToc, type TocMode } from "./toc-placement.js";
39
+ import { linkIconAttrs } from "./link-icons.js";
39
40
  import DocsFooter from "./DocsFooter.astro";
40
41
  import SearchDialog from "./SearchDialog.astro";
41
42
  import TagList from "./TagList.astro";
@@ -405,6 +406,32 @@ interface Props {
405
406
  * in per-page.
406
407
  */
407
408
  wideLayout?: boolean;
409
+
410
+ /**
411
+ * Which chrome this page wears.
412
+ *
413
+ * `"docs"` (default) is the full documentation shell. `"blog"` drops
414
+ * the sidebar and its nav fetch, and turns on the byline row — the
415
+ * blog is the same SITE, not a second design system, so the header,
416
+ * footer, search, page actions, TOC and every theme token stay
417
+ * identical. See plans/blog-capability.md Phase 3.
418
+ */
419
+ chrome?: "docs" | "blog";
420
+
421
+ /** Post authors. No byline is rendered when absent — never a placeholder. */
422
+ author?: string[];
423
+ /** ISO publish date for the byline. */
424
+ publishedDate?: string;
425
+ /** ISO updated date; shown only when it differs from publishedDate. */
426
+ updatedDate?: string;
427
+ /**
428
+ * Reading estimate in whole minutes. Computed at EMIT time from the
429
+ * TreeNode word count — a browser-side count would re-walk the DOM on
430
+ * every load for a number that cannot change.
431
+ */
432
+ readingMinutes?: number;
433
+ /** Optional hero image URL, rendered above the prose. */
434
+ heroImage?: string;
408
435
  /**
409
436
  * Table-of-contents placement. Default `"top"`.
410
437
  * - `"top"` — expandable "On this page" disclosure at the top of the
@@ -416,6 +443,18 @@ interface Props {
416
443
  * See plans/ask-branch1-placement-toc.md.
417
444
  */
418
445
  toc?: TocMode;
446
+ /**
447
+ * Internal/external link-icon glyphs. When set, a small trailing icon
448
+ * marks links in the prose — external (absolute / protocol-relative
449
+ * href) vs internal (root / relative) — classified purely by href
450
+ * shape in CSS. Either side may be absent to mark only that link kind.
451
+ * Sourced from `content.linkIcons` in `dogsbay.config.yml`.
452
+ * See plans/link-resolution-and-icons.md.
453
+ */
454
+ linkIcons?: {
455
+ external?: string;
456
+ internal?: string;
457
+ };
419
458
  class?: string;
420
459
  }
421
460
 
@@ -476,9 +515,22 @@ const {
476
515
  basePath,
477
516
  navMode = "client",
478
517
  wideLayout = false,
518
+ chrome = "docs",
519
+ author,
520
+ publishedDate,
521
+ updatedDate,
522
+ readingMinutes,
523
+ heroImage,
524
+ linkIcons,
479
525
  class: className,
480
526
  } = Astro.props;
481
527
 
528
+ // Link icons — classified in CSS by href shape. `data-link-icons`
529
+ // lists which kinds are active; the glyphs ride in CSS custom props
530
+ // (single-quoted so they're valid `content:` string values).
531
+ const { tokens: _linkIconTokens, style: _linkIconStyle } =
532
+ linkIconAttrs(linkIcons);
533
+
482
534
  // Resolve LLM action visibility + placement once. The component
483
535
  // guards against missing markdownBody / mdUrl internally, but we
484
536
  // also gate at the layout level so the slots stay empty when
@@ -562,9 +614,54 @@ const computedCanonical = canonicalUrl
562
614
  ? canonicalOrigin + Astro.url.pathname
563
615
  : undefined);
564
616
 
565
- // Markdown mirror — append `.md` to the current path for the alternate link
617
+ /**
618
+ * Format an ISO date for a byline.
619
+ *
620
+ * Runs at BUILD time, so the output is baked into the HTML and every
621
+ * reader sees the same string — no hydration flash, and no dependence on
622
+ * the visitor's locale for a date the author wrote. Falls back to the
623
+ * raw value if it will not parse, so a typo shows up as itself rather
624
+ * than as "Invalid Date".
625
+ */
626
+ function _formatPostDate(iso: string): string {
627
+ const d = new Date(iso);
628
+ if (Number.isNaN(d.getTime())) return iso;
629
+ // timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
630
+ // 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
631
+ // negative-offset zone (the default on many CI runners) would render
632
+ // "31 December 2025". DocsFooter already carries this fix; these
633
+ // copies dropped it.
634
+ return d.toLocaleDateString("en-GB", {
635
+ year: "numeric",
636
+ month: "long",
637
+ day: "numeric",
638
+ timeZone: "UTC",
639
+ });
640
+ }
641
+
642
+ // Markdown mirror for the alternate link.
643
+ //
644
+ // Leaf pages append `.md` to the trimmed path (`/getting-started/` →
645
+ // `/getting-started.md`), matching the emitted `<slug>.md.ts` endpoint.
646
+ // The SITE INDEX is the exception: it is emitted as `index.md.ts`, so
647
+ // its mirror is `<base>/index.md`. Appending `.md` there produced
648
+ // `/.md` (root-served) or `/blog.md` (mounted) — neither exists, and on
649
+ // a mounted site `/blog.md` also falls outside the `/blog/*` route.
650
+ // Both leaf and index URLs carry a trailing slash under Astro's
651
+ // directory build format, so only "is this the site root" distinguishes
652
+ // them. Kept in step with `shouldRewriteToMarkdown`, which is handed the
653
+ // same combined prefix.
654
+ // Use the `basePath` PROP, not import.meta.env.BASE_URL. Astro's `base`
655
+ // carries only site.url's path (urlBase); the prop carries the COMBINED
656
+ // urlBase + site.basePath prefix — the same value the middleware gets —
657
+ // so on a `site.basePath: /docs` deploy the two would otherwise disagree
658
+ // about which page is the index.
659
+ const mdMirrorBase = (basePath ?? "").replace(/\/+$/, "");
660
+ const mdMirrorPath = Astro.url.pathname.replace(/\/$/, "");
566
661
  const mdMirrorHref = mdMirror
567
- ? (Astro.url.pathname.replace(/\/$/, "") || "") + ".md"
662
+ ? mdMirrorPath === mdMirrorBase
663
+ ? `${mdMirrorBase}/index.md`
664
+ : `${mdMirrorPath}.md`
568
665
  : undefined;
569
666
 
570
667
  // Tag keywords for HTML meta + JSON-LD. Slug-based identifiers
@@ -614,6 +711,50 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
614
711
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
615
712
  <title>{title} | {siteName}</title>
616
713
 
714
+ <style is:global>
715
+ /*
716
+ The sticky-chrome contract. This header is exactly this tall, and
717
+ anything else that sticks (the release-comparison toolbar) offsets
718
+ by the same variable — so they cannot drift apart.
719
+ */
720
+ :root {
721
+ --db-header-height: 3rem;
722
+ }
723
+
724
+ /*
725
+ Internal/external link icons. Opt-in via `content.linkIcons`,
726
+ which sets `data-link-icons` (which kinds are active) and the
727
+ `--db-link-icon-*` custom props (the glyphs) on <body>. Links are
728
+ classified purely by href shape, so it covers both structured
729
+ links and raw-HTML anchors from imported content. Scoped to
730
+ `.docs-prose` so nav/header/footer links are untouched.
731
+ */
732
+ body[data-link-icons~="external"] .docs-prose a[href^="http://" i]::after,
733
+ body[data-link-icons~="external"] .docs-prose a[href^="https://" i]::after,
734
+ body[data-link-icons~="external"] .docs-prose a[href^="//"]::after {
735
+ content: var(--db-link-icon-external, "");
736
+ display: inline-block;
737
+ margin-inline-start: 0.15em;
738
+ font-size: 0.85em;
739
+ line-height: 1;
740
+ vertical-align: baseline;
741
+ opacity: 0.7;
742
+ }
743
+ body[data-link-icons~="internal"]
744
+ .docs-prose
745
+ a[href^="/"]:not([href^="//"])::after,
746
+ body[data-link-icons~="internal"] .docs-prose a[href^="./"]::after,
747
+ body[data-link-icons~="internal"] .docs-prose a[href^="../"]::after {
748
+ content: var(--db-link-icon-internal, "");
749
+ display: inline-block;
750
+ margin-inline-start: 0.15em;
751
+ font-size: 0.85em;
752
+ line-height: 1;
753
+ vertical-align: baseline;
754
+ opacity: 0.7;
755
+ }
756
+ </style>
757
+
617
758
  {metaDescription && <meta name="description" content={metaDescription} />}
618
759
  {favicon && <link rel="icon" href={favicon} />}
619
760
  {themeColor && <meta name="theme-color" content={themeColor} />}
@@ -708,8 +849,19 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
708
849
  <body
709
850
  class="bg-background text-foreground antialiased"
710
851
  data-pagefind-ignore={excludeFromSearch ? "" : undefined}
852
+ data-link-icons={_linkIconTokens || undefined}
853
+ style={_linkIconStyle || undefined}
711
854
  >
712
855
  <SidebarProvider>
856
+ {/*
857
+ chrome="blog" drops the sidebar COLUMN only. SidebarProvider and
858
+ SidebarInset stay, so the content column, header offset and
859
+ sticky contract are byte-identical to a docs page — which is what
860
+ makes the blog read as the same site rather than a lookalike.
861
+ With no <Sidebar> sibling the inset's peer-* selectors simply do
862
+ not match and it fills the width.
863
+ */}
864
+ {chrome === "docs" && (
713
865
  <Sidebar collapsible="icon">
714
866
  <SidebarHeader>
715
867
  <SidebarMenu>
@@ -742,6 +894,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
742
894
  <DocsNavClient
743
895
  currentPath={currentPath}
744
896
  basePath={basePath ?? ""}
897
+ namespace={multiSource?.namespace}
745
898
  version={multiSource?.version}
746
899
  locale={multiSource?.locale}
747
900
  />
@@ -755,6 +908,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
755
908
  <SidebarNavTree
756
909
  items={filterNavByAxis(group.items, {
757
910
  basePath: basePath ?? "/docs",
911
+ namespace: multiSource?.namespace,
758
912
  version: multiSource?.version,
759
913
  locale: multiSource?.locale,
760
914
  })}
@@ -769,6 +923,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
769
923
  <SidebarNavTree
770
924
  items={filterNavByAxis(nav, {
771
925
  basePath: basePath ?? "/docs",
926
+ namespace: multiSource?.namespace,
772
927
  version: multiSource?.version,
773
928
  locale: multiSource?.locale,
774
929
  })}
@@ -781,10 +936,17 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
781
936
 
782
937
  <SidebarRail />
783
938
  </Sidebar>
939
+ )}
784
940
 
785
941
  <SidebarInset>
786
- <header class="sticky top-0 z-40 flex h-12 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur-sm supports-[backdrop-filter]:bg-background/60">
787
- <SidebarTrigger class="-ml-1" />
942
+ {/*
943
+ Height comes from `--db-header-height` (declared on :root in
944
+ <head>) — the CONTRACT any other sticky chrome offsets by, e.g.
945
+ the release-comparison toolbar. One variable, so a change here
946
+ can never leave another sticky bar overlapping or floating.
947
+ */}
948
+ <header class="sticky top-0 z-40 flex h-[var(--db-header-height)] shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur-sm supports-[backdrop-filter]:bg-background/60">
949
+ {chrome === "docs" && <SidebarTrigger class="-ml-1" />}
788
950
  <Separator orientation="vertical" class="mr-2 h-4" />
789
951
  <span class="text-sm text-muted-foreground" data-page-title>{title}</span>
790
952
  <div class="ml-auto flex items-center gap-2">
@@ -827,6 +989,8 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
827
989
  pagefindUrl={pagefindUrl}
828
990
  navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
829
991
  taxonomyDisplay={taxonomyDisplay}
992
+ scopeProduct={multiSource?.namespace}
993
+ scopeVersion={multiSource?.version}
830
994
  />
831
995
  )}
832
996
  {repoUrl && (
@@ -891,6 +1055,18 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
891
1055
  ))}
892
1056
  {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
893
1057
  {pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
1058
+ {/*
1059
+ Multi-source axis filters — so search on a versioned/multi-
1060
+ product site can be scoped to (or faceted by) the current
1061
+ product and version. `product` = namespace, `version` = the
1062
+ version segment. Only emitted when the axis is active.
1063
+ */}
1064
+ {multiSource?.namespace && (
1065
+ <div hidden data-pagefind-filter={`product:${multiSource.namespace}`}></div>
1066
+ )}
1067
+ {multiSource?.version && (
1068
+ <div hidden data-pagefind-filter={`version:${multiSource.version}`}></div>
1069
+ )}
894
1070
  {/*
895
1071
  Custom-taxonomy filters. Any taxonomy declared in
896
1072
  `dogsbay.config.yml` that isn't one of the five built-ins
@@ -944,12 +1120,62 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
944
1120
  {title}
945
1121
  </h1>
946
1122
  )}
1123
+ {/*
1124
+ Byline — blog chrome only, and only when there is
1125
+ something to say. An absent author means NO byline row,
1126
+ never "Unknown": a placeholder byline is worse than none,
1127
+ because it reads as data rather than as a gap.
1128
+
1129
+ Sits between the H1 and the lede so the reader gets
1130
+ attribution before the summary, which is the order a
1131
+ post is scanned in.
1132
+ */}
1133
+ {chrome === "blog" && (author?.length || publishedDate || readingMinutes) && (
1134
+ <div
1135
+ class="not-prose mb-6 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"
1136
+ data-post-byline
1137
+ data-pagefind-ignore
1138
+ >
1139
+ {author && author.length > 0 && (
1140
+ <span class="font-medium text-foreground">{author.join(", ")}</span>
1141
+ )}
1142
+ {author && author.length > 0 && publishedDate && (
1143
+ <span aria-hidden="true">&middot;</span>
1144
+ )}
1145
+ {publishedDate && (
1146
+ <time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
1147
+ )}
1148
+ {publishedDate && readingMinutes && <span aria-hidden="true">&middot;</span>}
1149
+ {readingMinutes && <span>{readingMinutes} min read</span>}
1150
+ {updatedDate && updatedDate !== publishedDate && (
1151
+ <span>
1152
+ &middot; Updated <time datetime={updatedDate}>{_formatPostDate(updatedDate)}</time>
1153
+ </span>
1154
+ )}
1155
+ </div>
1156
+ )}
1157
+
947
1158
  {autoLede && description && (
948
1159
  <p class="text-lg text-muted-foreground mb-6">
949
1160
  {description}
950
1161
  </p>
951
1162
  )}
952
1163
 
1164
+ {/*
1165
+ Hero image. aspect-boxed on purpose — an unsized image
1166
+ above the fold is the classic layout-shift source, and it
1167
+ would shove the prose down after paint.
1168
+ */}
1169
+ {chrome === "blog" && heroImage && (
1170
+ <img
1171
+ src={heroImage}
1172
+ alt=""
1173
+ class="not-prose mb-8 w-full rounded-lg border border-border object-cover aspect-[2/1]"
1174
+ loading="eager"
1175
+ decoding="async"
1176
+ />
1177
+ )}
1178
+
953
1179
  {hasMetaStrip && (
954
1180
  <div
955
1181
  class="not-prose mb-6 flex flex-wrap items-center gap-2"
@@ -996,6 +1222,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
996
1222
  lastUpdated={lastUpdated}
997
1223
  prev={prev}
998
1224
  next={next}
1225
+ {/*
1226
+ Blog adjacency is chronological, so "Previous/Next"
1227
+ would describe the wrong axis — prev is the NEWER post.
1228
+ */}
1229
+ prevLabel={chrome === "blog" ? "Newer" : undefined}
1230
+ nextLabel={chrome === "blog" ? "Older" : undefined}
999
1231
  copyright={copyright}
1000
1232
  llmsLink={llmFooterLink}
1001
1233
  llmsLinkHref={llmsLinkHrefResolved}
@@ -17,7 +17,10 @@
17
17
  * - HTML per page: ~200 bytes (this placeholder + a tiny script
18
18
  * tag) vs ~600 KB+ for the SSR tree at scale.
19
19
  * - No-JS users see only the skeleton + the `<noscript>` fallback
20
- * link. A `sitemap.xml` link covers no-JS navigation.
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.
21
24
  * - First paint waits for the JS bundle + the JSON fetch. On a 4G
22
25
  * connection that's typically <200 ms; the skeleton fills the
23
26
  * space until then.
@@ -34,21 +37,32 @@ interface Props {
34
37
  * so multi-axis sites work the same as SSR.
35
38
  */
36
39
  basePath?: string;
40
+ /** Current source's product/namespace, if multi-product site. */
41
+ namespace?: string;
37
42
  /** Current source's version axis value, if multi-version site. */
38
43
  version?: string;
39
44
  /** Current source's locale axis value, if multi-locale site. */
40
45
  locale?: string;
41
46
  }
42
47
 
43
- const { currentPath, basePath = "", version, locale } = Astro.props;
48
+ const { currentPath, basePath = "", namespace, version, locale } = Astro.props;
44
49
  const navUrl = `${basePath}/_dogsbay/nav.json`;
45
50
  ---
46
51
 
52
+ {/*
53
+ `role="navigation"` is REQUIRED here, not decoration: `aria-label` and
54
+ `aria-busy` are prohibited on a generic div (axe: aria-prohibited-attr,
55
+ serious) because a role-less element has no accessible name to label.
56
+ Giving the nav container its real role makes both attributes legal and
57
+ makes the landmark discoverable — it was previously neither.
58
+ */}
47
59
  <div
48
60
  id="docs-nav-root"
61
+ role="navigation"
49
62
  data-nav-url={navUrl}
50
63
  data-current-path={currentPath}
51
64
  data-base-path={basePath}
65
+ data-namespace={namespace ?? ""}
52
66
  data-version={version ?? ""}
53
67
  data-locale={locale ?? ""}
54
68
  aria-busy="true"
@@ -73,7 +87,11 @@ const navUrl = `${basePath}/_dogsbay/nav.json`;
73
87
  <noscript>
74
88
  <p class="px-2 py-1.5 text-sm text-sidebar-foreground/70">
75
89
  JavaScript is required to render the sidebar. Use the
76
- <a href={`${basePath}/sitemap.xml`} class="underline">sitemap</a>
90
+ {/* sitemap-index.xml, not sitemap.xml — Dogsbay emits the
91
+ sitemap-index / sitemap-0 pair directly (see emitSitemapFiles);
92
+ `sitemap.xml` has never existed, so this no-JS fallback link
93
+ 404'd on every site. */}
94
+ <a href={`${basePath}/sitemap-index.xml`} class="underline">sitemap</a>
77
95
  to browse all pages.
78
96
  </p>
79
97
  </noscript>
@@ -57,6 +57,14 @@ interface Props {
57
57
  * back to slugs when undefined.
58
58
  */
59
59
  taxonomyDisplay?: Record<string, TaxonomyDisplay>;
60
+ /**
61
+ * Current page's product (namespace) and version. On a multi-product /
62
+ * versioned site, search opens PRE-SCOPED to these — a Calico 3.32 page
63
+ * searches Calico 3.32 by default. The scope is seeded as normal facet
64
+ * selections, so the reader can untick them to search wider.
65
+ */
66
+ scopeProduct?: string;
67
+ scopeVersion?: string;
60
68
  }
61
69
 
62
70
  const {
@@ -64,6 +72,8 @@ const {
64
72
  navUrl,
65
73
  placeholder = "Search docs...",
66
74
  taxonomyDisplay,
75
+ scopeProduct,
76
+ scopeVersion,
67
77
  } = Astro.props;
68
78
  ---
69
79
 
@@ -72,6 +82,8 @@ const {
72
82
  data-pagefind-url={pagefindUrl}
73
83
  data-nav-url={navUrl}
74
84
  data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
85
+ data-scope-product={scopeProduct ?? ""}
86
+ data-scope-version={scopeVersion ?? ""}
75
87
  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"
76
88
  >
77
89
  <form method="dialog" class="flex flex-col">
@@ -215,6 +227,9 @@ const {
215
227
  };
216
228
 
217
229
  const dialog = document.querySelector<HTMLDialogElement>("[data-search-dialog]");
230
+ // Current page's product/version — search opens pre-scoped to these.
231
+ const scopeProduct = dialog?.dataset.scopeProduct || "";
232
+ const scopeVersion = dialog?.dataset.scopeVersion || "";
218
233
  const trigger = document.querySelector<HTMLButtonElement>("[data-search-trigger]");
219
234
  const input = dialog?.querySelector<HTMLInputElement>("[data-search-input]");
220
235
  const resultsBox = dialog?.querySelector<HTMLDivElement>("[data-search-results]");
@@ -652,9 +667,22 @@ const {
652
667
  const fromUrl = parseFiltersFromUrl(new URLSearchParams(window.location.search));
653
668
  input!.value = fromUrl.query;
654
669
  filters = fromUrl.filters;
670
+ const hadUrlState = fromUrl.query.length > 0 || countActiveFilters(filters) > 0;
671
+ // Auto-scope: a FRESH open (no filters/query carried in the URL) on a
672
+ // multi-product/versioned site starts scoped to the CURRENT product +
673
+ // version. Seeded as ordinary facet selections, so the reader can
674
+ // untick "Product: calico" / "Version: 3.32" to search wider. A URL
675
+ // that already carries state wins (shared/roundtripped searches).
676
+ if (!hadUrlState) {
677
+ if (scopeProduct) filters.product = [scopeProduct];
678
+ if (scopeVersion) filters.version = [scopeVersion];
679
+ }
655
680
  renderFacets();
656
681
 
657
- const hasInitial = input!.value.length > 0 || countActiveFilters(filters) > 0;
682
+ // Run immediately only when there's a query or the state came from the
683
+ // URL. A fresh open shows the empty prompt with the scope pre-ticked —
684
+ // results appear (scoped) as soon as the reader types.
685
+ const hasInitial = hadUrlState;
658
686
  if (hasInitial) {
659
687
  runSearch(input!.value);
660
688
  } else {
@@ -37,6 +37,9 @@ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Version
37
37
  {currentRow?.entry.eol && (
38
38
  <span class="ml-1 rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">EOL</span>
39
39
  )}
40
+ {currentRow?.entry.prerelease && (
41
+ <span class="ml-1 rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">Pre</span>
42
+ )}
40
43
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-1 transition-transform"><polyline points="6 9 12 15 18 9"/></svg>
41
44
  </summary>
42
45
  <ul class="absolute right-0 z-50 mt-1 min-w-[10rem] rounded-md border border-border bg-popover p-1 text-sm shadow-md">
@@ -59,6 +62,9 @@ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Version
59
62
  {row.entry.eol && (
60
63
  <span class="rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">EOL</span>
61
64
  )}
65
+ {row.entry.prerelease && (
66
+ <span class="rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">Pre</span>
67
+ )}
62
68
  {row.entry.default && !row.isCurrent && (
63
69
  <span class="text-[10px] text-muted-foreground">default</span>
64
70
  )}
@@ -23,11 +23,63 @@
23
23
  */
24
24
  import { filterNavByAxis } from "./nav-filter.js";
25
25
 
26
+ interface NavMark {
27
+ kind: "added" | "changed" | "removed" | "moved";
28
+ label?: string;
29
+ subtree?: boolean;
30
+ }
31
+
26
32
  interface NavItem {
27
33
  label: string;
28
34
  href?: string;
29
35
  icon?: string;
30
36
  children?: NavItem[];
37
+ mark?: NavMark;
38
+ }
39
+
40
+ /**
41
+ * A state marker beside a nav label (release comparisons mark changed /
42
+ * new / removed pages; the same slot serves "new since your last
43
+ * visit", deprecation flags, version badges).
44
+ *
45
+ * Accessibility contract — mirrors SidebarNavMark.astro, which renders
46
+ * the server-side tree:
47
+ * - **Never colour alone** (WCAG 1.4.1): a distinct GLYPH carries the
48
+ * meaning, so it survives greyscale and colour-blindness; colour only
49
+ * reinforces.
50
+ * - **Survives forced-colors** (High Contrast strips backgrounds): the
51
+ * glyph is real text, so it always renders.
52
+ * - **Announced**: a visually-hidden word rides along, so the row reads
53
+ * "MySQL, changed". It is a sibling span, NOT an aria-label on the
54
+ * link — an aria-label would REPLACE the page name in the
55
+ * accessibility tree, losing the label it exists to announce.
56
+ */
57
+ const MARK_GLYPH: Record<NavMark["kind"], string> = {
58
+ added: "+",
59
+ changed: "•",
60
+ removed: "−",
61
+ moved: "→",
62
+ };
63
+
64
+ function buildMark(mark: NavMark): HTMLElement {
65
+ const text = mark.label ?? (mark.subtree ? `contains ${mark.kind}` : mark.kind);
66
+ const wrap = document.createElement("span");
67
+ wrap.className = "db-nav-mark";
68
+ wrap.dataset.navMark = mark.kind;
69
+ if (mark.subtree) wrap.dataset.navMarkSubtree = "";
70
+ wrap.title = text;
71
+
72
+ const glyph = document.createElement("span");
73
+ glyph.setAttribute("aria-hidden", "true");
74
+ glyph.textContent = MARK_GLYPH[mark.kind];
75
+ wrap.appendChild(glyph);
76
+
77
+ const sr = document.createElement("span");
78
+ sr.className = "sr-only";
79
+ sr.textContent = text;
80
+ wrap.appendChild(sr);
81
+
82
+ return wrap;
31
83
  }
32
84
 
33
85
  /**
@@ -123,6 +175,14 @@ export function renderItem(item: NavItem, current: string, level: number): HTMLL
123
175
  if (active) link.dataset.active = "true";
124
176
  row.appendChild(link);
125
177
 
178
+ if (item.mark) {
179
+ // A SUBTREE mark describes DESCENDANTS — never strike through this
180
+ // row for it (a surviving group whose child was deleted would read
181
+ // as a deleted section).
182
+ if (!item.mark.subtree) link.dataset.navMarkRow = item.mark.kind;
183
+ row.appendChild(buildMark(item.mark));
184
+ }
185
+
126
186
  const submenu = document.createElement("ul");
127
187
  submenu.id = submenuId;
128
188
  submenu.dataset.navSubmenu = "";
@@ -173,6 +233,11 @@ export function renderItem(item: NavItem, current: string, level: number): HTMLL
173
233
  label.textContent = item.label;
174
234
  summary.appendChild(label);
175
235
 
236
+ // A collapsed group HIDES its children, so a change inside must
237
+ // signal outward — the same visibility rule the diff renderer
238
+ // applies to tabs.
239
+ if (item.mark) summary.appendChild(buildMark(item.mark));
240
+
176
241
  details.appendChild(summary);
177
242
 
178
243
  const childUl = document.createElement("ul");
@@ -212,10 +277,17 @@ export function renderItem(item: NavItem, current: string, level: number): HTMLL
212
277
  }
213
278
 
214
279
  const label = document.createElement("span");
215
- label.className = "truncate";
280
+ label.className = "min-w-0 flex-1 truncate";
216
281
  label.textContent = item.label;
217
282
  a.appendChild(label);
218
283
 
284
+ if (item.mark) {
285
+ // strikethrough only for a page that IS removed, never for an
286
+ // aggregate about its children
287
+ if (!item.mark.subtree) a.dataset.navMarkRow = item.mark.kind;
288
+ a.appendChild(buildMark(item.mark));
289
+ }
290
+
219
291
  li.appendChild(a);
220
292
  }
221
293
 
@@ -304,13 +376,20 @@ export async function hydrateDocsNav(): Promise<void> {
304
376
  }
305
377
  const current = normalize(root.dataset.currentPath || location.pathname);
306
378
  const basePath = root.dataset.basePath || "";
379
+ const namespace = root.dataset.namespace || undefined;
307
380
  const version = root.dataset.version || undefined;
308
381
  const locale = root.dataset.locale || undefined;
309
382
 
310
383
  try {
311
384
  const nav = await fetchNav(navUrl);
312
385
  const filtered = filterNavByAxis(nav, {
313
- basePath: basePath || "/docs",
386
+ // Pass basePath through as-is — an empty basePath (root-served
387
+ // site) must stay "", matching the SSR path's `?? "/docs"`
388
+ // (which keeps ""). Coercing "" → "/docs" makes the filter look
389
+ // for `/docs/…` bucket hrefs that don't exist and blanks the
390
+ // whole sidebar on a root-served multi-source site.
391
+ basePath,
392
+ namespace: namespace || undefined,
314
393
  version: version || undefined,
315
394
  locale: locale || undefined,
316
395
  });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Internal/external link-icon affordance — the tiny bit of logic behind
3
+ * the `data-link-icons` + `--db-link-icon-*` attributes DocsLayout stamps
4
+ * on `<body>`. Classification itself is pure CSS (by href shape); this
5
+ * only turns the configured glyphs into the attribute + custom-property
6
+ * values. See plans/link-resolution-and-icons.md.
7
+ */
8
+
9
+ export interface LinkIcons {
10
+ /** Glyph after external links (absolute / protocol-relative href). */
11
+ external?: string;
12
+ /** Glyph after internal links (root / relative href). */
13
+ internal?: string;
14
+ }
15
+
16
+ export interface LinkIconAttrs {
17
+ /**
18
+ * Space-separated active kinds for `data-link-icons` (`"external"`,
19
+ * `"internal"`, or both) — undefined when the feature is off, so the
20
+ * attribute is omitted entirely.
21
+ */
22
+ tokens?: string;
23
+ /**
24
+ * Inline `style` value setting the `--db-link-icon-*` custom
25
+ * properties to the (single-quoted, CSS-string-safe) glyphs —
26
+ * undefined when nothing is active.
27
+ */
28
+ style?: string;
29
+ }
30
+
31
+ /** CSS-string-escape a glyph so it's a valid single-quoted `content:` value. */
32
+ function cssString(glyph: string): string {
33
+ return glyph.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
34
+ }
35
+
36
+ /**
37
+ * Build the `<body>` attributes for the link-icon feature. A side is
38
+ * active only when its glyph is a non-empty string; an absent config (or
39
+ * all-empty) yields `{}` so DocsLayout emits no attributes.
40
+ */
41
+ export function linkIconAttrs(icons: LinkIcons | undefined): LinkIconAttrs {
42
+ const external = icons?.external?.trim() ? icons.external : "";
43
+ const internal = icons?.internal?.trim() ? icons.internal : "";
44
+ const tokens = [external ? "external" : "", internal ? "internal" : ""]
45
+ .filter(Boolean)
46
+ .join(" ");
47
+ const style = [
48
+ external ? `--db-link-icon-external: '${cssString(external)}'` : "",
49
+ internal ? `--db-link-icon-internal: '${cssString(internal)}'` : "",
50
+ ]
51
+ .filter(Boolean)
52
+ .join("; ");
53
+ return { tokens: tokens || undefined, style: style || undefined };
54
+ }
@@ -7,6 +7,11 @@
7
7
  * Returns the path to rewrite to (the `.md` mirror endpoint) when the
8
8
  * request should be served as markdown; returns `null` when the normal
9
9
  * HTML response should pass through.
10
+ *
11
+ * `basePath` is the prefix the site is SERVED at (Dogsbay's combined
12
+ * urlBase + basePath). It is needed only to recognise the site index,
13
+ * whose mirror is `<base>/index.md` rather than `<base>.md` — see
14
+ * `shouldRewriteToMarkdown`.
10
15
  */
11
16
 
12
17
  const Q_PARAM_RE = /^\s*q\s*=\s*([0-9.]+)\s*$/i;
@@ -28,6 +33,7 @@ const Q_PARAM_RE = /^\s*q\s*=\s*([0-9.]+)\s*$/i;
28
33
  export function shouldRewriteToMarkdown(
29
34
  accept: string | null | undefined,
30
35
  pathname: string,
36
+ basePath = "",
31
37
  ): string | null {
32
38
  if (!accept) return null;
33
39
  if (!acceptsMarkdown(accept)) return null;
@@ -35,8 +41,38 @@ export function shouldRewriteToMarkdown(
35
41
  if (hasNonHtmlExtension(pathname)) return null;
36
42
 
37
43
  const trimmed = pathname.replace(/\/$/, "");
38
- const target = trimmed === "" ? "/.md" : `${trimmed}.md`;
39
- return target;
44
+ const base = basePath.replace(/\/+$/, "");
45
+
46
+ // A request outside the served prefix is not ours to rewrite. Without
47
+ // this guard, `("/", "/docs")` fell through to `"" + ".md"` — a
48
+ // RELATIVE target, resolved against whatever the request path was.
49
+ if (base && trimmed !== base && !trimmed.startsWith(`${base}/`)) return null;
50
+
51
+ // The site index is emitted as `index.md.ts`, so its mirror is
52
+ // `<base>/index.md`. Every other page emitted by the shipped importers
53
+ // has its mirror at `<path>.md`: Dogsbay builds in Astro's directory
54
+ // format, so a leaf at `/getting-started/` maps to
55
+ // `/getting-started.md`, and a directory index like `guides/index.md`
56
+ // is NORMALIZED to slug `guides` (see import-mkdocs.ts's
57
+ // `.replace(/\/index$/, "")`), emitting `guides.astro` + `guides.md.ts`
58
+ // — so `/guides.md` exists too.
59
+ //
60
+ // Appending `.md` to the site index produced `/.md` (root-served) or
61
+ // `/blog.md` (mounted); neither exists, and `/blog.md` additionally
62
+ // falls OUTSIDE the `/blog/*` Workers route. Leaf and index URLs both
63
+ // carry a trailing slash, so only the base comparison distinguishes
64
+ // them.
65
+ //
66
+ // CAVEAT: a caller driving `exportAstroProject` directly with an
67
+ // unnormalized `<dir>/index` slug gets `src/pages/<dir>/index.astro`
68
+ // (served `/<dir>/`) whose only mirror is `/<dir>/index.md`, and this
69
+ // returns `/<dir>.md` — a 404. No shipped importer does that. If one
70
+ // ever should, generalize the sibling `.md.ts` emitter in
71
+ // `format-astro/src/project.ts` rather than guessing here from a URL
72
+ // that cannot distinguish the two shapes.
73
+ if (trimmed === base) return `${base}/index.md`;
74
+
75
+ return `${trimmed}.md`;
40
76
  }
41
77
 
42
78
  function acceptsMarkdown(accept: string): boolean {
package/src/nav-filter.ts CHANGED
@@ -1,18 +1,17 @@
1
1
  /**
2
2
  * Multi-source nav filtering.
3
3
  *
4
- * When a docs site has multiple versions (or, in PR 5, locales)
5
- * configured, every page's emitted nav.json contains entries
6
- * from EVERY version. Without filtering, the sidebar shows
7
- * duplicate sectionsonce per version which is confusing
8
- * UX (writers see "Glossary" twice).
4
+ * When a docs site has multiple products/versions/locales, every page's
5
+ * emitted nav.json contains entries from EVERY bucket. Without filtering,
6
+ * the sidebar shows a product's sections once per version, and every other
7
+ * product tooconfusing UX. The fix: filter the nav tree to the current
8
+ * page's (namespace, locale, version) bucket.
9
9
  *
10
- * The fix: filter the nav tree to entries that match the
11
- * current page's version (or, eventually, locale). Pure
12
- * function; takes nav + axis filter, returns a pruned copy.
13
- *
14
- * The axis switchers handle navigation BETWEEN versions; the
15
- * sidebar nav reflects only the active axis bucket.
10
+ * The match is a single COMPOSED prefix in the canonical URL order
11
+ * `/<basePath>/<namespace>/<locale>/<version>/...` whichever of those
12
+ * axes the current page carries. The axis switchers handle navigation
13
+ * BETWEEN buckets; the sidebar reflects only the active one. Pure
14
+ * function: nav + filter pruned copy.
16
15
  */
17
16
 
18
17
  interface NavItem {
@@ -22,145 +21,59 @@ interface NavItem {
22
21
  }
23
22
 
24
23
  export interface NavFilter {
25
- /** Site basePath (e.g. "/docs"). Used to compose the version prefix. */
24
+ /** Site basePath (e.g. "" for root, "/docs"). */
26
25
  basePath: string;
27
- /**
28
- * Current page's effective version. When undefined, no
29
- * version filtering is applied single-version sites pass
30
- * the full nav through unchanged.
31
- */
32
- version?: string;
33
- /**
34
- * Current page's effective locale. When set, nav items are
35
- * filtered to those whose href starts with the corresponding
36
- * locale segment (`<basePath>/<locale>/`).
37
- */
26
+ /** Current page's product/namespace segment (outermost), if any. */
27
+ namespace?: string;
28
+ /** Current page's locale segment (after namespace), if any. */
38
29
  locale?: string;
30
+ /** Current page's version segment (innermost, next to the page), if any. */
31
+ version?: string;
39
32
  }
40
33
 
41
34
  /**
42
- * Walk the nav tree and drop entries that don't belong to the
43
- * current version + locale. Group nodes (no `href`, with
44
- * `children`) survive iff any descendant survives empty
45
- * groups are pruned.
46
- *
47
- * Items without `href` AND without `children` are unusual but
48
- * pass through unchanged (defensive — never silently drop a
49
- * node we don't understand).
50
- *
51
- * Both filters apply concurrently: a multi-version multi-locale
52
- * site filters by BOTH simultaneously, so an item must match
53
- * /<basePath>/<locale>/.../<version>/... structurally.
35
+ * Prune the nav to the current page's bucket. Group nodes (no `href`,
36
+ * with `children`) survive iff a descendant survives; empty groups and
37
+ * childless/href-less nodes are dropped (else a non-current bucket's group
38
+ * lingers as a phantom header).
54
39
  */
55
- export function filterNavByAxis(
56
- items: NavItem[],
57
- filter: NavFilter,
58
- ): NavItem[] {
59
- if (!filter.version && !filter.locale) return items;
40
+ export function filterNavByAxis(items: NavItem[], filter: NavFilter): NavItem[] {
41
+ // Canonical order: namespace → locale → version. Only the axes the
42
+ // current page actually carries contribute to the match prefix.
43
+ const segs = [filter.namespace, filter.locale, filter.version].filter(
44
+ (s): s is string => s !== undefined && s !== "",
45
+ );
46
+ if (segs.length === 0) return items;
60
47
 
61
- // Locale axis prefix is the OUTERMOST per the canonical URL
62
- // composition: /<basePath>/<locale>/<version>/<ns>/<slug>.
63
- // We check the locale prefix first (basePath/<locale>/), then
64
- // (when version is also active) check that <version> is the
65
- // immediately-following segment.
66
- const localePrefix = filter.locale
67
- ? prefixFor(filter.basePath, filter.locale)
68
- : null;
69
- const versionSegment = filter.version ?? null;
48
+ const base = filter.basePath.replace(/\/$/, "");
49
+ const prefix = `${base}/${segs.join("/")}/`;
50
+ const prefixNoSlash = prefix.replace(/\/$/, "");
70
51
 
71
- return items.flatMap((item) =>
72
- filterOne(item, localePrefix, versionSegment, filter.basePath),
73
- );
52
+ return items.flatMap((item) => filterOne(item, prefix, prefixNoSlash));
74
53
  }
75
54
 
76
- function filterOne(
77
- item: NavItem,
78
- localePrefix: string | null,
79
- versionSegment: string | null,
80
- basePath: string,
81
- ): NavItem[] {
55
+ function filterOne(item: NavItem, prefix: string, prefixNoSlash: string): NavItem[] {
82
56
  if (item.children && item.children.length > 0) {
83
- const kept = item.children.flatMap((c) =>
84
- filterOne(c, localePrefix, versionSegment, basePath),
85
- );
57
+ const kept = item.children.flatMap((c) => filterOne(c, prefix, prefixNoSlash));
86
58
  if (kept.length === 0) return [];
87
59
  return [{ ...item, children: kept }];
88
60
  }
89
61
  if (item.href !== undefined) {
90
- if (!hrefMatchesAxes(item.href, localePrefix, versionSegment, basePath)) {
91
- return [];
92
- }
93
- return [item];
62
+ return hrefMatchesPrefix(item.href, prefix, prefixNoSlash) ? [item] : [];
94
63
  }
95
- return [item];
96
- }
97
-
98
- /**
99
- * Check that an href belongs to the requested (locale, version)
100
- * combination. Either prefix can be null — meaning that axis
101
- * isn't being filtered.
102
- */
103
- function hrefMatchesAxes(
104
- href: string,
105
- localePrefix: string | null,
106
- versionSegment: string | null,
107
- basePath: string,
108
- ): boolean {
109
- // External URLs aren't axis-bucketed.
110
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:")) {
111
- return false;
112
- }
113
-
114
- // Step 1: locale check. If locale axis is active, the href
115
- // must be inside /<basePath>/<locale>/.
116
- if (localePrefix !== null) {
117
- if (!hrefMatchesPrefix(href, localePrefix)) return false;
118
- }
119
-
120
- // Step 2: version check. The version segment is positioned
121
- // AFTER the locale segment when both are active, otherwise
122
- // immediately after basePath.
123
- if (versionSegment !== null) {
124
- const baseTrimmed = basePath.replace(/\/$/, "");
125
- const localeSegStart = localePrefix
126
- ? localePrefix.replace(/\/$/, "")
127
- : baseTrimmed;
128
- const versionPrefix = `${localeSegStart}/${versionSegment}/`;
129
- const versionPrefixNoSlash = versionPrefix.replace(/\/$/, "");
130
- if (
131
- !href.startsWith(versionPrefix) &&
132
- href !== versionPrefixNoSlash
133
- ) {
134
- return false;
135
- }
136
- }
137
-
138
- return true;
139
- }
140
-
141
- /**
142
- * Compose the URL prefix for a given version under the
143
- * configured basePath. Always ends in `/` so prefix-matching
144
- * doesn't accept partial segments (`/docs/v1` shouldn't match
145
- * `/docs/v10/...`).
146
- */
147
- function prefixFor(basePath: string, segment: string): string {
148
- const base = basePath.replace(/\/$/, "");
149
- return `${base}/${segment}/`;
64
+ // Childless AND href-less: no navigation, no bucket membership. In a
65
+ // filtered view it must be dropped.
66
+ return [];
150
67
  }
151
68
 
152
69
  /**
153
- * Whether an href belongs to the given version prefix. Tolerates
154
- * trailing slashes and missing-trailing-slash variants nav
155
- * importers don't all canonicalise the same way.
70
+ * Whether an href belongs to the composed bucket prefix. Tolerates the
71
+ * bucket's landing page itself (`/<prefix>` with no trailing slash);
72
+ * external URLs never match.
156
73
  */
157
- function hrefMatchesPrefix(href: string, prefix: string): boolean {
158
- // Skip external URLs.
74
+ function hrefMatchesPrefix(href: string, prefix: string, prefixNoSlash: string): boolean {
159
75
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:")) {
160
76
  return false;
161
77
  }
162
- // Match `/docs/v1/...` AND `/docs/v1` (the version's landing
163
- // page itself, if a writer linked to it directly).
164
- const trimmedPrefix = prefix.replace(/\/$/, "");
165
- return href.startsWith(prefix) || href === trimmedPrefix;
78
+ return href.startsWith(prefix) || href === prefixNoSlash;
166
79
  }
package/src/switcher.ts CHANGED
@@ -11,6 +11,14 @@ export interface AxisEntry {
11
11
  id: string;
12
12
  label?: string;
13
13
  eol?: boolean;
14
+ /** Pre-release mark (version axis only) — the moving next/beta head. */
15
+ prerelease?: boolean;
16
+ /**
17
+ * Served but kept OFF the switcher (version axis only). Marks the number
18
+ * a `latest` alias points at, so the dropdown shows one "3.32 (latest)"
19
+ * row instead of both "latest" and "3.32".
20
+ */
21
+ hidden?: boolean;
14
22
  default?: boolean;
15
23
  }
16
24
 
@@ -73,9 +81,32 @@ export interface BuildRowsInput {
73
81
  multiSource: MultiSourceMeta;
74
82
  }
75
83
 
84
+ /**
85
+ * Order two version ids. Compares numeric components left-to-right
86
+ * (`3.32` > `3.31`, `3.23-2` > `3.23-1`); a non-numeric alias (`latest`,
87
+ * `next`) sorts ABOVE any numbered version so descending order puts it
88
+ * first. When NEITHER id has any digits — a non-semver / codename scheme
89
+ * (`Boron`, `Argon`) — returns 0 so the sort is a no-op and the author's
90
+ * DECLARED order is preserved (JS sort is stable), rather than silently
91
+ * alphabetising and putting the newest release at the bottom.
92
+ */
93
+ export function compareVersionIds(a: string, b: string): number {
94
+ const na = (a.match(/\d+/g) ?? []).map(Number);
95
+ const nb = (b.match(/\d+/g) ?? []).map(Number);
96
+ if (na.length === 0 && nb.length === 0) return 0; // codenames → keep declared order
97
+ if (na.length === 0) return 1; // alias > number
98
+ if (nb.length === 0) return -1;
99
+ const len = Math.max(na.length, nb.length);
100
+ for (let i = 0; i < len; i++) {
101
+ const d = (na[i] ?? 0) - (nb[i] ?? 0);
102
+ if (d !== 0) return d;
103
+ }
104
+ return a.localeCompare(b);
105
+ }
106
+
76
107
  export function buildSwitcherRows(input: BuildRowsInput): SwitcherRow[] {
77
108
  const { axis, switcherMap, multiSource } = input;
78
- const entries = axis === "version" ? switcherMap.versions : switcherMap.locales;
109
+ const allEntries = axis === "version" ? switcherMap.versions : switcherMap.locales;
79
110
  const currentId =
80
111
  axis === "version" ? multiSource.version : multiSource.locale;
81
112
  const otherAxis: SwitcherAxis = axis === "version" ? "locale" : "version";
@@ -84,6 +115,56 @@ export function buildSwitcherRows(input: BuildRowsInput): SwitcherRow[] {
84
115
 
85
116
  const variants = switcherMap.byLogicalKey[logicalKeyFor(multiSource)] ?? [];
86
117
 
118
+ // On a MULTI-PRODUCT (namespace-active) site the declared version list
119
+ // is the UNION across products, but a page's variants are same-namespace
120
+ // (the logical key is namespaced). Scope the version switcher to the
121
+ // versions this page's product actually has, so a Calico page never
122
+ // offers a Calico-Enterprise version. Single-product sites (no
123
+ // namespace) keep the full declared list, with fallbacks for pages
124
+ // missing in some version.
125
+ let entries = allEntries;
126
+ if (axis === "version" && multiSource.namespace !== undefined) {
127
+ const available = new Set(
128
+ variants.map((v) => v.version).filter((v): v is string => v !== undefined),
129
+ );
130
+ entries = allEntries.filter((e) => available.has(e.id) || e.id === currentId);
131
+ }
132
+
133
+ // The version dropdown always reads newest → oldest (3.32, 3.31, …),
134
+ // regardless of the declared order. Aliases (e.g. "latest") that don't
135
+ // parse as versions sort to the top, ahead of the numbers.
136
+ //
137
+ // `latest` alias + its target: a version marked `hidden` is the number
138
+ // the `latest` alias points at (Docusaurus's lastVersion). Show ONE row
139
+ // "3.32 (latest)" (linking to /latest/), derive its label from the hidden
140
+ // sibling, and drop hidden rows from the dropdown.
141
+ //
142
+ // The hidden number is DECLARED but NOT SERVED: `migrate-docusaurus
143
+ // --latest-alias` emits one source for the newest branch, at `latest`.
144
+ // That mirrors the source, where `versions: { "3.32": { path: "latest" } }`
145
+ // REPLACES the segment — docs.tigera.io has /calico/latest/ and no
146
+ // /calico/3.32/. Serving both invented a URL tree the source never had
147
+ // and duplicated every page of the newest release (338 on Calico) with
148
+ // no canonical. So the hidden entry is label-only metadata.
149
+ //
150
+ // `currentFoldedIntoLatest` still matters for a corpus that DOES serve
151
+ // the number (a hand-written config may), so a reader on `/3.32/…` has
152
+ // the merged `latest` row highlighted rather than no row at all. No
153
+ // `hidden` sibling → no merge.
154
+ let currentFoldedIntoLatest = false;
155
+ if (axis === "version") {
156
+ entries = [...entries].sort((a, b) => compareVersionIds(b.id, a.id));
157
+ const hiddenTarget = entries.find((e) => e.hidden && /\d/.test(e.id));
158
+ currentFoldedIntoLatest = hiddenTarget?.id === currentId;
159
+ entries = entries
160
+ .filter((e) => !e.hidden)
161
+ .map((e) =>
162
+ e.id === "latest" && hiddenTarget
163
+ ? { ...e, label: `${hiddenTarget.label ?? hiddenTarget.id} (latest)` }
164
+ : e,
165
+ );
166
+ }
167
+
87
168
  return entries.map((entry) => {
88
169
  const match = variants.find((v) => {
89
170
  // Match on this axis.
@@ -98,7 +179,7 @@ export function buildSwitcherRows(input: BuildRowsInput): SwitcherRow[] {
98
179
  return {
99
180
  entry,
100
181
  url: match?.url ?? null,
101
- isCurrent: entry.id === currentId,
182
+ isCurrent: entry.id === currentId || (entry.id === "latest" && currentFoldedIntoLatest),
102
183
  };
103
184
  });
104
185
  }