]> git.otsuka.systems Git - cotsuka.github.io/commitdiff
implement search function (#21)
authorCameron Otsuka <2507281+cotsuka@users.noreply.github.com>
Tue, 30 Dec 2025 06:40:59 +0000 (22:40 -0800)
committerGitHub <noreply@github.com>
Tue, 30 Dec 2025 06:40:59 +0000 (22:40 -0800)
15 files changed:
astro.config.mjs
bun.lock
package.json
pagefind.yml [new file with mode: 0644]
src/components/head/base.astro
src/components/heading.astro
src/components/metadata.astro
src/components/ui/figure.astro
src/layouts/article.astro
src/pages/404.astro
src/pages/articles/[date]-[id]/index.astro
src/pages/podcasts/[id]/index.astro
src/pages/reviews/[category]/[id]/index.astro
src/pages/search.astro [new file with mode: 0644]
src/utils/globals.ts

index de05dc3a98a9f27ecbdeb55c417d6eaa9bd71987..08176cd0e34347e8c172792d92f66ad2ab9d7135 100644 (file)
@@ -27,4 +27,11 @@ export default defineConfig({
       theme: 'monokai',
     },
   },
+  vite: {
+    build: {
+      rollupOptions: {
+        external: '/pagefind/pagefind.js',
+      },
+    },
+  },
 });
index c0d060e84970eea1ad09b0712dcb65e4fe7bdab0..778239790817e8a43e0d5621b12cd7da8d8de257 100644 (file)
--- a/bun.lock
+++ b/bun.lock
@@ -16,6 +16,7 @@
         "@vercel/og": "^0.8.6",
         "astro": "5.16.6",
         "astro-icon": "^1.1.5",
+        "pagefind": "^1.4.0",
         "react": "^19.2.3",
         "react-dom": "^19.2.3",
         "recharts": "^3.6.0",
 
     "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
 
+    "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ=="],
+
+    "@pagefind/darwin-x64": ["@pagefind/darwin-x64@1.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A=="],
+
+    "@pagefind/freebsd-x64": ["@pagefind/freebsd-x64@1.4.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q=="],
+
+    "@pagefind/linux-arm64": ["@pagefind/linux-arm64@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw=="],
+
+    "@pagefind/linux-x64": ["@pagefind/linux-x64@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg=="],
+
+    "@pagefind/windows-x64": ["@pagefind/windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g=="],
+
     "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
 
     "@reduxjs/toolkit": ["@reduxjs/toolkit@2.8.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A=="],
 
     "package-manager-detector": ["package-manager-detector@1.5.0", "", {}, "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw=="],
 
+    "pagefind": ["pagefind@1.4.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
+
     "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
 
     "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
index 7a53ad45c894e5f18a66053ad66b25e9c38784f7..ab6963116457a901ecbcaea01b104e1cf9a9ac1c 100644 (file)
@@ -5,7 +5,7 @@
   "type": "module",
   "scripts": {
     "dev": "bunx --bun astro dev",
-    "build": "bunx --bun astro build",
+    "build": "bunx --bun astro build && bunx --bun pagefind",
     "preview": "bunx --bun astro preview",
     "astro": "bunx --bun astro",
     "lint": "bunx --bun eslint src/",
@@ -53,6 +53,7 @@
     "@vercel/og": "^0.8.6",
     "astro": "5.16.6",
     "astro-icon": "^1.1.5",
+    "pagefind": "^1.4.0",
     "react": "^19.2.3",
     "react-dom": "^19.2.3",
     "recharts": "^3.6.0"
diff --git a/pagefind.yml b/pagefind.yml
new file mode 100644 (file)
index 0000000..6b545ec
--- /dev/null
@@ -0,0 +1 @@
+site: dist
index 288155583f1a0d8950a9eba2d201c21263713a0b..81642d0ecfbc0b95841abc2b9cb4ac7a1d28e109 100644 (file)
@@ -35,6 +35,7 @@ const { title, description } = Astro.props;
   name="twitter:image"
   property="og:image"
   content={new URL('opengraph.png', canonicalURL)}
+  data-pagefind-default-meta="image[content]"
 />
 <meta property="og:image:type" content="image/png" />
 <meta property="og:image:width" content="1200" />
index 7298ba4b4a290952f4b9c12d2cd024e33ad14237..8f2e1ab15e84108b046dde7dfd153c212f1ecde9 100644 (file)
@@ -2,7 +2,7 @@
 import Navigation from '@components/navigation.astro';
 ---
 
-<h1><a href="/">Cameron Otsuka</a></h1>
+<h1 data-pagefind-ignore><a href="/">Cameron Otsuka</a></h1>
 <Navigation />
 
 <style>
index b862ed486f06ec4af804f41dd372568411e8110a..239b00a0effdcd344cd55b0c2aeabdb3161ad6e2 100644 (file)
@@ -14,7 +14,7 @@ const modifiedDate = entryData.modified
   : publishedDate;
 ---
 
-<details>
+<details data-pagefind-ignore>
   <summary>Metadata</summary>
   <ul>
     {entryData.description && <li>Description: {entryData.description}</li>}
@@ -26,7 +26,11 @@ const modifiedDate = entryData.modified
       )
     }
     <li>Published: <time datetime={publishedDate}>{publishedDate}</time></li>
