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;