@dogsbay/docs-layout 0.2.0-beta.9 → 0.2.0-beta.90
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 +4 -4
- package/src/DocsLayout.astro +252 -31
- package/src/DocsNavClient.astro +89 -0
- package/src/DocsToc.astro +1 -1
- package/src/SearchDialog.astro +272 -32
- package/src/docs-nav-client.ts +265 -0
- package/src/json-ld.ts +77 -0
- package/src/search-facets.ts +511 -9
- package/src/toc-placement.ts +71 -0
- package/src/version-redirect.ts +23 -0
package/src/SearchDialog.astro
CHANGED
|
@@ -28,10 +28,26 @@ import type { TaxonomyDisplay } from "@dogsbay/types";
|
|
|
28
28
|
|
|
29
29
|
interface Props {
|
|
30
30
|
/**
|
|
31
|
-
* Path
|
|
32
|
-
*
|
|
31
|
+
* Path where Pagefind's index lives, e.g. `/pagefind/` or
|
|
32
|
+
* `/<repo>/pagefind/` for subpath-mounted deploys. NO DEFAULT —
|
|
33
|
+
* a host-root default would silently 404 on subpath deploys
|
|
34
|
+
* (GH Pages project pages, multi-mount Cloudflare). The
|
|
35
|
+
* format-astro emitter passes the combined-prefix-aware URL;
|
|
36
|
+
* manual instantiations must do the same. When undefined, the
|
|
37
|
+
* search dialog throws on first open with a clear console error.
|
|
33
38
|
*/
|
|
34
39
|
pagefindUrl?: string;
|
|
40
|
+
/**
|
|
41
|
+
* URL of the site's `nav.json` (typically `${basePath}/_dogsbay/nav.json`).
|
|
42
|
+
* Used to render hierarchical facets in document order: when the
|
|
43
|
+
* `category` facet (or any taxonomy flagged `hierarchical: true`)
|
|
44
|
+
* is segment-encoded — i.e. its values are individual path segments
|
|
45
|
+
* not slash-joined — the tree shape is derived from nav. Without
|
|
46
|
+
* `navUrl`, hierarchical segment-encoded facets fall back to a flat
|
|
47
|
+
* list (still functional, just no tree structure). Slash-encoded
|
|
48
|
+
* taxonomies don't need nav and build trees from their values directly.
|
|
49
|
+
*/
|
|
50
|
+
navUrl?: string;
|
|
35
51
|
/** Placeholder text for the search input */
|
|
36
52
|
placeholder?: string;
|
|
37
53
|
/**
|
|
@@ -44,7 +60,8 @@ interface Props {
|
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
const {
|
|
47
|
-
pagefindUrl
|
|
63
|
+
pagefindUrl,
|
|
64
|
+
navUrl,
|
|
48
65
|
placeholder = "Search docs...",
|
|
49
66
|
taxonomyDisplay,
|
|
50
67
|
} = Astro.props;
|
|
@@ -53,6 +70,7 @@ const {
|
|
|
53
70
|
<dialog
|
|
54
71
|
data-search-dialog
|
|
55
72
|
data-pagefind-url={pagefindUrl}
|
|
73
|
+
data-nav-url={navUrl}
|
|
56
74
|
data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
|
|
57
75
|
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"
|
|
58
76
|
>
|
|
@@ -156,13 +174,19 @@ const {
|
|
|
156
174
|
shapeFacets,
|
|
157
175
|
resolveFacetLabel,
|
|
158
176
|
resolveFacetTitle,
|
|
177
|
+
sortFacetNames,
|
|
159
178
|
filterStateToUrlParams,
|
|
160
179
|
parseFiltersFromUrl,
|
|
161
180
|
filterStateToPagefindFilters,
|
|
162
181
|
toggleFilter,
|
|
163
182
|
countActiveFilters,
|
|
183
|
+
buildFacetTree,
|
|
184
|
+
computeTreeState,
|
|
185
|
+
toggleTreeNode,
|
|
164
186
|
type FacetMap,
|
|
187
|
+
type FacetTreeNode,
|
|
165
188
|
type FilterState,
|
|
189
|
+
type NavLike,
|
|
166
190
|
type TaxonomyDisplayMap,
|
|
167
191
|
} from "./search-facets.js";
|
|
168
192
|
|
|
@@ -177,10 +201,15 @@ const {
|
|
|
177
201
|
meta: { title?: string };
|
|
178
202
|
sub_results?: Array<{ title: string; url: string; excerpt: string }>;
|
|
179
203
|
};
|
|
204
|
+
// Filter values match what filterStateToPagefindFilters emits:
|
|
205
|
+
// each facet wrapped in `{any: [...]}` for OR-within-facet semantics.
|
|
206
|
+
// Pagefind also accepts other operator shapes (`all`/`none`/`not`,
|
|
207
|
+
// bare strings, bare arrays) but we only emit the `any` form.
|
|
208
|
+
type PagefindFilterValue = { any: string[] };
|
|
180
209
|
type PagefindModule = {
|
|
181
210
|
search(
|
|
182
211
|
query: string,
|
|
183
|
-
options?: { filters?: Record<string,
|
|
212
|
+
options?: { filters?: Record<string, PagefindFilterValue> },
|
|
184
213
|
): Promise<{ results: PagefindResult[] }>;
|
|
185
214
|
filters(): Promise<Record<string, Record<string, number>>>;
|
|
186
215
|
};
|
|
@@ -204,6 +233,15 @@ const {
|
|
|
204
233
|
let filters: FilterState = {};
|
|
205
234
|
let availableFacets: FacetMap = {};
|
|
206
235
|
|
|
236
|
+
// Nav data cached after first fetch — used to derive the
|
|
237
|
+
// segment-encoded hierarchical-facet tree (the auto-`category`
|
|
238
|
+
// case). Null while pending or absent. The fetch fires lazily
|
|
239
|
+
// the first time renderFacets() encounters a hierarchical facet,
|
|
240
|
+
// not eagerly on dialog open, so sites without hierarchical
|
|
241
|
+
// facets never pay for it.
|
|
242
|
+
let navData: NavLike[] | null = null;
|
|
243
|
+
let loadingNav: Promise<void> | null = null;
|
|
244
|
+
|
|
207
245
|
// Display config baked into a data attribute by the Astro
|
|
208
246
|
// template — parsed lazily.
|
|
209
247
|
const taxonomyDisplay: TaxonomyDisplayMap = (() => {
|
|
@@ -218,7 +256,22 @@ const {
|
|
|
218
256
|
async function ensurePagefindLoaded() {
|
|
219
257
|
if (pagefind) return;
|
|
220
258
|
if (loadingPagefind) return loadingPagefind;
|
|
221
|
-
|
|
259
|
+
// pagefindUrl is required — a "/pagefind/" fallback would
|
|
260
|
+
// silently 404 on subpath-mounted deploys (GH Pages project
|
|
261
|
+
// pages, multi-mount Cloudflare). The emitter always passes
|
|
262
|
+
// a combined-prefix-aware value via data-pagefind-url. If it's
|
|
263
|
+
// missing the page wasn't built through format-astro and the
|
|
264
|
+
// caller forgot to pass it.
|
|
265
|
+
const dataUrl = dialog!.dataset.pagefindUrl;
|
|
266
|
+
if (!dataUrl) {
|
|
267
|
+
console.error(
|
|
268
|
+
"[dogsbay] SearchDialog: pagefindUrl prop missing. " +
|
|
269
|
+
"Pass the combined-prefix path (e.g. '/<base>/pagefind/') " +
|
|
270
|
+
"from your DocsLayout instantiation.",
|
|
271
|
+
);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const url = dataUrl + "pagefind.js";
|
|
222
275
|
loadingPagefind = (async () => {
|
|
223
276
|
try {
|
|
224
277
|
const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
|
|
@@ -237,6 +290,49 @@ const {
|
|
|
237
290
|
return loadingPagefind;
|
|
238
291
|
}
|
|
239
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Lazy nav.json fetch, kicked off the first time a hierarchical
|
|
295
|
+
* facet is rendered. Mirrors the pattern in `docs-nav-client.ts` —
|
|
296
|
+
* one fetch per session, `same-origin` credentials so cookied
|
|
297
|
+
* mounts work, module-level cache.
|
|
298
|
+
*
|
|
299
|
+
* When the fetch resolves we re-call `renderFacets()` so the
|
|
300
|
+
* previously-flat segment-encoded facet upgrades to its tree
|
|
301
|
+
* shape — without this, a slow nav fetch would leave the facets
|
|
302
|
+
* stuck on the flat fallback until the user toggled a filter.
|
|
303
|
+
*
|
|
304
|
+
* Network errors are logged and swallowed; the helper's flat
|
|
305
|
+
* fallback keeps the dialog functional.
|
|
306
|
+
*/
|
|
307
|
+
async function ensureNavLoaded() {
|
|
308
|
+
if (navData !== null) return;
|
|
309
|
+
if (loadingNav) return loadingNav;
|
|
310
|
+
const url = dialog!.dataset.navUrl;
|
|
311
|
+
if (!url) return; // navUrl not provided — flat fallback stays
|
|
312
|
+
loadingNav = (async () => {
|
|
313
|
+
try {
|
|
314
|
+
const res = await fetch(url, { credentials: "same-origin" });
|
|
315
|
+
if (!res.ok) throw new Error(`nav.json fetch failed: ${res.status}`);
|
|
316
|
+
const data = (await res.json()) as unknown;
|
|
317
|
+
navData = Array.isArray(data) ? (data as NavLike[]) : [];
|
|
318
|
+
// Re-render so hierarchical segment-encoded facets pick up
|
|
319
|
+
// the nav shape. Cheap — no Pagefind round-trip, just a
|
|
320
|
+
// DOM rebuild from `availableFacets`.
|
|
321
|
+
if (Object.keys(availableFacets).length > 0) {
|
|
322
|
+
renderFacets();
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
console.warn(
|
|
326
|
+
"[dogsbay] failed to load nav.json (hierarchical facets fall back to flat list):",
|
|
327
|
+
err,
|
|
328
|
+
);
|
|
329
|
+
// Mark as loaded-with-empty so we don't keep retrying.
|
|
330
|
+
navData = [];
|
|
331
|
+
}
|
|
332
|
+
})();
|
|
333
|
+
return loadingNav;
|
|
334
|
+
}
|
|
335
|
+
|
|
240
336
|
function escapeHtml(s: string): string {
|
|
241
337
|
return s
|
|
242
338
|
.replace(/&/g, "&")
|
|
@@ -313,54 +409,171 @@ const {
|
|
|
313
409
|
resultsBox!.innerHTML = html;
|
|
314
410
|
}
|
|
315
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Hierarchical-facet caches, rebuilt on every renderFacets() so
|
|
414
|
+
* they stay aligned with the current filter state + available
|
|
415
|
+
* facets. The trees are the source for `value → node` lookups
|
|
416
|
+
* during click handling (parent click expands selection to all
|
|
417
|
+
* descendants — needs the node to know what to add).
|
|
418
|
+
*/
|
|
419
|
+
const facetTrees = new Map<string, FacetTreeNode[]>();
|
|
420
|
+
const facetNodesByValue = new Map<string, Map<string, FacetTreeNode>>();
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Decide whether a facet renders as a tree. `category` defaults
|
|
424
|
+
* to hierarchical (auto-derived path segments are the canonical
|
|
425
|
+
* use case); explicit override via `taxonomyDisplay[name].hierarchical`
|
|
426
|
+
* wins both ways.
|
|
427
|
+
*/
|
|
428
|
+
function isHierarchicalFacet(name: string): boolean {
|
|
429
|
+
const flag = taxonomyDisplay[name]?.hierarchical;
|
|
430
|
+
if (typeof flag === "boolean") return flag;
|
|
431
|
+
return name === "category";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Flatten a tree into a `value → node` map so the click handler
|
|
436
|
+
* can look up the clicked node without re-walking the tree.
|
|
437
|
+
*/
|
|
438
|
+
function indexTree(nodes: FacetTreeNode[]): Map<string, FacetTreeNode> {
|
|
439
|
+
const out = new Map<string, FacetTreeNode>();
|
|
440
|
+
const visit = (n: FacetTreeNode): void => {
|
|
441
|
+
out.set(n.value, n);
|
|
442
|
+
for (const c of n.children) visit(c);
|
|
443
|
+
};
|
|
444
|
+
for (const r of nodes) visit(r);
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Recursive HTML for one tree node. Synthetic parents (`hasValue:
|
|
450
|
+
* false`) render as a clickable group header — checking them
|
|
451
|
+
* still cascades to descendants via toggleTreeNode. Indent scales
|
|
452
|
+
* with `node.depth`.
|
|
453
|
+
*/
|
|
454
|
+
function renderTreeNode(name: string, node: FacetTreeNode): string {
|
|
455
|
+
const state = computeTreeState(node, name, filters);
|
|
456
|
+
const id = `facet-${name}-${node.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
457
|
+
const indent = node.depth * 12;
|
|
458
|
+
const label = escapeHtml(node.label);
|
|
459
|
+
const countText = node.hasValue ? `${node.count}` : "";
|
|
460
|
+
const stateAttr =
|
|
461
|
+
state === "checked" ? "checked" : state === "indeterminate" ? 'data-tree-indeterminate="true"' : "";
|
|
462
|
+
const childrenHtml = node.children
|
|
463
|
+
.map((c) => renderTreeNode(name, c))
|
|
464
|
+
.join("");
|
|
465
|
+
return `
|
|
466
|
+
<li>
|
|
467
|
+
<label
|
|
468
|
+
for="${id}"
|
|
469
|
+
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent"
|
|
470
|
+
style="padding-left: ${0.5 + indent / 16}rem"
|
|
471
|
+
>
|
|
472
|
+
<input
|
|
473
|
+
type="checkbox"
|
|
474
|
+
id="${id}"
|
|
475
|
+
data-facet-name="${escapeHtml(name)}"
|
|
476
|
+
data-facet-value="${escapeHtml(node.value)}"
|
|
477
|
+
data-tree-node="true"
|
|
478
|
+
${state === "checked" ? "checked" : ""}
|
|
479
|
+
${state === "indeterminate" ? 'data-tree-indeterminate="true"' : ""}
|
|
480
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
481
|
+
/>
|
|
482
|
+
<span class="flex-1 truncate">${label}</span>
|
|
483
|
+
<span class="text-xs text-muted-foreground">${countText}</span>
|
|
484
|
+
</label>
|
|
485
|
+
${childrenHtml ? `<ul class="space-y-0.5">${childrenHtml}</ul>` : ""}
|
|
486
|
+
</li>
|
|
487
|
+
`;
|
|
488
|
+
}
|
|
489
|
+
|
|
316
490
|
/**
|
|
317
491
|
* Build the facets sidebar. Runs once after Pagefind discovers
|
|
318
492
|
* filters, then again whenever filter state changes (so checkbox
|
|
319
493
|
* `checked` reflects current selections). When the corpus has no
|
|
320
494
|
* filters, the sidebar stays hidden — single-column layout.
|
|
495
|
+
*
|
|
496
|
+
* Branches per facet: hierarchical taxonomies (and the built-in
|
|
497
|
+
* `category` default) render as a tree; flat taxonomies keep the
|
|
498
|
+
* original checkbox-list shape. The hierarchical path falls back
|
|
499
|
+
* to a flat list automatically when segment-encoded values lack a
|
|
500
|
+
* `nav` source — preserves render-ability until Phase 3 wires the
|
|
501
|
+
* nav.json fetch.
|
|
321
502
|
*/
|
|
322
503
|
function renderFacets() {
|
|
323
|
-
const facetNames =
|
|
504
|
+
const facetNames = sortFacetNames(
|
|
505
|
+
Object.keys(availableFacets),
|
|
506
|
+
taxonomyDisplay,
|
|
507
|
+
);
|
|
324
508
|
if (facetNames.length === 0) {
|
|
325
509
|
facetsBox!.classList.add("hidden");
|
|
326
510
|
return;
|
|
327
511
|
}
|
|
328
512
|
facetsBox!.classList.remove("hidden");
|
|
329
513
|
|
|
514
|
+
facetTrees.clear();
|
|
515
|
+
facetNodesByValue.clear();
|
|
516
|
+
|
|
330
517
|
const activeCount = countActiveFilters(filters);
|
|
331
518
|
const clearAll = activeCount > 0
|
|
332
519
|
? `<button type="button" data-clear-filters class="mb-3 w-full rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">Clear all (${activeCount})</button>`
|
|
333
520
|
: "";
|
|
334
521
|
|
|
522
|
+
let needsNav = false;
|
|
335
523
|
const groups = facetNames
|
|
336
524
|
.map((name) => {
|
|
337
525
|
const entries = availableFacets[name];
|
|
338
526
|
const title = escapeHtml(resolveFacetTitle(name));
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
527
|
+
const hierarchical = isHierarchicalFacet(name);
|
|
528
|
+
|
|
529
|
+
let items: string;
|
|
530
|
+
if (hierarchical) {
|
|
531
|
+
// Every hierarchical facet wants nav.json — slash-encoded
|
|
532
|
+
// for sort order at each depth (so docs follow nav rather
|
|
533
|
+
// than count-desc), segment-encoded for the tree structure
|
|
534
|
+
// itself. Without nav, slash-encoded still renders a tree
|
|
535
|
+
// (using values alone) but in count-desc order, which is
|
|
536
|
+
// wrong on corpora where one branch dwarfs the rest
|
|
537
|
+
// (openshift's rest_api section). Fire ensureNavLoaded()
|
|
538
|
+
// any time a hierarchical facet renders with navData still
|
|
539
|
+
// null — when it resolves, renderFacets re-runs and
|
|
540
|
+
// upgrades the sort.
|
|
541
|
+
if (navData === null) needsNav = true;
|
|
542
|
+
const tree = buildFacetTree(name, entries, {
|
|
543
|
+
display: taxonomyDisplay,
|
|
544
|
+
nav: navData ?? undefined,
|
|
545
|
+
});
|
|
546
|
+
facetTrees.set(name, tree);
|
|
547
|
+
facetNodesByValue.set(name, indexTree(tree));
|
|
548
|
+
items = tree.map((n) => renderTreeNode(name, n)).join("");
|
|
549
|
+
} else {
|
|
550
|
+
items = entries
|
|
551
|
+
.map((entry) => {
|
|
552
|
+
const checked = (filters[name] ?? []).includes(entry.value);
|
|
553
|
+
const label = escapeHtml(
|
|
554
|
+
resolveFacetLabel(name, entry.value, taxonomyDisplay),
|
|
555
|
+
);
|
|
556
|
+
const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
557
|
+
return `
|
|
558
|
+
<li>
|
|
559
|
+
<label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
|
|
560
|
+
<input
|
|
561
|
+
type="checkbox"
|
|
562
|
+
id="${id}"
|
|
563
|
+
data-facet-name="${escapeHtml(name)}"
|
|
564
|
+
data-facet-value="${escapeHtml(entry.value)}"
|
|
565
|
+
${checked ? "checked" : ""}
|
|
566
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
567
|
+
/>
|
|
568
|
+
<span class="flex-1 truncate">${label}</span>
|
|
569
|
+
<span class="text-xs text-muted-foreground">${entry.count}</span>
|
|
570
|
+
</label>
|
|
571
|
+
</li>
|
|
572
|
+
`;
|
|
573
|
+
})
|
|
574
|
+
.join("");
|
|
575
|
+
}
|
|
576
|
+
|
|
364
577
|
return `
|
|
365
578
|
<fieldset class="mb-3">
|
|
366
579
|
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
|
|
@@ -371,6 +584,20 @@ const {
|
|
|
371
584
|
.join("");
|
|
372
585
|
|
|
373
586
|
facetsBox!.innerHTML = clearAll + groups;
|
|
587
|
+
|
|
588
|
+
// `indeterminate` is a JS property, not an HTML attribute —
|
|
589
|
+
// can't set via innerHTML. Walk new checkboxes and set it
|
|
590
|
+
// post-paint so the visual tri-state matches state.
|
|
591
|
+
const indet = facetsBox!.querySelectorAll<HTMLInputElement>(
|
|
592
|
+
'input[type="checkbox"][data-tree-indeterminate="true"]',
|
|
593
|
+
);
|
|
594
|
+
for (const el of Array.from(indet)) el.indeterminate = true;
|
|
595
|
+
|
|
596
|
+
// Lazy nav fetch — fired only when at least one hierarchical
|
|
597
|
+
// segment-encoded facet rendered with the flat fallback. The
|
|
598
|
+
// fetch's resolution handler calls renderFacets() again so the
|
|
599
|
+
// facet upgrades to its real tree shape without user action.
|
|
600
|
+
if (needsNav) void ensureNavLoaded();
|
|
374
601
|
}
|
|
375
602
|
|
|
376
603
|
async function runSearch(query: string) {
|
|
@@ -475,13 +702,26 @@ const {
|
|
|
475
702
|
});
|
|
476
703
|
|
|
477
704
|
// Facet checkbox toggling — event delegation on the sidebar.
|
|
705
|
+
// Tree nodes route through toggleTreeNode (parent click expands
|
|
706
|
+
// to all descendants); flat checkboxes use toggleFilter as before.
|
|
707
|
+
// We compute state from the pre-click filter, NOT from the
|
|
708
|
+
// checkbox's post-click `checked` value — the browser has already
|
|
709
|
+
// flipped it by the time `change` fires, so reading it would
|
|
710
|
+
// invert our toggle direction for indeterminate parents.
|
|
478
711
|
facetsBox.addEventListener("change", (e) => {
|
|
479
712
|
const target = e.target as HTMLInputElement;
|
|
480
713
|
if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
|
|
481
714
|
const name = target.dataset.facetName;
|
|
482
715
|
const value = target.dataset.facetValue;
|
|
483
716
|
if (!name || !value) return;
|
|
484
|
-
|
|
717
|
+
if (target.dataset.treeNode === "true") {
|
|
718
|
+
const node = facetNodesByValue.get(name)?.get(value);
|
|
719
|
+
if (!node) return;
|
|
720
|
+
const currentState = computeTreeState(node, name, filters);
|
|
721
|
+
filters = toggleTreeNode(filters, name, node, currentState);
|
|
722
|
+
} else {
|
|
723
|
+
filters = toggleFilter(filters, name, value);
|
|
724
|
+
}
|
|
485
725
|
renderFacets();
|
|
486
726
|
runSearch(input!.value);
|
|
487
727
|
syncUrl();
|
|
@@ -0,0 +1,265 @@
|
|
|
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 NavItem {
|
|
27
|
+
label: string;
|
|
28
|
+
href?: string;
|
|
29
|
+
icon?: string;
|
|
30
|
+
children?: NavItem[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Module-level cache so multiple page loads in the same SPA session
|
|
35
|
+
* share the same fetched nav data. Cleared by re-loads (full page
|
|
36
|
+
* navigations) but Astro's view transitions re-execute the script
|
|
37
|
+
* module without reloading the page — so on view-transition the
|
|
38
|
+
* cached promise is reused and no extra fetch fires.
|
|
39
|
+
*/
|
|
40
|
+
let navPromise: Promise<NavItem[]> | null = null;
|
|
41
|
+
|
|
42
|
+
function fetchNav(url: string): Promise<NavItem[]> {
|
|
43
|
+
if (!navPromise) {
|
|
44
|
+
navPromise = fetch(url, { credentials: "same-origin" }).then((r) => {
|
|
45
|
+
if (!r.ok) throw new Error(`nav.json fetch failed: ${r.status}`);
|
|
46
|
+
return r.json() as Promise<NavItem[]>;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return navPromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalize(path: string): string {
|
|
53
|
+
return path.replace(/\/$/, "") || "/";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasActiveDescendant(item: NavItem, current: string): boolean {
|
|
57
|
+
if (item.href && normalize(item.href) === current) return true;
|
|
58
|
+
return item.children?.some((c) => hasActiveDescendant(c, current)) ?? false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render a single nav item into a `<li>` DOM node. Matches
|
|
63
|
+
* SidebarNavTree's per-level class set exactly so styling stays in
|
|
64
|
+
* sync. Padding is computed from `level` the same way (`8 + level*12`
|
|
65
|
+
* pixels) so indentation lines up across the same render.
|
|
66
|
+
*/
|
|
67
|
+
function renderItem(item: NavItem, current: string, level: number): HTMLLIElement {
|
|
68
|
+
const li = document.createElement("li");
|
|
69
|
+
li.dataset.sidebar = "nav-tree-item";
|
|
70
|
+
|
|
71
|
+
const active = item.href ? normalize(item.href) === current : false;
|
|
72
|
+
const hasChildren = !!item.children && item.children.length > 0;
|
|
73
|
+
const padLeft = `${8 + level * 12}px`;
|
|
74
|
+
const heightClass = level === 0 ? "h-8" : "h-7";
|
|
75
|
+
|
|
76
|
+
if (hasChildren) {
|
|
77
|
+
const details = document.createElement("details");
|
|
78
|
+
if (active || hasActiveDescendant(item, current)) details.open = true;
|
|
79
|
+
|
|
80
|
+
const summary = document.createElement("summary");
|
|
81
|
+
summary.className = [
|
|
82
|
+
"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
|
+
heightClass,
|
|
84
|
+
active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
|
|
85
|
+
]
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(" ");
|
|
88
|
+
summary.style.paddingLeft = padLeft;
|
|
89
|
+
if (item.href) summary.dataset.navHref = item.href;
|
|
90
|
+
if (active) summary.dataset.active = "true";
|
|
91
|
+
|
|
92
|
+
// Chevron SVG — matches SidebarNavTree's rotation-on-open via CSS
|
|
93
|
+
// (`details[open] > summary [data-chevron] { transform: rotate(90deg); }`).
|
|
94
|
+
summary.innerHTML =
|
|
95
|
+
'<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
|
+
|
|
97
|
+
if (item.icon) {
|
|
98
|
+
const iconSpan = document.createElement("span");
|
|
99
|
+
iconSpan.className = "shrink-0 [&>svg]:size-4";
|
|
100
|
+
iconSpan.innerHTML = item.icon;
|
|
101
|
+
summary.appendChild(iconSpan);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const label = document.createElement("span");
|
|
105
|
+
label.className = "truncate";
|
|
106
|
+
label.textContent = item.label;
|
|
107
|
+
summary.appendChild(label);
|
|
108
|
+
|
|
109
|
+
details.appendChild(summary);
|
|
110
|
+
|
|
111
|
+
// Recurse — nested `<ul>` mirrors SidebarNavTree's `<Astro.self>`.
|
|
112
|
+
const childUl = document.createElement("ul");
|
|
113
|
+
childUl.className = "flex min-w-0 flex-col";
|
|
114
|
+
childUl.dataset.sidebar = "nav-tree";
|
|
115
|
+
childUl.dataset.level = String(level + 1);
|
|
116
|
+
for (const child of item.children!) {
|
|
117
|
+
childUl.appendChild(renderItem(child, current, level + 1));
|
|
118
|
+
}
|
|
119
|
+
details.appendChild(childUl);
|
|
120
|
+
|
|
121
|
+
li.appendChild(details);
|
|
122
|
+
} else {
|
|
123
|
+
const a = document.createElement("a");
|
|
124
|
+
a.href = item.href || "#";
|
|
125
|
+
a.className = [
|
|
126
|
+
"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",
|
|
127
|
+
heightClass,
|
|
128
|
+
active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
|
|
129
|
+
]
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.join(" ");
|
|
132
|
+
a.style.paddingLeft = padLeft;
|
|
133
|
+
if (active) a.dataset.active = "true";
|
|
134
|
+
if (item.href) a.dataset.navHref = item.href;
|
|
135
|
+
|
|
136
|
+
// Spacer to align leaf text with branch text (which has a chevron).
|
|
137
|
+
const spacer = document.createElement("span");
|
|
138
|
+
spacer.className = "size-4 shrink-0";
|
|
139
|
+
a.appendChild(spacer);
|
|
140
|
+
|
|
141
|
+
if (item.icon) {
|
|
142
|
+
const iconSpan = document.createElement("span");
|
|
143
|
+
iconSpan.className = "shrink-0 [&>svg]:size-4";
|
|
144
|
+
iconSpan.innerHTML = item.icon;
|
|
145
|
+
a.appendChild(iconSpan);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const label = document.createElement("span");
|
|
149
|
+
label.className = "truncate";
|
|
150
|
+
label.textContent = item.label;
|
|
151
|
+
a.appendChild(label);
|
|
152
|
+
|
|
153
|
+
li.appendChild(a);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return li;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderTree(items: NavItem[], current: string, root: HTMLElement): void {
|
|
160
|
+
const ul = document.createElement("ul");
|
|
161
|
+
ul.className =
|
|
162
|
+
"flex min-w-0 flex-col w-full group-data-[collapsible=icon]:hidden";
|
|
163
|
+
ul.dataset.sidebar = "nav-tree";
|
|
164
|
+
ul.dataset.level = "0";
|
|
165
|
+
for (const item of items) {
|
|
166
|
+
ul.appendChild(renderItem(item, current, 0));
|
|
167
|
+
}
|
|
168
|
+
// Replace skeleton in one DOM op so there's no flash of partial state.
|
|
169
|
+
root.replaceChildren(ul);
|
|
170
|
+
root.removeAttribute("aria-busy");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Re-highlight the active item without rebuilding the whole tree.
|
|
175
|
+
* Used on `astro:page-load` when view transitions land on a new
|
|
176
|
+
* route — we re-toggle the `data-active` attribute and re-apply the
|
|
177
|
+
* active classes, then expand the new active branch's ancestors.
|
|
178
|
+
*
|
|
179
|
+
* Cheaper than a full re-render (no fetch, no DOM rebuild). For
|
|
180
|
+
* the wider hydration loop we still re-render when a brand-new nav
|
|
181
|
+
* structure is needed (e.g. switching versions), but path-only
|
|
182
|
+
* navigation just re-highlights.
|
|
183
|
+
*/
|
|
184
|
+
function rehighlight(root: HTMLElement, current: string): void {
|
|
185
|
+
const ACTIVE_CLASSES = [
|
|
186
|
+
"bg-sidebar-accent",
|
|
187
|
+
"font-medium",
|
|
188
|
+
"text-sidebar-accent-foreground",
|
|
189
|
+
];
|
|
190
|
+
const items = root.querySelectorAll<HTMLElement>("[data-nav-href]");
|
|
191
|
+
for (const el of Array.from(items)) {
|
|
192
|
+
const href = el.dataset.navHref;
|
|
193
|
+
const active = !!href && normalize(href) === current;
|
|
194
|
+
if (active) {
|
|
195
|
+
el.dataset.active = "true";
|
|
196
|
+
el.classList.add(...ACTIVE_CLASSES);
|
|
197
|
+
} else {
|
|
198
|
+
delete el.dataset.active;
|
|
199
|
+
el.classList.remove(...ACTIVE_CLASSES);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Expand ancestors of the new active item.
|
|
203
|
+
const active = root.querySelector<HTMLElement>('[data-active="true"]');
|
|
204
|
+
if (active) {
|
|
205
|
+
let parent: HTMLElement | null = active.parentElement;
|
|
206
|
+
while (parent) {
|
|
207
|
+
if (parent.tagName === "DETAILS") {
|
|
208
|
+
(parent as HTMLDetailsElement).open = true;
|
|
209
|
+
}
|
|
210
|
+
parent = parent.parentElement;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Public entry point. Called once from `<DocsNavClient />`'s inline
|
|
217
|
+
* script tag. Idempotent — subsequent calls (e.g. from view
|
|
218
|
+
* transitions) are no-ops once the tree has been rendered; they just
|
|
219
|
+
* re-highlight against the new pathname.
|
|
220
|
+
*/
|
|
221
|
+
export async function hydrateDocsNav(): Promise<void> {
|
|
222
|
+
const root = document.getElementById("docs-nav-root");
|
|
223
|
+
if (!root) return;
|
|
224
|
+
|
|
225
|
+
const navUrl = root.dataset.navUrl;
|
|
226
|
+
if (!navUrl) {
|
|
227
|
+
console.warn("docs-nav: missing data-nav-url");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const current = normalize(root.dataset.currentPath || location.pathname);
|
|
231
|
+
const basePath = root.dataset.basePath || "";
|
|
232
|
+
const version = root.dataset.version || undefined;
|
|
233
|
+
const locale = root.dataset.locale || undefined;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const nav = await fetchNav(navUrl);
|
|
237
|
+
const filtered = filterNavByAxis(nav, {
|
|
238
|
+
basePath: basePath || "/docs",
|
|
239
|
+
version: version || undefined,
|
|
240
|
+
locale: locale || undefined,
|
|
241
|
+
});
|
|
242
|
+
renderTree(filtered, current, root);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error("docs-nav: hydration failed", err);
|
|
245
|
+
root.setAttribute("aria-busy", "false");
|
|
246
|
+
// Keep skeleton so layout doesn't collapse on failure; a real
|
|
247
|
+
// user will reload or follow the noscript link.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Re-run on view transitions. astro:page-load fires both on initial
|
|
252
|
+
// load and after each ClientRouter transition; the module-level
|
|
253
|
+
// `navPromise` cache makes the post-transition path a fast
|
|
254
|
+
// highlight-only pass without re-fetching.
|
|
255
|
+
document.addEventListener("astro:page-load", () => {
|
|
256
|
+
const root = document.getElementById("docs-nav-root");
|
|
257
|
+
if (!root) return;
|
|
258
|
+
// If tree is already rendered (no aria-busy), just re-highlight.
|
|
259
|
+
if (root.getAttribute("aria-busy") === null) {
|
|
260
|
+
const current = normalize(root.dataset.currentPath || location.pathname);
|
|
261
|
+
rehighlight(root, current);
|
|
262
|
+
} else {
|
|
263
|
+
void hydrateDocsNav();
|
|
264
|
+
}
|
|
265
|
+
});
|