@dogsbay/docs-layout 0.2.0-beta.0
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 +59 -0
- package/src/DocsFooter.astro +96 -0
- package/src/DocsHeader.astro +41 -0
- package/src/DocsLayout.astro +884 -0
- package/src/DocsNav.astro +62 -0
- package/src/DocsSidebar.astro +35 -0
- package/src/DocsToc.astro +50 -0
- package/src/LocaleSwitcher.astro +87 -0
- package/src/PageActions.astro +281 -0
- package/src/SearchDialog.astro +529 -0
- package/src/StatusBadge.astro +79 -0
- package/src/TagList.astro +124 -0
- package/src/TaxonomyIndex.astro +148 -0
- package/src/TaxonomyTerm.astro +181 -0
- package/src/TypeBadge.astro +63 -0
- package/src/VersionSwitcher.astro +86 -0
- package/src/json-ld.ts +55 -0
- package/src/llm-actions.ts +128 -0
- package/src/markdown-negotiation.ts +76 -0
- package/src/nav-filter.ts +166 -0
- package/src/pagination.ts +39 -0
- package/src/search-facets.ts +232 -0
- package/src/switcher.ts +138 -0
- package/src/tag-list-data.ts +202 -0
- package/src/toc-kind.css +52 -0
- package/src/version-redirect.ts +147 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Search dialog wired to Pagefind.
|
|
4
|
+
*
|
|
5
|
+
* Loads `/{pagefindUrl}/pagefind.js` lazily on first open. No JavaScript ships
|
|
6
|
+
* to the page until the user actually opens search (Cmd+K / Ctrl+K / `/`),
|
|
7
|
+
* so the cost is paid only by users who search.
|
|
8
|
+
*
|
|
9
|
+
* Pagefind itself is generated by the post-build step
|
|
10
|
+
* `pagefind --site dist` declared in the project's package.json.
|
|
11
|
+
*
|
|
12
|
+
* Faceted filtering: when the indexed Pagefind data has filters
|
|
13
|
+
* (`<div data-pagefind-filter="...">` elements emitted by DocsLayout
|
|
14
|
+
* for tagged pages), the dialog grows a checkbox column on the left
|
|
15
|
+
* for narrowing results by tag / type / status / audience / category.
|
|
16
|
+
* Active filters mirror to URL params so a filtered search is
|
|
17
|
+
* shareable. When the corpus has no filters, the dialog renders the
|
|
18
|
+
* pre-facets layout — single column, byte-identical to the v1.
|
|
19
|
+
*
|
|
20
|
+
* Display labels for facet checkboxes flow from
|
|
21
|
+
* `siteConfig.taxonomyDisplay` — the same map that drives chip
|
|
22
|
+
* rendering, so "Concept: Accessibility" appears in both places.
|
|
23
|
+
*
|
|
24
|
+
* Styling: uses Dogsbay theme tokens (popover / accent / muted-foreground).
|
|
25
|
+
* Drop-in dark/light mode via the existing theme.
|
|
26
|
+
*/
|
|
27
|
+
import type { TaxonomyDisplay } from "@dogsbay/types";
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
/**
|
|
31
|
+
* Path prefix where Pagefind's index lives. Default: "/pagefind/".
|
|
32
|
+
* Override when a site uses a non-root base path (e.g. "/docs/pagefind/").
|
|
33
|
+
*/
|
|
34
|
+
pagefindUrl?: string;
|
|
35
|
+
/** Placeholder text for the search input */
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Per-taxonomy display config (`prefixes` + `labels`). When set,
|
|
39
|
+
* facet checkboxes render with human labels ("Concept:
|
|
40
|
+
* Accessibility") instead of raw slugs (`concept/a11y`). Fall
|
|
41
|
+
* back to slugs when undefined.
|
|
42
|
+
*/
|
|
43
|
+
taxonomyDisplay?: Record<string, TaxonomyDisplay>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
pagefindUrl = "/pagefind/",
|
|
48
|
+
placeholder = "Search docs...",
|
|
49
|
+
taxonomyDisplay,
|
|
50
|
+
} = Astro.props;
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
<dialog
|
|
54
|
+
data-search-dialog
|
|
55
|
+
data-pagefind-url={pagefindUrl}
|
|
56
|
+
data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
|
|
57
|
+
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
|
+
>
|
|
59
|
+
<form method="dialog" class="flex flex-col">
|
|
60
|
+
<div class="flex items-center gap-2 border-b border-border px-4">
|
|
61
|
+
<svg
|
|
62
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
63
|
+
width="16"
|
|
64
|
+
height="16"
|
|
65
|
+
viewBox="0 0 24 24"
|
|
66
|
+
fill="none"
|
|
67
|
+
stroke="currentColor"
|
|
68
|
+
stroke-width="2"
|
|
69
|
+
stroke-linecap="round"
|
|
70
|
+
stroke-linejoin="round"
|
|
71
|
+
class="size-4 shrink-0 text-muted-foreground"
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
>
|
|
74
|
+
<circle cx="11" cy="11" r="8" />
|
|
75
|
+
<path d="m21 21-4.3-4.3" />
|
|
76
|
+
</svg>
|
|
77
|
+
|
|
78
|
+
<input
|
|
79
|
+
type="search"
|
|
80
|
+
data-search-input
|
|
81
|
+
placeholder={placeholder}
|
|
82
|
+
autocomplete="off"
|
|
83
|
+
spellcheck="false"
|
|
84
|
+
class="flex h-12 w-full rounded-md bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
85
|
+
aria-label="Search documentation"
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<kbd class="rounded border border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
|
89
|
+
Esc
|
|
90
|
+
</kbd>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/*
|
|
94
|
+
Two-pane layout: facets column + results. The facets column
|
|
95
|
+
hides when no filters are present in the index (single-column
|
|
96
|
+
mode falls back to v1 behavior). Mobile: facets collapse into
|
|
97
|
+
a disclosure above the results.
|
|
98
|
+
*/}
|
|
99
|
+
<div data-search-body class="flex max-h-[60vh]">
|
|
100
|
+
<aside
|
|
101
|
+
data-search-facets
|
|
102
|
+
class="hidden w-56 shrink-0 overflow-y-auto border-r border-border p-3 text-sm"
|
|
103
|
+
aria-label="Filter results"
|
|
104
|
+
>
|
|
105
|
+
{/* Populated at runtime once Pagefind reports filter list */}
|
|
106
|
+
</aside>
|
|
107
|
+
|
|
108
|
+
<div
|
|
109
|
+
data-search-results
|
|
110
|
+
class="flex-1 overflow-y-auto p-2 text-sm"
|
|
111
|
+
aria-live="polite"
|
|
112
|
+
>
|
|
113
|
+
<div data-search-empty class="px-3 py-8 text-center text-muted-foreground">
|
|
114
|
+
Start typing to search...
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</form>
|
|
119
|
+
</dialog>
|
|
120
|
+
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
data-search-trigger
|
|
124
|
+
class="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
125
|
+
aria-label="Open search"
|
|
126
|
+
>
|
|
127
|
+
<svg
|
|
128
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
129
|
+
width="14"
|
|
130
|
+
height="14"
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
stroke-width="2"
|
|
135
|
+
stroke-linecap="round"
|
|
136
|
+
stroke-linejoin="round"
|
|
137
|
+
aria-hidden="true"
|
|
138
|
+
>
|
|
139
|
+
<circle cx="11" cy="11" r="8" />
|
|
140
|
+
<path d="m21 21-4.3-4.3" />
|
|
141
|
+
</svg>
|
|
142
|
+
<span class="hidden sm:inline">Search</span>
|
|
143
|
+
<kbd class="hidden rounded border border-border px-1.5 py-0.5 font-mono text-[10px] sm:inline">
|
|
144
|
+
⌘K
|
|
145
|
+
</kbd>
|
|
146
|
+
</button>
|
|
147
|
+
|
|
148
|
+
<script>
|
|
149
|
+
/**
|
|
150
|
+
* Pagefind search wiring.
|
|
151
|
+
*
|
|
152
|
+
* The pagefind module is loaded the first time the dialog opens. Subsequent
|
|
153
|
+
* opens reuse it. Nothing is loaded for users who never search.
|
|
154
|
+
*/
|
|
155
|
+
import {
|
|
156
|
+
shapeFacets,
|
|
157
|
+
resolveFacetLabel,
|
|
158
|
+
resolveFacetTitle,
|
|
159
|
+
filterStateToUrlParams,
|
|
160
|
+
parseFiltersFromUrl,
|
|
161
|
+
filterStateToPagefindFilters,
|
|
162
|
+
toggleFilter,
|
|
163
|
+
countActiveFilters,
|
|
164
|
+
type FacetMap,
|
|
165
|
+
type FilterState,
|
|
166
|
+
type TaxonomyDisplayMap,
|
|
167
|
+
} from "./search-facets.js";
|
|
168
|
+
|
|
169
|
+
type PagefindResult = {
|
|
170
|
+
id: string;
|
|
171
|
+
data(): Promise<PagefindResultData>;
|
|
172
|
+
};
|
|
173
|
+
type PagefindResultData = {
|
|
174
|
+
url: string;
|
|
175
|
+
raw_url: string;
|
|
176
|
+
excerpt: string;
|
|
177
|
+
meta: { title?: string };
|
|
178
|
+
sub_results?: Array<{ title: string; url: string; excerpt: string }>;
|
|
179
|
+
};
|
|
180
|
+
type PagefindModule = {
|
|
181
|
+
search(
|
|
182
|
+
query: string,
|
|
183
|
+
options?: { filters?: Record<string, string[]> },
|
|
184
|
+
): Promise<{ results: PagefindResult[] }>;
|
|
185
|
+
filters(): Promise<Record<string, Record<string, number>>>;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const dialog = document.querySelector<HTMLDialogElement>("[data-search-dialog]");
|
|
189
|
+
const trigger = document.querySelector<HTMLButtonElement>("[data-search-trigger]");
|
|
190
|
+
const input = dialog?.querySelector<HTMLInputElement>("[data-search-input]");
|
|
191
|
+
const resultsBox = dialog?.querySelector<HTMLDivElement>("[data-search-results]");
|
|
192
|
+
const emptyState = dialog?.querySelector<HTMLDivElement>("[data-search-empty]");
|
|
193
|
+
const facetsBox = dialog?.querySelector<HTMLElement>("[data-search-facets]");
|
|
194
|
+
|
|
195
|
+
if (!dialog || !trigger || !input || !resultsBox || !facetsBox) {
|
|
196
|
+
// Component not present on this page — bail.
|
|
197
|
+
} else {
|
|
198
|
+
let pagefind: PagefindModule | null = null;
|
|
199
|
+
let loadingPagefind: Promise<void> | null = null;
|
|
200
|
+
let activeQueryToken = 0;
|
|
201
|
+
|
|
202
|
+
// Active filter state. Updated by checkbox toggles, the
|
|
203
|
+
// URL-restore step on dialog open, and "Clear all".
|
|
204
|
+
let filters: FilterState = {};
|
|
205
|
+
let availableFacets: FacetMap = {};
|
|
206
|
+
|
|
207
|
+
// Display config baked into a data attribute by the Astro
|
|
208
|
+
// template — parsed lazily.
|
|
209
|
+
const taxonomyDisplay: TaxonomyDisplayMap = (() => {
|
|
210
|
+
try {
|
|
211
|
+
const raw = dialog!.dataset.taxonomyDisplay;
|
|
212
|
+
return raw ? (JSON.parse(raw) as TaxonomyDisplayMap) : {};
|
|
213
|
+
} catch {
|
|
214
|
+
return {};
|
|
215
|
+
}
|
|
216
|
+
})();
|
|
217
|
+
|
|
218
|
+
async function ensurePagefindLoaded() {
|
|
219
|
+
if (pagefind) return;
|
|
220
|
+
if (loadingPagefind) return loadingPagefind;
|
|
221
|
+
const url = (dialog!.dataset.pagefindUrl ?? "/pagefind/") + "pagefind.js";
|
|
222
|
+
loadingPagefind = (async () => {
|
|
223
|
+
try {
|
|
224
|
+
const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
|
|
225
|
+
pagefind = mod;
|
|
226
|
+
// Discover facets once, on first load. Pagefind builds
|
|
227
|
+
// this from the body-level data-pagefind-filter elements.
|
|
228
|
+
if (typeof mod.filters === "function") {
|
|
229
|
+
const raw = await mod.filters();
|
|
230
|
+
availableFacets = shapeFacets(raw);
|
|
231
|
+
renderFacets();
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn("[dogsbay] failed to load Pagefind:", err);
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
return loadingPagefind;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function escapeHtml(s: string): string {
|
|
241
|
+
return s
|
|
242
|
+
.replace(/&/g, "&")
|
|
243
|
+
.replace(/</g, "<")
|
|
244
|
+
.replace(/>/g, ">");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Append a text fragment (`#:~:text=...`) so the browser highlights
|
|
249
|
+
* matched terms on arrival. If the URL already has a fragment (e.g.
|
|
250
|
+
* `#section-id` from Pagefind sub-results), append using `&text=` per
|
|
251
|
+
* the spec. Falls back to no fragment if the query is empty.
|
|
252
|
+
*
|
|
253
|
+
* Browser support: Chrome, Edge, Firefox 131+, Safari 18.2+.
|
|
254
|
+
* Where unsupported, the fragment is harmless — page just doesn't
|
|
255
|
+
* highlight.
|
|
256
|
+
*/
|
|
257
|
+
function withTextFragment(url: string, query: string): string {
|
|
258
|
+
const trimmed = query.trim();
|
|
259
|
+
if (!trimmed) return url;
|
|
260
|
+
// Pagefind tokenizes the query; for highlighting we want whole-phrase
|
|
261
|
+
// matches plus individual terms. Browsers can take multiple text=
|
|
262
|
+
// fragments separated by `&`.
|
|
263
|
+
const terms = trimmed
|
|
264
|
+
.split(/\s+/)
|
|
265
|
+
.filter((t) => t.length >= 2) // skip 1-char filler tokens
|
|
266
|
+
.slice(0, 5) // cap at 5 terms (URL length)
|
|
267
|
+
.map((t) => encodeURIComponent(t));
|
|
268
|
+
if (terms.length === 0) return url;
|
|
269
|
+
const fragment = "text=" + terms.join("&text=");
|
|
270
|
+
const sep = url.includes("#") ? "&" : "#:~:";
|
|
271
|
+
return url + sep + fragment;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderResults(results: Array<PagefindResultData>, query: string) {
|
|
275
|
+
if (!results.length) {
|
|
276
|
+
resultsBox!.innerHTML = `<div class="px-3 py-8 text-center text-muted-foreground">No results found.</div>`;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const html = results
|
|
280
|
+
.map((r) => {
|
|
281
|
+
const title = escapeHtml(r.meta?.title ?? r.url);
|
|
282
|
+
// Page-level link with text fragment for top-of-page highlighting
|
|
283
|
+
const pageHref = withTextFragment(r.url, query);
|
|
284
|
+
// Sub-results: section-level matches with their own anchor URLs.
|
|
285
|
+
// We render them under the main result so users can jump straight
|
|
286
|
+
// to a specific heading. Each sub-result URL already has a fragment
|
|
287
|
+
// (e.g. /docs/foo/#section-id), so withTextFragment uses & instead
|
|
288
|
+
// of #:~: to combine the two.
|
|
289
|
+
const subHtml = (r.sub_results ?? [])
|
|
290
|
+
.slice(0, 3)
|
|
291
|
+
.map((sub) => `
|
|
292
|
+
<a href="${withTextFragment(sub.url, query)}" class="block rounded-md py-1.5 pl-6 pr-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground">
|
|
293
|
+
<div class="flex items-center gap-1.5">
|
|
294
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0" aria-hidden="true">
|
|
295
|
+
<polyline points="9 18 15 12 9 6"></polyline>
|
|
296
|
+
</svg>
|
|
297
|
+
<span class="truncate">${escapeHtml(sub.title)}</span>
|
|
298
|
+
</div>
|
|
299
|
+
</a>
|
|
300
|
+
`)
|
|
301
|
+
.join("");
|
|
302
|
+
return `
|
|
303
|
+
<div class="mb-1">
|
|
304
|
+
<a href="${pageHref}" class="block rounded-md p-3 hover:bg-accent">
|
|
305
|
+
<div class="font-medium">${title}</div>
|
|
306
|
+
<div class="mt-1 line-clamp-2 text-xs text-muted-foreground [&_mark]:rounded-sm [&_mark]:bg-primary/20 [&_mark]:px-0.5 [&_mark]:font-medium [&_mark]:text-foreground">${r.excerpt}</div>
|
|
307
|
+
</a>
|
|
308
|
+
${subHtml}
|
|
309
|
+
</div>
|
|
310
|
+
`;
|
|
311
|
+
})
|
|
312
|
+
.join("");
|
|
313
|
+
resultsBox!.innerHTML = html;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build the facets sidebar. Runs once after Pagefind discovers
|
|
318
|
+
* filters, then again whenever filter state changes (so checkbox
|
|
319
|
+
* `checked` reflects current selections). When the corpus has no
|
|
320
|
+
* filters, the sidebar stays hidden — single-column layout.
|
|
321
|
+
*/
|
|
322
|
+
function renderFacets() {
|
|
323
|
+
const facetNames = Object.keys(availableFacets);
|
|
324
|
+
if (facetNames.length === 0) {
|
|
325
|
+
facetsBox!.classList.add("hidden");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
facetsBox!.classList.remove("hidden");
|
|
329
|
+
|
|
330
|
+
const activeCount = countActiveFilters(filters);
|
|
331
|
+
const clearAll = activeCount > 0
|
|
332
|
+
? `<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
|
+
: "";
|
|
334
|
+
|
|
335
|
+
const groups = facetNames
|
|
336
|
+
.map((name) => {
|
|
337
|
+
const entries = availableFacets[name];
|
|
338
|
+
const title = escapeHtml(resolveFacetTitle(name));
|
|
339
|
+
const items = entries
|
|
340
|
+
.map((entry) => {
|
|
341
|
+
const checked = (filters[name] ?? []).includes(entry.value);
|
|
342
|
+
const label = escapeHtml(
|
|
343
|
+
resolveFacetLabel(name, entry.value, taxonomyDisplay),
|
|
344
|
+
);
|
|
345
|
+
const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
346
|
+
return `
|
|
347
|
+
<li>
|
|
348
|
+
<label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
|
|
349
|
+
<input
|
|
350
|
+
type="checkbox"
|
|
351
|
+
id="${id}"
|
|
352
|
+
data-facet-name="${escapeHtml(name)}"
|
|
353
|
+
data-facet-value="${escapeHtml(entry.value)}"
|
|
354
|
+
${checked ? "checked" : ""}
|
|
355
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
356
|
+
/>
|
|
357
|
+
<span class="flex-1 truncate">${label}</span>
|
|
358
|
+
<span class="text-xs text-muted-foreground">${entry.count}</span>
|
|
359
|
+
</label>
|
|
360
|
+
</li>
|
|
361
|
+
`;
|
|
362
|
+
})
|
|
363
|
+
.join("");
|
|
364
|
+
return `
|
|
365
|
+
<fieldset class="mb-3">
|
|
366
|
+
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
|
|
367
|
+
<ul class="space-y-0.5">${items}</ul>
|
|
368
|
+
</fieldset>
|
|
369
|
+
`;
|
|
370
|
+
})
|
|
371
|
+
.join("");
|
|
372
|
+
|
|
373
|
+
facetsBox!.innerHTML = clearAll + groups;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function runSearch(query: string) {
|
|
377
|
+
const trimmed = query.trim();
|
|
378
|
+
const hasActiveFilters = countActiveFilters(filters) > 0;
|
|
379
|
+
if (!trimmed && !hasActiveFilters) {
|
|
380
|
+
if (emptyState) emptyState.style.display = "";
|
|
381
|
+
resultsBox!.innerHTML = "";
|
|
382
|
+
if (emptyState) resultsBox!.appendChild(emptyState);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
await ensurePagefindLoaded();
|
|
386
|
+
if (!pagefind) {
|
|
387
|
+
resultsBox!.innerHTML = `<div class="px-3 py-8 text-center text-muted-foreground">Search index unavailable. Run <code>pnpm build</code> to generate it.</div>`;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const token = ++activeQueryToken;
|
|
391
|
+
// Filter-only searches (empty query, active filters): Pagefind
|
|
392
|
+
// accepts an empty / null query and returns the full filtered
|
|
393
|
+
// result set.
|
|
394
|
+
const search = await pagefind.search(trimmed || null as unknown as string, {
|
|
395
|
+
filters: filterStateToPagefindFilters(filters),
|
|
396
|
+
});
|
|
397
|
+
// Bail if a newer query has started while we were awaiting
|
|
398
|
+
if (token !== activeQueryToken) return;
|
|
399
|
+
const data = await Promise.all(search.results.slice(0, 8).map((r) => r.data()));
|
|
400
|
+
if (token !== activeQueryToken) return;
|
|
401
|
+
renderResults(data, trimmed);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Mirror current state to the URL via replaceState so reload /
|
|
406
|
+
* paste preserves it. Only mutates the URL while the dialog is
|
|
407
|
+
* open; closing the dialog leaves the state in place so users
|
|
408
|
+
* can copy the link AFTER searching.
|
|
409
|
+
*/
|
|
410
|
+
function syncUrl() {
|
|
411
|
+
const params = filterStateToUrlParams(input!.value, filters);
|
|
412
|
+
const newUrl = params.toString().length > 0
|
|
413
|
+
? `${window.location.pathname}?${params}${window.location.hash}`
|
|
414
|
+
: `${window.location.pathname}${window.location.hash}`;
|
|
415
|
+
window.history.replaceState({}, "", newUrl);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function openDialog() {
|
|
419
|
+
// Prefetch index in the background; user might be slow to type
|
|
420
|
+
ensurePagefindLoaded();
|
|
421
|
+
dialog!.showModal();
|
|
422
|
+
|
|
423
|
+
// Restore from URL params on open. Keeps the dialog roundtrippable
|
|
424
|
+
// — paste a saved URL, open search, see the prior query + filters.
|
|
425
|
+
const fromUrl = parseFiltersFromUrl(new URLSearchParams(window.location.search));
|
|
426
|
+
input!.value = fromUrl.query;
|
|
427
|
+
filters = fromUrl.filters;
|
|
428
|
+
renderFacets();
|
|
429
|
+
|
|
430
|
+
const hasInitial = input!.value.length > 0 || countActiveFilters(filters) > 0;
|
|
431
|
+
if (hasInitial) {
|
|
432
|
+
runSearch(input!.value);
|
|
433
|
+
} else {
|
|
434
|
+
if (emptyState) {
|
|
435
|
+
resultsBox!.innerHTML = "";
|
|
436
|
+
resultsBox!.appendChild(emptyState);
|
|
437
|
+
emptyState.style.display = "";
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Focus after the dialog is in the layout tree
|
|
441
|
+
requestAnimationFrame(() => input!.focus());
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
trigger.addEventListener("click", openDialog);
|
|
445
|
+
|
|
446
|
+
// Keyboard shortcut: Cmd+K / Ctrl+K / `/` (when not focused on a form field)
|
|
447
|
+
document.addEventListener("keydown", (e) => {
|
|
448
|
+
const target = e.target as HTMLElement;
|
|
449
|
+
const inField =
|
|
450
|
+
target.tagName === "INPUT" ||
|
|
451
|
+
target.tagName === "TEXTAREA" ||
|
|
452
|
+
target.isContentEditable;
|
|
453
|
+
|
|
454
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
455
|
+
e.preventDefault();
|
|
456
|
+
if (dialog!.open) {
|
|
457
|
+
dialog!.close();
|
|
458
|
+
} else {
|
|
459
|
+
openDialog();
|
|
460
|
+
}
|
|
461
|
+
} else if (e.key === "/" && !inField && !dialog!.open) {
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
openDialog();
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Debounced query
|
|
468
|
+
let debounceTimer: number | undefined;
|
|
469
|
+
input.addEventListener("input", () => {
|
|
470
|
+
clearTimeout(debounceTimer);
|
|
471
|
+
debounceTimer = window.setTimeout(() => {
|
|
472
|
+
runSearch(input!.value);
|
|
473
|
+
syncUrl();
|
|
474
|
+
}, 120);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Facet checkbox toggling — event delegation on the sidebar.
|
|
478
|
+
facetsBox.addEventListener("change", (e) => {
|
|
479
|
+
const target = e.target as HTMLInputElement;
|
|
480
|
+
if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
|
|
481
|
+
const name = target.dataset.facetName;
|
|
482
|
+
const value = target.dataset.facetValue;
|
|
483
|
+
if (!name || !value) return;
|
|
484
|
+
filters = toggleFilter(filters, name, value);
|
|
485
|
+
renderFacets();
|
|
486
|
+
runSearch(input!.value);
|
|
487
|
+
syncUrl();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// "Clear all" button (rendered when filters are active).
|
|
491
|
+
facetsBox.addEventListener("click", (e) => {
|
|
492
|
+
const target = e.target as HTMLElement;
|
|
493
|
+
const clear = target.closest<HTMLButtonElement>("[data-clear-filters]");
|
|
494
|
+
if (!clear) return;
|
|
495
|
+
filters = {};
|
|
496
|
+
renderFacets();
|
|
497
|
+
runSearch(input!.value);
|
|
498
|
+
syncUrl();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Click outside to close
|
|
502
|
+
dialog.addEventListener("click", (e) => {
|
|
503
|
+
if (e.target === dialog) dialog!.close();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Close when a result is clicked. For cross-page links the browser
|
|
507
|
+
// unloads the dialog implicitly, but for same-page hash navigation
|
|
508
|
+
// (e.g. clicking a sub-result heading anchor on the current page) the
|
|
509
|
+
// dialog otherwise stays open with no visible state change.
|
|
510
|
+
// Don't preventDefault — let the browser handle the actual navigation.
|
|
511
|
+
resultsBox.addEventListener("click", (e) => {
|
|
512
|
+
const target = e.target as HTMLElement;
|
|
513
|
+
const link = target.closest("a");
|
|
514
|
+
if (link && resultsBox!.contains(link)) {
|
|
515
|
+
dialog!.close();
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
</script>
|
|
520
|
+
|
|
521
|
+
<style is:global>
|
|
522
|
+
/* Theme-matching highlight for text fragments arriving via #:~:text=...
|
|
523
|
+
The browser default is a flat yellow that clashes with custom themes;
|
|
524
|
+
this inherits from --primary so it adapts to light/dark. */
|
|
525
|
+
::target-text {
|
|
526
|
+
background-color: oklch(from var(--primary) l c h / 0.3);
|
|
527
|
+
color: inherit;
|
|
528
|
+
}
|
|
529
|
+
</style>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* StatusBadge — surfaces page lifecycle state.
|
|
4
|
+
*
|
|
5
|
+
* Closed enum: `draft | preview | stable | deprecated`. Renders
|
|
6
|
+
* nothing for `stable` (the default state — no visual noise on
|
|
7
|
+
* shipped pages).
|
|
8
|
+
*
|
|
9
|
+
* `deprecated` and `draft` get strong colors; `preview` is muted.
|
|
10
|
+
*
|
|
11
|
+
* When `href` is set, the badge renders as a link (e.g. to a
|
|
12
|
+
* `/by-status/<value>/` browse page). `<DocsLayout>` computes the
|
|
13
|
+
* href from `siteConfig.taxonomyIndexPaths.status` when the user
|
|
14
|
+
* has declared `taxonomies.status:` in their config.
|
|
15
|
+
*/
|
|
16
|
+
type Status = "draft" | "preview" | "stable" | "deprecated";
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
status?: Status;
|
|
20
|
+
/** Optional link target — renders the badge as `<a>` when set. */
|
|
21
|
+
href?: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { status, href, class: className } = Astro.props;
|
|
26
|
+
|
|
27
|
+
// Pre-compute the style + label strings in the script block. Doing
|
|
28
|
+
// the lookup in JSX with a typed cast (e.g. `styles[status as Exclude<Status, "stable">]`)
|
|
29
|
+
// trips esbuild — the `<` in the generic argument is parsed as JSX.
|
|
30
|
+
function styleFor(s: Status): string {
|
|
31
|
+
switch (s) {
|
|
32
|
+
case "draft":
|
|
33
|
+
return "border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300";
|
|
34
|
+
case "preview":
|
|
35
|
+
return "border-blue-500/40 bg-blue-500/15 text-blue-700 dark:text-blue-300";
|
|
36
|
+
case "deprecated":
|
|
37
|
+
return "border-destructive/50 bg-destructive/15 text-destructive";
|
|
38
|
+
default:
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function labelFor(s: Status): string {
|
|
44
|
+
switch (s) {
|
|
45
|
+
case "draft":
|
|
46
|
+
return "Draft";
|
|
47
|
+
case "preview":
|
|
48
|
+
return "Preview";
|
|
49
|
+
case "deprecated":
|
|
50
|
+
return "Deprecated";
|
|
51
|
+
default:
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const visible = status !== undefined && status !== "stable";
|
|
57
|
+
const style = status ? styleFor(status) : "";
|
|
58
|
+
const label = status ? labelFor(status) : "";
|
|
59
|
+
const baseClasses = "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide";
|
|
60
|
+
const linkClasses = "transition-colors hover:opacity-80";
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
{visible && href && (
|
|
64
|
+
<a
|
|
65
|
+
href={href}
|
|
66
|
+
class:list={[baseClasses, linkClasses, style, className]}
|
|
67
|
+
data-status={status}
|
|
68
|
+
>
|
|
69
|
+
{label}
|
|
70
|
+
</a>
|
|
71
|
+
)}
|
|
72
|
+
{visible && !href && (
|
|
73
|
+
<span
|
|
74
|
+
class:list={[baseClasses, style, className]}
|
|
75
|
+
data-status={status}
|
|
76
|
+
>
|
|
77
|
+
{label}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|