-    <li>Last Modified: <time datetime={modifiedDate}>{modifiedDate}</time></li>
+    <li>
+      Last Modified: <time datetime={modifiedDate} data-pagefind-sort="date"
+        >{modifiedDate}</time
+      >
+    </li>
     {entryData.tags && <li>Tags: {entryData.tags.join(', ')}</li>}
     {
       entryData.posse && (
index 223f78d38de17cbe01adac16255f0b27c4012cde..ea1d91cf60373ced65c6cd9c3ec9b909b1a698f0 100644 (file)
@@ -12,7 +12,12 @@ const { image, alt, formats = ['avif', 'webp'] } = Astro.props;
 ---
 
 <figure>
-  <Picture src={image} alt={alt} formats={formats} />
+  <Picture
+    src={image}
+    alt={alt}
+    formats={formats}
+    data-pagefind-index-attrs="alt"
+  />
   <figcaption><slot /></figcaption>
 </figure>
 
index 86fdf10c67dd635505ce5bdf1d2d287dca15bb53..83bd63793cf0075e27a4e9d73fb0023e68d7f364 100644 (file)
@@ -26,7 +26,7 @@ const {
     modifiedTime={modifiedTime}
     tags={tags}
   />
-  <article>
+  <article data-pagefind-body>
     <slot />
   </article>
 </Base>
index 27946efd3079a5e0b0c84f2bbd64bbde6f113bea..124469a154712682c02219abf0d9a16cba214397 100644 (file)
@@ -4,6 +4,8 @@ import Callout from '@components/ui/callout.astro';
 ---
 
 <Base title="404" description="Not Found">
-  <Callout level="error"> 404 Not Found </Callout>
-  <p>Oops! The page you requested is not found.</p>
+  <div data-pagefind-ignore>
+    <Callout level="error"> 404 Not Found </Callout>
+    <p>Oops! The page you requested is not found.</p>
+  </div>
 </Base>
index afe253c6ea935915275aa9aba672a4262bde7745..00c0833e8babe44fee2acedcc300deb6b119d53f 100644 (file)
@@ -24,7 +24,13 @@ const { Content } = await render(entry);
     entry.data.date.toISOString()}
   tags={entry.data.tags}
 >
-  <h2>{entry.data.title}</h2>
+  <h2
+    data-pagefind-meta="title"
+    data-pagefind-ignore
+    data-pagefind-filter="category:article"
+  >
+    {entry.data.title}
+  </h2>
   <Metadata entryData={entry.data} />
   <Content />
 </Article>
index 0ff9a90f9e7717c2f76616314122835f5a5717df..0a72c2f4cf4197b125c9446d6cae91d03353cd7a 100644 (file)
@@ -23,7 +23,13 @@ const { Content } = await render(entry);
     entry.data.date.toISOString()}
   tags={entry.data.tags}
 >
-  <h2>{entry.data.title}</h2>
+  <h2
+    data-pagefind-meta="title"
+    data-pagefind-ignore
+    data-pagefind-filter="category:podcast"
+  >
+    {entry.data.title}
+  </h2>
   <Metadata entryData={entry.data} />
   <Content />
 </Article>
index 714fdc106109e16b303ada72c886efff5857cff8..29237de80a5e8462fa92cbd9c6f47dd367c1752e 100644 (file)
@@ -23,7 +23,13 @@ const { Content } = await render(entry);
     entry.data.date.toISOString()}
   tags={entry.data.tags}
 >
-  <h2>{entry.data.title}</h2>
+  <h2
+    data-pagefind-meta="title"
+    data-pagefind-ignore
+    data-pagefind-filter="category:review"
+  >
+    {entry.data.title}
+  </h2>
   <Metadata entryData={entry.data} />
   <Content />
 </Article>
