@dogsbay/docs-layout 0.2.0-beta.92 → 0.2.0-beta.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/BlogIndex.astro +174 -0
- package/src/DocsFooter.astro +27 -3
- package/src/DocsLayout.astro +236 -4
- package/src/DocsNavClient.astro +21 -3
- package/src/SearchDialog.astro +29 -1
- package/src/VersionSwitcher.astro +6 -0
- package/src/docs-nav-client.ts +167 -13
- 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/nav-filter.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-source nav filtering.
|
|
3
3
|
*
|
|
4
|
-
* When a docs site has multiple versions
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* When a docs site has multiple products/versions/locales, every page's
|
|
5
|
+
* emitted nav.json contains entries from EVERY bucket. Without filtering,
|
|
6
|
+
* the sidebar shows a product's sections once per version, and every other
|
|
7
|
+
* product too — confusing UX. The fix: filter the nav tree to the current
|
|
8
|
+
* page's (namespace, locale, version) bucket.
|
|
9
9
|
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* sidebar nav reflects only the active axis bucket.
|
|
10
|
+
* The match is a single COMPOSED prefix in the canonical URL order
|
|
11
|
+
* `/<basePath>/<namespace>/<locale>/<version>/...` — whichever of those
|
|
12
|
+
* axes the current page carries. The axis switchers handle navigation
|
|
13
|
+
* BETWEEN buckets; the sidebar reflects only the active one. Pure
|
|
14
|
+
* function: nav + filter → pruned copy.
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
17
|
interface NavItem {
|
|
@@ -22,145 +21,59 @@ interface NavItem {
|
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
export interface NavFilter {
|
|
25
|
-
/** Site basePath (e.g. "/docs").
|
|
24
|
+
/** Site basePath (e.g. "" for root, "/docs"). */
|
|
26
25
|
basePath: string;
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* the full nav through unchanged.
|
|
31
|
-
*/
|
|
32
|
-
version?: string;
|
|
33
|
-
/**
|
|
34
|
-
* Current page's effective locale. When set, nav items are
|
|
35
|
-
* filtered to those whose href starts with the corresponding
|
|
36
|
-
* locale segment (`<basePath>/<locale>/`).
|
|
37
|
-
*/
|
|
26
|
+
/** Current page's product/namespace segment (outermost), if any. */
|
|
27
|
+
namespace?: string;
|
|
28
|
+
/** Current page's locale segment (after namespace), if any. */
|
|
38
29
|
locale?: string;
|
|
30
|
+
/** Current page's version segment (innermost, next to the page), if any. */
|
|
31
|
+
version?: string;
|
|
39
32
|
}
|
|
40
33
|
|
|
41
34
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* Items without `href` AND without `children` are unusual but
|
|
48
|
-
* pass through unchanged (defensive — never silently drop a
|
|
49
|
-
* node we don't understand).
|
|
50
|
-
*
|
|
51
|
-
* Both filters apply concurrently: a multi-version multi-locale
|
|
52
|
-
* site filters by BOTH simultaneously, so an item must match
|
|
53
|
-
* /<basePath>/<locale>/.../<version>/... structurally.
|
|
35
|
+
* Prune the nav to the current page's bucket. Group nodes (no `href`,
|
|
36
|
+
* with `children`) survive iff a descendant survives; empty groups and
|
|
37
|
+
* childless/href-less nodes are dropped (else a non-current bucket's group
|
|
38
|
+
* lingers as a phantom header).
|
|
54
39
|
*/
|
|
55
|
-
export function filterNavByAxis(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
40
|
+
export function filterNavByAxis(items: NavItem[], filter: NavFilter): NavItem[] {
|
|
41
|
+
// Canonical order: namespace → locale → version. Only the axes the
|
|
42
|
+
// current page actually carries contribute to the match prefix.
|
|
43
|
+
const segs = [filter.namespace, filter.locale, filter.version].filter(
|
|
44
|
+
(s): s is string => s !== undefined && s !== "",
|
|
45
|
+
);
|
|
46
|
+
if (segs.length === 0) return items;
|
|
60
47
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// (when version is also active) check that <version> is the
|
|
65
|
-
// immediately-following segment.
|
|
66
|
-
const localePrefix = filter.locale
|
|
67
|
-
? prefixFor(filter.basePath, filter.locale)
|
|
68
|
-
: null;
|
|
69
|
-
const versionSegment = filter.version ?? null;
|
|
48
|
+
const base = filter.basePath.replace(/\/$/, "");
|
|
49
|
+
const prefix = `${base}/${segs.join("/")}/`;
|
|
50
|
+
const prefixNoSlash = prefix.replace(/\/$/, "");
|
|
70
51
|
|
|
71
|
-
return items.flatMap((item) =>
|
|
72
|
-
filterOne(item, localePrefix, versionSegment, filter.basePath),
|
|
73
|
-
);
|
|
52
|
+
return items.flatMap((item) => filterOne(item, prefix, prefixNoSlash));
|
|
74
53
|
}
|
|
75
54
|
|
|
76
|
-
function filterOne(
|
|
77
|
-
item: NavItem,
|
|
78
|
-
localePrefix: string | null,
|
|
79
|
-
versionSegment: string | null,
|
|
80
|
-
basePath: string,
|
|
81
|
-
): NavItem[] {
|
|
55
|
+
function filterOne(item: NavItem, prefix: string, prefixNoSlash: string): NavItem[] {
|
|
82
56
|
if (item.children && item.children.length > 0) {
|
|
83
|
-
const kept = item.children.flatMap((c) =>
|
|
84
|
-
filterOne(c, localePrefix, versionSegment, basePath),
|
|
85
|
-
);
|
|
57
|
+
const kept = item.children.flatMap((c) => filterOne(c, prefix, prefixNoSlash));
|
|
86
58
|
if (kept.length === 0) return [];
|
|
87
59
|
return [{ ...item, children: kept }];
|
|
88
60
|
}
|
|
89
61
|
if (item.href !== undefined) {
|
|
90
|
-
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
return [item];
|
|
62
|
+
return hrefMatchesPrefix(item.href, prefix, prefixNoSlash) ? [item] : [];
|
|
94
63
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Check that an href belongs to the requested (locale, version)
|
|
100
|
-
* combination. Either prefix can be null — meaning that axis
|
|
101
|
-
* isn't being filtered.
|
|
102
|
-
*/
|
|
103
|
-
function hrefMatchesAxes(
|
|
104
|
-
href: string,
|
|
105
|
-
localePrefix: string | null,
|
|
106
|
-
versionSegment: string | null,
|
|
107
|
-
basePath: string,
|
|
108
|
-
): boolean {
|
|
109
|
-
// External URLs aren't axis-bucketed.
|
|
110
|
-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:")) {
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Step 1: locale check. If locale axis is active, the href
|
|
115
|
-
// must be inside /<basePath>/<locale>/.
|
|
116
|
-
if (localePrefix !== null) {
|
|
117
|
-
if (!hrefMatchesPrefix(href, localePrefix)) return false;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Step 2: version check. The version segment is positioned
|
|
121
|
-
// AFTER the locale segment when both are active, otherwise
|
|
122
|
-
// immediately after basePath.
|
|
123
|
-
if (versionSegment !== null) {
|
|
124
|
-
const baseTrimmed = basePath.replace(/\/$/, "");
|
|
125
|
-
const localeSegStart = localePrefix
|
|
126
|
-
? localePrefix.replace(/\/$/, "")
|
|
127
|
-
: baseTrimmed;
|
|
128
|
-
const versionPrefix = `${localeSegStart}/${versionSegment}/`;
|
|
129
|
-
const versionPrefixNoSlash = versionPrefix.replace(/\/$/, "");
|
|
130
|
-
if (
|
|
131
|
-
!href.startsWith(versionPrefix) &&
|
|
132
|
-
href !== versionPrefixNoSlash
|
|
133
|
-
) {
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return true;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Compose the URL prefix for a given version under the
|
|
143
|
-
* configured basePath. Always ends in `/` so prefix-matching
|
|
144
|
-
* doesn't accept partial segments (`/docs/v1` shouldn't match
|
|
145
|
-
* `/docs/v10/...`).
|
|
146
|
-
*/
|
|
147
|
-
function prefixFor(basePath: string, segment: string): string {
|
|
148
|
-
const base = basePath.replace(/\/$/, "");
|
|
149
|
-
return `${base}/${segment}/`;
|
|
64
|
+
// Childless AND href-less: no navigation, no bucket membership. In a
|
|
65
|
+
// filtered view it must be dropped.
|
|
66
|
+
return [];
|
|
150
67
|
}
|
|
151
68
|
|
|
152
69
|
/**
|
|
153
|
-
* Whether an href belongs to the
|
|
154
|
-
*
|
|
155
|
-
*
|
|
70
|
+
* Whether an href belongs to the composed bucket prefix. Tolerates the
|
|
71
|
+
* bucket's landing page itself (`/<prefix>` with no trailing slash);
|
|
72
|
+
* external URLs never match.
|
|
156
73
|
*/
|
|
157
|
-
function hrefMatchesPrefix(href: string, prefix: string): boolean {
|
|
158
|
-
// Skip external URLs.
|
|
74
|
+
function hrefMatchesPrefix(href: string, prefix: string, prefixNoSlash: string): boolean {
|
|
159
75
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith("mailto:")) {
|
|
160
76
|
return false;
|
|
161
77
|
}
|
|
162
|
-
|
|
163
|
-
// page itself, if a writer linked to it directly).
|
|
164
|
-
const trimmedPrefix = prefix.replace(/\/$/, "");
|
|
165
|
-
return href.startsWith(prefix) || href === trimmedPrefix;
|
|
78
|
+
return href.startsWith(prefix) || href === prefixNoSlash;
|
|
166
79
|
}
|
package/src/switcher.ts
CHANGED
|
@@ -11,6 +11,14 @@ export interface AxisEntry {
|
|
|
11
11
|
id: string;
|
|
12
12
|
label?: string;
|
|
13
13
|
eol?: boolean;
|
|
14
|
+
/** Pre-release mark (version axis only) — the moving next/beta head. */
|
|
15
|
+
prerelease?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Served but kept OFF the switcher (version axis only). Marks the number
|
|
18
|
+
* a `latest` alias points at, so the dropdown shows one "3.32 (latest)"
|
|
19
|
+
* row instead of both "latest" and "3.32".
|
|
20
|
+
*/
|
|
21
|
+
hidden?: boolean;
|
|
14
22
|
default?: boolean;
|
|
15
23
|
}
|
|
16
24
|
|
|
@@ -73,9 +81,32 @@ export interface BuildRowsInput {
|
|
|
73
81
|
multiSource: MultiSourceMeta;
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Order two version ids. Compares numeric components left-to-right
|
|
86
|
+
* (`3.32` > `3.31`, `3.23-2` > `3.23-1`); a non-numeric alias (`latest`,
|
|
87
|
+
* `next`) sorts ABOVE any numbered version so descending order puts it
|
|
88
|
+
* first. When NEITHER id has any digits — a non-semver / codename scheme
|
|
89
|
+
* (`Boron`, `Argon`) — returns 0 so the sort is a no-op and the author's
|
|
90
|
+
* DECLARED order is preserved (JS sort is stable), rather than silently
|
|
91
|
+
* alphabetising and putting the newest release at the bottom.
|
|
92
|
+
*/
|
|
93
|
+
export function compareVersionIds(a: string, b: string): number {
|
|
94
|
+
const na = (a.match(/\d+/g) ?? []).map(Number);
|
|
95
|
+
const nb = (b.match(/\d+/g) ?? []).map(Number);
|
|
96
|
+
if (na.length === 0 && nb.length === 0) return 0; // codenames → keep declared order
|
|
97
|
+
if (na.length === 0) return 1; // alias > number
|
|
98
|
+
if (nb.length === 0) return -1;
|
|
99
|
+
const len = Math.max(na.length, nb.length);
|
|
100
|
+
for (let i = 0; i < len; i++) {
|
|
101
|
+
const d = (na[i] ?? 0) - (nb[i] ?? 0);
|
|
102
|
+
if (d !== 0) return d;
|
|
103
|
+
}
|
|
104
|
+
return a.localeCompare(b);
|
|
105
|
+
}
|
|
106
|
+
|
|
76
107
|
export function buildSwitcherRows(input: BuildRowsInput): SwitcherRow[] {
|
|
77
108
|
const { axis, switcherMap, multiSource } = input;
|
|
78
|
-
const
|
|
109
|
+
const allEntries = axis === "version" ? switcherMap.versions : switcherMap.locales;
|
|
79
110
|
const currentId =
|
|
80
111
|
axis === "version" ? multiSource.version : multiSource.locale;
|
|
81
112
|
const otherAxis: SwitcherAxis = axis === "version" ? "locale" : "version";
|
|
@@ -84,6 +115,56 @@ export function buildSwitcherRows(input: BuildRowsInput): SwitcherRow[] {
|
|
|
84
115
|
|
|
85
116
|
const variants = switcherMap.byLogicalKey[logicalKeyFor(multiSource)] ?? [];
|
|
86
117
|
|
|
118
|
+
// On a MULTI-PRODUCT (namespace-active) site the declared version list
|
|
119
|
+
// is the UNION across products, but a page's variants are same-namespace
|
|
120
|
+
// (the logical key is namespaced). Scope the version switcher to the
|
|
121
|
+
// versions this page's product actually has, so a Calico page never
|
|
122
|
+
// offers a Calico-Enterprise version. Single-product sites (no
|
|
123
|
+
// namespace) keep the full declared list, with fallbacks for pages
|
|
124
|
+
// missing in some version.
|
|
125
|
+
let entries = allEntries;
|
|
126
|
+
if (axis === "version" && multiSource.namespace !== undefined) {
|
|
127
|
+
const available = new Set(
|
|
128
|
+
variants.map((v) => v.version).filter((v): v is string => v !== undefined),
|
|
129
|
+
);
|
|
130
|
+
entries = allEntries.filter((e) => available.has(e.id) || e.id === currentId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The version dropdown always reads newest → oldest (3.32, 3.31, …),
|
|
134
|
+
// regardless of the declared order. Aliases (e.g. "latest") that don't
|
|
135
|
+
// parse as versions sort to the top, ahead of the numbers.
|
|
136
|
+
//
|
|
137
|
+
// `latest` alias + its target: a version marked `hidden` is the number
|
|
138
|
+
// the `latest` alias points at (Docusaurus's lastVersion). Show ONE row
|
|
139
|
+
// "3.32 (latest)" (linking to /latest/), derive its label from the hidden
|
|
140
|
+
// sibling, and drop hidden rows from the dropdown.
|
|
141
|
+
//
|
|
142
|
+
// The hidden number is DECLARED but NOT SERVED: `migrate-docusaurus
|
|
143
|
+
// --latest-alias` emits one source for the newest branch, at `latest`.
|
|
144
|
+
// That mirrors the source, where `versions: { "3.32": { path: "latest" } }`
|
|
145
|
+
// REPLACES the segment — docs.tigera.io has /calico/latest/ and no
|
|
146
|
+
// /calico/3.32/. Serving both invented a URL tree the source never had
|
|
147
|
+
// and duplicated every page of the newest release (338 on Calico) with
|
|
148
|
+
// no canonical. So the hidden entry is label-only metadata.
|
|
149
|
+
//
|
|
150
|
+
// `currentFoldedIntoLatest` still matters for a corpus that DOES serve
|
|
151
|
+
// the number (a hand-written config may), so a reader on `/3.32/…` has
|
|
152
|
+
// the merged `latest` row highlighted rather than no row at all. No
|
|
153
|
+
// `hidden` sibling → no merge.
|
|
154
|
+
let currentFoldedIntoLatest = false;
|
|
155
|
+
if (axis === "version") {
|
|
156
|
+
entries = [...entries].sort((a, b) => compareVersionIds(b.id, a.id));
|
|
157
|
+
const hiddenTarget = entries.find((e) => e.hidden && /\d/.test(e.id));
|
|
158
|
+
currentFoldedIntoLatest = hiddenTarget?.id === currentId;
|
|
159
|
+
entries = entries
|
|
160
|
+
.filter((e) => !e.hidden)
|
|
161
|
+
.map((e) =>
|
|
162
|
+
e.id === "latest" && hiddenTarget
|
|
163
|
+
? { ...e, label: `${hiddenTarget.label ?? hiddenTarget.id} (latest)` }
|
|
164
|
+
: e,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
87
168
|
return entries.map((entry) => {
|
|
88
169
|
const match = variants.find((v) => {
|
|
89
170
|
// Match on this axis.
|
|
@@ -98,7 +179,7 @@ export function buildSwitcherRows(input: BuildRowsInput): SwitcherRow[] {
|
|
|
98
179
|
return {
|
|
99
180
|
entry,
|
|
100
181
|
url: match?.url ?? null,
|
|
101
|
-
isCurrent: entry.id === currentId,
|
|
182
|
+
isCurrent: entry.id === currentId || (entry.id === "latest" && currentFoldedIntoLatest),
|
|
102
183
|
};
|
|
103
184
|
});
|
|
104
185
|
}
|