master
1import type {
2 PagefindSearchResult,
3 PagefindSearchResults,
4} from '@/types/PagefindSearchAPI.d.ts';
5import { t } from '@utils/i18n';
6import { type JSX, type ParentComponent, createSignal, onMount } from 'solid-js';
7import IMaterialSymboChevronRight from '~icons/material-symbols/chevron-right';
8import IMaterialSymbolSearchRounded from '~icons/material-symbols/search-rounded';
9
10type Props = {
11 inputId: string;
12 icon?: JSX.Element;
13};
14
15const bundlePath = `${import.meta.env.BASE_URL}pagefind`;
16const baseUrl = import.meta.env.BASE_URL;
17interface PagefindInstance {
18 init(): Promise<void>;
19 options(opts: { baseUrl: string; basePath: string }): Promise<void>;
20 debouncedSearch(text: string, delay: number): Promise<PagefindSearchResults> | null;
21}
22
23const [isLoading, setIsLoading] = createSignal(false);
24const [searchResults, setSearchResults] = createSignal<
25 { url: string; title: string; excerpt: string }[]
26>([]);
27const [noResults, setNoResults] = createSignal(false);
28let pagefind: PagefindInstance | null = null;
29let searchSequence = 0;
30
31async function search(text: string) {
32 const currentSequence = ++searchSequence;
33 if (!text) {
34 setIsLoading(false);
35 setSearchResults([]);
36 setNoResults(false);
37 return;
38 }
39 if (!pagefind) {
40 setIsLoading(true);
41 return;
42 }
43 setIsLoading(true);
44 try {
45 const searchResponse = await pagefind?.debouncedSearch(text, 300);
46 if (currentSequence !== searchSequence) return;
47 if (!searchResponse) {
48 setIsLoading(false);
49 return;
50 }
51 const { results } = searchResponse;
52 if (results.length === 0) {
53 setSearchResults([]);
54 setNoResults(true);
55 setIsLoading(false);
56 return;
57 }
58 setNoResults(false);
59 const processed = await Promise.all(
60 results.map(async (r: PagefindSearchResult) => {
61 const data = await r.data();
62 return { url: data.url, title: data.meta.title, excerpt: data.excerpt };
63 })
64 );
65 if (currentSequence === searchSequence) {
66 setSearchResults(processed);
67 setIsLoading(false);
68 }
69 } catch (error) {
70 console.error('Search error:', error);
71 if (currentSequence === searchSequence) {
72 setIsLoading(false);
73 setNoResults(true);
74 setSearchResults([]);
75 }
76 }
77}
78function setupSearch(inputId: string) {
79 const searchInput = document.getElementById(inputId) as HTMLInputElement | null;
80 if (!searchInput) {
81 console.error(`Pagefind: Input element with id "${inputId}" not found`);
82 return;
83 }
84 searchInput.addEventListener('input', (e) => {
85 const value = (e.target as HTMLInputElement).value.trim();
86 search(value);
87 });
88}
89
90const PagefindSearch: ParentComponent<Props> = (props) => {
91 onMount(async () => {
92 if (!pagefind) {
93 pagefind = await import(/* @vite-ignore */ `${bundlePath}/pagefind.js`);
94 if (!pagefind) {
95 console.error('Pagefind: Failed to load pagefind.js');
96 return;
97 }
98 await pagefind.options({ baseUrl, basePath: bundlePath });
99 await pagefind.init();
100 }
101 setupSearch(props.inputId);
102
103 document.addEventListener('astro:page-load', () => setupSearch(props.inputId));
104 });
105 return (
106 <div class="w-full" data-pagefind-ui>
107 <label class="input input-bordered flex w-full items-center gap-2">
108 <input
109 id="search-input"
110 type="search"
111 spellcheck="false"
112 autocorrect="off"
113 autocomplete="off"
114 autocapitalize="off"
115 class="grow"
116 placeholder={t.button.search()}
117 />
118 <IMaterialSymbolSearchRounded height="1.875rem" width="1.875rem" />
119 </label>
120 <div
121 class="search-result mt-4 flex h-fit max-h-[calc(60vh-8rem)] flex-col items-center gap-2 overflow-y-auto text-center"
122 aria-label={t.search.searchResults()}
123 tabindex="-1"
124 >
125 {isLoading()
126 ? Array.from({ length: 2 }).map((_, _i) => (
127 <div class="w-full rounded-md p-2">
128 <div class="flex flex-row items-center gap-1">
129 <span class="skeleton h-6 w-48"></span>
130 </div>
131 <div class="skeleton mt-2 h-4 w-full"></div>
132 <div class="skeleton mt-1 h-4 w-3/4"></div>
133 </div>
134 ))
135 : noResults()
136 ? t.search.noSearchResults()
137 : searchResults().map((result) => (
138 <a
139 href={result.url}
140 class="group hover:bg-primary/30 w-full rounded-md p-2 duration-150"
141 >
142 <div class="flex flex-row items-center gap-1 text-center">
143 <span class="group-hover:text-primary text-lg duration-150">
144 {result.title}
145 </span>
146 <IMaterialSymboChevronRight height="1.25rem" width="1.25rem" />
147 </div>
148 <div
149 class="text-sm opacity-60"
150 innerHTML={result.excerpt.replaceAll(
151 '<mark>',
152 '<mark class="text-primary-content bg-primary/70">'
153 )}
154 />
155 </a>
156 ))}
157 </div>
158 </div>
159 );
160};
161
162export default PagefindSearch;