]> git.otsuka.systems Git - cotsuka.github.io/commitdiff
use pagefind components
authorCameron Otsuka <cameron@otsuka.haus>
Sat, 11 Apr 2026 03:13:30 +0000 (20:13 -0700)
committerCameron Otsuka <cameron@otsuka.haus>
Sat, 11 Apr 2026 03:13:30 +0000 (20:13 -0700)
bun.lock
package.json
src/pages/articles/[date]-[id]/index.astro
src/pages/bitcoin.astro
src/pages/index.astro
src/pages/podcasts/[id]/index.astro
src/pages/reviews/[category]/[id]/index.astro
src/pages/search.astro
src/styles/style.css

index 090929e71b630d6640b4c1e25746f4555de9ab61..64a5fea038aacfd56928d96682b484326baf1a9d 100644 (file)
--- a/bun.lock
+++ b/bun.lock
@@ -9,23 +9,24 @@
         "@astrojs/rss": "4.0.18",
         "@astrojs/sitemap": "3.7.2",
         "@iconify-json/mdi": "^1.2.3",
+        "@pagefind/component-ui": "^1.5.0",
         "@vercel/og": "^0.8.6",
         "astro": "6.1.5",
         "astro-icon": "^1.1.5",
-        "pagefind": "^1.4.0",
+        "pagefind": "^1.5.0",
       },
       "devDependencies": {
         "@astrojs/check": "0.9.8",
         "@eslint/js": "^9.39.4",
-        "@types/bun": "^1.3.10",
+        "@types/bun": "^1.3.12",
         "eslint": "^9.39.4",
-        "eslint-plugin-astro": "^1.6.0",
+        "eslint-plugin-astro": "^1.7.0",
         "lint-staged": "^16.4.0",
-        "prettier": "^3.8.1",
+        "prettier": "^3.8.2",
         "prettier-plugin-astro": "^0.14.1",
         "simple-git-hooks": "^2.13.1",
         "typescript": "^5.9.3",
-        "typescript-eslint": "^8.57.1",
+        "typescript-eslint": "^8.58.1",
       },
     },
   },
 
     "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
 
+    "@pagefind/component-ui": ["@pagefind/component-ui@1.5.0", "", { "dependencies": { "adequate-little-templates": "^1.0.2", "bcp-47": "^2.1.0" } }, "sha512-lOyk1+7x5Ds0TkAXXR0o6XYITtzmx9QjtrhiPNxI7zuwmO6tr0fqaxTengz3LtvvhHoXawW/h8BEHaTaQMTegQ=="],
+
     "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXQVlxALU9+Lz/LxkAa+RvaxY1cnRKUDCuwl9o8PY5Lg/znP573y4WIbVOOIz8Bv7uj7r00TGy7pD+NSLMJGBw=="],
 
     "@pagefind/darwin-x64": ["@pagefind/darwin-x64@1.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+LK1Xq5n/B0hHc08DW61SnfIlfLKyXZ1oKcbfZ1MimE7Rn0Q6R0VI/TlC04f/JDPm+67zAOwPGizzvefOi5vqQ=="],
 
     "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
 
+    "adequate-little-templates": ["adequate-little-templates@1.0.2", "", {}, "sha512-d0tFFG538l3Y4CElRKtticzrMr/OGohs32/nf3Ewn8rbTMBYwkVNe8mPdXWrDnMlz+ZVUFQjsTmsBD5zCtU4wg=="],
+
     "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
 
     "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
 
     "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
 
+    "bcp-47": ["bcp-47@2.1.0", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w=="],
+
     "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
 
     "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
index 0fb9264f8b732145cda7ed8ce2090bf99c65be8b..815106ed236d364357b6c7713c27c1d50f431ec7 100644 (file)
@@ -47,6 +47,7 @@
     "@astrojs/rss": "4.0.18",
     "@astrojs/sitemap": "3.7.2",
     "@iconify-json/mdi": "^1.2.3",
+    "@pagefind/component-ui": "^1.5.0",
     "@vercel/og": "^0.8.6",
     "astro": "6.1.5",
     "astro-icon": "^1.1.5",
index 00c0833e8babe44fee2acedcc300deb6b119d53f..ded22932b9d7c730cab9797f949f6b6c9a4bea4f 100644 (file)
@@ -24,11 +24,7 @@ const { Content } = await render(entry);
     entry.data.date.toISOString()}
   tags={entry.data.tags}
 >
