@dogsbay/docs-layout 0.2.0-beta.10 → 0.2.0-beta.100
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 +7 -5
- package/src/BlogIndex.astro +179 -0
- package/src/DocsFooter.astro +27 -3
- package/src/DocsLayout.astro +541 -40
- package/src/DocsNavClient.astro +107 -0
- package/src/DocsToc.astro +1 -1
- package/src/SearchDialog.astro +301 -33
- package/src/TagList.astro +17 -2
- package/src/VersionSwitcher.astro +6 -0
- package/src/docs-nav-client.ts +419 -0
- package/src/json-ld.ts +112 -0
- package/src/link-icons.ts +54 -0
- package/src/markdown-negotiation.ts +38 -2
- package/src/nav-filter.ts +42 -129
- package/src/search-facets.ts +511 -9
- package/src/switcher.ts +83 -2
- package/src/toc-placement.ts +71 -0
- package/src/version-redirect.ts +23 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Client-rendered drop-in replacement for SidebarNavTree.
|
|
4
|
+
*
|
|
5
|
+
* Emits an empty placeholder with the metadata the hydration script
|
|
6
|
+
* needs (nav-url to fetch, current-path to highlight, basePath for
|
|
7
|
+
* version/locale filtering). The actual tree DOM is rendered by
|
|
8
|
+
* `docs-nav-client.ts` after a single fetch of `nav.json` (cached
|
|
9
|
+
* once per session — subsequent navigations re-highlight without
|
|
10
|
+
* re-fetching, courtesy of view transitions).
|
|
11
|
+
*
|
|
12
|
+
* The placeholder shows a few skeleton rows so the layout doesn't
|
|
13
|
+
* shift when the real tree pops in. Skeleton uses the same width as
|
|
14
|
+
* the sidebar so visual jump is minimal even on a slow connection.
|
|
15
|
+
*
|
|
16
|
+
* Trade-offs vs SidebarNavTree:
|
|
17
|
+
* - HTML per page: ~200 bytes (this placeholder + a tiny script
|
|
18
|
+
* tag) vs ~600 KB+ for the SSR tree at scale.
|
|
19
|
+
* - No-JS users see only the skeleton + the `<noscript>` fallback
|
|
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.
|
|
24
|
+
* - First paint waits for the JS bundle + the JSON fetch. On a 4G
|
|
25
|
+
* connection that's typically <200 ms; the skeleton fills the
|
|
26
|
+
* space until then.
|
|
27
|
+
*
|
|
28
|
+
* See plans/client-rendered-nav.md.
|
|
29
|
+
*/
|
|
30
|
+
interface Props {
|
|
31
|
+
/** Current page's URL pathname; the script uses it to mark the active item. */
|
|
32
|
+
currentPath: string;
|
|
33
|
+
/**
|
|
34
|
+
* URL prefix the host serves under (combined `urlBase` + `basePath`).
|
|
35
|
+
* The script joins this with `/_dogsbay/nav.json` to locate the
|
|
36
|
+
* fetchable nav tree; also threaded to the version/locale filter
|
|
37
|
+
* so multi-axis sites work the same as SSR.
|
|
38
|
+
*/
|
|
39
|
+
basePath?: string;
|
|
40
|
+
/** Current source's product/namespace, if multi-product site. */
|
|
41
|
+
namespace?: string;
|
|
42
|
+
/** Current source's version axis value, if multi-version site. */
|
|
43
|
+
version?: string;
|
|
44
|
+
/** Current source's locale axis value, if multi-locale site. */
|
|
45
|
+
locale?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { currentPath, basePath = "", namespace, version, locale } = Astro.props;
|
|
49
|
+
const navUrl = `${basePath}/_dogsbay/nav.json`;
|
|
50
|
+
---
|
|
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
|
+
*/}
|
|
59
|
+
<div
|
|
60
|
+
id="docs-nav-root"
|
|
61
|
+
role="navigation"
|
|
62
|
+
data-nav-url={navUrl}
|
|
63
|
+
data-current-path={currentPath}
|
|
64
|
+
data-base-path={basePath}
|
|
65
|
+
data-namespace={namespace ?? ""}
|
|
66
|
+
data-version={version ?? ""}
|
|
67
|
+
data-locale={locale ?? ""}
|
|
68
|
+
aria-busy="true"
|
|
69
|
+
aria-label="Documentation navigation"
|
|
70
|
+
>
|
|
71
|
+
<ul class="flex min-w-0 flex-col" data-sidebar="nav-tree" data-level="0">
|
|
72
|
+
{[0, 1, 2, 3, 4, 5].map((i) => (
|
|
73
|
+
<li>
|
|
74
|
+
<div
|
|
75
|
+
class="flex h-8 w-full items-center gap-2 rounded-md px-2"
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
>
|
|
78
|
+
<div class="h-2 w-2 rounded-full bg-sidebar-foreground/10" />
|
|
79
|
+
<div
|
|
80
|
+
class="h-2 rounded bg-sidebar-foreground/10"
|
|
81
|
+
style={`width: ${50 + ((i * 17) % 35)}%;`}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
</li>
|
|
85
|
+
))}
|
|
86
|
+
</ul>
|
|
87
|
+
<noscript>
|
|
88
|
+
<p class="px-2 py-1.5 text-sm text-sidebar-foreground/70">
|
|
89
|
+
JavaScript is required to render the sidebar. Use the
|
|
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>
|
|
95
|
+
to browse all pages.
|
|
96
|
+
</p>
|
|
97
|
+
</noscript>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<script>
|
|
101
|
+
// Single import — Astro/Vite bundles this into one shared chunk
|
|
102
|
+
// referenced from every page that uses DocsNavClient. Browsers
|
|
103
|
+
// cache the chunk, so the nav script ships once per session
|
|
104
|
+
// regardless of how many pages the user visits.
|
|
105
|
+
import { hydrateDocsNav } from "./docs-nav-client.ts";
|
|
106
|
+
hydrateDocsNav();
|
|
107
|
+
</script>
|
package/src/DocsToc.astro
CHANGED
|
@@ -33,7 +33,7 @@ const filtered = headings.filter(h => h.depth >= minDepth && h.depth <= maxDepth
|
|
|
33
33
|
|
|
34
34
|
{filtered.length > 0 && (
|
|
35
35
|
<nav class:list={["text-sm", className]} aria-label="Table of contents">
|
|
36
|
-
<div class="text-xs font-semibold uppercase text-muted-foreground">{title}</div>
|
|
36
|
+
{title && <div class="text-xs font-semibold uppercase text-muted-foreground">{title}</div>}
|
|
37
37
|
<ul class="mt-2 space-y-1">
|
|
38
38
|
{filtered.map(h => (
|
|
39
39
|
<li>
|
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
|
/**
|
|
@@ -41,19 +57,33 @@ interface Props {
|
|
|
41
57
|
* back to slugs when undefined.
|
|
42
58
|
*/
|
|
43
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;
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
const {
|
|
47
|
-
pagefindUrl
|
|
71
|
+
pagefindUrl,
|
|
72
|
+
navUrl,
|
|
48
73
|
placeholder = "Search docs...",
|
|
49
74
|
taxonomyDisplay,
|
|
75
|
+
scopeProduct,
|
|
76
|
+
scopeVersion,
|
|
50
77
|
} = Astro.props;
|
|
51
78
|
---
|
|
52
79
|
|
|
53
80
|
<dialog
|
|
54
81
|
data-search-dialog
|
|
55
82
|
data-pagefind-url={pagefindUrl}
|
|
83
|
+
data-nav-url={navUrl}
|
|
56
84
|
data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
|
|
85
|
+
data-scope-product={scopeProduct ?? ""}
|
|
86
|
+
data-scope-version={scopeVersion ?? ""}
|
|
57
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"
|
|
58
88
|
>
|
|
59
89
|
<form method="dialog" class="flex flex-col">
|
|
@@ -156,13 +186,19 @@ const {
|
|
|
156
186
|
shapeFacets,
|
|
157
187
|
resolveFacetLabel,
|
|
158
188
|
resolveFacetTitle,
|
|
189
|
+
sortFacetNames,
|
|
159
190
|
filterStateToUrlParams,
|
|
160
191
|
parseFiltersFromUrl,
|
|
161
192
|
filterStateToPagefindFilters,
|
|
162
193
|
toggleFilter,
|
|
163
194
|
countActiveFilters,
|
|
195
|
+
buildFacetTree,
|
|
196
|
+
computeTreeState,
|
|
197
|
+
toggleTreeNode,
|
|
164
198
|
type FacetMap,
|
|
199
|
+
type FacetTreeNode,
|
|
165
200
|
type FilterState,
|
|
201
|
+
type NavLike,
|
|
166
202
|
type TaxonomyDisplayMap,
|
|
167
203
|
} from "./search-facets.js";
|
|
168
204
|
|
|
@@ -177,15 +213,23 @@ const {
|
|
|
177
213
|
meta: { title?: string };
|
|
178
214
|
sub_results?: Array<{ title: string; url: string; excerpt: string }>;
|
|
179
215
|
};
|
|
216
|
+
// Filter values match what filterStateToPagefindFilters emits:
|
|
217
|
+
// each facet wrapped in `{any: [...]}` for OR-within-facet semantics.
|
|
218
|
+
// Pagefind also accepts other operator shapes (`all`/`none`/`not`,
|
|
219
|
+
// bare strings, bare arrays) but we only emit the `any` form.
|
|
220
|
+
type PagefindFilterValue = { any: string[] };
|
|
180
221
|
type PagefindModule = {
|
|
181
222
|
search(
|
|
182
223
|
query: string,
|
|
183
|
-
options?: { filters?: Record<string,
|
|
224
|
+
options?: { filters?: Record<string, PagefindFilterValue> },
|
|
184
225
|
): Promise<{ results: PagefindResult[] }>;
|
|
185
226
|
filters(): Promise<Record<string, Record<string, number>>>;
|
|
186
227
|
};
|
|
187
228
|
|
|
188
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 || "";
|
|
189
233
|
const trigger = document.querySelector<HTMLButtonElement>("[data-search-trigger]");
|
|
190
234
|
const input = dialog?.querySelector<HTMLInputElement>("[data-search-input]");
|
|
191
235
|
const resultsBox = dialog?.querySelector<HTMLDivElement>("[data-search-results]");
|
|
@@ -204,6 +248,15 @@ const {
|
|
|
204
248
|
let filters: FilterState = {};
|
|
205
249
|
let availableFacets: FacetMap = {};
|
|
206
250
|
|
|
251
|
+
// Nav data cached after first fetch — used to derive the
|
|
252
|
+
// segment-encoded hierarchical-facet tree (the auto-`category`
|
|
253
|
+
// case). Null while pending or absent. The fetch fires lazily
|
|
254
|
+
// the first time renderFacets() encounters a hierarchical facet,
|
|
255
|
+
// not eagerly on dialog open, so sites without hierarchical
|
|
256
|
+
// facets never pay for it.
|
|
257
|
+
let navData: NavLike[] | null = null;
|
|
258
|
+
let loadingNav: Promise<void> | null = null;
|
|
259
|
+
|
|
207
260
|
// Display config baked into a data attribute by the Astro
|
|
208
261
|
// template — parsed lazily.
|
|
209
262
|
const taxonomyDisplay: TaxonomyDisplayMap = (() => {
|
|
@@ -218,7 +271,22 @@ const {
|
|
|
218
271
|
async function ensurePagefindLoaded() {
|
|
219
272
|
if (pagefind) return;
|
|
220
273
|
if (loadingPagefind) return loadingPagefind;
|
|
221
|
-
|
|
274
|
+
// pagefindUrl is required — a "/pagefind/" fallback would
|
|
275
|
+
// silently 404 on subpath-mounted deploys (GH Pages project
|
|
276
|
+
// pages, multi-mount Cloudflare). The emitter always passes
|
|
277
|
+
// a combined-prefix-aware value via data-pagefind-url. If it's
|
|
278
|
+
// missing the page wasn't built through format-astro and the
|
|
279
|
+
// caller forgot to pass it.
|
|
280
|
+
const dataUrl = dialog!.dataset.pagefindUrl;
|
|
281
|
+
if (!dataUrl) {
|
|
282
|
+
console.error(
|
|
283
|
+
"[dogsbay] SearchDialog: pagefindUrl prop missing. " +
|
|
284
|
+
"Pass the combined-prefix path (e.g. '/<base>/pagefind/') " +
|
|
285
|
+
"from your DocsLayout instantiation.",
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const url = dataUrl + "pagefind.js";
|
|
222
290
|
loadingPagefind = (async () => {
|
|
223
291
|
try {
|
|
224
292
|
const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
|
|
@@ -237,6 +305,49 @@ const {
|
|
|
237
305
|
return loadingPagefind;
|
|
238
306
|
}
|
|
239
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Lazy nav.json fetch, kicked off the first time a hierarchical
|
|
310
|
+
* facet is rendered. Mirrors the pattern in `docs-nav-client.ts` —
|
|
311
|
+
* one fetch per session, `same-origin` credentials so cookied
|
|
312
|
+
* mounts work, module-level cache.
|
|
313
|
+
*
|
|
314
|
+
* When the fetch resolves we re-call `renderFacets()` so the
|
|
315
|
+
* previously-flat segment-encoded facet upgrades to its tree
|
|
316
|
+
* shape — without this, a slow nav fetch would leave the facets
|
|
317
|
+
* stuck on the flat fallback until the user toggled a filter.
|
|
318
|
+
*
|
|
319
|
+
* Network errors are logged and swallowed; the helper's flat
|
|
320
|
+
* fallback keeps the dialog functional.
|
|
321
|
+
*/
|
|
322
|
+
async function ensureNavLoaded() {
|
|
323
|
+
if (navData !== null) return;
|
|
324
|
+
if (loadingNav) return loadingNav;
|
|
325
|
+
const url = dialog!.dataset.navUrl;
|
|
326
|
+
if (!url) return; // navUrl not provided — flat fallback stays
|
|
327
|
+
loadingNav = (async () => {
|
|
328
|
+
try {
|
|
329
|
+
const res = await fetch(url, { credentials: "same-origin" });
|
|
330
|
+
if (!res.ok) throw new Error(`nav.json fetch failed: ${res.status}`);
|
|
331
|
+
const data = (await res.json()) as unknown;
|
|
332
|
+
navData = Array.isArray(data) ? (data as NavLike[]) : [];
|
|
333
|
+
// Re-render so hierarchical segment-encoded facets pick up
|
|
334
|
+
// the nav shape. Cheap — no Pagefind round-trip, just a
|
|
335
|
+
// DOM rebuild from `availableFacets`.
|
|
336
|
+
if (Object.keys(availableFacets).length > 0) {
|
|
337
|
+
renderFacets();
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.warn(
|
|
341
|
+
"[dogsbay] failed to load nav.json (hierarchical facets fall back to flat list):",
|
|
342
|
+
err,
|
|
343
|
+
);
|
|
344
|
+
// Mark as loaded-with-empty so we don't keep retrying.
|
|
345
|
+
navData = [];
|
|
346
|
+
}
|
|
347
|
+
})();
|
|
348
|
+
return loadingNav;
|
|
349
|
+
}
|
|
350
|
+
|
|
240
351
|
function escapeHtml(s: string): string {
|
|
241
352
|
return s
|
|
242
353
|
.replace(/&/g, "&")
|
|
@@ -313,54 +424,171 @@ const {
|
|
|
313
424
|
resultsBox!.innerHTML = html;
|
|
314
425
|
}
|
|
315
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Hierarchical-facet caches, rebuilt on every renderFacets() so
|
|
429
|
+
* they stay aligned with the current filter state + available
|
|
430
|
+
* facets. The trees are the source for `value → node` lookups
|
|
431
|
+
* during click handling (parent click expands selection to all
|
|
432
|
+
* descendants — needs the node to know what to add).
|
|
433
|
+
*/
|
|
434
|
+
const facetTrees = new Map<string, FacetTreeNode[]>();
|
|
435
|
+
const facetNodesByValue = new Map<string, Map<string, FacetTreeNode>>();
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Decide whether a facet renders as a tree. `category` defaults
|
|
439
|
+
* to hierarchical (auto-derived path segments are the canonical
|
|
440
|
+
* use case); explicit override via `taxonomyDisplay[name].hierarchical`
|
|
441
|
+
* wins both ways.
|
|
442
|
+
*/
|
|
443
|
+
function isHierarchicalFacet(name: string): boolean {
|
|
444
|
+
const flag = taxonomyDisplay[name]?.hierarchical;
|
|
445
|
+
if (typeof flag === "boolean") return flag;
|
|
446
|
+
return name === "category";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Flatten a tree into a `value → node` map so the click handler
|
|
451
|
+
* can look up the clicked node without re-walking the tree.
|
|
452
|
+
*/
|
|
453
|
+
function indexTree(nodes: FacetTreeNode[]): Map<string, FacetTreeNode> {
|
|
454
|
+
const out = new Map<string, FacetTreeNode>();
|
|
455
|
+
const visit = (n: FacetTreeNode): void => {
|
|
456
|
+
out.set(n.value, n);
|
|
457
|
+
for (const c of n.children) visit(c);
|
|
458
|
+
};
|
|
459
|
+
for (const r of nodes) visit(r);
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Recursive HTML for one tree node. Synthetic parents (`hasValue:
|
|
465
|
+
* false`) render as a clickable group header — checking them
|
|
466
|
+
* still cascades to descendants via toggleTreeNode. Indent scales
|
|
467
|
+
* with `node.depth`.
|
|
468
|
+
*/
|
|
469
|
+
function renderTreeNode(name: string, node: FacetTreeNode): string {
|
|
470
|
+
const state = computeTreeState(node, name, filters);
|
|
471
|
+
const id = `facet-${name}-${node.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
472
|
+
const indent = node.depth * 12;
|
|
473
|
+
const label = escapeHtml(node.label);
|
|
474
|
+
const countText = node.hasValue ? `${node.count}` : "";
|
|
475
|
+
const stateAttr =
|
|
476
|
+
state === "checked" ? "checked" : state === "indeterminate" ? 'data-tree-indeterminate="true"' : "";
|
|
477
|
+
const childrenHtml = node.children
|
|
478
|
+
.map((c) => renderTreeNode(name, c))
|
|
479
|
+
.join("");
|
|
480
|
+
return `
|
|
481
|
+
<li>
|
|
482
|
+
<label
|
|
483
|
+
for="${id}"
|
|
484
|
+
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent"
|
|
485
|
+
style="padding-left: ${0.5 + indent / 16}rem"
|
|
486
|
+
>
|
|
487
|
+
<input
|
|
488
|
+
type="checkbox"
|
|
489
|
+
id="${id}"
|
|
490
|
+
data-facet-name="${escapeHtml(name)}"
|
|
491
|
+
data-facet-value="${escapeHtml(node.value)}"
|
|
492
|
+
data-tree-node="true"
|
|
493
|
+
${state === "checked" ? "checked" : ""}
|
|
494
|
+
${state === "indeterminate" ? 'data-tree-indeterminate="true"' : ""}
|
|
495
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
496
|
+
/>
|
|
497
|
+
<span class="flex-1 truncate">${label}</span>
|
|
498
|
+
<span class="text-xs text-muted-foreground">${countText}</span>
|
|
499
|
+
</label>
|
|
500
|
+
${childrenHtml ? `<ul class="space-y-0.5">${childrenHtml}</ul>` : ""}
|
|
501
|
+
</li>
|
|
502
|
+
`;
|
|
503
|
+
}
|
|
504
|
+
|
|
316
505
|
/**
|
|
317
506
|
* Build the facets sidebar. Runs once after Pagefind discovers
|
|
318
507
|
* filters, then again whenever filter state changes (so checkbox
|
|
319
508
|
* `checked` reflects current selections). When the corpus has no
|
|
320
509
|
* filters, the sidebar stays hidden — single-column layout.
|
|
510
|
+
*
|
|
511
|
+
* Branches per facet: hierarchical taxonomies (and the built-in
|
|
512
|
+
* `category` default) render as a tree; flat taxonomies keep the
|
|
513
|
+
* original checkbox-list shape. The hierarchical path falls back
|
|
514
|
+
* to a flat list automatically when segment-encoded values lack a
|
|
515
|
+
* `nav` source — preserves render-ability until Phase 3 wires the
|
|
516
|
+
* nav.json fetch.
|
|
321
517
|
*/
|
|
322
518
|
function renderFacets() {
|
|
323
|
-
const facetNames =
|
|
519
|
+
const facetNames = sortFacetNames(
|
|
520
|
+
Object.keys(availableFacets),
|
|
521
|
+
taxonomyDisplay,
|
|
522
|
+
);
|
|
324
523
|
if (facetNames.length === 0) {
|
|
325
524
|
facetsBox!.classList.add("hidden");
|
|
326
525
|
return;
|
|
327
526
|
}
|
|
328
527
|
facetsBox!.classList.remove("hidden");
|
|
329
528
|
|
|
529
|
+
facetTrees.clear();
|
|
530
|
+
facetNodesByValue.clear();
|
|
531
|
+
|
|
330
532
|
const activeCount = countActiveFilters(filters);
|
|
331
533
|
const clearAll = activeCount > 0
|
|
332
534
|
? `<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
535
|
: "";
|
|
334
536
|
|
|
537
|
+
let needsNav = false;
|
|
335
538
|
const groups = facetNames
|
|
336
539
|
.map((name) => {
|
|
337
540
|
const entries = availableFacets[name];
|
|
338
541
|
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
|
-
|
|
542
|
+
const hierarchical = isHierarchicalFacet(name);
|
|
543
|
+
|
|
544
|
+
let items: string;
|
|
545
|
+
if (hierarchical) {
|
|
546
|
+
// Every hierarchical facet wants nav.json — slash-encoded
|
|
547
|
+
// for sort order at each depth (so docs follow nav rather
|
|
548
|
+
// than count-desc), segment-encoded for the tree structure
|
|
549
|
+
// itself. Without nav, slash-encoded still renders a tree
|
|
550
|
+
// (using values alone) but in count-desc order, which is
|
|
551
|
+
// wrong on corpora where one branch dwarfs the rest
|
|
552
|
+
// (openshift's rest_api section). Fire ensureNavLoaded()
|
|
553
|
+
// any time a hierarchical facet renders with navData still
|
|
554
|
+
// null — when it resolves, renderFacets re-runs and
|
|
555
|
+
// upgrades the sort.
|
|
556
|
+
if (navData === null) needsNav = true;
|
|
557
|
+
const tree = buildFacetTree(name, entries, {
|
|
558
|
+
display: taxonomyDisplay,
|
|
559
|
+
nav: navData ?? undefined,
|
|
560
|
+
});
|
|
561
|
+
facetTrees.set(name, tree);
|
|
562
|
+
facetNodesByValue.set(name, indexTree(tree));
|
|
563
|
+
items = tree.map((n) => renderTreeNode(name, n)).join("");
|
|
564
|
+
} else {
|
|
565
|
+
items = entries
|
|
566
|
+
.map((entry) => {
|
|
567
|
+
const checked = (filters[name] ?? []).includes(entry.value);
|
|
568
|
+
const label = escapeHtml(
|
|
569
|
+
resolveFacetLabel(name, entry.value, taxonomyDisplay),
|
|
570
|
+
);
|
|
571
|
+
const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
572
|
+
return `
|
|
573
|
+
<li>
|
|
574
|
+
<label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
|
|
575
|
+
<input
|
|
576
|
+
type="checkbox"
|
|
577
|
+
id="${id}"
|
|
578
|
+
data-facet-name="${escapeHtml(name)}"
|
|
579
|
+
data-facet-value="${escapeHtml(entry.value)}"
|
|
580
|
+
${checked ? "checked" : ""}
|
|
581
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
582
|
+
/>
|
|
583
|
+
<span class="flex-1 truncate">${label}</span>
|
|
584
|
+
<span class="text-xs text-muted-foreground">${entry.count}</span>
|
|
585
|
+
</label>
|
|
586
|
+
</li>
|
|
587
|
+
`;
|
|
588
|
+
})
|
|
589
|
+
.join("");
|
|
590
|
+
}
|
|
591
|
+
|
|
364
592
|
return `
|
|
365
593
|
<fieldset class="mb-3">
|
|
366
594
|
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
|
|
@@ -371,6 +599,20 @@ const {
|
|
|
371
599
|
.join("");
|
|
372
600
|
|
|
373
601
|
facetsBox!.innerHTML = clearAll + groups;
|
|
602
|
+
|
|
603
|
+
// `indeterminate` is a JS property, not an HTML attribute —
|
|
604
|
+
// can't set via innerHTML. Walk new checkboxes and set it
|
|
605
|
+
// post-paint so the visual tri-state matches state.
|
|
606
|
+
const indet = facetsBox!.querySelectorAll<HTMLInputElement>(
|
|
607
|
+
'input[type="checkbox"][data-tree-indeterminate="true"]',
|
|
608
|
+
);
|
|
609
|
+
for (const el of Array.from(indet)) el.indeterminate = true;
|
|
610
|
+
|
|
611
|
+
// Lazy nav fetch — fired only when at least one hierarchical
|
|
612
|
+
// segment-encoded facet rendered with the flat fallback. The
|
|
613
|
+
// fetch's resolution handler calls renderFacets() again so the
|
|
614
|
+
// facet upgrades to its real tree shape without user action.
|
|
615
|
+
if (needsNav) void ensureNavLoaded();
|
|
374
616
|
}
|
|
375
617
|
|
|
376
618
|
async function runSearch(query: string) {
|
|
@@ -425,9 +667,22 @@ const {
|
|
|
425
667
|
const fromUrl = parseFiltersFromUrl(new URLSearchParams(window.location.search));
|
|
426
668
|
input!.value = fromUrl.query;
|
|
427
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
|
+
}
|
|
428
680
|
renderFacets();
|
|
429
681
|
|
|
430
|
-
|
|
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;
|
|
431
686
|
if (hasInitial) {
|
|
432
687
|
runSearch(input!.value);
|
|
433
688
|
} else {
|
|
@@ -475,13 +730,26 @@ const {
|
|
|
475
730
|
});
|
|
476
731
|
|
|
477
732
|
// Facet checkbox toggling — event delegation on the sidebar.
|
|
733
|
+
// Tree nodes route through toggleTreeNode (parent click expands
|
|
734
|
+
// to all descendants); flat checkboxes use toggleFilter as before.
|
|
735
|
+
// We compute state from the pre-click filter, NOT from the
|
|
736
|
+
// checkbox's post-click `checked` value — the browser has already
|
|
737
|
+
// flipped it by the time `change` fires, so reading it would
|
|
738
|
+
// invert our toggle direction for indeterminate parents.
|
|
478
739
|
facetsBox.addEventListener("change", (e) => {
|
|
479
740
|
const target = e.target as HTMLInputElement;
|
|
480
741
|
if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
|
|
481
742
|
const name = target.dataset.facetName;
|
|
482
743
|
const value = target.dataset.facetValue;
|
|
483
744
|
if (!name || !value) return;
|
|
484
|
-
|
|
745
|
+
if (target.dataset.treeNode === "true") {
|
|
746
|
+
const node = facetNodesByValue.get(name)?.get(value);
|
|
747
|
+
if (!node) return;
|
|
748
|
+
const currentState = computeTreeState(node, name, filters);
|
|
749
|
+
filters = toggleTreeNode(filters, name, node, currentState);
|
|
750
|
+
} else {
|
|
751
|
+
filters = toggleFilter(filters, name, value);
|
|
752
|
+
}
|
|
485
753
|
renderFacets();
|
|
486
754
|
runSearch(input!.value);
|
|
487
755
|
syncUrl();
|
package/src/TagList.astro
CHANGED
|
@@ -72,8 +72,13 @@ const chips = buildChips(tags, { indexPath, prefixes, labels });
|
|
|
72
72
|
*/
|
|
73
73
|
const PALETTE: Record<TagPaletteName, string> = {
|
|
74
74
|
blue: "border-blue-500/40 bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
|
75
|
+
// amber-800, not -700, in light mode: amber-700 on amber-500/15 is
|
|
76
|
+
// 4.48:1 — under 4.5:1 by a hair, and failing regardless of the
|
|
77
|
+
// opacity bug below. -800 gives 6.32:1. Every other colour clears it
|
|
78
|
+
// at -700 (4.76–8.60:1). Dark mode is unaffected: text-*-300 on the
|
|
79
|
+
// same tint over a dark page is 9–12:1 across the palette.
|
|
75
80
|
amber:
|
|
76
|
-
"border-amber-500/40 bg-amber-500/15 text-amber-
|
|
81
|
+
"border-amber-500/40 bg-amber-500/15 text-amber-800 dark:text-amber-300",
|
|
77
82
|
emerald:
|
|
78
83
|
"border-emerald-500/40 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
|
79
84
|
violet:
|
|
@@ -108,7 +113,17 @@ const DEFAULT_CLASS =
|
|
|
108
113
|
>
|
|
109
114
|
{chip.prefixLabel ? (
|
|
110
115
|
<Fragment>
|
|
111
|
-
|
|
116
|
+
{/*
|
|
117
|
+
No opacity here. `opacity-70` on the prefix blended the
|
|
118
|
+
text 30% toward the chip background and put EVERY colour
|
|
119
|
+
in the palette below 4.5:1 — measured 2.75–3.95:1, worst
|
|
120
|
+
amber, best slate. It is 12px text, so 4.5:1 applies.
|
|
121
|
+
|
|
122
|
+
The prefix/leaf distinction is carried by font-weight
|
|
123
|
+
instead: the leaf below is font-semibold against the
|
|
124
|
+
anchor's font-medium. Weight costs no contrast.
|
|
125
|
+
*/}
|
|
126
|
+
<span>{chip.prefixLabel}:</span>
|
|
112
127
|
<span class="font-semibold">{chip.leafLabel}</span>
|
|
113
128
|
</Fragment>
|
|
114
129
|
) : (
|
|
@@ -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
|
)}
|