@dogsbay/docs-layout 0.2.0-beta.14 → 0.2.0-beta.140
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 +9 -5
- package/src/BlogIndex.astro +179 -0
- package/src/DocsFooter.astro +79 -15
- package/src/DocsLayout.astro +638 -44
- package/src/DocsNavClient.astro +113 -0
- package/src/DocsToc.astro +1 -1
- package/src/SearchDialog.astro +317 -33
- package/src/TagList.astro +24 -3
- package/src/VersionSwitcher.astro +7 -1
- package/src/docs-nav-client.ts +422 -0
- package/src/json-ld.ts +4 -53
- package/src/link-icons.ts +54 -0
- package/src/markdown-negotiation.ts +38 -2
- package/src/nav-filter.ts +68 -127
- package/src/pagefind-base-url.ts +59 -0
- package/src/search-facets.ts +511 -9
- package/src/switcher.ts +93 -4
- package/src/tag-list-data.ts +26 -3
- package/src/toc-kind.css +10 -10
- package/src/toc-placement.ts +71 -0
- package/src/version-redirect.ts +17 -4
|
@@ -37,11 +37,14 @@ 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">
|
|
43
46
|
{rows.map((row) => {
|
|
44
|
-
const href = row.url ?? fallbackLandingUrl(basePath, row.entry.id);
|
|
47
|
+
const href = row.url ?? fallbackLandingUrl(basePath, row.entry.id, row.entry.root === true);
|
|
45
48
|
const isFallback = row.url === null && !row.isCurrent;
|
|
46
49
|
return (
|
|
47
50
|
<li>
|
|
@@ -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
|
)}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side sidebar nav hydration.
|
|
3
|
+
*
|
|
4
|
+
* Fetches `/_dogsbay/nav.json` once per session, renders the tree DOM
|
|
5
|
+
* to mirror `@dogsbay/ui/sidebar/SidebarNavTree.astro`'s structure,
|
|
6
|
+
* and re-highlights the current page on each Astro view-transition
|
|
7
|
+
* page-load.
|
|
8
|
+
*
|
|
9
|
+
* Design constraints:
|
|
10
|
+
* - DOM output matches SidebarNavTree exactly (same tags, classes,
|
|
11
|
+
* data-attributes) so Tailwind's compiled CSS styles us correctly
|
|
12
|
+
* and any sidebar-system selectors (e.g. `data-sidebar="nav-tree"`
|
|
13
|
+
* hooks) keep working.
|
|
14
|
+
* - Filter by `version` / `locale` matches `nav-filter.ts`'s SSR
|
|
15
|
+
* filter so a multi-axis site renders the same nav as `ssr-full`
|
|
16
|
+
* mode (without the multiplicative HTML cost).
|
|
17
|
+
* - One fetch per session. The `nav.json` URL is served with
|
|
18
|
+
* `Cache-Control: immutable` by Astro's static build (it lives
|
|
19
|
+
* under `public/_dogsbay/`), so the browser cache holds it across
|
|
20
|
+
* navigations.
|
|
21
|
+
*
|
|
22
|
+
* See plans/client-rendered-nav.md.
|
|
23
|
+
*/
|
|
24
|
+
import { filterNavByAxis } from "./nav-filter.js";
|
|
25
|
+
|
|
26
|
+
interface NavMark {
|
|
27
|
+
kind: "added" | "changed" | "removed" | "moved";
|
|
28
|
+
label?: string;
|
|
29
|
+
subtree?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface NavItem {
|
|
33
|
+
label: string;
|
|
34
|
+
href?: string;
|
|
35
|
+
icon?: string;
|
|
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;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Module-level cache so multiple page loads in the same SPA session
|
|
87
|
+
* share the same fetched nav data. Cleared by re-loads (full page
|
|
88
|
+
* navigations) but Astro's view transitions re-execute the script
|
|
89
|
+
* module without reloading the page — so on view-transition the
|
|
90
|
+
* cached promise is reused and no extra fetch fires.
|
|
91
|
+
*/
|
|
92
|
+
let navPromise: Promise<NavItem[]> | null = null;
|
|
93
|
+
|
|
94
|
+
function fetchNav(url: string): Promise<NavItem[]> {
|
|
95
|
+
if (!navPromise) {
|
|
96
|
+
navPromise = fetch(url, { credentials: "same-origin" }).then((r) => {
|
|
97
|
+
if (!r.ok) throw new Error(`nav.json fetch failed: ${r.status}`);
|
|
98
|
+
return r.json() as Promise<NavItem[]>;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return navPromise;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalize(path: string): string {
|
|
105
|
+
return path.replace(/\/$/, "") || "/";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hasActiveDescendant(item: NavItem, current: string): boolean {
|
|
109
|
+
if (item.href && normalize(item.href) === current) return true;
|
|
110
|
+
return item.children?.some((c) => hasActiveDescendant(c, current)) ?? false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Render a single nav item into a `<li>` DOM node. Matches
|
|
115
|
+
* SidebarNavTree's per-level class set exactly so styling stays in
|
|
116
|
+
* sync. Padding is computed from `level` the same way (`8 + level*12`
|
|
117
|
+
* pixels) so indentation lines up across the same render.
|
|
118
|
+
*/
|
|
119
|
+
export function renderItem(item: NavItem, current: string, level: number): HTMLLIElement {
|
|
120
|
+
const li = document.createElement("li");
|
|
121
|
+
li.dataset.sidebar = "nav-tree-item";
|
|
122
|
+
|
|
123
|
+
const active = item.href ? normalize(item.href) === current : false;
|
|
124
|
+
const hasChildren = !!item.children && item.children.length > 0;
|
|
125
|
+
const padLeft = `${8 + level * 12}px`;
|
|
126
|
+
const heightClass = level === 0 ? "h-8" : "h-7";
|
|
127
|
+
|
|
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.
|
|
209
|
+
const details = document.createElement("details");
|
|
210
|
+
if (active || hasActiveDescendant(item, current)) details.open = true;
|
|
211
|
+
|
|
212
|
+
const summary = document.createElement("summary");
|
|
213
|
+
summary.className = [
|
|
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",
|
|
215
|
+
heightClass,
|
|
216
|
+
]
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.join(" ");
|
|
219
|
+
summary.style.paddingLeft = padLeft;
|
|
220
|
+
|
|
221
|
+
summary.innerHTML =
|
|
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>';
|
|
223
|
+
|
|
224
|
+
if (item.icon) {
|
|
225
|
+
const iconSpan = document.createElement("span");
|
|
226
|
+
iconSpan.className = "shrink-0 [&>svg]:size-4";
|
|
227
|
+
iconSpan.innerHTML = item.icon;
|
|
228
|
+
summary.appendChild(iconSpan);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const label = document.createElement("span");
|
|
232
|
+
label.className = "min-w-0 flex-1 truncate";
|
|
233
|
+
label.textContent = item.label;
|
|
234
|
+
summary.appendChild(label);
|
|
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
|
+
|
|
241
|
+
details.appendChild(summary);
|
|
242
|
+
|
|
243
|
+
const childUl = document.createElement("ul");
|
|
244
|
+
childUl.className = "flex min-w-0 flex-col";
|
|
245
|
+
childUl.dataset.sidebar = "nav-tree";
|
|
246
|
+
childUl.dataset.level = String(level + 1);
|
|
247
|
+
for (const child of item.children!) {
|
|
248
|
+
childUl.appendChild(renderItem(child, current, level + 1));
|
|
249
|
+
}
|
|
250
|
+
details.appendChild(childUl);
|
|
251
|
+
|
|
252
|
+
li.appendChild(details);
|
|
253
|
+
} else {
|
|
254
|
+
const a = document.createElement("a");
|
|
255
|
+
a.href = item.href || "#";
|
|
256
|
+
a.className = [
|
|
257
|
+
"flex w-full min-w-0 items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2",
|
|
258
|
+
heightClass,
|
|
259
|
+
active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
|
|
260
|
+
]
|
|
261
|
+
.filter(Boolean)
|
|
262
|
+
.join(" ");
|
|
263
|
+
a.style.paddingLeft = padLeft;
|
|
264
|
+
if (active) a.dataset.active = "true";
|
|
265
|
+
if (item.href) a.dataset.navHref = item.href;
|
|
266
|
+
|
|
267
|
+
// Spacer to align leaf text with branch text (which has a chevron).
|
|
268
|
+
const spacer = document.createElement("span");
|
|
269
|
+
spacer.className = "size-4 shrink-0";
|
|
270
|
+
a.appendChild(spacer);
|
|
271
|
+
|
|
272
|
+
if (item.icon) {
|
|
273
|
+
const iconSpan = document.createElement("span");
|
|
274
|
+
iconSpan.className = "shrink-0 [&>svg]:size-4";
|
|
275
|
+
iconSpan.innerHTML = item.icon;
|
|
276
|
+
a.appendChild(iconSpan);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const label = document.createElement("span");
|
|
280
|
+
label.className = "min-w-0 flex-1 truncate";
|
|
281
|
+
label.textContent = item.label;
|
|
282
|
+
a.appendChild(label);
|
|
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
|
+
|
|
291
|
+
li.appendChild(a);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return li;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function renderTree(items: NavItem[], current: string, root: HTMLElement): void {
|
|
298
|
+
const ul = document.createElement("ul");
|
|
299
|
+
ul.className =
|
|
300
|
+
"flex min-w-0 flex-col w-full group-data-[collapsible=icon]:hidden";
|
|
301
|
+
ul.dataset.sidebar = "nav-tree";
|
|
302
|
+
ul.dataset.level = "0";
|
|
303
|
+
for (const item of items) {
|
|
304
|
+
ul.appendChild(renderItem(item, current, 0));
|
|
305
|
+
}
|
|
306
|
+
// Replace skeleton in one DOM op so there's no flash of partial state.
|
|
307
|
+
root.replaceChildren(ul);
|
|
308
|
+
root.removeAttribute("aria-busy");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Re-highlight the active item without rebuilding the whole tree.
|
|
313
|
+
* Used on `astro:page-load` when view transitions land on a new
|
|
314
|
+
* route — we re-toggle the `data-active` attribute and re-apply the
|
|
315
|
+
* active classes, then expand the new active branch's ancestors.
|
|
316
|
+
*
|
|
317
|
+
* Cheaper than a full re-render (no fetch, no DOM rebuild). For
|
|
318
|
+
* the wider hydration loop we still re-render when a brand-new nav
|
|
319
|
+
* structure is needed (e.g. switching versions), but path-only
|
|
320
|
+
* navigation just re-highlights.
|
|
321
|
+
*/
|
|
322
|
+
function rehighlight(root: HTMLElement, current: string): void {
|
|
323
|
+
const ACTIVE_CLASSES = [
|
|
324
|
+
"bg-sidebar-accent",
|
|
325
|
+
"font-medium",
|
|
326
|
+
"text-sidebar-accent-foreground",
|
|
327
|
+
];
|
|
328
|
+
const items = root.querySelectorAll<HTMLElement>("[data-nav-href]");
|
|
329
|
+
for (const el of Array.from(items)) {
|
|
330
|
+
const href = el.dataset.navHref;
|
|
331
|
+
const active = !!href && normalize(href) === current;
|
|
332
|
+
if (active) {
|
|
333
|
+
el.dataset.active = "true";
|
|
334
|
+
el.classList.add(...ACTIVE_CLASSES);
|
|
335
|
+
} else {
|
|
336
|
+
delete el.dataset.active;
|
|
337
|
+
el.classList.remove(...ACTIVE_CLASSES);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
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.
|
|
343
|
+
const active = root.querySelector<HTMLElement>('[data-active="true"]');
|
|
344
|
+
if (active) {
|
|
345
|
+
let parent: HTMLElement | null = active.parentElement;
|
|
346
|
+
while (parent) {
|
|
347
|
+
if (parent.tagName === "DETAILS") {
|
|
348
|
+
(parent as HTMLDetailsElement).open = true;
|
|
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
|
+
}
|
|
357
|
+
parent = parent.parentElement;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Public entry point. Called once from `<DocsNavClient />`'s inline
|
|
364
|
+
* script tag. Idempotent — subsequent calls (e.g. from view
|
|
365
|
+
* transitions) are no-ops once the tree has been rendered; they just
|
|
366
|
+
* re-highlight against the new pathname.
|
|
367
|
+
*/
|
|
368
|
+
export async function hydrateDocsNav(): Promise<void> {
|
|
369
|
+
const root = document.getElementById("docs-nav-root");
|
|
370
|
+
if (!root) return;
|
|
371
|
+
|
|
372
|
+
const navUrl = root.dataset.navUrl;
|
|
373
|
+
if (!navUrl) {
|
|
374
|
+
console.warn("docs-nav: missing data-nav-url");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const current = normalize(root.dataset.currentPath || location.pathname);
|
|
378
|
+
const basePath = root.dataset.basePath || "";
|
|
379
|
+
const namespace = root.dataset.namespace || undefined;
|
|
380
|
+
const version = root.dataset.version || undefined;
|
|
381
|
+
const locale = root.dataset.locale || undefined;
|
|
382
|
+
const rootVersion = root.dataset.rootVersion || undefined;
|
|
383
|
+
const versionSegments = (root.dataset.versionSegments || "").split(" ").filter(Boolean);
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const nav = await fetchNav(navUrl);
|
|
387
|
+
const filtered = filterNavByAxis(nav, {
|
|
388
|
+
// Pass basePath through as-is — an empty basePath (root-served
|
|
389
|
+
// site) must stay "", matching the SSR path's `?? "/docs"`
|
|
390
|
+
// (which keeps ""). Coercing "" → "/docs" makes the filter look
|
|
391
|
+
// for `/docs/…` bucket hrefs that don't exist and blanks the
|
|
392
|
+
// whole sidebar on a root-served multi-source site.
|
|
393
|
+
basePath,
|
|
394
|
+
namespace: namespace || undefined,
|
|
395
|
+
version: version || undefined,
|
|
396
|
+
locale: locale || undefined,
|
|
397
|
+
...(rootVersion ? { rootVersion, versionSegments } : {}),
|
|
398
|
+
});
|
|
399
|
+
renderTree(filtered, current, root);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.error("docs-nav: hydration failed", err);
|
|
402
|
+
root.setAttribute("aria-busy", "false");
|
|
403
|
+
// Keep skeleton so layout doesn't collapse on failure; a real
|
|
404
|
+
// user will reload or follow the noscript link.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Re-run on view transitions. astro:page-load fires both on initial
|
|
409
|
+
// load and after each ClientRouter transition; the module-level
|
|
410
|
+
// `navPromise` cache makes the post-transition path a fast
|
|
411
|
+
// highlight-only pass without re-fetching.
|
|
412
|
+
document.addEventListener("astro:page-load", () => {
|
|
413
|
+
const root = document.getElementById("docs-nav-root");
|
|
414
|
+
if (!root) return;
|
|
415
|
+
// If tree is already rendered (no aria-busy), just re-highlight.
|
|
416
|
+
if (root.getAttribute("aria-busy") === null) {
|
|
417
|
+
const current = normalize(root.dataset.currentPath || location.pathname);
|
|
418
|
+
rehighlight(root, current);
|
|
419
|
+
} else {
|
|
420
|
+
void hydrateDocsNav();
|
|
421
|
+
}
|
|
422
|
+
});
|
package/src/json-ld.ts
CHANGED
|
@@ -1,55 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* JSON-LD helpers
|
|
3
|
-
*
|
|
4
|
-
* `customJsonLd` prop shape.
|
|
5
|
-
*
|
|
6
|
-
* The 100daysofdocs SEO audit flagged that DocsLayout was emitting
|
|
7
|
-
* `Article` for every page regardless of intent — tutorials and
|
|
8
|
-
* how-to pages should render as `HowTo` for SERP rich results.
|
|
9
|
-
* This module owns the type selection so it's testable without
|
|
10
|
-
* spinning up an Astro renderer.
|
|
2
|
+
* JSON-LD helpers for DocsLayout. They live in `@dogsbay/site-files`,
|
|
3
|
+
* which dogsbay-ssg uses too, so both pipelines emit the same blocks.
|
|
11
4
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* Map a `meta.type` value (the open-string `pageType` prop on
|
|
15
|
-
* DocsLayout) to a Schema.org `@type`.
|
|
16
|
-
*
|
|
17
|
-
* The mapping intentionally accepts a few common spelling
|
|
18
|
-
* variants (`"how-to"` / `"howto"`, `"reference"` / `"ref"` /
|
|
19
|
-
* `"api"`) so writers don't have to learn one canonical spelling
|
|
20
|
-
* to get the right structured data. Anything else falls back to
|
|
21
|
-
* `Article`, which is Google's "general docs page" assumption.
|
|
22
|
-
*/
|
|
23
|
-
export function jsonLdTypeFor(pageType: string | undefined): string {
|
|
24
|
-
switch (pageType) {
|
|
25
|
-
case "tutorial":
|
|
26
|
-
case "how-to":
|
|
27
|
-
case "howto":
|
|
28
|
-
return "HowTo";
|
|
29
|
-
case "reference":
|
|
30
|
-
case "ref":
|
|
31
|
-
case "api":
|
|
32
|
-
return "TechArticle";
|
|
33
|
-
case "course":
|
|
34
|
-
return "Course";
|
|
35
|
-
default:
|
|
36
|
-
return "Article";
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Normalize the `customJsonLd` prop into an array. DocsLayout
|
|
42
|
-
* accepts either a single object (most common case — one extra
|
|
43
|
-
* Course / Organization block) or an array (sites that layer
|
|
44
|
-
* several blocks per page).
|
|
45
|
-
*
|
|
46
|
-
* Returns an empty array for `undefined` so the template can
|
|
47
|
-
* iterate uniformly without conditional rendering.
|
|
48
|
-
*/
|
|
49
|
-
export function normalizeCustomJsonLd(
|
|
50
|
-
raw: Record<string, unknown> | Record<string, unknown>[] | undefined,
|
|
51
|
-
): Record<string, unknown>[] {
|
|
52
|
-
if (!raw) return [];
|
|
53
|
-
if (Array.isArray(raw)) return raw;
|
|
54
|
-
return [raw];
|
|
55
|
-
}
|
|
5
|
+
export { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd, buildWebSiteJsonLd } from "@dogsbay/site-files";
|
|
6
|
+
export type { BuildArticleJsonLdOptions } from "@dogsbay/site-files";
|
|
@@ -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 {
|