-  <h2
-    data-pagefind-meta="title"
-    data-pagefind-ignore
-    data-pagefind-filter="category:article"
-  >
+  <h2 data-pagefind-meta="title" data-pagefind-filter="category:article">
     {entry.data.title}
   </h2>
   <Metadata entryData={entry.data} />
index 4f000fd15e5c1d20366dd4cf3650a3e3afa4a163..37562640223c963c89a36a2011f53427be3107ed 100644 (file)
@@ -3,13 +3,15 @@ import Base from '@layouts/base.astro';
 ---
 
 <Base title="Bitcoin" description="Cameron Otsuka's Bitcoin resources.">
-  <h2>Bitcoin</h2>
-  <object data="/public/bitcoin.pdf" type="application/pdf" height="890">
-    <p>
-      Your browser doesn't support embedded PDFs.
-      <a href="/public/bitcoin.pdf">Download the PDF</a> instead.
-    </p>
-  </object>
+  <article data-pagefind-body data-pagefind-filter="category:page">
+    <h2 data-pagefind-meta="title">Bitcoin</h2>
+    <object data="/public/bitcoin.pdf" type="application/pdf" height="890">
+      <p>
+        Your browser doesn't support embedded PDFs.
+        <a href="/public/bitcoin.pdf">Download the PDF</a> instead.
+      </p>
+    </object>
+  </article>
 </Base>
 
 <style>
index f96a4be3cd41314eeec184b3bc1d79e7b807045a..ff32067cc398b73a99225d16ba46b92352e3e2a4 100644 (file)
@@ -29,28 +29,34 @@ const contributions = [
   title="Home"
   description="Personal site of Cameron Otsuka, Head of Data and Analytics at Build Asset Management. Writing on Bitcoin, cryptography, privacy, security, and technology."
 >
-  <section>
-    <p>
-      I am <strong>Head of Data and Analytics</strong> at Build Asset Management,
-      where I've helped launch a <a href="https://buildbitcoin.com/"
-        >private credit fund</a
-      > investing into over-collateralized <a href="/bitcoin/">bitcoin</a
-      >-backed loans, a <a href="https://bfix.fund/">fixed income ETF</a> and related
-      vehicles, and built the internal tech stack that glues everything together.
-    </p>
-  </section>
-  <section>
-    <h2>Contributions</h2>
-    <ul>
-      {
-        contributions.map((contribution) => {
-          return (
-            <li>
-              <a href={contribution.url}>{contribution.title}</a>
-            </li>
-          );
-        })
-      }
-    </ul>
-  </section>
+  <article
+    data-pagefind-meta="title:Home"
+    data-pagefind-body
+    data-pagefind-filter="category:page"
+  >
+    <section>
+      <p>
+        I am <strong>Head of Data and Analytics</strong> at Build Asset Management,
+        where I've helped launch a <a href="https://buildbitcoin.com/"
+          >private credit fund</a
+        > investing into over-collateralized <a href="/bitcoin/">bitcoin</a
+        >-backed loans, a <a href="https://bfix.fund/">fixed income ETF</a> and related
+        vehicles, and built the internal tech stack that glues everything together.
+      </p>
+    </section>
+    <section data-pagefind-body>
+      <h2>Contributions</h2>
+      <ul>
+        {
+          contributions.map((contribution) => {
+            return (
+              <li>
+                <a href={contribution.url}>{contribution.title}</a>
+              </li>
+            );
+          })
+        }
+      </ul>
+    </section>
+  </article>
 </Base>
index 0a72c2f4cf4197b125c9446d6cae91d03353cd7a..877de38e9ac20c4170bb2805365d1f35837c1c39 100644 (file)
@@ -23,11 +23,7 @@ const { Content } = await render(entry);
     entry.data.date.toISOString()}
   tags={entry.data.tags}
 >
-  <h2
-    data-pagefind-meta="title"
-    data-pagefind-ignore
-    data-pagefind-filter="category:podcast"
-  >
+  <h2 data-pagefind-meta="title" data-pagefind-filter="category:podcast">
     {entry.data.title}
   </h2>
   <Metadata entryData={entry.data} />
index 29237de80a5e8462fa92cbd9c6f47dd367c1752e..e98d605555fa22da32e7b10520c9d815d120bbfb 100644 (file)
@@ -23,11 +23,7 @@ const { Content } = await render(entry);
     entry.data.date.toISOString()}
   tags={entry.data.tags}
 >
-  <h2
-    data-pagefind-meta="title"
-    data-pagefind-ignore
-    data-pagefind-filter="category:review"
-  >
+  <h2 data-pagefind-meta="title" data-pagefind-filter="category:review">
     {entry.data.title}
   </h2>
   <Metadata entryData={entry.data} />
