@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
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
/** The version served at the site root (`path: ""`), if any. */
|
|
47
|
+
rootVersion?: string;
|
|
48
|
+
/** The other versions' URL segments (left out of the root version's nav). */
|
|
49
|
+
versionSegments?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { currentPath, basePath = "", namespace, version, locale, rootVersion, versionSegments } = Astro.props;
|
|
53
|
+
const navUrl = `${basePath}/_dogsbay/nav.json`;
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
{/*
|
|
57
|
+
`role="navigation"` is REQUIRED here, not decoration: `aria-label` and
|
|
58
|
+
`aria-busy` are prohibited on a generic div (axe: aria-prohibited-attr,
|
|
59
|
+
serious) because a role-less element has no accessible name to label.
|
|
60
|
+
Giving the nav container its real role makes both attributes legal and
|
|
61
|
+
makes the landmark discoverable — it was previously neither.
|
|
62
|
+
*/}
|
|
63
|
+
<div
|
|
64
|
+
id="docs-nav-root"
|
|
65
|
+
role="navigation"
|
|
66
|
+
data-nav-url={navUrl}
|
|
67
|
+
data-current-path={currentPath}
|
|
68
|
+
data-base-path={basePath}
|
|
69
|
+
data-namespace={namespace ?? ""}
|
|
70
|
+
data-version={version ?? ""}
|
|
71
|
+
data-locale={locale ?? ""}
|
|
72
|
+
data-root-version={rootVersion ?? ""}
|
|
73
|
+
data-version-segments={(versionSegments ?? []).join(" ")}
|
|
74
|
+
aria-busy="true"
|
|
75
|
+
aria-label="Documentation navigation"
|
|
76
|
+
>
|
|
77
|
+
<ul class="flex min-w-0 flex-col" data-sidebar="nav-tree" data-level="0">
|
|
78
|
+
{[0, 1, 2, 3, 4, 5].map((i) => (
|
|
79
|
+
<li>
|
|
80
|
+
<div
|
|
81
|
+
class="flex h-8 w-full items-center gap-2 rounded-md px-2"
|
|
82
|
+
aria-hidden="true"
|
|
83
|
+
>
|
|
84
|
+
<div class="h-2 w-2 rounded-full bg-sidebar-foreground/10" />
|
|
85
|
+
<div
|
|
86
|
+
class="h-2 rounded bg-sidebar-foreground/10"
|
|
87
|
+
style={`width: ${50 + ((i * 17) % 35)}%;`}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
</li>
|
|
91
|
+
))}
|
|
92
|
+
</ul>
|
|
93
|
+
<noscript>
|
|
94
|
+
<p class="px-2 py-1.5 text-sm text-sidebar-foreground/70">
|
|
95
|
+
JavaScript is required to render the sidebar. Use the
|
|
96
|
+
{/* sitemap-index.xml, not sitemap.xml — Dogsbay emits the
|
|
97
|
+
sitemap-index / sitemap-0 pair directly (see emitSitemapFiles);
|
|
98
|
+
`sitemap.xml` has never existed, so this no-JS fallback link
|
|
99
|
+
404'd on every site. */}
|
|
100
|
+
<a href={`${basePath}/sitemap-index.xml`} class="underline">sitemap</a>
|
|
101
|
+
to browse all pages.
|
|
102
|
+
</p>
|
|
103
|
+
</noscript>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<script>
|
|
107
|
+
// Single import — Astro/Vite bundles this into one shared chunk
|
|
108
|
+
// referenced from every page that uses DocsNavClient. Browsers
|
|
109
|
+
// cache the chunk, so the nav script ships once per session
|
|
110
|
+
// regardless of how many pages the user visits.
|
|
111
|
+
import { hydrateDocsNav } from "./docs-nav-client.ts";
|
|
112
|
+
hydrateDocsNav();
|
|
113
|
+
</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,35 @@ 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
|
+
* The base Pagefind prefixes result URLs with. Pagefind's own default is
|
|
42
|
+
* the folder above the bundle, which is right for GitHub Pages project
|
|
43
|
+
* sites and doubles the prefix on Workers subpath mounts (their index
|
|
44
|
+
* already carries the mount). format-astro computes the right value per
|
|
45
|
+
* layout; when absent, Pagefind's default is left alone. See
|
|
46
|
+
* `pagefind-base-url.ts`.
|
|
47
|
+
*/
|
|
48
|
+
pagefindBaseUrl?: string;
|
|
49
|
+
/**
|
|
50
|
+
* URL of the site's `nav.json` (typically `${basePath}/_dogsbay/nav.json`).
|
|
51
|
+
* Used to render hierarchical facets in document order: when the
|
|
52
|
+
* `category` facet (or any taxonomy flagged `hierarchical: true`)
|
|
53
|
+
* is segment-encoded — i.e. its values are individual path segments
|
|
54
|
+
* not slash-joined — the tree shape is derived from nav. Without
|
|
55
|
+
* `navUrl`, hierarchical segment-encoded facets fall back to a flat
|
|
56
|
+
* list (still functional, just no tree structure). Slash-encoded
|
|
57
|
+
* taxonomies don't need nav and build trees from their values directly.
|
|
58
|
+
*/
|
|
59
|
+
navUrl?: string;
|
|
35
60
|
/** Placeholder text for the search input */
|
|
36
61
|
placeholder?: string;
|
|
37
62
|
/**
|
|
@@ -41,19 +66,35 @@ interface Props {
|
|
|
41
66
|
* back to slugs when undefined.
|
|
42
67
|
*/
|
|
43
68
|
taxonomyDisplay?: Record<string, TaxonomyDisplay>;
|
|
69
|
+
/**
|
|
70
|
+
* Current page's product (namespace) and version. On a multi-product /
|
|
71
|
+
* versioned site, search opens PRE-SCOPED to these — a Calico 3.32 page
|
|
72
|
+
* searches Calico 3.32 by default. The scope is seeded as normal facet
|
|
73
|
+
* selections, so the reader can untick them to search wider.
|
|
74
|
+
*/
|
|
75
|
+
scopeProduct?: string;
|
|
76
|
+
scopeVersion?: string;
|
|
44
77
|
}
|
|
45
78
|
|
|
46
79
|
const {
|
|
47
|
-
pagefindUrl
|
|
80
|
+
pagefindUrl,
|
|
81
|
+
pagefindBaseUrl,
|
|
82
|
+
navUrl,
|
|
48
83
|
placeholder = "Search docs...",
|
|
49
84
|
taxonomyDisplay,
|
|
85
|
+
scopeProduct,
|
|
86
|
+
scopeVersion,
|
|
50
87
|
} = Astro.props;
|
|
51
88
|
---
|
|
52
89
|
|
|
53
90
|
<dialog
|
|
54
91
|
data-search-dialog
|
|
55
92
|
data-pagefind-url={pagefindUrl}
|
|
93
|
+
data-pagefind-base-url={pagefindBaseUrl}
|
|
94
|
+
data-nav-url={navUrl}
|
|
56
95
|
data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
|
|
96
|
+
data-scope-product={scopeProduct ?? ""}
|
|
97
|
+
data-scope-version={scopeVersion ?? ""}
|
|
57
98
|
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
99
|
>
|
|
59
100
|
<form method="dialog" class="flex flex-col">
|
|
@@ -156,15 +197,22 @@ const {
|
|
|
156
197
|
shapeFacets,
|
|
157
198
|
resolveFacetLabel,
|
|
158
199
|
resolveFacetTitle,
|
|
200
|
+
sortFacetNames,
|
|
159
201
|
filterStateToUrlParams,
|
|
160
202
|
parseFiltersFromUrl,
|
|
161
203
|
filterStateToPagefindFilters,
|
|
162
204
|
toggleFilter,
|
|
163
205
|
countActiveFilters,
|
|
206
|
+
buildFacetTree,
|
|
207
|
+
computeTreeState,
|
|
208
|
+
toggleTreeNode,
|
|
164
209
|
type FacetMap,
|
|
210
|
+
type FacetTreeNode,
|
|
165
211
|
type FilterState,
|
|
212
|
+
type NavLike,
|
|
166
213
|
type TaxonomyDisplayMap,
|
|
167
214
|
} from "./search-facets.js";
|
|
215
|
+
import { applyPagefindBaseUrl } from "./pagefind-base-url.js";
|
|
168
216
|
|
|
169
217
|
type PagefindResult = {
|
|
170
218
|
id: string;
|
|
@@ -177,15 +225,24 @@ const {
|
|
|
177
225
|
meta: { title?: string };
|
|
178
226
|
sub_results?: Array<{ title: string; url: string; excerpt: string }>;
|
|
179
227
|
};
|
|
228
|
+
// Filter values match what filterStateToPagefindFilters emits:
|
|
229
|
+
// each facet wrapped in `{any: [...]}` for OR-within-facet semantics.
|
|
230
|
+
// Pagefind also accepts other operator shapes (`all`/`none`/`not`,
|
|
231
|
+
// bare strings, bare arrays) but we only emit the `any` form.
|
|
232
|
+
type PagefindFilterValue = { any: string[] };
|
|
180
233
|
type PagefindModule = {
|
|
181
234
|
search(
|
|
182
235
|
query: string,
|
|
183
|
-
options?: { filters?: Record<string,
|
|
236
|
+
options?: { filters?: Record<string, PagefindFilterValue> },
|
|
184
237
|
): Promise<{ results: PagefindResult[] }>;
|
|
185
238
|
filters(): Promise<Record<string, Record<string, number>>>;
|
|
239
|
+
options?(opts: { baseUrl?: string }): Promise<void>;
|
|
186
240
|
};
|
|
187
241
|
|
|
188
242
|
const dialog = document.querySelector<HTMLDialogElement>("[data-search-dialog]");
|
|
243
|
+
// Current page's product/version — search opens pre-scoped to these.
|
|
244
|
+
const scopeProduct = dialog?.dataset.scopeProduct || "";
|
|
245
|
+
const scopeVersion = dialog?.dataset.scopeVersion || "";
|
|
189
246
|
const trigger = document.querySelector<HTMLButtonElement>("[data-search-trigger]");
|
|
190
247
|
const input = dialog?.querySelector<HTMLInputElement>("[data-search-input]");
|
|
191
248
|
const resultsBox = dialog?.querySelector<HTMLDivElement>("[data-search-results]");
|
|
@@ -204,6 +261,15 @@ const {
|
|
|
204
261
|
let filters: FilterState = {};
|
|
205
262
|
let availableFacets: FacetMap = {};
|
|
206
263
|
|
|
264
|
+
// Nav data cached after first fetch — used to derive the
|
|
265
|
+
// segment-encoded hierarchical-facet tree (the auto-`category`
|
|
266
|
+
// case). Null while pending or absent. The fetch fires lazily
|
|
267
|
+
// the first time renderFacets() encounters a hierarchical facet,
|
|
268
|
+
// not eagerly on dialog open, so sites without hierarchical
|
|
269
|
+
// facets never pay for it.
|
|
270
|
+
let navData: NavLike[] | null = null;
|
|
271
|
+
let loadingNav: Promise<void> | null = null;
|
|
272
|
+
|
|
207
273
|
// Display config baked into a data attribute by the Astro
|
|
208
274
|
// template — parsed lazily.
|
|
209
275
|
const taxonomyDisplay: TaxonomyDisplayMap = (() => {
|
|
@@ -218,10 +284,28 @@ const {
|
|
|
218
284
|
async function ensurePagefindLoaded() {
|
|
219
285
|
if (pagefind) return;
|
|
220
286
|
if (loadingPagefind) return loadingPagefind;
|
|
221
|
-
|
|
287
|
+
// pagefindUrl is required — a "/pagefind/" fallback would
|
|
288
|
+
// silently 404 on subpath-mounted deploys (GH Pages project
|
|
289
|
+
// pages, multi-mount Cloudflare). The emitter always passes
|
|
290
|
+
// a combined-prefix-aware value via data-pagefind-url. If it's
|
|
291
|
+
// missing the page wasn't built through format-astro and the
|
|
292
|
+
// caller forgot to pass it.
|
|
293
|
+
const dataUrl = dialog!.dataset.pagefindUrl;
|
|
294
|
+
if (!dataUrl) {
|
|
295
|
+
console.error(
|
|
296
|
+
"[dogsbay] SearchDialog: pagefindUrl prop missing. " +
|
|
297
|
+
"Pass the combined-prefix path (e.g. '/<base>/pagefind/') " +
|
|
298
|
+
"from your DocsLayout instantiation.",
|
|
299
|
+
);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const url = dataUrl + "pagefind.js";
|
|
222
303
|
loadingPagefind = (async () => {
|
|
223
304
|
try {
|
|
224
305
|
const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
|
|
306
|
+
// Before the first search: result URLs are built with this base.
|
|
307
|
+
// Without it a Workers subpath mount links to /<mount>/<mount>/page/.
|
|
308
|
+
await applyPagefindBaseUrl(mod, dialog!.dataset.pagefindBaseUrl);
|
|
225
309
|
pagefind = mod;
|
|
226
310
|
// Discover facets once, on first load. Pagefind builds
|
|
227
311
|
// this from the body-level data-pagefind-filter elements.
|
|
@@ -237,6 +321,49 @@ const {
|
|
|
237
321
|
return loadingPagefind;
|
|
238
322
|
}
|
|
239
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Lazy nav.json fetch, kicked off the first time a hierarchical
|
|
326
|
+
* facet is rendered. Mirrors the pattern in `docs-nav-client.ts` —
|
|
327
|
+
* one fetch per session, `same-origin` credentials so cookied
|
|
328
|
+
* mounts work, module-level cache.
|
|
329
|
+
*
|
|
330
|
+
* When the fetch resolves we re-call `renderFacets()` so the
|
|
331
|
+
* previously-flat segment-encoded facet upgrades to its tree
|
|
332
|
+
* shape — without this, a slow nav fetch would leave the facets
|
|
333
|
+
* stuck on the flat fallback until the user toggled a filter.
|
|
334
|
+
*
|
|
335
|
+
* Network errors are logged and swallowed; the helper's flat
|
|
336
|
+
* fallback keeps the dialog functional.
|
|
337
|
+
*/
|
|
338
|
+
async function ensureNavLoaded() {
|
|
339
|
+
if (navData !== null) return;
|
|
340
|
+
if (loadingNav) return loadingNav;
|
|
341
|
+
const url = dialog!.dataset.navUrl;
|
|
342
|
+
if (!url) return; // navUrl not provided — flat fallback stays
|
|
343
|
+
loadingNav = (async () => {
|
|
344
|
+
try {
|
|
345
|
+
const res = await fetch(url, { credentials: "same-origin" });
|
|
346
|
+
if (!res.ok) throw new Error(`nav.json fetch failed: ${res.status}`);
|
|
347
|
+
const data = (await res.json()) as unknown;
|
|
348
|
+
navData = Array.isArray(data) ? (data as NavLike[]) : [];
|
|
349
|
+
// Re-render so hierarchical segment-encoded facets pick up
|
|
350
|
+
// the nav shape. Cheap — no Pagefind round-trip, just a
|
|
351
|
+
// DOM rebuild from `availableFacets`.
|
|
352
|
+
if (Object.keys(availableFacets).length > 0) {
|
|
353
|
+
renderFacets();
|
|
354
|
+
}
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.warn(
|
|
357
|
+
"[dogsbay] failed to load nav.json (hierarchical facets fall back to flat list):",
|
|
358
|
+
err,
|
|
359
|
+
);
|
|
360
|
+
// Mark as loaded-with-empty so we don't keep retrying.
|
|
361
|
+
navData = [];
|
|
362
|
+
}
|
|
363
|
+
})();
|
|
364
|
+
return loadingNav;
|
|
365
|
+
}
|
|
366
|
+
|
|
240
367
|
function escapeHtml(s: string): string {
|
|
241
368
|
return s
|
|
242
369
|
.replace(/&/g, "&")
|
|
@@ -313,54 +440,171 @@ const {
|
|
|
313
440
|
resultsBox!.innerHTML = html;
|
|
314
441
|
}
|
|
315
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Hierarchical-facet caches, rebuilt on every renderFacets() so
|
|
445
|
+
* they stay aligned with the current filter state + available
|
|
446
|
+
* facets. The trees are the source for `value → node` lookups
|
|
447
|
+
* during click handling (parent click expands selection to all
|
|
448
|
+
* descendants — needs the node to know what to add).
|
|
449
|
+
*/
|
|
450
|
+
const facetTrees = new Map<string, FacetTreeNode[]>();
|
|
451
|
+
const facetNodesByValue = new Map<string, Map<string, FacetTreeNode>>();
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Decide whether a facet renders as a tree. `category` defaults
|
|
455
|
+
* to hierarchical (auto-derived path segments are the canonical
|
|
456
|
+
* use case); explicit override via `taxonomyDisplay[name].hierarchical`
|
|
457
|
+
* wins both ways.
|
|
458
|
+
*/
|
|
459
|
+
function isHierarchicalFacet(name: string): boolean {
|
|
460
|
+
const flag = taxonomyDisplay[name]?.hierarchical;
|
|
461
|
+
if (typeof flag === "boolean") return flag;
|
|
462
|
+
return name === "category";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Flatten a tree into a `value → node` map so the click handler
|
|
467
|
+
* can look up the clicked node without re-walking the tree.
|
|
468
|
+
*/
|
|
469
|
+
function indexTree(nodes: FacetTreeNode[]): Map<string, FacetTreeNode> {
|
|
470
|
+
const out = new Map<string, FacetTreeNode>();
|
|
471
|
+
const visit = (n: FacetTreeNode): void => {
|
|
472
|
+
out.set(n.value, n);
|
|
473
|
+
for (const c of n.children) visit(c);
|
|
474
|
+
};
|
|
475
|
+
for (const r of nodes) visit(r);
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Recursive HTML for one tree node. Synthetic parents (`hasValue:
|
|
481
|
+
* false`) render as a clickable group header — checking them
|
|
482
|
+
* still cascades to descendants via toggleTreeNode. Indent scales
|
|
483
|
+
* with `node.depth`.
|
|
484
|
+
*/
|
|
485
|
+
function renderTreeNode(name: string, node: FacetTreeNode): string {
|
|
486
|
+
const state = computeTreeState(node, name, filters);
|
|
487
|
+
const id = `facet-${name}-${node.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
488
|
+
const indent = node.depth * 12;
|
|
489
|
+
const label = escapeHtml(node.label);
|
|
490
|
+
const countText = node.hasValue ? `${node.count}` : "";
|
|
491
|
+
const stateAttr =
|
|
492
|
+
state === "checked" ? "checked" : state === "indeterminate" ? 'data-tree-indeterminate="true"' : "";
|
|
493
|
+
const childrenHtml = node.children
|
|
494
|
+
.map((c) => renderTreeNode(name, c))
|
|
495
|
+
.join("");
|
|
496
|
+
return `
|
|
497
|
+
<li>
|
|
498
|
+
<label
|
|
499
|
+
for="${id}"
|
|
500
|
+
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent"
|
|
501
|
+
style="padding-left: ${0.5 + indent / 16}rem"
|
|
502
|
+
>
|
|
503
|
+
<input
|
|
504
|
+
type="checkbox"
|
|
505
|
+
id="${id}"
|
|
506
|
+
data-facet-name="${escapeHtml(name)}"
|
|
507
|
+
data-facet-value="${escapeHtml(node.value)}"
|
|
508
|
+
data-tree-node="true"
|
|
509
|
+
${state === "checked" ? "checked" : ""}
|
|
510
|
+
${state === "indeterminate" ? 'data-tree-indeterminate="true"' : ""}
|
|
511
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
512
|
+
/>
|
|
513
|
+
<span class="flex-1 truncate">${label}</span>
|
|
514
|
+
<span class="text-xs text-muted-foreground">${countText}</span>
|
|
515
|
+
</label>
|
|
516
|
+
${childrenHtml ? `<ul class="space-y-0.5">${childrenHtml}</ul>` : ""}
|
|
517
|
+
</li>
|
|
518
|
+
`;
|
|
519
|
+
}
|
|
520
|
+
|
|
316
521
|
/**
|
|
317
522
|
* Build the facets sidebar. Runs once after Pagefind discovers
|
|
318
523
|
* filters, then again whenever filter state changes (so checkbox
|
|
319
524
|
* `checked` reflects current selections). When the corpus has no
|
|
320
525
|
* filters, the sidebar stays hidden — single-column layout.
|
|
526
|
+
*
|
|
527
|
+
* Branches per facet: hierarchical taxonomies (and the built-in
|
|
528
|
+
* `category` default) render as a tree; flat taxonomies keep the
|
|
529
|
+
* original checkbox-list shape. The hierarchical path falls back
|
|
530
|
+
* to a flat list automatically when segment-encoded values lack a
|
|
531
|
+
* `nav` source — preserves render-ability until Phase 3 wires the
|
|
532
|
+
* nav.json fetch.
|
|
321
533
|
*/
|
|
322
534
|
function renderFacets() {
|
|
323
|
-
const facetNames =
|
|
535
|
+
const facetNames = sortFacetNames(
|
|
536
|
+
Object.keys(availableFacets),
|
|
537
|
+
taxonomyDisplay,
|
|
538
|
+
);
|
|
324
539
|
if (facetNames.length === 0) {
|
|
325
540
|
facetsBox!.classList.add("hidden");
|
|
326
541
|
return;
|
|
327
542
|
}
|
|
328
543
|
facetsBox!.classList.remove("hidden");
|
|
329
544
|
|
|
545
|
+
facetTrees.clear();
|
|
546
|
+
facetNodesByValue.clear();
|
|
547
|
+
|
|
330
548
|
const activeCount = countActiveFilters(filters);
|
|
331
549
|
const clearAll = activeCount > 0
|
|
332
550
|
? `<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
551
|
: "";
|
|
334
552
|
|
|
553
|
+
let needsNav = false;
|
|
335
554
|
const groups = facetNames
|
|
336
555
|
.map((name) => {
|
|
337
556
|
const entries = availableFacets[name];
|
|
338
557
|
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
|
-
|
|
558
|
+
const hierarchical = isHierarchicalFacet(name);
|
|
559
|
+
|
|
560
|
+
let items: string;
|
|
561
|
+
if (hierarchical) {
|
|
562
|
+
// Every hierarchical facet wants nav.json — slash-encoded
|
|
563
|
+
// for sort order at each depth (so docs follow nav rather
|
|
564
|
+
// than count-desc), segment-encoded for the tree structure
|
|
565
|
+
// itself. Without nav, slash-encoded still renders a tree
|
|
566
|
+
// (using values alone) but in count-desc order, which is
|
|
567
|
+
// wrong on corpora where one branch dwarfs the rest
|
|
568
|
+
// (openshift's rest_api section). Fire ensureNavLoaded()
|
|
569
|
+
// any time a hierarchical facet renders with navData still
|
|
570
|
+
// null — when it resolves, renderFacets re-runs and
|
|
571
|
+
// upgrades the sort.
|
|
572
|
+
if (navData === null) needsNav = true;
|
|
573
|
+
const tree = buildFacetTree(name, entries, {
|
|
574
|
+
display: taxonomyDisplay,
|
|
575
|
+
nav: navData ?? undefined,
|
|
576
|
+
});
|
|
577
|
+
facetTrees.set(name, tree);
|
|
578
|
+
facetNodesByValue.set(name, indexTree(tree));
|
|
579
|
+
items = tree.map((n) => renderTreeNode(name, n)).join("");
|
|
580
|
+
} else {
|
|
581
|
+
items = entries
|
|
582
|
+
.map((entry) => {
|
|
583
|
+
const checked = (filters[name] ?? []).includes(entry.value);
|
|
584
|
+
const label = escapeHtml(
|
|
585
|
+
resolveFacetLabel(name, entry.value, taxonomyDisplay),
|
|
586
|
+
);
|
|
587
|
+
const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
588
|
+
return `
|
|
589
|
+
<li>
|
|
590
|
+
<label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
|
|
591
|
+
<input
|
|
592
|
+
type="checkbox"
|
|
593
|
+
id="${id}"
|
|
594
|
+
data-facet-name="${escapeHtml(name)}"
|
|
595
|
+
data-facet-value="${escapeHtml(entry.value)}"
|
|
596
|
+
${checked ? "checked" : ""}
|
|
597
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
598
|
+
/>
|
|
599
|
+
<span class="flex-1 truncate">${label}</span>
|
|
600
|
+
<span class="text-xs text-muted-foreground">${entry.count}</span>
|
|
601
|
+
</label>
|
|
602
|
+
</li>
|
|
603
|
+
`;
|
|
604
|
+
})
|
|
605
|
+
.join("");
|
|
606
|
+
}
|
|
607
|
+
|
|
364
608
|
return `
|
|
365
609
|
<fieldset class="mb-3">
|
|
366
610
|
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
|
|
@@ -371,6 +615,20 @@ const {
|
|
|
371
615
|
.join("");
|
|
372
616
|
|
|
373
617
|
facetsBox!.innerHTML = clearAll + groups;
|
|
618
|
+
|
|
619
|
+
// `indeterminate` is a JS property, not an HTML attribute —
|
|
620
|
+
// can't set via innerHTML. Walk new checkboxes and set it
|
|
621
|
+
// post-paint so the visual tri-state matches state.
|
|
622
|
+
const indet = facetsBox!.querySelectorAll<HTMLInputElement>(
|
|
623
|
+
'input[type="checkbox"][data-tree-indeterminate="true"]',
|
|
624
|
+
);
|
|
625
|
+
for (const el of Array.from(indet)) el.indeterminate = true;
|
|
626
|
+
|
|
627
|
+
// Lazy nav fetch — fired only when at least one hierarchical
|
|
628
|
+
// segment-encoded facet rendered with the flat fallback. The
|
|
629
|
+
// fetch's resolution handler calls renderFacets() again so the
|
|
630
|
+
// facet upgrades to its real tree shape without user action.
|
|
631
|
+
if (needsNav) void ensureNavLoaded();
|
|
374
632
|
}
|
|
375
633
|
|
|
376
634
|
async function runSearch(query: string) {
|
|
@@ -425,9 +683,22 @@ const {
|
|
|
425
683
|
const fromUrl = parseFiltersFromUrl(new URLSearchParams(window.location.search));
|
|
426
684
|
input!.value = fromUrl.query;
|
|
427
685
|
filters = fromUrl.filters;
|
|
686
|
+
const hadUrlState = fromUrl.query.length > 0 || countActiveFilters(filters) > 0;
|
|
687
|
+
// Auto-scope: a FRESH open (no filters/query carried in the URL) on a
|
|
688
|
+
// multi-product/versioned site starts scoped to the CURRENT product +
|
|
689
|
+
// version. Seeded as ordinary facet selections, so the reader can
|
|
690
|
+
// untick "Product: calico" / "Version: 3.32" to search wider. A URL
|
|
691
|
+
// that already carries state wins (shared/roundtripped searches).
|
|
692
|
+
if (!hadUrlState) {
|
|
693
|
+
if (scopeProduct) filters.product = [scopeProduct];
|
|
694
|
+
if (scopeVersion) filters.version = [scopeVersion];
|
|
695
|
+
}
|
|
428
696
|
renderFacets();
|
|
429
697
|
|
|
430
|
-
|
|
698
|
+
// Run immediately only when there's a query or the state came from the
|
|
699
|
+
// URL. A fresh open shows the empty prompt with the scope pre-ticked —
|
|
700
|
+
// results appear (scoped) as soon as the reader types.
|
|
701
|
+
const hasInitial = hadUrlState;
|
|
431
702
|
if (hasInitial) {
|
|
432
703
|
runSearch(input!.value);
|
|
433
704
|
} else {
|
|
@@ -475,13 +746,26 @@ const {
|
|
|
475
746
|
});
|
|
476
747
|
|
|
477
748
|
// Facet checkbox toggling — event delegation on the sidebar.
|
|
749
|
+
// Tree nodes route through toggleTreeNode (parent click expands
|
|
750
|
+
// to all descendants); flat checkboxes use toggleFilter as before.
|
|
751
|
+
// We compute state from the pre-click filter, NOT from the
|
|
752
|
+
// checkbox's post-click `checked` value — the browser has already
|
|
753
|
+
// flipped it by the time `change` fires, so reading it would
|
|
754
|
+
// invert our toggle direction for indeterminate parents.
|
|
478
755
|
facetsBox.addEventListener("change", (e) => {
|
|
479
756
|
const target = e.target as HTMLInputElement;
|
|
480
757
|
if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
|
|
481
758
|
const name = target.dataset.facetName;
|
|
482
759
|
const value = target.dataset.facetValue;
|
|
483
760
|
if (!name || !value) return;
|
|
484
|
-
|
|
761
|
+
if (target.dataset.treeNode === "true") {
|
|
762
|
+
const node = facetNodesByValue.get(name)?.get(value);
|
|
763
|
+
if (!node) return;
|
|
764
|
+
const currentState = computeTreeState(node, name, filters);
|
|
765
|
+
filters = toggleTreeNode(filters, name, node, currentState);
|
|
766
|
+
} else {
|
|
767
|
+
filters = toggleFilter(filters, name, value);
|
|
768
|
+
}
|
|
485
769
|
renderFacets();
|
|
486
770
|
runSearch(input!.value);
|
|
487
771
|
syncUrl();
|
package/src/TagList.astro
CHANGED
|
@@ -48,6 +48,11 @@ interface Props {
|
|
|
48
48
|
* text. URL still uses the slug.
|
|
49
49
|
*/
|
|
50
50
|
labels?: Record<string, string>;
|
|
51
|
+
/**
|
|
52
|
+
* Give chips whose prefix has no color a stable palette color,
|
|
53
|
+
* hashed from the tag's first segment. Default false.
|
|
54
|
+
*/
|
|
55
|
+
autoColor?: boolean;
|
|
51
56
|
/** Optional aria-label override; default "Tags". */
|
|
52
57
|
label?: string;
|
|
53
58
|
class?: string;
|
|
@@ -58,11 +63,12 @@ const {
|
|
|
58
63
|
indexPath = "/tags",
|
|
59
64
|
prefixes,
|
|
60
65
|
labels,
|
|
66
|
+
autoColor = false,
|
|
61
67
|
label: ariaLabel = "Tags",
|
|
62
68
|
class: className,
|
|
63
69
|
} = Astro.props;
|
|
64
70
|
|
|
65
|
-
const chips = buildChips(tags, { indexPath, prefixes, labels });
|
|
71
|
+
const chips = buildChips(tags, { indexPath, prefixes, labels, autoColor });
|
|
66
72
|
|
|
67
73
|
/**
|
|
68
74
|
* Closed palette → Tailwind class strings. Each entry is one
|
|
@@ -72,8 +78,13 @@ const chips = buildChips(tags, { indexPath, prefixes, labels });
|
|
|
72
78
|
*/
|
|
73
79
|
const PALETTE: Record<TagPaletteName, string> = {
|
|
74
80
|
blue: "border-blue-500/40 bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
|
81
|
+
// amber-800, not -700, in light mode: amber-700 on amber-500/15 is
|
|
82
|
+
// 4.48:1 — under 4.5:1 by a hair, and failing regardless of the
|
|
83
|
+
// opacity bug below. -800 gives 6.32:1. Every other colour clears it
|
|
84
|
+
// at -700 (4.76–8.60:1). Dark mode is unaffected: text-*-300 on the
|
|
85
|
+
// same tint over a dark page is 9–12:1 across the palette.
|
|
75
86
|
amber:
|
|
76
|
-
"border-amber-500/40 bg-amber-500/15 text-amber-
|
|
87
|
+
"border-amber-500/40 bg-amber-500/15 text-amber-800 dark:text-amber-300",
|
|
77
88
|
emerald:
|
|
78
89
|
"border-emerald-500/40 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
|
79
90
|
violet:
|
|
@@ -108,7 +119,17 @@ const DEFAULT_CLASS =
|
|
|
108
119
|
>
|
|
109
120
|
{chip.prefixLabel ? (
|
|
110
121
|
<Fragment>
|
|
111
|
-
|
|
122
|
+
{/*
|
|
123
|
+
No opacity here. `opacity-70` on the prefix blended the
|
|
124
|
+
text 30% toward the chip background and put EVERY colour
|
|
125
|
+
in the palette below 4.5:1 — measured 2.75–3.95:1, worst
|
|
126
|
+
amber, best slate. It is 12px text, so 4.5:1 applies.
|
|
127
|
+
|
|
128
|
+
The prefix/leaf distinction is carried by font-weight
|
|
129
|
+
instead: the leaf below is font-semibold against the
|
|
130
|
+
anchor's font-medium. Weight costs no contrast.
|
|
131
|
+
*/}
|
|
132
|
+
<span>{chip.prefixLabel}:</span>
|
|
112
133
|
<span class="font-semibold">{chip.leafLabel}</span>
|
|
113
134
|
</Fragment>
|
|
114
135
|
) : (
|