@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/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 (or, in PR 5, locales)
5
- * configured, every page's emitted nav.json contains entries
6
- * from EVERY version. Without filtering, the sidebar shows
7
- * duplicate sectionsonce per version which is confusing
8
- * UX (writers see "Glossary" twice).
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 tooconfusing UX. The fix: filter the nav tree to the current
8
+ * page's (namespace, locale, version) bucket.
9
9
  *
10
- * The fix: filter the nav tree to entries that match the
11
- * current page's version (or, eventually, locale). Pure
12
- * function; takes nav + axis filter, returns a pruned copy.
13
- *
14
- * The axis switchers handle navigation BETWEEN versions; the
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"). Used to compose the version prefix. */
24
+ /** Site basePath (e.g. "" for root, "/docs"). */
26
25
  basePath: string;
27
- /**
28
- * Current page's effective version. When undefined, no
29
- * version filtering is applied single-version sites pass
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
- * Walk the nav tree and drop entries that don't belong to the
43
- * current version + locale. Group nodes (no `href`, with
44
- * `children`) survive iff any descendant survives empty
45
- * groups are pruned.
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
- items: NavItem[],
57
- filter: NavFilter,
58
- ): NavItem[] {
59
- if (!filter.version && !filter.locale) return items;
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
- // Locale axis prefix is the OUTERMOST per the canonical URL
62
- // composition: /<basePath>/<locale>/<version>/<ns>/<slug>.
63
- // We check the locale prefix first (basePath/<locale>/), then
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
- if (!hrefMatchesAxes(item.href, localePrefix, versionSegment, basePath)) {
91
- return [];
92
- }
93
- return [item];
62
+ return hrefMatchesPrefix(item.href, prefix, prefixNoSlash) ? [item] : [];
94
63
  }
95
- return [item];
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 given version prefix. Tolerates
154
- * trailing slashes and missing-trailing-slash variants nav
155
- * importers don't all canonicalise the same way.
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
- // Match `/docs/v1/...` AND `/docs/v1` (the version's landing
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 entries = axis === "version" ? switcherMap.versions : switcherMap.locales;
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
  }