@dogsbay/docs-layout 0.2.0-beta.93 → 0.2.0-beta.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,10 @@
17
17
  * - HTML per page: ~200 bytes (this placeholder + a tiny script
18
18
  * tag) vs ~600 KB+ for the SSR tree at scale.
19
19
  * - No-JS users see only the skeleton + the `<noscript>` fallback
20
- * link. A `sitemap.xml` link covers no-JS navigation.
20
+ * link. A `sitemap-index.xml` link covers no-JS navigation
21
+ * note `emitSitemapFiles` only runs when `site.url` is a valid
22
+ * http(s) URL, so a site without one has no sitemap for this
23
+ * fallback to reach.
21
24
  * - First paint waits for the JS bundle + the JSON fetch. On a 4G
22
25
  * connection that's typically <200 ms; the skeleton fills the
23
26
  * space until then.
@@ -34,21 +37,32 @@ interface Props {
34
37
  * so multi-axis sites work the same as SSR.
35
38
  */
36
39
  basePath?: string;
40
+ /** Current source's product/namespace, if multi-product site. */
41
+ namespace?: string;
37
42
  /** Current source's version axis value, if multi-version site. */
38
43
  version?: string;
39
44
  /** Current source's locale axis value, if multi-locale site. */
40
45
  locale?: string;
41
46
  }
42
47
 
43
- const { currentPath, basePath = "", version, locale } = Astro.props;
48
+ const { currentPath, basePath = "", namespace, version, locale } = Astro.props;
44
49
  const navUrl = `${basePath}/_dogsbay/nav.json`;
45
50
  ---
46
51
 
52
+ {/*
53
+ `role="navigation"` is REQUIRED here, not decoration: `aria-label` and
54
+ `aria-busy` are prohibited on a generic div (axe: aria-prohibited-attr,
55
+ serious) because a role-less element has no accessible name to label.
56
+ Giving the nav container its real role makes both attributes legal and
57
+ makes the landmark discoverable — it was previously neither.
58
+ */}
47
59
  <div
48
60
  id="docs-nav-root"
61
+ role="navigation"
49
62
  data-nav-url={navUrl}
50
63
  data-current-path={currentPath}
51
64
  data-base-path={basePath}
65
+ data-namespace={namespace ?? ""}
52
66
  data-version={version ?? ""}
53
67
  data-locale={locale ?? ""}
54
68
  aria-busy="true"
@@ -73,7 +87,11 @@ const navUrl = `${basePath}/_dogsbay/nav.json`;
73
87
  <noscript>
74
88
  <p class="px-2 py-1.5 text-sm text-sidebar-foreground/70">
75
89
  JavaScript is required to render the sidebar. Use the
76
- <a href={`${basePath}/sitemap.xml`} class="underline">sitemap</a>
90
+ {/* sitemap-index.xml, not sitemap.xml — Dogsbay emits the
91
+ sitemap-index / sitemap-0 pair directly (see emitSitemapFiles);
92
+ `sitemap.xml` has never existed, so this no-JS fallback link
93
+ 404'd on every site. */}
94
+ <a href={`${basePath}/sitemap-index.xml`} class="underline">sitemap</a>
77
95
  to browse all pages.
78
96
  </p>
79
97
  </noscript>
@@ -57,6 +57,14 @@ interface Props {
57
57
  * back to slugs when undefined.
58
58
  */
59
59
  taxonomyDisplay?: Record<string, TaxonomyDisplay>;
60
+ /**
61
+ * Current page's product (namespace) and version. On a multi-product /
62
+ * versioned site, search opens PRE-SCOPED to these — a Calico 3.32 page
63
+ * searches Calico 3.32 by default. The scope is seeded as normal facet
64
+ * selections, so the reader can untick them to search wider.
65
+ */
66
+ scopeProduct?: string;
67
+ scopeVersion?: string;
60
68
  }
61
69
 