diff --git a/src/pages/search.astro b/src/pages/search.astro
new file mode 100644 (file)
index 0000000..1c0fda9
--- /dev/null
@@ -0,0 +1,162 @@
+---
+import Base from '@layouts/base.astro';
+---
+
+<Base title="Search" description="Search articles, reviews, and podcasts">
+  <div data-pagefind-ignore="all">
+    <h2>Search</h2>
+    <div id="searchContainer">
+      <input
+        type="search"
+        id="searchInput"
+        placeholder="Enter query..."
+        autocomplete="off"
+        aria-label="Search site content"
+      />
+    </div>
+    <fieldset>
+      <legend>Search result filters</legend>
+      <ul>
+        <li>
+          <input
+            type="checkbox"
+            id="articleFilter"
+            name="searchFilter"
+            value="article"
+            checked
+          />
+          <label for="articleFilter">Articles</label>
+        </li>
+        <li>
+          <input
+            type="checkbox"
+            id="podcastFilter"
+            name="searchFilter"
+            value="podcast"
+            checked
+          />
+          <label for="podcastFilter">Podcasts</label>
+        </li>
+        <li>
+          <input
+            type="checkbox"
+            id="reviewFilter"
+            name="searchFilter"
+            value="review"
+            checked
+          />
+          <label for="reviewFilter">Reviews</label>
+        </li>
+      </ul>
+    </fieldset>
+    <dl id="searchResults" aria-live="polite"></dl>
+    <template id="searchResultTemplate">
+      <dt class="searchResult">
+        <a href=""></a>
+      </dt>
+      <dd class="excerpt"></dd>
+    </template>
+  </div>
+</Base>
+
+<style>
+  #searchInput {
+    width: 100%;
+  }
+  ul {
+    display: flex;
+    justify-content: space-evenly;
+    list-style: none;
+  }
+  #searchResults {
+    margin-top: 1rem;
+  }
+</style>
+
+<script>
+  const searchField = document.querySelector('#searchInput');
+  const resultsContainer = document.querySelector('#searchResults');
+  const template = document.querySelector('#searchResultTemplate');
+  const fieldset = document.querySelector('fieldset');
+  let pagefind = null;
+
+  if (!searchField || !resultsContainer || !template || !fieldset) {
+    throw new Error('Required DOM elements not found');
+  }
+
+  async function loadPagefind() {
+    if (pagefind) return pagefind;
+
+    if (import.meta.env.DEV) {
+      console.warn(
+        'Search is unavailable in dev mode. Run `bun run build` first.',
+      );
+      return null;
+    }
+
+    try {
+      const pf = await import('/pagefind/pagefind.js');
+      await pf.init();
+      pagefind = pf;
+      return;
+    } catch (error) {
+      console.error('Failed to load Pagefind:', error);
+      throw error;
+    }
+  }
+
+  function getSelectedFilters() {
+    return Array.from(
+      fieldset.querySelectorAll('input[name="searchFilter"]:checked'),
+    ).map((el) => el.value);
+  }
+
+  function renderResult(data) {
+    const clone = document.importNode(template.content, true);
+    const link = clone.querySelector('a');
+    const excerptEl = clone.querySelector('.excerpt');
+
+    link.href = data.url;
+    link.textContent = data.meta?.title || data.url;
+    excerptEl.innerHTML = data.excerpt;
+
+    return clone;
+  }
+
+  async function getSearchResults(query, filters) {
+    if (!query.trim()) {
+      resultsContainer.replaceChildren();
+      return;
+    }
+
+    try {
+      await loadPagefind();
+      if (!pagefind) return;
+
+      const options = filters.length
+        ? { filters: { category: { any: filters } } }
+        : {};
+      const response = await pagefind.debouncedSearch(query, options);
+
+      if (!response) return; // superseded by newer search
+
+      const results = await Promise.all(
+        response.results.slice(0, 10).map((r) => r.data()),
+      );
+
+      resultsContainer.replaceChildren(...results.map(renderResult));
+    } catch (error) {
+      console.error('Search failed:', error);
+    }
+  }
+
+  const triggerSearch = () =>
+    getSearchResults(searchField.value, getSelectedFilters());
+
+  searchField.addEventListener('focus', loadPagefind, { once: true });
+  searchField.addEventListener('input', triggerSearch);
+
+  fieldset.addEventListener('change', (e) => {
+    if (e.target.name === 'searchFilter') triggerSearch();
+  });
+</script>
index 34d037e162cd898029e1355c7c87e05385008a1a..9f04544b9245846ae09d31dab362fe9e6affec47 100644 (file)
@@ -18,6 +18,7 @@ export const menuItems: { title: string; url: string }[] = [
   { title: 'Articles', url: '/articles' },
   { title: 'Podcasts', url: '/podcasts' },
   { title: 'Reviews', url: '/reviews' },
+  { title: 'Search', url: '/search' },
 ];
 
 export const socials: {