@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.
@@ -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
  /**
@@ -64,7 +116,7 @@ function hasActiveDescendant(item: NavItem, current: string): boolean {
64
116
  * sync. Padding is computed from `level` the same way (`8 + level*12`
65
117
  * pixels) so indentation lines up across the same render.
66
118
  */
67
- function renderItem(item: NavItem, current: string, level: number): HTMLLIElement {
119
+ export function renderItem(item: NavItem, current: string, level: number): HTMLLIElement {
68
120
  const li = document.createElement("li");
69
121
  li.dataset.sidebar = "nav-tree-item";
70
122
 
@@ -73,7 +125,87 @@ function renderItem(item: NavItem, current: string, level: number): HTMLLIElemen
73
125
  const padLeft = `${8 + level * 12}px`;
74
126
  const heightClass = level === 0 ? "h-8" : "h-7";
75
127
 
76
- if (hasChildren) {
128
+ if (hasChildren && item.href) {
129
+ // A branch that is ALSO a page (section landing page) uses the APG
130
+ // "disclosure navigation" pattern: a real link (navigates) and a
131
+ // SEPARATE toggle <button> (expands/collapses), as siblings. Nesting
132
+ // a focusable <a> inside the interactive <summary> — as this did
133
+ // before — is an axe `nested-interactive` violation ("interactive
134
+ // controls must not be nested"). Native <details> can't hold a
135
+ // visible-when-collapsed header link without that nesting, so a
136
+ // page-branch drops <details> for link + button + sibling submenu.
137
+ const open = active || hasActiveDescendant(item, current);
138
+ const submenuId = `nav-sub-${item.href.replace(/[^a-z0-9]+/gi, "-")}-${level}`;
139
+
140
+ const row = document.createElement("div");
141
+ row.className = [
142
+ "flex w-full min-w-0 items-center gap-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
143
+ heightClass,
144
+ active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
145
+ ]
146
+ .filter(Boolean)
147
+ .join(" ");
148
+ row.style.paddingLeft = padLeft;
149
+
150
+ const toggle = document.createElement("button");
151
+ toggle.type = "button";
152
+ toggle.dataset.navToggle = "";
153
+ toggle.setAttribute("aria-controls", submenuId);
154
+ toggle.setAttribute("aria-expanded", String(open));
155
+ toggle.setAttribute("aria-label", `Toggle ${item.label} section`);
156
+ toggle.className =
157
+ "shrink-0 rounded outline-none ring-sidebar-ring focus-visible:ring-2";
158
+ toggle.innerHTML =
159
+ '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
160
+ row.appendChild(toggle);
161
+
162
+ if (item.icon) {
163
+ const iconSpan = document.createElement("span");
164
+ iconSpan.className = "shrink-0 [&>svg]:size-4";
165
+ iconSpan.innerHTML = item.icon;
166
+ row.appendChild(iconSpan);
167
+ }
168
+
169
+ const link = document.createElement("a");
170
+ link.className =
171
+ "min-w-0 flex-1 truncate text-inherit no-underline outline-none ring-sidebar-ring focus-visible:ring-2";
172
+ link.href = item.href;
173
+ link.textContent = item.label;
174
+ link.dataset.navHref = item.href; // rehighlight keys off this
175
+ if (active) link.dataset.active = "true";
176
+ row.appendChild(link);
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
+
186
+ const submenu = document.createElement("ul");
187
+ submenu.id = submenuId;
188
+ submenu.dataset.navSubmenu = "";
189
+ submenu.className = "flex min-w-0 flex-col";
190
+ submenu.dataset.sidebar = "nav-tree";
191
+ submenu.dataset.level = String(level + 1);
192
+ for (const child of item.children!) {
193
+ submenu.appendChild(renderItem(child, current, level + 1));
194
+ }
195
+ if (!open) submenu.hidden = true;
196
+
197
+ toggle.addEventListener("click", () => {
198
+ const isOpen = toggle.getAttribute("aria-expanded") === "true";
199
+ toggle.setAttribute("aria-expanded", String(!isOpen));
200
+ submenu.hidden = isOpen;
201
+ });
202
+
203
+ li.appendChild(row);
204
+ li.appendChild(submenu);
205
+ } else if (hasChildren) {
206
+ // Pure grouping label (no href): native <details>/<summary> is fully
207
+ // accessible here — the summary is the only interactive control, and
208
+ // its label is a plain <span>. Zero-JS expand/collapse.
77
209
  const details = document.createElement("details");
78
210
  if (active || hasActiveDescendant(item, current)) details.open = true;
79
211
 
@@ -81,16 +213,11 @@ function renderItem(item: NavItem, current: string, level: number): HTMLLIElemen
81
213
  summary.className = [
82
214
  "flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none [list-style:none] ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&::-webkit-details-marker]:hidden",
83
215
  heightClass,
84
- active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
85
216
  ]
86
217
  .filter(Boolean)
87
218
  .join(" ");
88
219
  summary.style.paddingLeft = padLeft;
89
- if (item.href) summary.dataset.navHref = item.href;
90
- if (active) summary.dataset.active = "true";
91
220
 
92
- // Chevron SVG — matches SidebarNavTree's rotation-on-open via CSS
93
- // (`details[open] > summary [data-chevron] { transform: rotate(90deg); }`).
94
221
  summary.innerHTML =
95
222
  '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
96
223
 
@@ -102,13 +229,17 @@ function renderItem(item: NavItem, current: string, level: number): HTMLLIElemen
102
229
  }
103
230
 
104
231
  const label = document.createElement("span");
105
- label.className = "truncate";
232
+ label.className = "min-w-0 flex-1 truncate";
106
233
  label.textContent = item.label;
107
234
  summary.appendChild(label);
108
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
+
109
241
  details.appendChild(summary);
110
242
 
111
- // Recurse — nested `<ul>` mirrors SidebarNavTree's `<Astro.self>`.
112
243
  const childUl = document.createElement("ul");
113
244
  childUl.className = "flex min-w-0 flex-col";
114
245
  childUl.dataset.sidebar = "nav-tree";
@@ -146,17 +277,24 @@ function renderItem(item: NavItem, current: string, level: number): HTMLLIElemen
146
277
  }
147
278
 
148
279
  const label = document.createElement("span");
149
- label.className = "truncate";
280
+ label.className = "min-w-0 flex-1 truncate";
150
281
  label.textContent = item.label;
151
282
  a.appendChild(label);
152
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
+
153
291
  li.appendChild(a);
154
292
  }
155
293
 
156
294
  return li;
157
295
  }