62
70
  const {
@@ -64,6 +72,8 @@ const {
64
72
  navUrl,
65
73
  placeholder = "Search docs...",
66
74
  taxonomyDisplay,
75
+ scopeProduct,
76
+ scopeVersion,
67
77
  } = Astro.props;
68
78
  ---
69
79
 
@@ -72,6 +82,8 @@ const {
72
82
  data-pagefind-url={pagefindUrl}
73
83
  data-nav-url={navUrl}
74
84
  data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
85
+ data-scope-product={scopeProduct ?? ""}
86
+ data-scope-version={scopeVersion ?? ""}
75
87
  class="fixed left-1/2 top-[10vh] z-50 w-[calc(100vw-2rem)] max-w-4xl -translate-x-1/2 rounded-xl border border-border bg-popover p-0 text-popover-foreground shadow-2xl backdrop:bg-black/40 backdrop:backdrop-blur-sm"
76
88
  >
77
89
  <form method="dialog" class="flex flex-col">
@@ -215,6 +227,9 @@ const {
215
227
  };
216
228
 
217
229
  const dialog = document.querySelector<HTMLDialogElement>("[data-search-dialog]");
230
+ // Current page's product/version — search opens pre-scoped to these.
231
+ const scopeProduct = dialog?.dataset.scopeProduct || "";
232
+ const scopeVersion = dialog?.dataset.scopeVersion || "";
218
233
  const trigger = document.querySelector<HTMLButtonElement>("[data-search-trigger]");
219
234
  const input = dialog?.querySelector<HTMLInputElement>("[data-search-input]");
220
235
  const resultsBox = dialog?.querySelector<HTMLDivElement>("[data-search-results]");
@@ -652,9 +667,22 @@ const {
652
667
  const fromUrl = parseFiltersFromUrl(new URLSearchParams(window.location.search));
653
668
  input!.value = fromUrl.query;
654
669
  filters = fromUrl.filters;
670
+ const hadUrlState = fromUrl.query.length > 0 || countActiveFilters(filters) > 0;
671
+ // Auto-scope: a FRESH open (no filters/query carried in the URL) on a
672
+ // multi-product/versioned site starts scoped to the CURRENT product +
673
+ // version. Seeded as ordinary facet selections, so the reader can
674
+ // untick "Product: calico" / "Version: 3.32" to search wider. A URL
675
+ // that already carries state wins (shared/roundtripped searches).
676
+ if (!hadUrlState) {
677
+ if (scopeProduct) filters.product = [scopeProduct];
678
+ if (scopeVersion) filters.version = [scopeVersion];
679
+ }
655
680
  renderFacets();
656
681
 
657
- const hasInitial = input!.value.length > 0 || countActiveFilters(filters) > 0;
682
+ // Run immediately only when there's a query or the state came from the
683
+ // URL. A fresh open shows the empty prompt with the scope pre-ticked —
684
+ // results appear (scoped) as soon as the reader types.
685
+ const hasInitial = hadUrlState;
658
686
  if (hasInitial) {
659
687
  runSearch(input!.value);
660
688
  } else {
@@ -37,6 +37,9 @@ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Version
37
37
  {currentRow?.entry.eol && (
38
38
  <span class="ml-1 rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">EOL</span>
39
39
  )}
40
+ {currentRow?.entry.prerelease && (
41
+ <span class="ml-1 rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">Pre</span>
42
+ )}
40
43
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-1 transition-transform"><polyline points="6 9 12 15 18 9"/></svg>
41
44
  </summary>
42
45
  <ul class="absolute right-0 z-50 mt-1 min-w-[10rem] rounded-md border border-border bg-popover p-1 text-sm shadow-md">
@@ -59,6 +62,9 @@ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Version
59
62
  {row.entry.eol && (
60
63
  <span class="rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">EOL</span>
61
64
  )}
65
+ {row.entry.prerelease && (
66
+ <span class="rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">Pre</span>
67
+ )}
62
68
  {row.entry.default && !row.isCurrent && (
63
69
  <span class="text-[10px] text-muted-foreground">default</span>
64
70
  )}
@@ -23,11 +23,63 @@
23
23
  */
24
24
  import { filterNavByAxis } from "./nav-filter.js";
25
25
 
26
+ interface NavMark {
27
+ kind: "added" | "changed" | "removed" | "moved";
28
+ label?: string;
29
+ subtree?: boolean;
30
+ }
31
+
26
32
  interface NavItem {
27
33
  label: string;
28
34
  href?: string;
29
35
  icon?: string;
30
36
  children?: NavItem[];
37
+ mark?: NavMark;
38
+ }
39
+
40
+ /**
41
+ * A state marker beside a nav label (release comparisons mark changed /
42
+ * new / removed pages; the same slot serves "new since your last
43
+ * visit", deprecation flags, version badges).
44
+ *
45
+ * Accessibility contract — mirrors SidebarNavMark.astro, which renders
46
+ * the server-side tree:
47
+ * - **Never colour alone** (WCAG 1.4.1): a distinct GLYPH carries the
48
+ * meaning, so it survives greyscale and colour-blindness; colour only
49
+ * reinforces.
50
+ * - **Survives forced-colors** (High Contrast strips backgrounds): the
51
+ * glyph is real text, so it always renders.
52
+ * - **Announced**: a visually-hidden word rides along, so the row reads
53
+ * "MySQL, changed". It is a sibling span, NOT an aria-label on the
54
+ * link — an aria-label would REPLACE the page name in the
55
+ * accessibility tree, losing the label it exists to announce.
56
+ */
57
+ const MARK_GLYPH: Record<NavMark["kind"], string> = {
58
+ added: "+",
59
+ changed: "•",
60
+ removed: "−",
61
+ moved: "→",
62
+ };
63
+
64
+ function buildMark(mark: NavMark): HTMLElement {
65
+ const text = mark.label ?? (mark.subtree ? `contains ${mark.kind}` : mark.kind);
66
+ const wrap = document.createElement("span");
67
+ wrap.className = "db-nav-mark";
68
+ wrap.dataset.navMark = mark.kind;
69
+ if (mark.subtree) wrap.dataset.navMarkSubtree = "";
70
+ wrap.title = text;
71
+
72
+ const glyph = document.createElement("span");
73
+ glyph.setAttribute("aria-hidden", "true");
74
+ glyph.textContent = MARK_GLYPH[mark.kind];
75
+ wrap.appendChild(glyph);
76
+
77
+ const sr = document.createElement("span");
78
+ sr.className = "sr-only";
79
+ sr.textContent = text;
80
+ wrap.appendChild(sr);
81
+
82
+ return wrap;
31
83
  }
32
84
 
33
85
  /**
@@ -123,6 +175,14 @@ export function renderItem(item: NavItem, current: string, level: number): HTMLL
123
175
  if (active) link.dataset.active = "true";
124
176
  row.appendChild(link);
125
177
 
178
+ if (item.mark) {
179
+ // A SUBTREE mark describes DESCENDANTS — never strike through this
180
+ // row for it (a surviving group whose child was deleted would read
181
+ // as a deleted section).
182
+ if (!item.mark.subtree) link.dataset.navMarkRow = item.mark.kind;
183
+ row.appendChild(buildMark(item.mark));
184
+ }
185
+
126
186
  const submenu = document.createElement("ul");
127
187
  submenu.id = submenuId;
128
188
  submenu.dataset.navSubmenu = "";
@@ -173,6 +233,11 @@ export function renderItem(item: NavItem, current: string, level: number): HTMLL
173
233
  label.textContent = item.label;
174
234
  summary.appendChild(label);
175
235
 
236
+ // A collapsed group HIDES its children, so a change inside must
237
+ // signal outward — the same visibility rule the diff renderer
238
+ // applies to tabs.
239
+ if (item.mark) summary.appendChild(buildMark(item.mark));
240
+
176
241
  details.appendChild(summary);
177
242
 
178
243
  const childUl = document.createElement("ul");
@@ -212,10 +277,17 @@ export function renderItem(item: NavItem, current: string, level: number): HTMLL
212
277
  }
213
278
 
214
279
  const label = document.createElement("span");
215
- label.className = "truncate";
280
+ label.className = "min-w-0 flex-1 truncate";
216
281
  label.textContent = item.label;
217
282
  a.appendChild(label);
218
283
 
284
+ if (item.mark) {
285
+ // strikethrough only for a page that IS removed, never for an
286
+ // aggregate about its children
287
+ if (!item.mark.subtree) a.dataset.navMarkRow = item.mark.kind;
288
+ a.appendChild(buildMark(item.mark));
289
+ }
290
+
219
291
  li.appendChild(a);
220
292
  }
221
293
 
@@ -304,13 +376,20 @@ export async function hydrateDocsNav(): Promise<void> {
304
376
  }
305
377
  const current = normalize(root.dataset.currentPath || location.pathname);
306
378
  const basePath = root.dataset.basePath || "";
379
+ const namespace = root.dataset.namespace || undefined;
307
380
  const version = root.dataset.version || undefined;
308
381
  const locale = root.dataset.locale || undefined;
309
382
 
310
383
  try {
311
384
  const nav = await fetchNav(navUrl);
312
385
  const filtered = filterNavByAxis(nav, {
313
- basePath: basePath || "/docs",
386
+ // Pass basePath through as-is — an empty basePath (root-served
387
+ // site) must stay "", matching the SSR path's `?? "/docs"`
388
+ // (which keeps ""). Coercing "" → "/docs" makes the filter look
389
+ // for `/docs/…` bucket hrefs that don't exist and blanks the
390
+ // whole sidebar on a root-served multi-source site.
391
+ basePath,
392
+ namespace: namespace || undefined,
314
393
  version: version || undefined,
315
394
  locale: locale || undefined,
316
395
  });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Internal/external link-icon affordance — the tiny bit of logic behind
3
+ * the `data-link-icons` + `--db-link-icon-*` attributes DocsLayout stamps
4
+ * on `<body>`. Classification itself is pure CSS (by href shape); this
5
+ * only turns the configured glyphs into the attribute + custom-property
6
+ * values. See plans/link-resolution-and-icons.md.
7
+ */
8
+
9
+ export interface LinkIcons {
10
+ /** Glyph after external links (absolute / protocol-relative href). */
11
+ external?: string;
12
+ /** Glyph after internal links (root / relative href). */
13
+ internal?: string;
14
+ }
15
+
16
+ export interface LinkIconAttrs {
17
+ /**
18
+ * Space-separated active kinds for `data-link-icons` (`"external"`,
19
+ * `"internal"`, or both) — undefined when the feature is off, so the
20
+ * attribute is omitted entirely.
21
+ */
22
+ tokens?: string;
23
+ /**
24
+ * Inline `style` value setting the `--db-link-icon-*` custom
25
+ * properties to the (single-quoted, CSS-string-safe) glyphs —
26
+ * undefined when nothing is active.
27
+ */
28
+ style?: string;
29
+ }
30
+
31
+ /** CSS-string-escape a glyph so it's a valid single-quoted `content:` value. */
32
+ function cssString(glyph: string): string {
33
+ return glyph.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
34
+ }
35
+
36
+ /**
37
+ * Build the `<body>` attributes for the link-icon feature. A side is
38
+ * active only when its glyph is a non-empty string; an absent config (or
39
+ * all-empty) yields `{}` so DocsLayout emits no attributes.
40
+ */
41
+ export function linkIconAttrs(icons: LinkIcons | undefined): LinkIconAttrs {
42
+ const external = icons?.external?.trim() ? icons.external : "";
43
+ const internal = icons?.internal?.trim() ? icons.internal : "";
44
+ const tokens = [external ? "external" : "", internal ? "internal" : ""]
45
+ .filter(Boolean)
46
+ .join(" ");
47
+ const style = [
48
+ external ? `--db-link-icon-external: '${cssString(external)}'` : "",
49
+ internal ? `--db-link-icon-internal: '${cssString(internal)}'` : "",
50
+ ]
51
+ .filter(Boolean)
52
+ .join("; ");
53
+ return { tokens: tokens || undefined, style: style || undefined };
54
+ }
@@ -7,6 +7,11 @@
7
7
  * Returns the path to rewrite to (the `.md` mirror endpoint) when the
8
8
  * request should be served as markdown; returns `null` when the normal
9
9
  * HTML response should pass through.
10
+ *
11
+ * `basePath` is the prefix the site is SERVED at (Dogsbay's combined
12
+ * urlBase + basePath). It is needed only to recognise the site index,
13
+ * whose mirror is `<base>/index.md` rather than `<base>.md` — see
14
+ * `shouldRewriteToMarkdown`.
10
15
  */
11
16
 
12
17
  const Q_PARAM_RE = /^\s*q\s*=\s*([0-9.]+)\s*$/i;
@@ -28,6 +33,7 @@ const Q_PARAM_RE = /^\s*q\s*=\s*([0-9.]+)\s*$/i;
28
33
  export function shouldRewriteToMarkdown(
29
34
  accept: string | null | undefined,
30
35
  pathname: string,
36
+ basePath = "",
31
37
  ): string | null {
32
38
  if (!accept) return null;
33
39
  if (!acceptsMarkdown(accept)) return null;
@@ -35,8 +41,38 @@ export function shouldRewriteToMarkdown(
35
41
  if (hasNonHtmlExtension(pathname)) return null;
36
42
 
37
43
  const trimmed = pathname.replace(/\/$/, "");
38
- const target = trimmed === "" ? "/.md" : `${trimmed}.md`;
39
- return target;
44
+ const base = basePath.replace(/\/+$/, "");
45
+
46
+ // A request outside the served prefix is not ours to rewrite. Without
47
+ // this guard, `("/", "/docs")` fell through to `"" + ".md"` — a
48
+ // RELATIVE target, resolved against whatever the request path was.
49
+ if (base && trimmed !== base && !trimmed.startsWith(`${base}/`)) return null;
50
+
51
+ // The site index is emitted as `index.md.ts`, so its mirror is
52
+ // `<base>/index.md`. Every other page emitted by the shipped importers
53
+ // has its mirror at `<path>.md`: Dogsbay builds in Astro's directory
54
+ // format, so a leaf at `/getting-started/` maps to
55
+ // `/getting-started.md`, and a directory index like `guides/index.md`
56
+ // is NORMALIZED to slug `guides` (see import-mkdocs.ts's
57
+ // `.replace(/\/index$/, "")`), emitting `guides.astro` + `guides.md.ts`
58
+ // — so `/guides.md` exists too.
59
+ //
60
+ // Appending `.md` to the site index produced `/.md` (root-served) or
61
+ // `/blog.md` (mounted); neither exists, and `/blog.md` additionally
62
+ // falls OUTSIDE the `/blog/*` Workers route. Leaf and index URLs both
63
+ // carry a trailing slash, so only the base comparison distinguishes
64
+ // them.
65
+ //
66
+ // CAVEAT: a caller driving `exportAstroProject` directly with an
67
+ // unnormalized `<dir>/index` slug gets `src/pages/<dir>/index.astro`
68
+ // (served `/<dir>/`) whose only mirror is `/<dir>/index.md`, and this
69
+ // returns `/<dir>.md` — a 404. No shipped importer does that. If one
70
+ // ever should, generalize the sibling `.md.ts` emitter in
71
+ // `format-astro/src/project.ts` rather than guessing here from a URL
72
+ // that cannot distinguish the two shapes.
73
+ if (trimmed === base) return `${base}/index.md`;
74
+
75
+ return `${trimmed}.md`;
40
76
  }
41
77
 
42
78
  function acceptsMarkdown(accept: string): boolean {
package/src/nav-filter.ts CHANGED
@@ -1,18 +1,17 @@
1
1
  /**
2
2
  * Multi-source nav filtering.
3
3
  *
4
- * When a docs site has multiple versions (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
  }