@dogsbay/docs-layout 0.2.0-beta.93 → 0.2.0-beta.95
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 +179 -0
- package/src/DocsFooter.astro +12 -2
- package/src/DocsLayout.astro +277 -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/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
|
}
|