@dogsbay/docs-layout 0.2.0-beta.0
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 +59 -0
- package/src/DocsFooter.astro +96 -0
- package/src/DocsHeader.astro +41 -0
- package/src/DocsLayout.astro +884 -0
- package/src/DocsNav.astro +62 -0
- package/src/DocsSidebar.astro +35 -0
- package/src/DocsToc.astro +50 -0
- package/src/LocaleSwitcher.astro +87 -0
- package/src/PageActions.astro +281 -0
- package/src/SearchDialog.astro +529 -0
- package/src/StatusBadge.astro +79 -0
- package/src/TagList.astro +124 -0
- package/src/TaxonomyIndex.astro +148 -0
- package/src/TaxonomyTerm.astro +181 -0
- package/src/TypeBadge.astro +63 -0
- package/src/VersionSwitcher.astro +86 -0
- package/src/json-ld.ts +55 -0
- package/src/llm-actions.ts +128 -0
- package/src/markdown-negotiation.ts +76 -0
- package/src/nav-filter.ts +166 -0
- package/src/pagination.ts +39 -0
- package/src/search-facets.ts +232 -0
- package/src/switcher.ts +138 -0
- package/src/tag-list-data.ts +202 -0
- package/src/toc-kind.css +52 -0
- package/src/version-redirect.ts +147 -0
package/src/switcher.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared logic for axis switchers (Version + Locale, with the
|
|
3
|
+
* same machinery extending to future axes). Pure functions —
|
|
4
|
+
* the Astro components are thin wrappers around this module.
|
|
5
|
+
*
|
|
6
|
+
* The switcherMap data structure is documented in
|
|
7
|
+
* `format-astro/src/project.ts` (the emitter side).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface AxisEntry {
|
|
11
|
+
id: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
eol?: boolean;
|
|
14
|
+
default?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SwitcherVariant {
|
|
18
|
+
locale?: string;
|
|
19
|
+
version?: string;
|
|
20
|
+
url: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SwitcherMap {
|
|
24
|
+
versions: AxisEntry[];
|
|
25
|
+
locales: AxisEntry[];
|
|
26
|
+
byLogicalKey: Record<string, SwitcherVariant[]>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MultiSourceMeta {
|
|
30
|
+
namespace?: string;
|
|
31
|
+
version?: string;
|
|
32
|
+
locale?: string;
|
|
33
|
+
originalSlug: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The axis the switcher is responsible for. */
|
|
37
|
+
export type SwitcherAxis = "version" | "locale";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compose the logical key the switcher uses to look up the
|
|
41
|
+
* current page's variants in `switcherMap.byLogicalKey`.
|
|
42
|
+
*/
|
|
43
|
+
export function logicalKeyFor(ms: MultiSourceMeta): string {
|
|
44
|
+
return `${ms.namespace ?? "docs"}/${ms.originalSlug}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the full row data the switcher dropdown needs to
|
|
49
|
+
* render: declared entries (in order) plus, per row, the
|
|
50
|
+
* resolved URL for that entry (or null if no variant exists
|
|
51
|
+
* in this dimension for the current page).
|
|
52
|
+
*
|
|
53
|
+
* Row resolution algorithm:
|
|
54
|
+
*
|
|
55
|
+
* For each declared entry on this axis:
|
|
56
|
+
* - Find a variant whose <axis>-id matches the entry AND
|
|
57
|
+
* whose OTHER axis values match the current page (the
|
|
58
|
+
* same locale when switching version, etc.).
|
|
59
|
+
* - If found: row.url is the variant's url.
|
|
60
|
+
* - Otherwise: row.url is null (rendered as "no equivalent"
|
|
61
|
+
* in the dropdown; clicking falls back to the axis's
|
|
62
|
+
* landing page).
|
|
63
|
+
*/
|
|
64
|
+
export interface SwitcherRow {
|
|
65
|
+
entry: AxisEntry;
|
|
66
|
+
url: string | null;
|
|
67
|
+
isCurrent: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface BuildRowsInput {
|
|
71
|
+
axis: SwitcherAxis;
|
|
72
|
+
switcherMap: SwitcherMap;
|
|
73
|
+
multiSource: MultiSourceMeta;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildSwitcherRows(input: BuildRowsInput): SwitcherRow[] {
|
|
77
|
+
const { axis, switcherMap, multiSource } = input;
|
|
78
|
+
const entries = axis === "version" ? switcherMap.versions : switcherMap.locales;
|
|
79
|
+
const currentId =
|
|
80
|
+
axis === "version" ? multiSource.version : multiSource.locale;
|
|
81
|
+
const otherAxis: SwitcherAxis = axis === "version" ? "locale" : "version";
|
|
82
|
+
const otherCurrent =
|
|
83
|
+
otherAxis === "version" ? multiSource.version : multiSource.locale;
|
|
84
|
+
|
|
85
|
+
const variants = switcherMap.byLogicalKey[logicalKeyFor(multiSource)] ?? [];
|
|
86
|
+
|
|
87
|
+
return entries.map((entry) => {
|
|
88
|
+
const match = variants.find((v) => {
|
|
89
|
+
// Match on this axis.
|
|
90
|
+
const thisMatch = axis === "version" ? v.version === entry.id : v.locale === entry.id;
|
|
91
|
+
if (!thisMatch) return false;
|
|
92
|
+
// And on the other axis (when active for this page).
|
|
93
|
+
if (otherCurrent === undefined) return true;
|
|
94
|
+
const otherMatch =
|
|
95
|
+
otherAxis === "version" ? v.version === otherCurrent : v.locale === otherCurrent;
|
|
96
|
+
return otherMatch;
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
entry,
|
|
100
|
+
url: match?.url ?? null,
|
|
101
|
+
isCurrent: entry.id === currentId,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether the switcher should render at all. Two gates:
|
|
108
|
+
* - the axis has ≥2 declared entries (single-axis sites
|
|
109
|
+
* don't need a dropdown);
|
|
110
|
+
* - the current page has metadata for that axis (some pages
|
|
111
|
+
* in a multi-version site may sit outside the axis, e.g.
|
|
112
|
+
* a custom 404 — they should not show the dropdown).
|
|
113
|
+
*/
|
|
114
|
+
export function shouldRenderSwitcher(
|
|
115
|
+
axis: SwitcherAxis,
|
|
116
|
+
switcherMap: SwitcherMap,
|
|
117
|
+
multiSource: MultiSourceMeta | undefined,
|
|
118
|
+
): boolean {
|
|
119
|
+
if (!multiSource) return false;
|
|
120
|
+
const entries = axis === "version" ? switcherMap.versions : switcherMap.locales;
|
|
121
|
+
if (entries.length < 2) return false;
|
|
122
|
+
const id = axis === "version" ? multiSource.version : multiSource.locale;
|
|
123
|
+
return id !== undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fallback URL for a row whose target variant doesn't exist —
|
|
128
|
+
* lands on the axis's "landing page" (`<basePath>/<id>/`).
|
|
129
|
+
* Lets the switcher always be clickable even when a page is
|
|
130
|
+
* untranslated for some axis combo.
|
|
131
|
+
*/
|
|
132
|
+
export function fallbackLandingUrl(
|
|
133
|
+
basePath: string,
|
|
134
|
+
axisEntryId: string,
|
|
135
|
+
): string {
|
|
136
|
+
const trimmed = basePath.replace(/\/$/, "");
|
|
137
|
+
return `${trimmed}/${axisEntryId}/`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure data transform for `<TagList>`. Splitting it out from the
|
|
3
|
+
* Astro component lets vitest unit-test the chip-building logic
|
|
4
|
+
* without needing a render container.
|
|
5
|
+
*
|
|
6
|
+
* See plans/tag-display-config.md.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Closed palette of tag-chip colors. Mirror of `@dogsbay/types`. */
|
|
10
|
+
export type TagPaletteName =
|
|
11
|
+
| "blue"
|
|
12
|
+
| "amber"
|
|
13
|
+
| "emerald"
|
|
14
|
+
| "violet"
|
|
15
|
+
| "rose"
|
|
16
|
+
| "slate";
|
|
17
|
+
|
|
18
|
+
export interface PrefixDisplay {
|
|
19
|
+
label?: string;
|
|
20
|
+
color?: TagPaletteName;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BuildChipsOptions {
|
|
24
|
+
/** Path prefix for term pages, e.g. `/tags`. */
|
|
25
|
+
indexPath: string;
|
|
26
|
+
/**
|
|
27
|
+
* Per-prefix display config. Key = top-level segment of the tag.
|
|
28
|
+
* Tag's first segment is matched here for two-part rendering.
|
|
29
|
+
*/
|
|
30
|
+
prefixes?: Record<string, PrefixDisplay>;
|
|
31
|
+
/**
|
|
32
|
+
* Per-tag leaf-label overrides. Key = full slug; value = display
|
|
33
|
+
* text. URL still uses the slug.
|
|
34
|
+
*/
|
|
35
|
+
labels?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ChipModel {
|
|
39
|
+
/** Original tag slug; used as `data-tag` and for the URL. */
|
|
40
|
+
tag: string;
|
|
41
|
+
/** Built href: `<indexPath>/<tag>/`. */
|
|
42
|
+
href: string;
|
|
43
|
+
/**
|
|
44
|
+
* Prefix label when two-part rendering applies (set when the
|
|
45
|
+
* tag's first segment matches a `prefixes` entry that supplies
|
|
46
|
+
* a `label`). `null` for flat chips.
|
|
47
|
+
*/
|
|
48
|
+
prefixLabel: string | null;
|
|
49
|
+
/** Display text for the leaf. Used in both rendering modes. */
|
|
50
|
+
leafLabel: string;
|
|
51
|
+
/** Palette color name to look up; null = default chip styling. */
|
|
52
|
+
color: TagPaletteName | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Filter empty / non-string entries from a raw tag list. Exported
|
|
57
|
+
* so the component can keep its render guard simple.
|
|
58
|
+
*/
|
|
59
|
+
export function filterTags(tags: readonly unknown[] | undefined): string[] {
|
|
60
|
+
return (tags ?? []).filter(
|
|
61
|
+
(t): t is string => typeof t === "string" && t.length > 0,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the human-readable display label for one tag.
|
|
67
|
+
*
|
|
68
|
+
* Used by both `<TagList>` chip rendering and HTML meta emission
|
|
69
|
+
* (`<meta property="article:tag">` / JSON-LD `Article.keywords`).
|
|
70
|
+
* Slug-based identifiers like `difficulty/1` mean nothing to a
|
|
71
|
+
* search-engine indexer or social-card preview — the label
|
|
72
|
+
* override (`Beginner`) is the unit that carries meaning.
|
|
73
|
+
*
|
|
74
|
+
* Resolution order:
|
|
75
|
+
* 1. `labels[fullSlug]` — explicit override wins.
|
|
76
|
+
* 2. Segment after the first `/` — for hierarchical tags without
|
|
77
|
+
* an override, the leaf carries the human-meaningful part.
|
|
78
|
+
* 3. The bare tag — flat tags / final fallback.
|
|
79
|
+
*
|
|
80
|
+
* Always returns a non-empty string; never returns the prefix
|
|
81
|
+
* label (that's chip-rendering chrome, not a stand-alone keyword).
|
|
82
|
+
*/
|
|
83
|
+
export function resolveTagDisplayLabel(
|
|
84
|
+
tag: string,
|
|
85
|
+
labels?: Record<string, string>,
|
|
86
|
+
): string {
|
|
87
|
+
const override = labels?.[tag];
|
|
88
|
+
if (override && override.length > 0) return override;
|
|
89
|
+
const slashIdx = tag.indexOf("/");
|
|
90
|
+
if (slashIdx === -1) return tag;
|
|
91
|
+
const remainder = tag.slice(slashIdx + 1);
|
|
92
|
+
return remainder.length > 0 ? remainder : tag;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Map a list of tag slugs to their human-readable display labels.
|
|
97
|
+
* Drops empty/non-string inputs (via `filterTags`); returns one
|
|
98
|
+
* label per tag in original order.
|
|
99
|
+
*
|
|
100
|
+
* The resulting array is the natural input for HTML meta tags
|
|
101
|
+
* (`<meta property="article:tag" content="${kw}">` per entry) and
|
|
102
|
+
* for JSON-LD `Article.keywords` (joined with `", "`).
|
|
103
|
+
*/
|
|
104
|
+
export function resolveTagKeywords(
|
|
105
|
+
tags: readonly unknown[] | undefined,
|
|
106
|
+
labels?: Record<string, string>,
|
|
107
|
+
): string[] {
|
|
108
|
+
return filterTags(tags).map((tag) => resolveTagDisplayLabel(tag, labels));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the display label for a term identified by its fullPath
|
|
113
|
+
* array (e.g. `["concept"]` or `["concept", "a11y"]`).
|
|
114
|
+
*
|
|
115
|
+
* Used by `<TaxonomyIndex>` term-cloud chips, `<TaxonomyTerm>`
|
|
116
|
+
* page headings and breadcrumbs, and sub-tag pills — anywhere a
|
|
117
|
+
* structural slug needs to render as a human label.
|
|
118
|
+
*
|
|
119
|
+
* Resolution order:
|
|
120
|
+
* 1. `labels[fullSlug]` (joined fullPath) — explicit override
|
|
121
|
+
* wins, regardless of depth.
|
|
122
|
+
* 2. For top-level terms (`fullPath.length === 1`):
|
|
123
|
+
* `prefixes[segment].label` — lets a top-level segment
|
|
124
|
+
* (`concept`, `difficulty`) render as "Concept", "Difficulty".
|
|
125
|
+
* 3. The leaf segment of the path — the rendered fallback for
|
|
126
|
+
* deeper terms with no override.
|
|
127
|
+
*
|
|
128
|
+
* URLs continue to use the slug-form `fullPath`. Display only.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveTermLabel(
|
|
131
|
+
fullPath: readonly string[],
|
|
132
|
+
prefixes?: Record<string, PrefixDisplay>,
|
|
133
|
+
labels?: Record<string, string>,
|
|
134
|
+
): string {
|
|
135
|
+
if (fullPath.length === 0) return "";
|
|
136
|
+
const slug = fullPath.join("/");
|
|
137
|
+
const override = labels?.[slug];
|
|
138
|
+
if (override && override.length > 0) return override;
|
|
139
|
+
if (fullPath.length === 1) {
|
|
140
|
+
const prefixLabel = prefixes?.[fullPath[0]]?.label;
|
|
141
|
+
if (prefixLabel && prefixLabel.length > 0) return prefixLabel;
|
|
142
|
+
}
|
|
143
|
+
return fullPath[fullPath.length - 1];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Map raw tag slugs to render-ready chip models, applying prefix
|
|
148
|
+
* + label config rules.
|
|
149
|
+
*
|
|
150
|
+
* Rules:
|
|
151
|
+
* - URL is always `<indexPath>/<tag>/` regardless of label
|
|
152
|
+
* override — taxonomy term pages live at the slug path.
|
|
153
|
+
* - Two-part chip path requires both: a `prefixes` entry for the
|
|
154
|
+
* first segment AND that entry having `label` or `color` set.
|
|
155
|
+
* Without either, falls back to flat rendering (back-compat for
|
|
156
|
+
* sites that haven't declared display config).
|
|
157
|
+
* - Leaf label resolution order: `labels[fullSlug]` override,
|
|
158
|
+
* then the segment after the first slash, then the bare tag
|
|
159
|
+
* (used when a prefix-only-color entry matches a non-nested tag).
|
|
160
|
+
* - Color from prefix entry; null when absent (default styling).
|
|
161
|
+
*/
|
|
162
|
+
export function buildChips(
|
|
163
|
+
tags: readonly unknown[] | undefined,
|
|
164
|
+
options: BuildChipsOptions,
|
|
165
|
+
): ChipModel[] {
|
|
166
|
+
const safe = filterTags(tags);
|
|
167
|
+
const { indexPath, prefixes, labels } = options;
|
|
168
|
+
|
|
169
|
+
return safe.map((tag): ChipModel => {
|
|
170
|
+
const href = `${indexPath}/${tag}/`;
|
|
171
|
+
const slashIdx = tag.indexOf("/");
|
|
172
|
+
const firstSegment = slashIdx === -1 ? tag : tag.slice(0, slashIdx);
|
|
173
|
+
const prefixEntry = prefixes?.[firstSegment];
|
|
174
|
+
const overrideLeaf = labels?.[tag];
|
|
175
|
+
|
|
176
|
+
// Flat chip: preserves the full slug (`#concept/a11y`) when no
|
|
177
|
+
// prefix entry matches. The slash signals hierarchy visually
|
|
178
|
+
// even without explicit display config.
|
|
179
|
+
if (!prefixEntry || (!prefixEntry.label && !prefixEntry.color)) {
|
|
180
|
+
return {
|
|
181
|
+
tag,
|
|
182
|
+
href,
|
|
183
|
+
prefixLabel: null,
|
|
184
|
+
leafLabel: overrideLeaf ?? tag,
|
|
185
|
+
color: null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Two-part chip: prefix takes the structural segment, leaf
|
|
190
|
+
// gets the meaningful name (override or remainder after first
|
|
191
|
+
// slash). Falls back to the bare tag for non-nested entries.
|
|
192
|
+
const remainder = slashIdx === -1 ? tag : tag.slice(slashIdx + 1);
|
|
193
|
+
const leafLabel = overrideLeaf ?? remainder;
|
|
194
|
+
return {
|
|
195
|
+
tag,
|
|
196
|
+
href,
|
|
197
|
+
prefixLabel: prefixEntry.label ?? null,
|
|
198
|
+
leafLabel: leafLabel.length > 0 ? leafLabel : tag,
|
|
199
|
+
color: prefixEntry.color ?? null,
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
}
|
package/src/toc-kind.css
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
.toc-kind {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
border-radius: 0.25rem;
|
|
4
|
+
padding: 0 0.3rem;
|
|
5
|
+
font-size: 0.6rem;
|
|
6
|
+
font-weight: 600;
|
|
7
|
+
letter-spacing: 0.02em;
|
|
8
|
+
text-transform: lowercase;
|
|
9
|
+
vertical-align: middle;
|
|
10
|
+
margin-right: 0.35rem;
|
|
11
|
+
line-height: 1.4;
|
|
12
|
+
}
|
|
13
|
+
.toc-kind-class {
|
|
14
|
+
background-color: oklch(0.55 0.18 265 / 0.15);
|
|
15
|
+
color: oklch(0.55 0.18 265);
|
|
16
|
+
}
|
|
17
|
+
.dark .toc-kind-class {
|
|
18
|
+
background-color: oklch(0.7 0.15 265 / 0.2);
|
|
19
|
+
color: oklch(0.7 0.15 265);
|
|
20
|
+
}
|
|
21
|
+
.toc-kind-meth {
|
|
22
|
+
background-color: oklch(0.55 0.15 155 / 0.15);
|
|
23
|
+
color: oklch(0.55 0.15 155);
|
|
24
|
+
}
|
|
25
|
+
.dark .toc-kind-meth {
|
|
26
|
+
background-color: oklch(0.7 0.15 155 / 0.2);
|
|
27
|
+
color: oklch(0.7 0.15 155);
|
|
28
|
+
}
|
|
29
|
+
.toc-kind-attr {
|
|
30
|
+
background-color: oklch(0.6 0.15 55 / 0.15);
|
|
31
|
+
color: oklch(0.6 0.15 55);
|
|
32
|
+
}
|
|
33
|
+
.dark .toc-kind-attr {
|
|
34
|
+
background-color: oklch(0.75 0.15 55 / 0.2);
|
|
35
|
+
color: oklch(0.75 0.15 55);
|
|
36
|
+
}
|
|
37
|
+
.toc-kind-func {
|
|
38
|
+
background-color: oklch(0.55 0.18 330 / 0.15);
|
|
39
|
+
color: oklch(0.55 0.18 330);
|
|
40
|
+
}
|
|
41
|
+
.dark .toc-kind-func {
|
|
42
|
+
background-color: oklch(0.7 0.15 330 / 0.2);
|
|
43
|
+
color: oklch(0.7 0.15 330);
|
|
44
|
+
}
|
|
45
|
+
.toc-kind-module {
|
|
46
|
+
background-color: oklch(0.5 0.12 200 / 0.15);
|
|
47
|
+
color: oklch(0.5 0.12 200);
|
|
48
|
+
}
|
|
49
|
+
.dark .toc-kind-module {
|
|
50
|
+
background-color: oklch(0.65 0.12 200 / 0.2);
|
|
51
|
+
color: oklch(0.65 0.12 200);
|
|
52
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default-axis redirect decision helper.
|
|
3
|
+
*
|
|
4
|
+
* Combines the default-version redirect (PR 4c) with the
|
|
5
|
+
* default-locale redirect (PR 5c) into one helper. When a
|
|
6
|
+
* request URL is missing the locale or version segment that
|
|
7
|
+
* the site has declared, the helper returns the canonical URL
|
|
8
|
+
* with the missing segment(s) injected in the correct order
|
|
9
|
+
* (locale outermost, then version, per the site's URL
|
|
10
|
+
* convention).
|
|
11
|
+
*
|
|
12
|
+
* Pure function — no I/O, no globals. Inlined into the emitted
|
|
13
|
+
* Astro middleware. The middleware issues a 302 to the returned
|
|
14
|
+
* URL (302 not 301 — the switchers let readers navigate away
|
|
15
|
+
* from the default, so we don't want browsers permanently
|
|
16
|
+
* caching the unprefixed URL as default content).
|
|
17
|
+
*
|
|
18
|
+
* The function is exported under the historical name
|
|
19
|
+
* `shouldRedirectToDefaultVersion` for backward compat but now
|
|
20
|
+
* also handles locale. New code can import the same name.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface AxisRedirectConfig {
|
|
24
|
+
/**
|
|
25
|
+
* URL prefix where docs are served, e.g. "/docs" or "" for
|
|
26
|
+
* root-served sites. Should be a normalized basePath
|
|
27
|
+
* (no trailing slash for non-empty values).
|
|
28
|
+
*/
|
|
29
|
+
basePath: string;
|
|
30
|
+
/** Default version id; required when version axis is active. */
|
|
31
|
+
defaultVersion?: string;
|
|
32
|
+
/** Full set of declared version ids. Empty/undefined → axis inactive. */
|
|
33
|
+
knownVersions?: string[];
|
|
34
|
+
/** Default locale id; required when locale axis is active. */
|
|
35
|
+
defaultLocale?: string;
|
|
36
|
+
/** Full set of declared locale ids. Empty/undefined → axis inactive. */
|
|
37
|
+
knownLocales?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns the redirect target when the pathname is missing one
|
|
42
|
+
* or more axis segments, or `null` when the request should pass
|
|
43
|
+
* through.
|
|
44
|
+
*
|
|
45
|
+
* Behaviour:
|
|
46
|
+
* - Skips when neither axis is active (knownVersions <2 AND
|
|
47
|
+
* knownLocales <2).
|
|
48
|
+
* - Skips paths outside the configured basePath.
|
|
49
|
+
* - Skips the bare basePath (root index handles its own
|
|
50
|
+
* redirect).
|
|
51
|
+
* - Skips asset paths (`_*` prefix, `pagefind`).
|
|
52
|
+
* - Determines which segments are PRESENT (locale first,
|
|
53
|
+
* version second per canonical order) by greedy matching
|
|
54
|
+
* against `knownLocales` then `knownVersions`. Missing
|
|
55
|
+
* segments are injected from the corresponding default.
|
|
56
|
+
*/
|
|
57
|
+
export function shouldRedirectToDefaultVersion(
|
|
58
|
+
pathname: string,
|
|
59
|
+
config: AxisRedirectConfig,
|
|
60
|
+
): string | null {
|
|
61
|
+
const versionAxisActive =
|
|
62
|
+
!!config.defaultVersion && (config.knownVersions ?? []).length >= 2;
|
|
63
|
+
const localeAxisActive =
|
|
64
|
+
!!config.defaultLocale && (config.knownLocales ?? []).length >= 2;
|
|
65
|
+
if (!versionAxisActive && !localeAxisActive) return null;
|
|
66
|
+
|
|
67
|
+
const basePath = config.basePath.replace(/\/$/, "");
|
|
68
|
+
|
|
69
|
+
// Strip basePath. If not under basePath, pass through.
|
|
70
|
+
let rest: string;
|
|
71
|
+
if (basePath === "") {
|
|
72
|
+
rest = pathname;
|
|
73
|
+
} else if (pathname === basePath || pathname === `${basePath}/`) {
|
|
74
|
+
rest = "";
|
|
75
|
+
} else if (pathname.startsWith(`${basePath}/`)) {
|
|
76
|
+
rest = pathname.slice(basePath.length);
|
|
77
|
+
} else {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!rest.startsWith("/")) rest = `/${rest}`;
|
|
82
|
+
const trimmed = rest.replace(/\/+$/, "");
|
|
83
|
+
if (trimmed === "" || trimmed === "/") return null;
|
|
84
|
+
|
|
85
|
+
const segments = trimmed.replace(/^\//, "").split("/");
|
|
86
|
+
if (segments.length === 0) return null;
|
|
87
|
+
|
|
88
|
+
// Skip Astro / Pagefind asset paths.
|
|
89
|
+
if (segments[0].startsWith("_") || segments[0] === "pagefind") return null;
|
|
90
|
+
|
|
91
|
+
// Greedy axis detection — locale outermost, version next.
|
|
92
|
+
const knownLocales = new Set(config.knownLocales ?? []);
|
|
93
|
+
const knownVersions = new Set(config.knownVersions ?? []);
|
|
94
|
+
|
|
95
|
+
let cursor = 0;
|
|
96
|
+
let presentLocale: string | undefined;
|
|
97
|
+
let presentVersion: string | undefined;
|
|
98
|
+
|
|
99
|
+
if (localeAxisActive && knownLocales.has(segments[0])) {
|
|
100
|
+
presentLocale = segments[0];
|
|
101
|
+
cursor = 1;
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
versionAxisActive &&
|
|
105
|
+
cursor < segments.length &&
|
|
106
|
+
knownVersions.has(segments[cursor])
|
|
107
|
+
) {
|
|
108
|
+
presentVersion = segments[cursor];
|
|
109
|
+
cursor += 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// What's the rest of the path beyond the recognised axis prefixes?
|
|
113
|
+
const tailSegments = segments.slice(cursor);
|
|
114
|
+
if (tailSegments.length === 0) {
|
|
115
|
+
// The path is JUST a known axis prefix (e.g. /docs/en/ or
|
|
116
|
+
// /docs/v1/). Treat as the axis's landing page; no redirect
|
|
117
|
+
// needed unless other axes are MISSING from the URL too.
|
|
118
|
+
if (
|
|
119
|
+
(localeAxisActive && presentLocale === undefined) ||
|
|
120
|
+
(versionAxisActive && presentVersion === undefined)
|
|
121
|
+
) {
|
|
122
|
+
// Some axis still missing — fall through to redirect.
|
|
123
|
+
} else {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// If all required-active axes are present, no redirect needed.
|
|
129
|
+
const localeNeeded = localeAxisActive && presentLocale === undefined;
|
|
130
|
+
const versionNeeded = versionAxisActive && presentVersion === undefined;
|
|
131
|
+
if (!localeNeeded && !versionNeeded) return null;
|
|
132
|
+
|
|
133
|
+
// Compose the redirect target with missing segments injected
|
|
134
|
+
// in canonical order (<locale>/<version>/<tail>).
|
|
135
|
+
const out: string[] = [];
|
|
136
|
+
if (localeAxisActive) {
|
|
137
|
+
out.push(presentLocale ?? config.defaultLocale!);
|
|
138
|
+
}
|
|
139
|
+
if (versionAxisActive) {
|
|
140
|
+
out.push(presentVersion ?? config.defaultVersion!);
|
|
141
|
+
}
|
|
142
|
+
out.push(...tailSegments);
|
|
143
|
+
|
|
144
|
+
// Preserve trailing slash from the original pathname.
|
|
145
|
+
const trailing = pathname.endsWith("/") && !pathname.endsWith(`${basePath}/`);
|
|
146
|
+
return `${basePath}/${out.join("/")}${trailing ? "/" : ""}`;
|
|
147
|
+
}
|