index 32c48517c62e0269fbe174c6c28efde538846cfa..2d1b64b1086bde87e9b5b0202d986ddb53bd9f18 100644 (file)
 ---
 import Base from '@layouts/base.astro';
+
+const pagefindResultTemplate = String.raw`
+  <li class="searchResult">
+    <h4 class="searchResultTitle">
+      <a
+        class="searchResultLink"
+        href="{{ meta.url | safeUrl }}"
+      >{{ meta.title }}</a>
+    </h4>
+    <p class="searchResultExcerpt">{{+ excerpt +}}</p>
+  </li>
+`;
 ---
 
 <Base title="Search" description="Search articles, reviews, and podcasts">
-  <div data-pagefind-ignore>
+  <script>
+    import '@pagefind/component-ui';
+    import '@pagefind/component-ui/css';
+  </script>
+
+  <section class="searchUi" data-pagefind-ignore>
     <h2>Search</h2>
-    <div id="searchContainer">
-      <input
-        type="search"
-        id="searchInput"
-        placeholder="Enter query..."
-        autocomplete="off"
-        autofocus
-      />
+    <pagefind-config preload></pagefind-config>
+
+    <pagefind-input autofocus placeholder="Enter query..."></pagefind-input>
+
+    <div class="searchFilters" aria-label="Search result filters">
+      <pagefind-filter-dropdown filter="category" label="Category"
+      ></pagefind-filter-dropdown>
+      <pagefind-filter-dropdown
+        filter="tag"
+        label="Tag"
+        sort="alphabetical"
+        wrap></pagefind-filter-dropdown>
     </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"></dl>
-    <template id="searchResultTemplate">
-      <dt class="searchResult">
-        <h4><a href=""></a></h4>
-      </dt>
-      <dd class="excerpt"></dd>
-    </template>
-  </div>
+
+    <pagefind-summary default-message="Type to search the site."
+    ></pagefind-summary>
+
+    <pagefind-results max-results="10" hide-sub-results>
+      <script
+        type="text/pagefind-template"
+        set:html={pagefindResultTemplate}
+        is:inline
+      />
+    </pagefind-results>
+  </section>
 </Base>
 
 <style>
-  #searchInput {
-    width: 100%;
-  }
-  ul {
-    display: flex;
-    justify-content: space-evenly;
-    list-style: none;
+  .searchUi {
+    --pf-text: var(--color-text);
+    --pf-text-secondary: var(--color-text);
+    --pf-text-muted: var(--color-text);
+    --pf-background: var(--color-bg);
+    --pf-border: var(--color-border);
+    --pf-border-focus: var(--color-link);
+    --pf-hover: none;
+    --pf-outline-focus: var(--color-link);
+    --pf-skeleton: var(--color-text);
+    --pf-skeleton-shine: var(--color-link);
+    --pf-shadow-sm: none;
+    --pf-shadow-md: none;
+    --pf-shadow-lg: none;
+    --pf-scroll-shadow: transparent;
+    --pf-font: var(--font-sans);
+    --pf-input-height: 2.5rem;
+    --pf-input-font-size: 1rem;
+    --pf-summary-font-size: 0.9rem;
+    --pf-results-gap: 0;
+    --pf-border-radius: 0;
   }
-  #searchResults {
-    margin-block-start: 1rem;
+
+  .searchFilters {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 1rem;
+    margin-block: 1rem;
   }
-</style>
 
-<script>
-  type SearchResult = {
-    url: string;
-    meta?: { title?: string };
-    excerpt: string;
-  };
-  type Pagefind = {
-    init: () => Promise<void>;
-    debouncedSearch: (
-      query: string,
-      options?: { filters?: { [key: string]: { any: string[] } } },
-    ) => Promise<{ results: { data: () => Promise<SearchResult> }[] } | null>;
-  };
-
-  const searchField = document.querySelector<HTMLInputElement>('#searchInput')!;
-  const resultsContainer =
-    document.querySelector<HTMLDListElement>('#searchResults')!;
-  const template = document.querySelector<HTMLTemplateElement>(
-    '#searchResultTemplate',
-  )!;
-  const fieldset = document.querySelector<HTMLFieldSetElement>('fieldset')!;
-  let pagefind: Pagefind | null = null;
-
-  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;
-    }
+  .searchFilters > pagefind-filter-dropdown {
+    min-width: 0;
+  }
 
