@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 +6 -5
- package/src/BlogIndex.astro +174 -0
- package/src/DocsFooter.astro +12 -2
- package/src/DocsLayout.astro +236 -4
- package/src/DocsNavClient.astro +21 -3
- package/src/SearchDialog.astro +29 -1
- package/src/VersionSwitcher.astro +6 -0
- package/src/docs-nav-client.ts +81 -2
- package/src/link-icons.ts +54 -0
- package/src/markdown-negotiation.ts +38 -2
- package/src/nav-filter.ts +42 -129
- package/src/switcher.ts +83 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/docs-layout",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
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/
|
|
33
|
-
"@dogsbay/
|
|
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.
|
|
37
|
-
"vitest": "^
|
|
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">·</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">·</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
|
+
← 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 →
|
|
169
|
+
</a>
|
|
170
|
+
) : (
|
|
171
|
+
<span></span>
|
|
172
|
+
)}
|
|
173
|
+
</nav>
|
|
174
|
+
)}
|
package/src/DocsFooter.astro
CHANGED
|
@@ -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">
|
|
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">
|
|
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>
|
package/src/DocsLayout.astro
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
787
|
-
|
|
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">·</span>
|
|
1144
|
+
)}
|
|
1145
|
+
{publishedDate && (
|
|
1146
|
+
<time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
|
|
1147
|
+
)}
|
|
1148
|
+
{publishedDate && readingMinutes && <span aria-hidden="true">·</span>}
|
|
1149
|
+
{readingMinutes && <span>{readingMinutes} min read</span>}
|
|
1150
|
+
{updatedDate && updatedDate !== publishedDate && (
|
|
1151
|
+
<span>
|
|
1152
|
+
· 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}
|
package/src/DocsNavClient.astro
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/src/SearchDialog.astro
CHANGED
|
@@ -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
|
-
|
|
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
|
)}
|
package/src/docs-nav-client.ts
CHANGED
|
@@ -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
|
|
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
|
|
39
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 too — confusing UX. The fix: filter the nav tree to the current
|
|
8
|
+
* page's (namespace, locale, version) bucket.
|
|
9
9
|
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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").
|
|
24
|
+
/** Site basePath (e.g. "" for root, "/docs"). */
|
|
26
25
|
basePath: string;
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
return [item];
|
|
62
|
+
return hrefMatchesPrefix(item.href, prefix, prefixNoSlash) ? [item] : [];
|
|
94
63
|
}
|
|
95
|
-
|
|
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
}
|