@specglass/theme-default 0.0.2
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/dist/__tests__/code-tabs.test.d.ts +2 -0
- package/dist/__tests__/code-tabs.test.d.ts.map +1 -0
- package/dist/__tests__/code-tabs.test.js +219 -0
- package/dist/__tests__/code-tabs.test.js.map +1 -0
- package/dist/__tests__/copy-button.test.d.ts +2 -0
- package/dist/__tests__/copy-button.test.d.ts.map +1 -0
- package/dist/__tests__/copy-button.test.js +116 -0
- package/dist/__tests__/copy-button.test.js.map +1 -0
- package/dist/__tests__/search-palette.test.d.ts +2 -0
- package/dist/__tests__/search-palette.test.d.ts.map +1 -0
- package/dist/__tests__/search-palette.test.js +71 -0
- package/dist/__tests__/search-palette.test.js.map +1 -0
- package/dist/__tests__/shiki.test.d.ts +2 -0
- package/dist/__tests__/shiki.test.d.ts.map +1 -0
- package/dist/__tests__/shiki.test.js +37 -0
- package/dist/__tests__/shiki.test.js.map +1 -0
- package/dist/__tests__/theme-css.test.d.ts +2 -0
- package/dist/__tests__/theme-css.test.d.ts.map +1 -0
- package/dist/__tests__/theme-css.test.js +124 -0
- package/dist/__tests__/theme-css.test.js.map +1 -0
- package/dist/__tests__/theme-helpers.test.d.ts +2 -0
- package/dist/__tests__/theme-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/theme-helpers.test.js +81 -0
- package/dist/__tests__/theme-helpers.test.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/islands/CodeTabs.d.ts +21 -0
- package/dist/islands/CodeTabs.d.ts.map +1 -0
- package/dist/islands/CodeTabs.js +125 -0
- package/dist/islands/CodeTabs.js.map +1 -0
- package/dist/islands/CopyButton.d.ts +16 -0
- package/dist/islands/CopyButton.d.ts.map +1 -0
- package/dist/islands/CopyButton.js +54 -0
- package/dist/islands/CopyButton.js.map +1 -0
- package/dist/islands/SearchPalette.d.ts +2 -0
- package/dist/islands/SearchPalette.d.ts.map +1 -0
- package/dist/islands/SearchPalette.js +109 -0
- package/dist/islands/SearchPalette.js.map +1 -0
- package/dist/islands/SearchResults.d.ts +2 -0
- package/dist/islands/SearchResults.d.ts.map +1 -0
- package/dist/islands/SearchResults.js +130 -0
- package/dist/islands/SearchResults.js.map +1 -0
- package/dist/islands/ThemeToggle.d.ts +12 -0
- package/dist/islands/ThemeToggle.d.ts.map +1 -0
- package/dist/islands/ThemeToggle.js +43 -0
- package/dist/islands/ThemeToggle.js.map +1 -0
- package/dist/layouts/DocPage.test.d.ts +2 -0
- package/dist/layouts/DocPage.test.d.ts.map +1 -0
- package/dist/layouts/DocPage.test.js +165 -0
- package/dist/layouts/DocPage.test.js.map +1 -0
- package/dist/lib/utils.d.ts +10 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +13 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/scripts/code-block-enhancer.d.ts +16 -0
- package/dist/scripts/code-block-enhancer.d.ts.map +1 -0
- package/dist/scripts/code-block-enhancer.js +55 -0
- package/dist/scripts/code-block-enhancer.js.map +1 -0
- package/dist/ui/command.d.ts +87 -0
- package/dist/ui/command.d.ts.map +1 -0
- package/dist/ui/command.js +28 -0
- package/dist/ui/command.js.map +1 -0
- package/dist/ui/dialog.d.ts +20 -0
- package/dist/ui/dialog.d.ts.map +1 -0
- package/dist/ui/dialog.js +22 -0
- package/dist/ui/dialog.js.map +1 -0
- package/dist/utils/parse-highlight-range.d.ts +12 -0
- package/dist/utils/parse-highlight-range.d.ts.map +1 -0
- package/dist/utils/parse-highlight-range.js +40 -0
- package/dist/utils/parse-highlight-range.js.map +1 -0
- package/dist/utils/parse-highlight-range.test.d.ts +2 -0
- package/dist/utils/parse-highlight-range.test.d.ts.map +1 -0
- package/dist/utils/parse-highlight-range.test.js +32 -0
- package/dist/utils/parse-highlight-range.test.js.map +1 -0
- package/dist/utils/schema-renderer.d.ts +38 -0
- package/dist/utils/schema-renderer.d.ts.map +1 -0
- package/dist/utils/schema-renderer.js +115 -0
- package/dist/utils/schema-renderer.js.map +1 -0
- package/dist/utils/schema-renderer.test.d.ts +2 -0
- package/dist/utils/schema-renderer.test.d.ts.map +1 -0
- package/dist/utils/schema-renderer.test.js +219 -0
- package/dist/utils/schema-renderer.test.js.map +1 -0
- package/dist/utils/shiki.d.ts +20 -0
- package/dist/utils/shiki.d.ts.map +1 -0
- package/dist/utils/shiki.js +84 -0
- package/dist/utils/shiki.js.map +1 -0
- package/dist/utils/sidebar-helpers.d.ts +10 -0
- package/dist/utils/sidebar-helpers.d.ts.map +1 -0
- package/dist/utils/sidebar-helpers.js +14 -0
- package/dist/utils/sidebar-helpers.js.map +1 -0
- package/dist/utils/theme-css.d.ts +21 -0
- package/dist/utils/theme-css.d.ts.map +1 -0
- package/dist/utils/theme-css.js +77 -0
- package/dist/utils/theme-css.js.map +1 -0
- package/dist/utils/theme-helpers.d.ts +28 -0
- package/dist/utils/theme-helpers.d.ts.map +1 -0
- package/dist/utils/theme-helpers.js +55 -0
- package/dist/utils/theme-helpers.js.map +1 -0
- package/dist/utils/toc-helpers.d.ts +12 -0
- package/dist/utils/toc-helpers.d.ts.map +1 -0
- package/dist/utils/toc-helpers.js +9 -0
- package/dist/utils/toc-helpers.js.map +1 -0
- package/package.json +68 -0
- package/src/components/ApiAuth.astro +116 -0
- package/src/components/ApiEndpoint.astro +75 -0
- package/src/components/ApiNavigation.astro +110 -0
- package/src/components/ApiParameters.astro +204 -0
- package/src/components/ApiResponse.astro +144 -0
- package/src/components/Callout.astro +54 -0
- package/src/components/Card.astro +46 -0
- package/src/components/CodeBlock.astro +142 -0
- package/src/components/CodeBlockGroup.astro +196 -0
- package/src/components/CodeTabs.astro +53 -0
- package/src/components/Footer.astro +41 -0
- package/src/components/Header.astro +80 -0
- package/src/components/Sidebar.astro +117 -0
- package/src/components/TabItem.astro +24 -0
- package/src/components/TableOfContents.astro +111 -0
- package/src/components/Tabs.astro +185 -0
- package/src/islands/CodeTabs.tsx +212 -0
- package/src/islands/CopyButton.tsx +101 -0
- package/src/islands/SearchPalette.tsx +307 -0
- package/src/islands/SearchResults.tsx +301 -0
- package/src/islands/ThemeToggle.tsx +107 -0
- package/src/layouts/ApiReferencePage.astro +239 -0
- package/src/layouts/DocPage.astro +199 -0
- package/src/layouts/DocPage.test.ts +183 -0
- package/src/layouts/LandingPage.astro +143 -0
- package/src/lib/utils.ts +13 -0
- package/src/styles/global.css +241 -0
- package/src/utils/parse-highlight-range.test.ts +40 -0
- package/src/utils/parse-highlight-range.ts +41 -0
- package/src/utils/schema-renderer.test.ts +269 -0
- package/src/utils/schema-renderer.ts +152 -0
- package/src/utils/shiki.ts +99 -0
- package/src/utils/sidebar-helpers.ts +24 -0
- package/src/utils/theme-css.ts +101 -0
- package/src/utils/theme-helpers.ts +59 -0
- package/src/utils/toc-helpers.ts +11 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import {
|
|
5
|
+
CommandDialog,
|
|
6
|
+
CommandInput,
|
|
7
|
+
CommandList,
|
|
8
|
+
CommandEmpty,
|
|
9
|
+
CommandItem,
|
|
10
|
+
} from "../ui/command";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* SearchPalette — Cmd+K command palette using shadcn/ui CommandDialog.
|
|
14
|
+
*
|
|
15
|
+
* Uses the proper shadcn/ui Command + Dialog composition:
|
|
16
|
+
* - CommandDialog wraps Radix Dialog for overlay, portal, focus trap, ARIA
|
|
17
|
+
* - CommandInput, CommandList, CommandItem from shadcn command component
|
|
18
|
+
* - Pagefind provides search (cmdk's shouldFilter disabled)
|
|
19
|
+
*
|
|
20
|
+
* Designed for `client:idle` hydration.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Shape of a Pagefind search result */
|
|
24
|
+
interface PagefindResult {
|
|
25
|
+
id: string;
|
|
26
|
+
url: string;
|
|
27
|
+
excerpt: string;
|
|
28
|
+
meta: {
|
|
29
|
+
title?: string;
|
|
30
|
+
image?: string;
|
|
31
|
+
};
|
|
32
|
+
sub_results?: Array<{
|
|
33
|
+
title: string;
|
|
34
|
+
url: string;
|
|
35
|
+
excerpt: string;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PagefindResponse {
|
|
40
|
+
results: Array<{
|
|
41
|
+
id: string;
|
|
42
|
+
data: () => Promise<PagefindResult>;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PagefindAPI {
|
|
47
|
+
init: () => Promise<void>;
|
|
48
|
+
search: (query: string) => Promise<PagefindResponse>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEBOUNCE_MS = 150;
|
|
52
|
+
const MAX_RESULTS = 8;
|
|
53
|
+
|
|
54
|
+
export function SearchPalette() {
|
|
55
|
+
const [open, setOpen] = useState(false);
|
|
56
|
+
const [query, setQuery] = useState("");
|
|
57
|
+
const [results, setResults] = useState<PagefindResult[]>([]);
|
|
58
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
59
|
+
const [isUnavailable, setIsUnavailable] = useState(false);
|
|
60
|
+
const pagefindRef = useRef<PagefindAPI | null>(null);
|
|
61
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
62
|
+
|
|
63
|
+
// Load Pagefind JS lazily on palette open
|
|
64
|
+
const loadPagefind = useCallback(async () => {
|
|
65
|
+
if (pagefindRef.current) return;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const pagefindPath = `/pagefind/pagefind.js`;
|
|
69
|
+
const pf = await import(/* @vite-ignore */ pagefindPath);
|
|
70
|
+
await pf.init();
|
|
71
|
+
pagefindRef.current = pf as PagefindAPI;
|
|
72
|
+
} catch {
|
|
73
|
+
setIsUnavailable(true);
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Perform search
|
|
78
|
+
const performSearch = useCallback(
|
|
79
|
+
async (searchQuery: string) => {
|
|
80
|
+
if (!searchQuery.trim()) {
|
|
81
|
+
setResults([]);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!pagefindRef.current) {
|
|
86
|
+
await loadPagefind();
|
|
87
|
+
if (!pagefindRef.current) return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setIsLoading(true);
|
|
91
|
+
try {
|
|
92
|
+
const response = await pagefindRef.current.search(searchQuery);
|
|
93
|
+
const loaded = await Promise.all(
|
|
94
|
+
response.results.slice(0, MAX_RESULTS).map((r) => r.data()),
|
|
95
|
+
);
|
|
96
|
+
setResults(loaded);
|
|
97
|
+
} catch {
|
|
98
|
+
setResults([]);
|
|
99
|
+
} finally {
|
|
100
|
+
setIsLoading(false);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
[loadPagefind],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Handle input changes with debounce
|
|
107
|
+
const handleValueChange = useCallback(
|
|
108
|
+
(value: string) => {
|
|
109
|
+
setQuery(value);
|
|
110
|
+
if (debounceRef.current) {
|
|
111
|
+
clearTimeout(debounceRef.current);
|
|
112
|
+
}
|
|
113
|
+
debounceRef.current = setTimeout(() => {
|
|
114
|
+
performSearch(value);
|
|
115
|
+
}, DEBOUNCE_MS);
|
|
116
|
+
},
|
|
117
|
+
[performSearch],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Global Cmd+K / Ctrl+K keyboard shortcut
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
123
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
setOpen((prev) => !prev);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
130
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
// Load Pagefind when palette opens
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (open) {
|
|
136
|
+
loadPagefind();
|
|
137
|
+
}
|
|
138
|
+
}, [open, loadPagefind]);
|
|
139
|
+
|
|
140
|
+
// Reset state when closing
|
|
141
|
+
const handleOpenChange = useCallback((isOpen: boolean) => {
|
|
142
|
+
setOpen(isOpen);
|
|
143
|
+
if (!isOpen) {
|
|
144
|
+
setTimeout(() => {
|
|
145
|
+
setQuery("");
|
|
146
|
+
setResults([]);
|
|
147
|
+
}, 150);
|
|
148
|
+
}
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
// Navigate to a search result
|
|
152
|
+
const handleSelect = useCallback(
|
|
153
|
+
(url: string) => {
|
|
154
|
+
handleOpenChange(false);
|
|
155
|
+
window.location.href = url;
|
|
156
|
+
},
|
|
157
|
+
[handleOpenChange],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Cleanup debounce on unmount
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
return () => {
|
|
163
|
+
if (debounceRef.current) {
|
|
164
|
+
clearTimeout(debounceRef.current);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div data-pagefind-ignore="">
|
|
171
|
+
{/* Trigger button */}
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
onClick={() => setOpen(true)}
|
|
175
|
+
className={cn(
|
|
176
|
+
"inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md",
|
|
177
|
+
"border border-border bg-surface text-text-muted",
|
|
178
|
+
"hover:bg-hover-bg hover:text-text transition-colors",
|
|
179
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
180
|
+
)}
|
|
181
|
+
aria-label="Search documentation (⌘K)"
|
|
182
|
+
>
|
|
183
|
+
<svg
|
|
184
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
185
|
+
width="14"
|
|
186
|
+
height="14"
|
|
187
|
+
viewBox="0 0 24 24"
|
|
188
|
+
fill="none"
|
|
189
|
+
stroke="currentColor"
|
|
190
|
+
strokeWidth="2"
|
|
191
|
+
strokeLinecap="round"
|
|
192
|
+
strokeLinejoin="round"
|
|
193
|
+
aria-hidden="true"
|
|
194
|
+
className="shrink-0"
|
|
195
|
+
>
|
|
196
|
+
<circle cx="11" cy="11" r="8" />
|
|
197
|
+
<path d="m21 21-4.3-4.3" />
|
|
198
|
+
</svg>
|
|
199
|
+
<span className="hidden sm:inline">Search…</span>
|
|
200
|
+
<kbd
|
|
201
|
+
className={cn(
|
|
202
|
+
"hidden sm:inline-flex items-center gap-0.5",
|
|
203
|
+
"rounded border border-border bg-surface-raised px-1.5 py-0.5",
|
|
204
|
+
"font-mono text-[10px] text-text-muted",
|
|
205
|
+
)}
|
|
206
|
+
>
|
|
207
|
+
⌘K
|
|
208
|
+
</kbd>
|
|
209
|
+
</button>
|
|
210
|
+
|
|
211
|
+
{/* shadcn/ui CommandDialog */}
|
|
212
|
+
<CommandDialog open={open} onOpenChange={handleOpenChange} shouldFilter={false} dialogTitle="Search documentation">
|
|
213
|
+
<CommandInput
|
|
214
|
+
value={query}
|
|
215
|
+
onValueChange={handleValueChange}
|
|
216
|
+
placeholder="Search documentation…"
|
|
217
|
+
/>
|
|
218
|
+
|
|
219
|
+
<CommandList>
|
|
220
|
+
{/* Dev-mode unavailable state */}
|
|
221
|
+
{isUnavailable && (
|
|
222
|
+
<div className="px-4 py-6 text-center text-sm text-text-muted">
|
|
223
|
+
<p className="mb-1">Search available after build</p>
|
|
224
|
+
<p className="text-xs opacity-70">
|
|
225
|
+
Run <code className="bg-surface-raised px-1 rounded">npx turbo build</code> to generate the search index
|
|
226
|
+
</p>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{/* Loading state */}
|
|
231
|
+
{isLoading && !isUnavailable && (
|
|
232
|
+
<div className="px-4 py-6 text-center text-sm text-text-muted">
|
|
233
|
+
Searching…
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{/* Empty state */}
|
|
238
|
+
{!isLoading && !isUnavailable && query.trim().length > 0 && (
|
|
239
|
+
<CommandEmpty>
|
|
240
|
+
No results found for “{query}”
|
|
241
|
+
</CommandEmpty>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{/* Prompt state */}
|
|
245
|
+
{!isUnavailable && !isLoading && query.trim().length === 0 && results.length === 0 && (
|
|
246
|
+
<div className="px-4 py-6 text-center text-sm text-text-muted">
|
|
247
|
+
Type to search documentation…
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{/* Results */}
|
|
252
|
+
{!isUnavailable &&
|
|
253
|
+
results.map((result) => (
|
|
254
|
+
<CommandItem
|
|
255
|
+
key={result.id}
|
|
256
|
+
value={result.id}
|
|
257
|
+
onSelect={() => handleSelect(result.url)}
|
|
258
|
+
className="flex flex-col items-start gap-1 px-3 py-2.5"
|
|
259
|
+
>
|
|
260
|
+
{/* Page title */}
|
|
261
|
+
<div className="font-medium text-text">
|
|
262
|
+
{result.meta?.title ?? "Untitled"}
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
{/* Sub-result heading */}
|
|
266
|
+
{result.sub_results?.[0]?.title &&
|
|
267
|
+
result.sub_results[0].title !== result.meta?.title && (
|
|
268
|
+
<div className="text-xs text-text-muted">
|
|
269
|
+
{result.sub_results[0].title}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{/* Content snippet */}
|
|
274
|
+
<div
|
|
275
|
+
className="text-xs text-text-muted line-clamp-2 [&_mark]:bg-primary/20 [&_mark]:text-text [&_mark]:rounded-sm [&_mark]:px-0.5"
|
|
276
|
+
dangerouslySetInnerHTML={{
|
|
277
|
+
__html:
|
|
278
|
+
result.sub_results?.[0]?.excerpt ?? result.excerpt ?? "",
|
|
279
|
+
}}
|
|
280
|
+
/>
|
|
281
|
+
</CommandItem>
|
|
282
|
+
))}
|
|
283
|
+
</CommandList>
|
|
284
|
+
|
|
285
|
+
{/* Footer with keyboard hints */}
|
|
286
|
+
{(results.length > 0 || query.trim().length > 0) && !isUnavailable && (
|
|
287
|
+
<div className="flex items-center justify-between border-t border-border px-3 py-2 text-xs text-text-muted">
|
|
288
|
+
<div className="flex items-center gap-3">
|
|
289
|
+
<span className="inline-flex items-center gap-1">
|
|
290
|
+
<kbd className="rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px]">↑↓</kbd>
|
|
291
|
+
navigate
|
|
292
|
+
</span>
|
|
293
|
+
<span className="inline-flex items-center gap-1">
|
|
294
|
+
<kbd className="rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px]">↵</kbd>
|
|
295
|
+
open
|
|
296
|
+
</span>
|
|
297
|
+
<span className="inline-flex items-center gap-1">
|
|
298
|
+
<kbd className="rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px]">esc</kbd>
|
|
299
|
+
close
|
|
300
|
+
</span>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
</CommandDialog>
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SearchResults — React island for Pagefind-powered documentation search.
|
|
5
|
+
*
|
|
6
|
+
* Provides a search input with debounced querying against the Pagefind index.
|
|
7
|
+
* Results display page title, section heading, and highlighted content snippets.
|
|
8
|
+
* Designed for `client:idle` hydration — search isn't needed immediately on load.
|
|
9
|
+
*
|
|
10
|
+
* Pagefind JS is loaded dynamically at runtime from /pagefind/pagefind.js
|
|
11
|
+
* (generated at build time by the Pagefind integration in @specglass/core).
|
|
12
|
+
*
|
|
13
|
+
* During dev server, the Pagefind index doesn't exist — handled gracefully
|
|
14
|
+
* with a "Search available after build" message.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Shape of a Pagefind search result (subset of the Pagefind API) */
|
|
18
|
+
interface PagefindResult {
|
|
19
|
+
id: string;
|
|
20
|
+
url: string;
|
|
21
|
+
excerpt: string;
|
|
22
|
+
meta: {
|
|
23
|
+
title?: string;
|
|
24
|
+
image?: string;
|
|
25
|
+
};
|
|
26
|
+
sub_results?: Array<{
|
|
27
|
+
title: string;
|
|
28
|
+
url: string;
|
|
29
|
+
excerpt: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PagefindResponse {
|
|
34
|
+
results: Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
data: () => Promise<PagefindResult>;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PagefindAPI {
|
|
41
|
+
init: () => Promise<void>;
|
|
42
|
+
search: (query: string) => Promise<PagefindResponse>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEBOUNCE_MS = 150;
|
|
46
|
+
const MAX_RESULTS = 8;
|
|
47
|
+
|
|
48
|
+
export function SearchResults() {
|
|
49
|
+
const [query, setQuery] = useState("");
|
|
50
|
+
const [results, setResults] = useState<PagefindResult[]>([]);
|
|
51
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
52
|
+
const [isUnavailable, setIsUnavailable] = useState(false);
|
|
53
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
54
|
+
const pagefindRef = useRef<PagefindAPI | null>(null);
|
|
55
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
56
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
57
|
+
const resultsRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
59
|
+
|
|
60
|
+
// Load Pagefind JS lazily on first focus
|
|
61
|
+
const loadPagefind = useCallback(async () => {
|
|
62
|
+
if (pagefindRef.current) return;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Pagefind generates this file at build time in the output directory.
|
|
66
|
+
// Dynamic concatenation prevents Vite from statically analyzing the path
|
|
67
|
+
// (the index only exists after `astro build`, not during dev).
|
|
68
|
+
const pagefindPath = `/pagefind/pagefind.js`;
|
|
69
|
+
const pf = await import(/* @vite-ignore */ pagefindPath);
|
|
70
|
+
await pf.init();
|
|
71
|
+
pagefindRef.current = pf as PagefindAPI;
|
|
72
|
+
} catch {
|
|
73
|
+
// During dev server, the index doesn't exist
|
|
74
|
+
setIsUnavailable(true);
|
|
75
|
+
}
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
// Perform search with debouncing
|
|
79
|
+
const performSearch = useCallback(
|
|
80
|
+
async (searchQuery: string) => {
|
|
81
|
+
if (!searchQuery.trim()) {
|
|
82
|
+
setResults([]);
|
|
83
|
+
setSelectedIndex(-1);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!pagefindRef.current) {
|
|
88
|
+
await loadPagefind();
|
|
89
|
+
if (!pagefindRef.current) return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setIsLoading(true);
|
|
93
|
+
try {
|
|
94
|
+
const response = await pagefindRef.current.search(searchQuery);
|
|
95
|
+
|
|
96
|
+
// Load full data for top results
|
|
97
|
+
const loaded = await Promise.all(
|
|
98
|
+
response.results.slice(0, MAX_RESULTS).map((r) => r.data()),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
setResults(loaded);
|
|
102
|
+
setSelectedIndex(-1);
|
|
103
|
+
} catch {
|
|
104
|
+
setResults([]);
|
|
105
|
+
} finally {
|
|
106
|
+
setIsLoading(false);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[loadPagefind],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Handle input changes with debounce
|
|
113
|
+
const handleInputChange = useCallback(
|
|
114
|
+
(value: string) => {
|
|
115
|
+
setQuery(value);
|
|
116
|
+
|
|
117
|
+
if (debounceRef.current) {
|
|
118
|
+
clearTimeout(debounceRef.current);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
debounceRef.current = setTimeout(() => {
|
|
122
|
+
performSearch(value);
|
|
123
|
+
}, DEBOUNCE_MS);
|
|
124
|
+
},
|
|
125
|
+
[performSearch],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Keyboard navigation
|
|
129
|
+
const handleKeyDown = useCallback(
|
|
130
|
+
(e: React.KeyboardEvent) => {
|
|
131
|
+
if (e.key === "ArrowDown") {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
|
|
134
|
+
} else if (e.key === "ArrowUp") {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
setSelectedIndex((prev) => Math.max(prev - 1, -1));
|
|
137
|
+
} else if (e.key === "Enter" && selectedIndex >= 0) {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
const result = results[selectedIndex];
|
|
140
|
+
if (result) {
|
|
141
|
+
window.location.href = result.url;
|
|
142
|
+
}
|
|
143
|
+
} else if (e.key === "Escape") {
|
|
144
|
+
setQuery("");
|
|
145
|
+
setResults([]);
|
|
146
|
+
setSelectedIndex(-1);
|
|
147
|
+
inputRef.current?.blur();
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
[results, selectedIndex],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Click outside to close
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
156
|
+
if (
|
|
157
|
+
resultsRef.current &&
|
|
158
|
+
!resultsRef.current.contains(e.target as Node) &&
|
|
159
|
+
inputRef.current &&
|
|
160
|
+
!inputRef.current.contains(e.target as Node)
|
|
161
|
+
) {
|
|
162
|
+
setIsFocused(false);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
167
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
// Cleanup debounce on unmount
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
return () => {
|
|
173
|
+
if (debounceRef.current) {
|
|
174
|
+
clearTimeout(debounceRef.current);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
const showResults = isFocused && query.trim().length > 0;
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="relative" data-pagefind-ignore="">
|
|
183
|
+
{/* Search input */}
|
|
184
|
+
<div className="relative">
|
|
185
|
+
<svg
|
|
186
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
187
|
+
width="16"
|
|
188
|
+
height="16"
|
|
189
|
+
viewBox="0 0 24 24"
|
|
190
|
+
fill="none"
|
|
191
|
+
stroke="currentColor"
|
|
192
|
+
strokeWidth="2"
|
|
193
|
+
strokeLinecap="round"
|
|
194
|
+
strokeLinejoin="round"
|
|
195
|
+
aria-hidden="true"
|
|
196
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
|
|
197
|
+
>
|
|
198
|
+
<circle cx="11" cy="11" r="8" />
|
|
199
|
+
<path d="m21 21-4.3-4.3" />
|
|
200
|
+
</svg>
|
|
201
|
+
<input
|
|
202
|
+
ref={inputRef}
|
|
203
|
+
type="search"
|
|
204
|
+
value={query}
|
|
205
|
+
onChange={(e) => handleInputChange(e.target.value)}
|
|
206
|
+
onFocus={() => {
|
|
207
|
+
setIsFocused(true);
|
|
208
|
+
loadPagefind();
|
|
209
|
+
}}
|
|
210
|
+
onKeyDown={handleKeyDown}
|
|
211
|
+
placeholder="Search docs…"
|
|
212
|
+
aria-label="Search documentation"
|
|
213
|
+
aria-expanded={showResults}
|
|
214
|
+
aria-controls="search-results"
|
|
215
|
+
aria-autocomplete="list"
|
|
216
|
+
aria-activedescendant={
|
|
217
|
+
selectedIndex >= 0
|
|
218
|
+
? `search-result-${results[selectedIndex]?.id}`
|
|
219
|
+
: undefined
|
|
220
|
+
}
|
|
221
|
+
role="combobox"
|
|
222
|
+
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-md border border-border bg-surface text-text placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors"
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{/* Results dropdown */}
|
|
227
|
+
{showResults && (
|
|
228
|
+
<div
|
|
229
|
+
ref={resultsRef}
|
|
230
|
+
id="search-results"
|
|
231
|
+
role="listbox"
|
|
232
|
+
aria-label="Search results"
|
|
233
|
+
className="absolute top-full left-0 right-0 mt-1 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface shadow-lg z-50"
|
|
234
|
+
style={{ minWidth: "320px" }}
|
|
235
|
+
>
|
|
236
|
+
{/* Unavailable state (dev server) */}
|
|
237
|
+
{isUnavailable && (
|
|
238
|
+
<div className="px-4 py-3 text-sm text-text-muted">
|
|
239
|
+
Search available after build
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Loading state */}
|
|
244
|
+
{isLoading && !isUnavailable && (
|
|
245
|
+
<div className="px-4 py-3 text-sm text-text-muted">
|
|
246
|
+
Searching…
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{/* No results */}
|
|
251
|
+
{!isLoading && !isUnavailable && results.length === 0 && (
|
|
252
|
+
<div className="px-4 py-3 text-sm text-text-muted">
|
|
253
|
+
No results found
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Results list */}
|
|
258
|
+
{!isUnavailable &&
|
|
259
|
+
results.map((result, index) => (
|
|
260
|
+
<a
|
|
261
|
+
key={result.id}
|
|
262
|
+
id={`search-result-${result.id}`}
|
|
263
|
+
href={result.url}
|
|
264
|
+
role="option"
|
|
265
|
+
aria-selected={index === selectedIndex}
|
|
266
|
+
className={`block px-4 py-3 text-sm no-underline border-b border-border last:border-b-0 transition-colors ${
|
|
267
|
+
index === selectedIndex
|
|
268
|
+
? "bg-hover-bg"
|
|
269
|
+
: "hover:bg-hover-bg"
|
|
270
|
+
}`}
|
|
271
|
+
>
|
|
272
|
+
{/* Page title */}
|
|
273
|
+
<div className="font-medium text-text mb-0.5">
|
|
274
|
+
{result.meta?.title ?? "Untitled"}
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* Sub-result heading (section) */}
|
|
278
|
+
{result.sub_results?.[0]?.title &&
|
|
279
|
+
result.sub_results[0].title !== result.meta?.title && (
|
|
280
|
+
<div className="text-xs text-text-muted mb-1">
|
|
281
|
+
{result.sub_results[0].title}
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{/* Content snippet with highlighted terms.
|
|
286
|
+
Pagefind generates sanitized HTML with <mark> tags for term highlighting.
|
|
287
|
+
ACCEPTED RISK: trusting Pagefind's build-time sanitization. */}
|
|
288
|
+
<div
|
|
289
|
+
className="text-xs text-text-muted line-clamp-2 [&_mark]:bg-primary/20 [&_mark]:text-text [&_mark]:rounded-sm [&_mark]:px-0.5"
|
|
290
|
+
dangerouslySetInnerHTML={{
|
|
291
|
+
__html:
|
|
292
|
+
result.sub_results?.[0]?.excerpt ?? result.excerpt ?? "",
|
|
293
|
+
}}
|
|
294
|
+
/>
|
|
295
|
+
</a>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|