-    try {
-      // @ts-expect-error - pagefind is generated at build time
-      const pf = await import('/pagefind/pagefind.js');
-      await pf.init();
-      pagefind = pf;
-      return;
-    } catch (error) {
-      console.error('Failed to load Pagefind:', error);
-      throw error;
-    }
+  pagefind-summary,
+  pagefind-results {
+    margin-block-start: 1rem;
   }
 
-  function getSelectedFilters(): string[] {
-    return Array.from(
-      fieldset.querySelectorAll<HTMLInputElement>(
-        'input[name="searchFilter"]:checked',
-      ),
-    ).map((el) => el.value);
+  :global(.searchUi .pf-dropdown-wrapper) {
+    display: flex;
+    width: 100%;
+    align-items: stretch;
   }
 
-  function renderResult(data: SearchResult) {
-    const clone = document.importNode(template.content, true);
-    const link = clone.querySelector('a')!;
-    const excerptEl = clone.querySelector('.excerpt')!;
+  :global(.searchUi .pf-dropdown-trigger) {
+    width: 100%;
+    justify-content: space-between;
+    font-size: 1rem;
+  }
 
-    link.href = data.url;
-    link.textContent = data.meta?.title || data.url;
-    (excerptEl as HTMLElement).innerHTML = data.excerpt;
+  :global(.searchUi .pf-dropdown-menu) {
+    min-width: 100%;
+  }
 
-    return clone;
+  :global(.searchUi .pf-dropdown-option),
+  :global(.searchUi .pf-dropdown-clear) {
+    font-size: 1rem;
   }
 
-  async function getSearchResults(query: string, filters: string[]) {
-    if (!query.trim()) {
-      resultsContainer.replaceChildren();
-      return;
-    }
+  :global(.searchUi .pf-summary) {
+    font-family: var(--font-serif);
+  }
 
-    try {
-      await loadPagefind();
-      if (!pagefind) return;
+  :global(.searchUi .pf-dropdown-clear) {
+    color: var(--color-link);
+  }
 
-      const options = filters.length
-        ? { filters: { category: { any: filters } } }
-        : {};
-      const response = await pagefind.debouncedSearch(query, options);
+  :global(.searchUi .pf-dropdown-clear:hover) {
+    text-decoration: underline;
+  }
 
-      if (!response) return; // superseded by newer search
+  :global(.searchUi .pf-dropdown-selected-badge),
+  :global(.searchUi .pf-filter-group-count) {
+    background: var(--color-highlight);
+    color: var(--color-bg);
+    border-radius: 0;
+  }
 
-      const results = await Promise.all(
-        response.results.slice(0, 10).map((r) => r.data()),
-      );
+  :global(.searchUi .searchResult) {
+    padding-block: 1rem;
+    border-block-end: 1px dashed var(--color-text);
+  }
 
-      resultsContainer.replaceChildren(...results.map(renderResult));
-    } catch (error) {
-      console.error('Search failed:', error);
-    }
+  :global(.searchUi .searchResult:first-child) {
+    padding-block-start: 0;
   }
 
-  const triggerSearch = () =>
-    getSearchResults(searchField.value, getSelectedFilters());
+  :global(.searchUi .searchResultExcerpt) {
+    margin-block-end: 0;
+    color: var(--color-text);
+    font-family: var(--font-serif);
+  }
 
-  searchField.addEventListener('focus', loadPagefind, { once: true });
-  searchField.addEventListener('input', triggerSearch);
+  @media (max-width: 640px) {
+    .searchFilters {
+      grid-template-columns: 1fr;
+    }
 
-  fieldset.addEventListener('change', (e) => {
-    if ((e.target as HTMLInputElement)?.name === 'searchFilter')
-      triggerSearch();
-  });
-</script>
+    :global(.searchUi :is(*, #\#):is(*, #\#):is(*, #\#) .pf-dropdown-menu) {
+      min-width: 100%;
+      width: 100%;
+      max-width: 100%;
+      inset-inline-start: 0;
+      left: auto;
+      transform: none;
+    }
+  }
+</style>
index 182f55ddefef4039a12dee5447bd644324644f1d..f250ab020c515879ab0089098a0489981515833b 100644 (file)
@@ -10,6 +10,7 @@
   --color-bg: light-dark(#e6e2d6, #000000);
   --color-text: light-dark(#000000, #e6e2d6);
   --color-link: light-dark(#82273d, #db7b8e);
+  --color-border: light-dark(#8f897d, #666666);
 
   /* Semantic colors */
   --color-info: light-dark(#06458e, #5199e6);