@dogsbay/docs-layout 0.2.0-beta.114 → 0.2.0-beta.116
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 +5 -3
- package/src/DocsLayout.astro +49 -11
- package/src/DocsNavClient.astro +7 -1
- package/src/SearchDialog.astro +16 -0
- package/src/TagList.astro +7 -1
- package/src/VersionSwitcher.astro +1 -1
- package/src/docs-nav-client.ts +3 -0
- package/src/json-ld.ts +4 -165
- package/src/nav-filter.ts +36 -8
- package/src/pagefind-base-url.ts +59 -0
- package/src/switcher.ts +10 -2
- package/src/tag-list-data.ts +26 -3
- package/src/version-redirect.ts +17 -4
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.116",
|
|
4
4
|
"description": "Standard documentation layout components for Dogsbay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -25,13 +25,15 @@
|
|
|
25
25
|
"./LocaleSwitcher.astro": "./src/LocaleSwitcher.astro",
|
|
26
26
|
"./nav-filter": "./src/nav-filter.ts",
|
|
27
27
|
"./search-facets": "./src/search-facets.ts",
|
|
28
|
+
"./pagefind-base-url": "./src/pagefind-base-url.ts",
|
|
28
29
|
"./tag-list-data": "./src/tag-list-data.ts",
|
|
29
30
|
"./llm-actions": "./src/llm-actions.ts",
|
|
30
31
|
"./json-ld": "./src/json-ld.ts"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@dogsbay/primitives": "0.2.0-beta.
|
|
34
|
-
"@dogsbay/ui": "0.2.0-beta.
|
|
34
|
+
"@dogsbay/primitives": "0.2.0-beta.116",
|
|
35
|
+
"@dogsbay/ui": "0.2.0-beta.116",
|
|
36
|
+
"@dogsbay/site-files": "0.2.0-beta.116"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"happy-dom": "^20.10.6",
|
package/src/DocsLayout.astro
CHANGED
|
@@ -45,7 +45,7 @@ import TypeBadge from "./TypeBadge.astro";
|
|
|
45
45
|
import PageActions from "./PageActions.astro";
|
|
46
46
|
import VersionSwitcher from "./VersionSwitcher.astro";
|
|
47
47
|
import LocaleSwitcher from "./LocaleSwitcher.astro";
|
|
48
|
-
import { filterNavByAxis } from "./nav-filter.js";
|
|
48
|
+
import { filterNavByAxis, rootVersionFilter } from "./nav-filter.js";
|
|
49
49
|
import type { LlmProviderName } from "./llm-actions.js";
|
|
50
50
|
import { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd, buildWebSiteJsonLd } from "./json-ld.js";
|
|
51
51
|
import { resolveTagKeywords } from "./tag-list-data.js";
|
|
@@ -89,6 +89,8 @@ type TagPaletteName =
|
|
|
89
89
|
interface Props {
|
|
90
90
|
/** Site name shown in header and sidebar logo */
|
|
91
91
|
siteName: string;
|
|
92
|
+
/** The line under the site name in the sidebar header (`site.tagline`). Default "Documentation". */
|
|
93
|
+
siteTagline?: string;
|
|
92
94
|
/** Page title for <title> tag */
|
|
93
95
|
title: string;
|
|
94
96
|
/** Home URL — also used as the base for the canonical link if absolute */
|
|
@@ -131,6 +133,10 @@ interface Props {
|
|
|
131
133
|
ogType?: "article" | "website";
|
|
132
134
|
/** Canonical URL for this page. Auto-computed from siteUrl + pathname when absolute siteUrl is provided. */
|
|
133
135
|
canonicalUrl?: string;
|
|
136
|
+
/** `hreflang` alternates for this page's copies in other languages (absolute URLs), written after the canonical link. */
|
|
137
|
+
alternates?: { hreflang: string; href: string }[];
|
|
138
|
+
/** JSON-LD `BreadcrumbList` for this page, written after its own JSON-LD block. */
|
|
139
|
+
breadcrumbJsonLd?: Record<string, unknown>;
|
|
134
140
|
/** Twitter / X handle including the leading "@" */
|
|
135
141
|
twitterHandle?: string;
|
|
136
142
|
/** Theme color hint for browsers (hex string) */
|
|
@@ -197,6 +203,12 @@ interface Props {
|
|
|
197
203
|
* a non-root base path (e.g. "/docs/pagefind/").
|
|
198
204
|
*/
|
|
199
205
|
pagefindUrl?: string;
|
|
206
|
+
/**
|
|
207
|
+
* Base Pagefind prefixes result URLs with. Forwarded to SearchDialog;
|
|
208
|
+
* format-astro passes the right value for the deploy layout. See
|
|
209
|
+
* `pagefind-base-url.ts` for why no constant works.
|
|
210
|
+
*/
|
|
211
|
+
pagefindBaseUrl?: string;
|
|
200
212
|
/**
|
|
201
213
|
* Whether the site emits per-page `.md` mirror endpoints. When true,
|
|
202
214
|
* the page advertises `<link rel="alternate" type="text/markdown">`
|
|
@@ -231,6 +243,12 @@ interface Props {
|
|
|
231
243
|
* `taxonomies.tags.labels`.
|
|
232
244
|
*/
|
|
233
245
|
tagLabels?: Record<string, string>;
|
|
246
|
+
/**
|
|
247
|
+
* Color chips whose prefix has no color automatically (a stable
|
|
248
|
+
* palette color per first segment). Sourced from
|
|
249
|
+
* `taxonomies.tags.autoColor`.
|
|
250
|
+
*/
|
|
251
|
+
tagAutoColor?: boolean;
|
|
234
252
|
/**
|
|
235
253
|
* Page lifecycle status. Renders a StatusBadge for non-stable
|
|
236
254
|
* states. Drafts/deprecated get strong colors.
|
|
@@ -351,7 +369,7 @@ interface Props {
|
|
|
351
369
|
* `@/data/switcherMap.json`.
|
|
352
370
|
*/
|
|
353
371
|
switcherMap?: {
|
|
354
|
-
versions: Array<{ id: string; label?: string; eol?: boolean; default?: boolean }>;
|
|
372
|
+
versions: Array<{ id: string; label?: string; eol?: boolean; default?: boolean; root?: boolean }>;
|
|
355
373
|
byLogicalKey: Record<string, Record<string, string>>;
|
|
356
374
|
};
|
|
357
375
|
/**
|
|
@@ -467,6 +485,7 @@ interface Props {
|
|
|
467
485
|
const {
|
|
468
486
|
poweredBy = false,
|
|
469
487
|
siteName,
|
|
488
|
+
siteTagline,
|
|
470
489
|
title,
|
|
471
490
|
siteUrl = "/",
|
|
472
491
|
nav,
|
|
@@ -486,6 +505,8 @@ const {
|
|
|
486
505
|
defaultOgImage,
|
|
487
506
|
ogType = "article",
|
|
488
507
|
canonicalUrl,
|
|
508
|
+
alternates = [],
|
|
509
|
+
breadcrumbJsonLd,
|
|
489
510
|
twitterHandle,
|
|
490
511
|
themeColor,
|
|
491
512
|
noindex,
|
|
@@ -501,11 +522,13 @@ const {
|
|
|
501
522
|
// Undefined here propagates to SearchDialog where the JS loader
|
|
502
523
|
// throws on first open instead of silently 404'ing the bundle.
|
|
503
524
|
pagefindUrl,
|
|
525
|
+
pagefindBaseUrl,
|
|
504
526
|
mdMirror = false,
|
|
505
527
|
tags,
|
|
506
528
|
tagsIndexPath = "/tags",
|
|
507
529
|
tagPrefixes,
|
|
508
530
|
tagLabels,
|
|
531
|
+
tagAutoColor = false,
|
|
509
532
|
status,
|
|
510
533
|
pageType,
|
|
511
534
|
audience,
|
|
@@ -532,6 +555,10 @@ const {
|
|
|
532
555
|
class: className,
|
|
533
556
|
} = Astro.props;
|
|
534
557
|
|
|
558
|
+
// The version served at the site root (`path: ""`), if any: the sidebar
|
|
559
|
+
// keeps its pages by leaving out the other versions' folders.
|
|
560
|
+
const navRootVersion = rootVersionFilter(switcherMap?.versions);
|
|
561
|
+
|
|
535
562
|
// Link icons — classified in CSS by href shape. `data-link-icons`
|
|
536
563
|
// lists which kinds are active; the glyphs ride in CSS custom props
|
|
537
564
|
// (single-quoted so they're valid `content:` string values).
|
|
@@ -763,11 +790,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
763
790
|
`--db-link-icon-*` custom props (the glyphs) on <body>. Links are
|
|
764
791
|
classified purely by href shape, so it covers both structured
|
|
765
792
|
links and raw-HTML anchors from imported content. Scoped to
|
|
766
|
-
`.docs-prose` so nav/header/footer links are untouched
|
|
793
|
+
`.docs-prose` so nav/header/footer links are untouched; buttons
|
|
794
|
+
(`<a data-variant>`, from Button.astro) carry no icon.
|
|
767
795
|
*/
|
|
768
|
-
body[data-link-icons~="external"] .docs-prose a[href^="http://" i]::after,
|
|
769
|
-
body[data-link-icons~="external"] .docs-prose a[href^="https://" i]::after,
|
|
770
|
-
body[data-link-icons~="external"] .docs-prose a[href^="//"]::after {
|
|
796
|
+
body[data-link-icons~="external"] .docs-prose a[href^="http://" i]:not([data-variant])::after,
|
|
797
|
+
body[data-link-icons~="external"] .docs-prose a[href^="https://" i]:not([data-variant])::after,
|
|
798
|
+
body[data-link-icons~="external"] .docs-prose a[href^="//"]:not([data-variant])::after {
|
|
771
799
|
content: var(--db-link-icon-external, "");
|
|
772
800
|
display: inline-block;
|
|
773
801
|
margin-inline-start: 0.15em;
|
|
@@ -778,9 +806,9 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
778
806
|
}
|
|
779
807
|
body[data-link-icons~="internal"]
|
|
780
808
|
.docs-prose
|
|
781
|
-
a[href^="/"]:not([href^="//"])::after,
|
|
782
|
-
body[data-link-icons~="internal"] .docs-prose a[href^="./"]::after,
|
|
783
|
-
body[data-link-icons~="internal"] .docs-prose a[href^="../"]::after {
|
|
809
|
+
a[href^="/"]:not([href^="//"]):not([data-variant])::after,
|
|
810
|
+
body[data-link-icons~="internal"] .docs-prose a[href^="./"]:not([data-variant])::after,
|
|
811
|
+
body[data-link-icons~="internal"] .docs-prose a[href^="../"]:not([data-variant])::after {
|
|
784
812
|
content: var(--db-link-icon-internal, "");
|
|
785
813
|
display: inline-block;
|
|
786
814
|
margin-inline-start: 0.15em;
|
|
@@ -821,6 +849,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
821
849
|
sidebar / header / TOC text from per-page excerpts. See main. */}
|
|
822
850
|
|
|
823
851
|
{computedCanonical && <link rel="canonical" href={computedCanonical} />}
|
|
852
|
+
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
|
|
824
853
|
{mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
|
|
825
854
|
{/* Programmatic llms.txt discovery — agents that follow head
|
|
826
855
|
link rels get the per-mount llms.txt without parsing HTML.
|
|
@@ -868,7 +897,10 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
868
897
|
Schema.org keywords string; Google reads this for topical
|
|
869
898
|
categorization in rich results. */}
|
|
870
899
|
{articleJsonLd && (
|
|
871
|
-
<script type="application/ld+json" set:html={JSON.stringify(articleJsonLd)} />
|
|
900
|
+
<script type="application/ld+json" set:html={JSON.stringify(articleJsonLd).replace(/</g, "\\u003c")} />
|
|
901
|
+
)}
|
|
902
|
+
{breadcrumbJsonLd && (
|
|
903
|
+
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c")} />
|
|
872
904
|
)}
|
|
873
905
|
|
|
874
906
|
{/* Per-page / per-site custom structured data. Layered on
|
|
@@ -957,7 +989,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
957
989
|
</div>
|
|
958
990
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
|
959
991
|
<span class="truncate font-semibold">{siteName}</span>
|
|
960
|
-
<span class="truncate text-xs text-sidebar-foreground/70">Documentation</span>
|
|
992
|
+
<span class="truncate text-xs text-sidebar-foreground/70">{siteTagline || "Documentation"}</span>
|
|
961
993
|
</div>
|
|
962
994
|
</SidebarMenuButton>
|
|
963
995
|
</SidebarMenuItem>
|
|
@@ -982,6 +1014,8 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
982
1014
|
namespace={multiSource?.namespace}
|
|
983
1015
|
version={multiSource?.version}
|
|
984
1016
|
locale={multiSource?.locale}
|
|
1017
|
+
rootVersion={navRootVersion.rootVersion}
|
|
1018
|
+
versionSegments={navRootVersion.versionSegments}
|
|
985
1019
|
/>
|
|
986
1020
|
</SidebarGroupContent>
|
|
987
1021
|
</SidebarGroup>
|
|
@@ -996,6 +1030,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
996
1030
|
namespace: multiSource?.namespace,
|
|
997
1031
|
version: multiSource?.version,
|
|
998
1032
|
locale: multiSource?.locale,
|
|
1033
|
+
...navRootVersion,
|
|
999
1034
|
})}
|
|
1000
1035
|
currentPath={currentPath}
|
|
1001
1036
|
/>
|
|
@@ -1011,6 +1046,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
1011
1046
|
namespace: multiSource?.namespace,
|
|
1012
1047
|
version: multiSource?.version,
|
|
1013
1048
|
locale: multiSource?.locale,
|
|
1049
|
+
...navRootVersion,
|
|
1014
1050
|
})}
|
|
1015
1051
|
currentPath={currentPath}
|
|
1016
1052
|
/>
|
|
@@ -1072,6 +1108,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
1072
1108
|
{!hideSearch && (
|
|
1073
1109
|
<SearchDialog
|
|
1074
1110
|
pagefindUrl={pagefindUrl}
|
|
1111
|
+
pagefindBaseUrl={pagefindBaseUrl}
|
|
1075
1112
|
navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
|
|
1076
1113
|
taxonomyDisplay={taxonomyDisplay}
|
|
1077
1114
|
scopeProduct={multiSource?.namespace}
|
|
@@ -1284,6 +1321,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
1284
1321
|
indexPath={tagsIndexPath}
|
|
1285
1322
|
prefixes={tagPrefixes}
|
|
1286
1323
|
labels={tagLabels}
|
|
1324
|
+
autoColor={tagAutoColor}
|
|
1287
1325
|
/>
|
|
1288
1326
|
)}
|
|
1289
1327
|
</div>
|
package/src/DocsNavClient.astro
CHANGED
|
@@ -43,9 +43,13 @@ interface Props {
|
|
|
43
43
|
version?: string;
|
|
44
44
|
/** Current source's locale axis value, if multi-locale site. */
|
|
45
45
|
locale?: string;
|
|
46
|
+
/** The version served at the site root (`path: ""`), if any. */
|
|
47
|
+
rootVersion?: string;
|
|
48
|
+
/** The other versions' URL segments (left out of the root version's nav). */
|
|
49
|
+
versionSegments?: string[];
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
const { currentPath, basePath = "", namespace, version, locale } = Astro.props;
|
|
52
|
+
const { currentPath, basePath = "", namespace, version, locale, rootVersion, versionSegments } = Astro.props;
|
|
49
53
|
const navUrl = `${basePath}/_dogsbay/nav.json`;
|
|
50
54
|
---
|
|
51
55
|
|
|
@@ -65,6 +69,8 @@ const navUrl = `${basePath}/_dogsbay/nav.json`;
|
|
|
65
69
|
data-namespace={namespace ?? ""}
|
|
66
70
|
data-version={version ?? ""}
|
|
67
71
|
data-locale={locale ?? ""}
|
|
72
|
+
data-root-version={rootVersion ?? ""}
|
|
73
|
+
data-version-segments={(versionSegments ?? []).join(" ")}
|
|
68
74
|
aria-busy="true"
|
|
69
75
|
aria-label="Documentation navigation"
|
|
70
76
|
>
|
package/src/SearchDialog.astro
CHANGED
|
@@ -37,6 +37,15 @@ interface Props {
|
|
|
37
37
|
* search dialog throws on first open with a clear console error.
|
|
38
38
|
*/
|
|
39
39
|
pagefindUrl?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The base Pagefind prefixes result URLs with. Pagefind's own default is
|
|
42
|
+
* the folder above the bundle, which is right for GitHub Pages project
|
|
43
|
+
* sites and doubles the prefix on Workers subpath mounts (their index
|
|
44
|
+
* already carries the mount). format-astro computes the right value per
|
|
45
|
+
* layout; when absent, Pagefind's default is left alone. See
|
|
46
|
+
* `pagefind-base-url.ts`.
|
|
47
|
+
*/
|
|
48
|
+
pagefindBaseUrl?: string;
|
|
40
49
|
/**
|
|
41
50
|
* URL of the site's `nav.json` (typically `${basePath}/_dogsbay/nav.json`).
|
|
42
51
|
* Used to render hierarchical facets in document order: when the
|
|
@@ -69,6 +78,7 @@ interface Props {
|
|
|
69
78
|
|
|
70
79
|
const {
|
|
71
80
|
pagefindUrl,
|
|
81
|
+
pagefindBaseUrl,
|
|
72
82
|
navUrl,
|
|
73
83
|
placeholder = "Search docs...",
|
|
74
84
|
taxonomyDisplay,
|
|
@@ -80,6 +90,7 @@ const {
|
|
|
80
90
|
<dialog
|
|
81
91
|
data-search-dialog
|
|
82
92
|
data-pagefind-url={pagefindUrl}
|
|
93
|
+
data-pagefind-base-url={pagefindBaseUrl}
|
|
83
94
|
data-nav-url={navUrl}
|
|
84
95
|
data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
|
|
85
96
|
data-scope-product={scopeProduct ?? ""}
|
|
@@ -201,6 +212,7 @@ const {
|
|
|
201
212
|
type NavLike,
|
|
202
213
|
type TaxonomyDisplayMap,
|
|
203
214
|
} from "./search-facets.js";
|
|
215
|
+
import { applyPagefindBaseUrl } from "./pagefind-base-url.js";
|
|
204
216
|
|
|
205
217
|
type PagefindResult = {
|
|
206
218
|
id: string;
|
|
@@ -224,6 +236,7 @@ const {
|
|
|
224
236
|
options?: { filters?: Record<string, PagefindFilterValue> },
|
|
225
237
|
): Promise<{ results: PagefindResult[] }>;
|
|
226
238
|
filters(): Promise<Record<string, Record<string, number>>>;
|
|
239
|
+
options?(opts: { baseUrl?: string }): Promise<void>;
|
|
227
240
|
};
|
|
228
241
|
|
|
229
242
|
const dialog = document.querySelector<HTMLDialogElement>("[data-search-dialog]");
|
|
@@ -290,6 +303,9 @@ const {
|
|
|
290
303
|
loadingPagefind = (async () => {
|
|
291
304
|
try {
|
|
292
305
|
const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
|
|
306
|
+
// Before the first search: result URLs are built with this base.
|
|
307
|
+
// Without it a Workers subpath mount links to /<mount>/<mount>/page/.
|
|
308
|
+
await applyPagefindBaseUrl(mod, dialog!.dataset.pagefindBaseUrl);
|
|
293
309
|
pagefind = mod;
|
|
294
310
|
// Discover facets once, on first load. Pagefind builds
|
|
295
311
|
// this from the body-level data-pagefind-filter elements.
|
package/src/TagList.astro
CHANGED
|
@@ -48,6 +48,11 @@ interface Props {
|
|
|
48
48
|
* text. URL still uses the slug.
|
|
49
49
|
*/
|
|
50
50
|
labels?: Record<string, string>;
|
|
51
|
+
/**
|
|
52
|
+
* Give chips whose prefix has no color a stable palette color,
|
|
53
|
+
* hashed from the tag's first segment. Default false.
|
|
54
|
+
*/
|
|
55
|
+
autoColor?: boolean;
|
|
51
56
|
/** Optional aria-label override; default "Tags". */
|
|
52
57
|
label?: string;
|
|
53
58
|
class?: string;
|
|
@@ -58,11 +63,12 @@ const {
|
|
|
58
63
|
indexPath = "/tags",
|
|
59
64
|
prefixes,
|
|
60
65
|
labels,
|
|
66
|
+
autoColor = false,
|
|
61
67
|
label: ariaLabel = "Tags",
|
|
62
68
|
class: className,
|
|
63
69
|
} = Astro.props;
|
|
64
70
|
|
|
65
|
-
const chips = buildChips(tags, { indexPath, prefixes, labels });
|
|
71
|
+
const chips = buildChips(tags, { indexPath, prefixes, labels, autoColor });
|
|
66
72
|
|
|
67
73
|
/**
|
|
68
74
|
* Closed palette → Tailwind class strings. Each entry is one
|
|
@@ -44,7 +44,7 @@ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Version
|
|
|
44
44
|
</summary>
|
|
45
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">
|
|
46
46
|
{rows.map((row) => {
|
|
47
|
-
const href = row.url ?? fallbackLandingUrl(basePath, row.entry.id);
|
|
47
|
+
const href = row.url ?? fallbackLandingUrl(basePath, row.entry.id, row.entry.root === true);
|
|
48
48
|
const isFallback = row.url === null && !row.isCurrent;
|
|
49
49
|
return (
|
|
50
50
|
<li>
|
package/src/docs-nav-client.ts
CHANGED
|
@@ -379,6 +379,8 @@ export async function hydrateDocsNav(): Promise<void> {
|
|
|
379
379
|
const namespace = root.dataset.namespace || undefined;
|
|
380
380
|
const version = root.dataset.version || undefined;
|
|
381
381
|
const locale = root.dataset.locale || undefined;
|
|
382
|
+
const rootVersion = root.dataset.rootVersion || undefined;
|
|
383
|
+
const versionSegments = (root.dataset.versionSegments || "").split(" ").filter(Boolean);
|
|
382
384
|
|
|
383
385
|
try {
|
|
384
386
|
const nav = await fetchNav(navUrl);
|
|
@@ -392,6 +394,7 @@ export async function hydrateDocsNav(): Promise<void> {
|
|
|
392
394
|
namespace: namespace || undefined,
|
|
393
395
|
version: version || undefined,
|
|
394
396
|
locale: locale || undefined,
|
|
397
|
+
...(rootVersion ? { rootVersion, versionSegments } : {}),
|
|
395
398
|
});
|
|
396
399
|
renderTree(filtered, current, root);
|
|
397
400
|
} catch (err) {
|
package/src/json-ld.ts
CHANGED
|
@@ -1,167 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* JSON-LD helpers
|
|
3
|
-
*
|
|
4
|
-
* `customJsonLd` prop shape.
|
|
5
|
-
*
|
|
6
|
-
* The 100daysofdocs SEO audit flagged that DocsLayout was emitting
|
|
7
|
-
* `Article` for every page regardless of intent — tutorials and
|
|
8
|
-
* how-to pages should render as `HowTo` for SERP rich results.
|
|
9
|
-
* This module owns the type selection so it's testable without
|
|
10
|
-
* spinning up an Astro renderer.
|
|
2
|
+
* JSON-LD helpers for DocsLayout. They live in `@dogsbay/site-files`,
|
|
3
|
+
* which dogsbay-ssg uses too, so both pipelines emit the same blocks.
|
|
11
4
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* Map a `meta.type` value (the open-string `pageType` prop on
|
|
15
|
-
* DocsLayout) to a Schema.org `@type`.
|
|
16
|
-
*
|
|
17
|
-
* The mapping intentionally accepts a few common spelling
|
|
18
|
-
* variants (`"how-to"` / `"howto"`, `"reference"` / `"ref"` /
|
|
19
|
-
* `"api"`) so writers don't have to learn one canonical spelling
|
|
20
|
-
* to get the right structured data. Anything else falls back to
|
|
21
|
-
* `Article`, which is Google's "general docs page" assumption.
|
|
22
|
-
*/
|
|
23
|
-
export function jsonLdTypeFor(pageType: string | undefined): string {
|
|
24
|
-
switch (pageType) {
|
|
25
|
-
case "tutorial":
|
|
26
|
-
case "how-to":
|
|
27
|
-
case "howto":
|
|
28
|
-
return "HowTo";
|
|
29
|
-
case "reference":
|
|
30
|
-
case "ref":
|
|
31
|
-
case "api":
|
|
32
|
-
return "TechArticle";
|
|
33
|
-
case "course":
|
|
34
|
-
return "Course";
|
|
35
|
-
default:
|
|
36
|
-
return "Article";
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Normalize the `customJsonLd` prop into an array. DocsLayout
|
|
42
|
-
* accepts either a single object (most common case — one extra
|
|
43
|
-
* Course / Organization block) or an array (sites that layer
|
|
44
|
-
* several blocks per page).
|
|
45
|
-
*
|
|
46
|
-
* Returns an empty array for `undefined` so the template can
|
|
47
|
-
* iterate uniformly without conditional rendering.
|
|
48
|
-
*/
|
|
49
|
-
export function normalizeCustomJsonLd(
|
|
50
|
-
raw: Record<string, unknown> | Record<string, unknown>[] | undefined,
|
|
51
|
-
): Record<string, unknown>[] {
|
|
52
|
-
if (!raw) return [];
|
|
53
|
-
if (Array.isArray(raw)) return raw;
|
|
54
|
-
return [raw];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface BuildArticleJsonLdOptions {
|
|
58
|
-
/** Already-resolved Schema.org `@type` (output of jsonLdTypeFor). */
|
|
59
|
-
type: string;
|
|
60
|
-
title: string;
|
|
61
|
-
/** Site name — used as the `provider` Organization for Course. */
|
|
62
|
-
siteName: string;
|
|
63
|
-
keywords: string[];
|
|
64
|
-
description?: string;
|
|
65
|
-
image?: string;
|
|
66
|
-
url?: string;
|
|
67
|
-
/**
|
|
68
|
-
* Page headings — used to synthesize `step[]` for HowTo. Each
|
|
69
|
-
* H2 (or H3 if no H2s exist) becomes a HowToStep with a deep
|
|
70
|
-
* link to the heading id.
|
|
71
|
-
*/
|
|
72
|
-
headings?: ReadonlyArray<{ depth: number; slug: string; text: string }>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Build the JSON-LD payload for the page's primary structured-data
|
|
77
|
-
* block, shaped per `@type`. The earlier implementation emitted
|
|
78
|
-
* Article-shaped fields (`headline`) regardless of @type, which
|
|
79
|
-
* meant HowTo / Course pages failed Google's Rich Results
|
|
80
|
-
* requirements (HowTo needs `name` + `step`, Course needs `name` +
|
|
81
|
-
* `description` + `provider`). See
|
|
82
|
-
* `packages/cli/src/audit/rules/seo/json-ld-required-fields.ts`
|
|
83
|
-
* for the validator that catches this.
|
|
84
|
-
*
|
|
85
|
-
* Schema.org's `name` is a Thing-level field accepted by every
|
|
86
|
-
* @type, so we always emit it. `headline` is added on top for
|
|
87
|
-
* Article-family types (Article, TechArticle) because Google's
|
|
88
|
-
* Rich Results validator specifically requires it there. HowTo
|
|
89
|
-
* gets a synthesized `step[]` from the page's H2 headings (each
|
|
90
|
-
* H2 = one procedure step); Course gets a `provider` Organization
|
|
91
|
-
* built from `siteName`.
|
|
92
|
-
*/
|
|
93
|
-
export function buildArticleJsonLd(
|
|
94
|
-
opts: BuildArticleJsonLdOptions,
|
|
95
|
-
): Record<string, unknown> {
|
|
96
|
-
const { type, title, siteName, keywords, description, image, url, headings } =
|
|
97
|
-
opts;
|
|
98
|
-
|
|
99
|
-
const block: Record<string, unknown> = {
|
|
100
|
-
"@context": "https://schema.org",
|
|
101
|
-
"@type": type,
|
|
102
|
-
name: title,
|
|
103
|
-
};
|
|
104
|
-
// Only when there are any. An empty `keywords: ""` asserts the page
|
|
105
|
-
// has no topics, which is a different claim from staying silent.
|
|
106
|
-
if (keywords.length > 0) block.keywords = keywords.join(", ");
|
|
107
|
-
|
|
108
|
-
if (type === "Article" || type === "TechArticle") {
|
|
109
|
-
block.headline = title;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (description) block.description = description;
|
|
113
|
-
if (image) block.image = image;
|
|
114
|
-
if (url) block.url = url;
|
|
115
|
-
|
|
116
|
-
if (type === "HowTo") {
|
|
117
|
-
let stepHeadings = (headings ?? []).filter((h) => h.depth === 2);
|
|
118
|
-
if (stepHeadings.length === 0) {
|
|
119
|
-
stepHeadings = (headings ?? []).filter((h) => h.depth === 3);
|
|
120
|
-
}
|
|
121
|
-
block.step = stepHeadings.map((h) => ({
|
|
122
|
-
"@type": "HowToStep",
|
|
123
|
-
name: h.text,
|
|
124
|
-
url: url ? `${url}#${h.slug}` : `#${h.slug}`,
|
|
125
|
-
}));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (type === "Course") {
|
|
129
|
-
block.provider = { "@type": "Organization", name: siteName };
|
|
130
|
-
if (!block.description) block.description = title;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return block;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Identity structured data for a page that is not an article — a
|
|
138
|
-
* homepage, a landing page, a section index.
|
|
139
|
-
*
|
|
140
|
-
* Exists because the article block is gated on `ogType === "article"`,
|
|
141
|
-
* so those pages emitted NO structured data at all. An external agent
|
|
142
|
-
* audit reported "No JSON-LD structured data found on homepage", which
|
|
143
|
-
* was accurate: the one page most likely to be an agent's entry point
|
|
144
|
-
* was the one describing itself the least.
|
|
145
|
-
*
|
|
146
|
-
* `WebSite` rather than `Organization` or `SoftwareApplication` on
|
|
147
|
-
* purpose. Those are claims about what the site's OWNER is, and
|
|
148
|
-
* guessing wrong is worse than saying less — a docs site for a product
|
|
149
|
-
* is not itself the product. Owners wanting a stronger identity type
|
|
150
|
-
* add it through `customJsonLd`, which layers on top of this.
|
|
151
|
-
*/
|
|
152
|
-
export function buildWebSiteJsonLd(opts: {
|
|
153
|
-
siteName: string;
|
|
154
|
-
title: string;
|
|
155
|
-
description?: string;
|
|
156
|
-
url?: string;
|
|
157
|
-
}): Record<string, unknown> {
|
|
158
|
-
const block: Record<string, unknown> = {
|
|
159
|
-
"@context": "https://schema.org",
|
|
160
|
-
"@type": "WebSite",
|
|
161
|
-
name: opts.siteName,
|
|
162
|
-
};
|
|
163
|
-
if (opts.title && opts.title !== opts.siteName) block.headline = opts.title;
|
|
164
|
-
if (opts.description) block.description = opts.description;
|
|
165
|
-
if (opts.url) block.url = opts.url;
|
|
166
|
-
return block;
|
|
167
|
-
}
|
|
5
|
+
export { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd, buildWebSiteJsonLd } from "@dogsbay/site-files";
|
|
6
|
+
export type { BuildArticleJsonLdOptions } from "@dogsbay/site-files";
|
package/src/nav-filter.ts
CHANGED
|
@@ -29,6 +29,27 @@ export interface NavFilter {
|
|
|
29
29
|
locale?: string;
|
|
30
30
|
/** Current page's version segment (innermost, next to the page), if any. */
|
|
31
31
|
version?: string;
|
|
32
|
+
/**
|
|
33
|
+
* The version served at the site root (`versions[].path: ""`): its pages
|
|
34
|
+
* have no version segment. When `version` is this one, the bucket is the
|
|
35
|
+
* namespace/locale prefix minus the other versions' folders
|
|
36
|
+
* (`versionSegments`) — the longest matching prefix wins.
|
|
37
|
+
*/
|
|
38
|
+
rootVersion?: string;
|
|
39
|
+
/** The other versions' URL segments, left out of the root version's bucket. */
|
|
40
|
+
versionSegments?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The root-version fields of a `NavFilter`, from the switcherMap header
|
|
45
|
+
* (`versions[].root`). Empty when no version is served at the root.
|
|
46
|
+
*/
|
|
47
|
+
export function rootVersionFilter(
|
|
48
|
+
versions: Array<{ id: string; root?: boolean }> | undefined,
|
|
49
|
+
): Pick<NavFilter, "rootVersion" | "versionSegments"> {
|
|
50
|
+
const root = versions?.find((v) => v.root)?.id;
|
|
51
|
+
if (!root) return {};
|
|
52
|
+
return { rootVersion: root, versionSegments: versions!.map((v) => v.id).filter((id) => id !== root) };
|
|
32
53
|
}
|
|
33
54
|
|
|
34
55
|
/**
|
|
@@ -40,26 +61,33 @@ export interface NavFilter {
|
|
|
40
61
|
export function filterNavByAxis(items: NavItem[], filter: NavFilter): NavItem[] {
|
|
41
62
|
// Canonical order: namespace → locale → version. Only the axes the
|
|
42
63
|
// current page actually carries contribute to the match prefix.
|
|
43
|
-
const
|
|
64
|
+
const atRoot = filter.rootVersion !== undefined && filter.version === filter.rootVersion;
|
|
65
|
+
const segs = [filter.namespace, filter.locale, atRoot ? undefined : filter.version].filter(
|
|
44
66
|
(s): s is string => s !== undefined && s !== "",
|
|
45
67
|
);
|
|
46
|
-
|
|
68
|
+
// The root version's bucket leaves out the other versions' folders under its prefix.
|
|
69
|
+
const others = atRoot ? (filter.versionSegments ?? []).filter((v) => v && v !== filter.rootVersion) : [];
|
|
70
|
+
if (segs.length === 0 && others.length === 0) return items;
|
|
47
71
|
|
|
48
72
|
const base = filter.basePath.replace(/\/$/, "");
|
|
49
|
-
const prefix = `${base}/${segs.join("/")}
|
|
50
|
-
const prefixNoSlash = prefix.replace(/\/$/, "");
|
|
73
|
+
const prefix = `${base}/${segs.join("/")}${segs.length ? "/" : ""}`;
|
|
74
|
+
const prefixNoSlash = segs.length ? prefix.replace(/\/$/, "") : base || "/";
|
|
75
|
+
const excluded = others.map((v) => `${prefix}${v}`);
|
|
51
76
|
|
|
52
|
-
return items.flatMap((item) => filterOne(item, prefix, prefixNoSlash));
|
|
77
|
+
return items.flatMap((item) => filterOne(item, prefix, prefixNoSlash, excluded));
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
function filterOne(item: NavItem, prefix: string, prefixNoSlash: string): NavItem[] {
|
|
80
|
+
function filterOne(item: NavItem, prefix: string, prefixNoSlash: string, excluded: string[]): NavItem[] {
|
|
56
81
|
if (item.children && item.children.length > 0) {
|
|
57
|
-
const kept = item.children.flatMap((c) => filterOne(c, prefix, prefixNoSlash));
|
|
82
|
+
const kept = item.children.flatMap((c) => filterOne(c, prefix, prefixNoSlash, excluded));
|
|
58
83
|
if (kept.length === 0) return [];
|
|
59
84
|
return [{ ...item, children: kept }];
|
|
60
85
|
}
|
|
61
86
|
if (item.href !== undefined) {
|
|
62
|
-
return hrefMatchesPrefix(item.href, prefix, prefixNoSlash)
|
|
87
|
+
return hrefMatchesPrefix(item.href, prefix, prefixNoSlash) &&
|
|
88
|
+
!excluded.some((x) => hrefMatchesPrefix(item.href!, `${x}/`, x))
|
|
89
|
+
? [item]
|
|
90
|
+
: [];
|
|
63
91
|
}
|
|
64
92
|
// Childless AND href-less: no navigation, no bucket membership. In a
|
|
65
93
|
// filtered view it must be dropped.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagefind result URLs, and the base they need.
|
|
3
|
+
*
|
|
4
|
+
* Pagefind stores each page's URL relative to the directory it indexed
|
|
5
|
+
* (`pagefind --site dist`), then prefixes every result with `baseUrl`. When
|
|
6
|
+
* the caller sets none, Pagefind derives it from where the bundle was loaded:
|
|
7
|
+
* the folder above `pagefind/`.
|
|
8
|
+
*
|
|
9
|
+
* That default is right for one of our two subpath layouts and wrong for the
|
|
10
|
+
* other, measured by decoding index fragments on live sites:
|
|
11
|
+
*
|
|
12
|
+
* | Deploy | Pages built into | Stored URL | Default base | Result |
|
|
13
|
+
* |---|---|---|---|---|
|
|
14
|
+
* | Workers subpath mount | `dist/<mount>/` (nested) | `/<mount>/page/` | `/<mount>/` | `/<mount>/<mount>/page/` — 404 |
|
|
15
|
+
* | GitHub Pages project | `dist/` (Astro `base`) | `/page/` | `/<repo>/` | `/<repo>/page/` — correct |
|
|
16
|
+
*
|
|
17
|
+
* So no single constant works: forcing `/` fixes the Workers mount and breaks
|
|
18
|
+
* every GitHub Pages project site. The emitter knows which layout it built and
|
|
19
|
+
* passes the base explicitly; this applies it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The slice of the Pagefind module this touches. */
|
|
23
|
+
export interface PagefindOptionsModule {
|
|
24
|
+
options?: (opts: { baseUrl?: string }) => Promise<void> | void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The base Pagefind should prefix results with: the served path that the
|
|
29
|
+
* INDEXED directory maps to.
|
|
30
|
+
*
|
|
31
|
+
* - Workers subpath mount — pages are built INTO `dist/<mount>/`, so the
|
|
32
|
+
* index already carries the mount in every URL: `/`.
|
|
33
|
+
* - Anything else — pages sit at the `dist/` root and Astro's `base` (the
|
|
34
|
+
* site.url path) is added at serve time, so that is the missing prefix:
|
|
35
|
+
* `<urlBase>/`, or `/` at the host root.
|
|
36
|
+
*
|
|
37
|
+
* Note `basePath` (dogsbay's own prefix) is never part of it: pages are
|
|
38
|
+
* physically written under it, so the index already contains it.
|
|
39
|
+
*/
|
|
40
|
+
export function pagefindBaseUrl(urlBase: string, nestedOutDir: boolean): string {
|
|
41
|
+
if (nestedOutDir) return "/";
|
|
42
|
+
const base = urlBase.replace(/\/+$/, "");
|
|
43
|
+
return base ? `${base}/` : "/";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Apply an emitted base to a loaded Pagefind module. A missing base (a page
|
|
48
|
+
* emitted before the base was passed) leaves Pagefind's own default, so an
|
|
49
|
+
* un-rebuilt site behaves exactly as before rather than changing under it.
|
|
50
|
+
*/
|
|
51
|
+
export async function applyPagefindBaseUrl(
|
|
52
|
+
mod: PagefindOptionsModule,
|
|
53
|
+
baseUrl: string | undefined,
|
|
54
|
+
): Promise<boolean> {
|
|
55
|
+
if (!baseUrl) return false;
|
|
56
|
+
if (typeof mod.options !== "function") return false;
|
|
57
|
+
await mod.options({ baseUrl });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
package/src/switcher.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface AxisEntry {
|
|
|
20
20
|
*/
|
|
21
21
|
hidden?: boolean;
|
|
22
22
|
default?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Served at the site root (version axis only; `versions[].path: ""` on the
|
|
25
|
+
* default version): its pages have no version segment, so its landing
|
|
26
|
+
* page is `<basePath>/`, and its per-page URLs come from the variants.
|
|
27
|
+
*/
|
|
28
|
+
root?: boolean;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export interface SwitcherVariant {
|
|
@@ -206,14 +212,16 @@ export function shouldRenderSwitcher(
|
|
|
206
212
|
|
|
207
213
|
/**
|
|
208
214
|
* Fallback URL for a row whose target variant doesn't exist —
|
|
209
|
-
* lands on the axis's "landing page" (`<basePath>/<id
|
|
215
|
+
* lands on the axis's "landing page" (`<basePath>/<id>/`, or
|
|
216
|
+
* `<basePath>/` for the version served at the root).
|
|
210
217
|
* Lets the switcher always be clickable even when a page is
|
|
211
218
|
* untranslated for some axis combo.
|
|
212
219
|
*/
|
|
213
220
|
export function fallbackLandingUrl(
|
|
214
221
|
basePath: string,
|
|
215
222
|
axisEntryId: string,
|
|
223
|
+
atRoot = false,
|
|
216
224
|
): string {
|
|
217
225
|
const trimmed = basePath.replace(/\/$/, "");
|
|
218
|
-
return `${trimmed}/${axisEntryId}/`;
|
|
226
|
+
return atRoot ? `${trimmed}/` : `${trimmed}/${axisEntryId}/`;
|
|
219
227
|
}
|
package/src/tag-list-data.ts
CHANGED
|
@@ -33,6 +33,29 @@ export interface BuildChipsOptions {
|
|
|
33
33
|
* text. URL still uses the slug.
|
|
34
34
|
*/
|
|
35
35
|
labels?: Record<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* Color chips whose prefix has no `color` with a palette color
|
|
38
|
+
* hashed from the first segment (`autoTagColor`). Default false.
|
|
39
|
+
*/
|
|
40
|
+
autoColor?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Palette order for automatic colors. */
|
|
44
|
+
const AUTO_PALETTE: readonly TagPaletteName[] = ["blue", "amber", "emerald", "violet", "rose", "slate"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A stable palette color for a tag segment: FNV-1a hash of the
|
|
48
|
+
* lowercased segment, modulo the palette. The same segment gets the
|
|
49
|
+
* same color on every page and every build; with more distinct tags
|
|
50
|
+
* than colors, colors repeat.
|
|
51
|
+
*/
|
|
52
|
+
export function autoTagColor(segment: string): TagPaletteName {
|
|
53
|
+
let hash = 0x811c9dc5;
|
|
54
|
+
for (const ch of segment.toLowerCase()) {
|
|
55
|
+
hash ^= ch.codePointAt(0)!;
|
|
56
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
57
|
+
}
|
|
58
|
+
return AUTO_PALETTE[hash % AUTO_PALETTE.length];
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
export interface ChipModel {
|
|
@@ -164,7 +187,7 @@ export function buildChips(
|
|
|
164
187
|
options: BuildChipsOptions,
|
|
165
188
|
): ChipModel[] {
|
|
166
189
|
const safe = filterTags(tags);
|
|
167
|
-
const { indexPath, prefixes, labels } = options;
|
|
190
|
+
const { indexPath, prefixes, labels, autoColor = false } = options;
|
|
168
191
|
|
|
169
192
|
return safe.map((tag): ChipModel => {
|
|
170
193
|
const href = `${indexPath}/${tag}/`;
|
|
@@ -182,7 +205,7 @@ export function buildChips(
|
|
|
182
205
|
href,
|
|
183
206
|
prefixLabel: null,
|
|
184
207
|
leafLabel: overrideLeaf ?? tag,
|
|
185
|
-
color: null,
|
|
208
|
+
color: autoColor ? autoTagColor(firstSegment) : null,
|
|
186
209
|
};
|
|
187
210
|
}
|
|
188
211
|
|
|
@@ -196,7 +219,7 @@ export function buildChips(
|
|
|
196
219
|
href,
|
|
197
220
|
prefixLabel: prefixEntry.label ?? null,
|
|
198
221
|
leafLabel: leafLabel.length > 0 ? leafLabel : tag,
|
|
199
|
-
color: prefixEntry.color ?? null,
|
|
222
|
+
color: prefixEntry.color ?? (autoColor ? autoTagColor(firstSegment) : null),
|
|
200
223
|
};
|
|
201
224
|
});
|
|
202
225
|
}
|
package/src/version-redirect.ts
CHANGED
|
@@ -31,6 +31,15 @@ export interface AxisRedirectConfig {
|
|
|
31
31
|
defaultVersion?: string;
|
|
32
32
|
/** Full set of declared version ids. Empty/undefined → axis inactive. */
|
|
33
33
|
knownVersions?: string[];
|
|
34
|
+
/**
|
|
35
|
+
* The version served at the site root (`versions[].path: ""`, the
|
|
36
|
+
* default version). A URL with no version segment is one of its pages,
|
|
37
|
+
* so the version is never injected; a URL starting with another known
|
|
38
|
+
* version's segment is that version's (the longest matching prefix
|
|
39
|
+
* wins). Only the locale redirect remains. format-astro emits no version
|
|
40
|
+
* config at all in that case; this keeps the helper right when given one.
|
|
41
|
+
*/
|
|
42
|
+
rootVersion?: string;
|
|
34
43
|
/** Default locale id; required when locale axis is active. */
|
|
35
44
|
defaultLocale?: string;
|
|
36
45
|
/** Full set of declared locale ids. Empty/undefined → axis inactive. */
|
|
@@ -126,7 +135,8 @@ export function shouldRedirectToDefaultVersion(
|
|
|
126
135
|
if (
|
|
127
136
|
versionAxisActive &&
|
|
128
137
|
cursor < segments.length &&
|
|
129
|
-
knownVersions.has(segments[cursor])
|
|
138
|
+
knownVersions.has(segments[cursor]) &&
|
|
139
|
+
segments[cursor] !== config.rootVersion
|
|
130
140
|
) {
|
|
131
141
|
presentVersion = segments[cursor];
|
|
132
142
|
cursor += 1;
|
|
@@ -134,13 +144,16 @@ export function shouldRedirectToDefaultVersion(
|
|
|
134
144
|
|
|
135
145
|
// What's the rest of the path beyond the recognised axis prefixes?
|
|
136
146
|
const tailSegments = segments.slice(cursor);
|
|
147
|
+
// With a root version, a URL without a version segment is already complete.
|
|
148
|
+
const versionMissing =
|
|
149
|
+
versionAxisActive && presentVersion === undefined && !config.rootVersion;
|
|
137
150
|
if (tailSegments.length === 0) {
|
|
138
151
|
// The path is JUST a known axis prefix (e.g. /docs/en/ or
|
|
139
152
|
// /docs/v1/). Treat as the axis's landing page; no redirect
|
|
140
153
|
// needed unless other axes are MISSING from the URL too.
|
|
141
154
|
if (
|
|
142
155
|
(localeAxisActive && presentLocale === undefined) ||
|
|
143
|
-
|
|
156
|
+
versionMissing
|
|
144
157
|
) {
|
|
145
158
|
// Some axis still missing — fall through to redirect.
|
|
146
159
|
} else {
|
|
@@ -150,7 +163,7 @@ export function shouldRedirectToDefaultVersion(
|
|
|
150
163
|
|
|
151
164
|
// If all required-active axes are present, no redirect needed.
|
|
152
165
|
const localeNeeded = localeAxisActive && presentLocale === undefined;
|
|
153
|
-
const versionNeeded =
|
|
166
|
+
const versionNeeded = versionMissing;
|
|
154
167
|
if (!localeNeeded && !versionNeeded) return null;
|
|
155
168
|
|
|
156
169
|
// Compose the redirect target with missing segments injected
|
|
@@ -159,7 +172,7 @@ export function shouldRedirectToDefaultVersion(
|
|
|
159
172
|
if (localeAxisActive) {
|
|
160
173
|
out.push(presentLocale ?? config.defaultLocale!);
|
|
161
174
|
}
|
|
162
|
-
if (versionAxisActive) {
|
|
175
|
+
if (versionAxisActive && (presentVersion !== undefined || !config.rootVersion)) {
|
|
163
176
|
out.push(presentVersion ?? config.defaultVersion!);
|
|
164
177
|
}
|
|
165
178
|
out.push(...tailSegments);
|