158
296
 
159
- function renderTree(items: NavItem[], current: string, root: HTMLElement): void {
297
+ export function renderTree(items: NavItem[], current: string, root: HTMLElement): void {
160
298
  const ul = document.createElement("ul");
161
299
  ul.className =
162
300
  "flex min-w-0 flex-col w-full group-data-[collapsible=icon]:hidden";
@@ -199,7 +337,9 @@ function rehighlight(root: HTMLElement, current: string): void {
199
337
  el.classList.remove(...ACTIVE_CLASSES);
200
338
  }
201
339
  }
202
- // Expand ancestors of the new active item.
340
+ // Expand ancestors of the new active item — both native <details>
341
+ // groups and the button-driven [data-nav-submenu] disclosures used by
342
+ // section-landing (href) branches.
203
343
  const active = root.querySelector<HTMLElement>('[data-active="true"]');
204
344
  if (active) {
205
345
  let parent: HTMLElement | null = active.parentElement;
@@ -207,6 +347,13 @@ function rehighlight(root: HTMLElement, current: string): void {
207
347
  if (parent.tagName === "DETAILS") {
208
348
  (parent as HTMLDetailsElement).open = true;
209
349
  }
350
+ if (parent.matches("[data-nav-submenu]")) {
351
+ parent.hidden = false;
352
+ const toggle = root.querySelector<HTMLElement>(
353
+ `[data-nav-toggle][aria-controls="${parent.id}"]`,
354
+ );
355
+ toggle?.setAttribute("aria-expanded", "true");
356
+ }
210
357
  parent = parent.parentElement;
211
358
  }
212
359
  }
@@ -229,13 +376,20 @@ export async function hydrateDocsNav(): Promise<void> {
229
376
  }
230
377
  const current = normalize(root.dataset.currentPath || location.pathname);
231
378
  const basePath = root.dataset.basePath || "";
379
+ const namespace = root.dataset.namespace || undefined;
232
380
  const version = root.dataset.version || undefined;
233
381
  const locale = root.dataset.locale || undefined;
234
382
 
235
383
  try {
236
384
  const nav = await fetchNav(navUrl);
237
385
  const filtered = filterNavByAxis(nav, {
238
- 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,
239
393
  version: version || undefined,
240
394
  locale: locale || undefined,
241
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 {