@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/DocsNavClient.astro
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/src/SearchDialog.astro
CHANGED
|
@@ -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
|
-
|
|
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
|
)}
|
package/src/docs-nav-client.ts
CHANGED
|
@@ -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
|
|
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
|
|
39
|
-
|
|
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 {
|