theme: 'monokai',
},
},
+ vite: {
+ build: {
+ rollupOptions: {
+ external: '/pagefind/pagefind.js',
+ },
+ },
+ },
});
"@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=="],
"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/",
"@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"
--- /dev/null
+site: dist
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" />
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>
: publishedDate;
---
-<details>
+<details data-pagefind-ignore>
<summary>Metadata</summary>
<ul>
{entryData.description && <li>Description: {entryData.description}</li>}
)
}
<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 && (
---
<figure>
- <Picture src={image} alt={alt} formats={formats} />
+ <Picture
+ src={image}
+ alt={alt}
+ formats={formats}
+ data-pagefind-index-attrs="alt"
+ />
<figcaption><slot /></figcaption>
</figure>
modifiedTime={modifiedTime}
tags={tags}
/>
- <article>
+ <article data-pagefind-body>
<slot />
</article>
</Base>
---
<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>
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>
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>
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>
--- /dev/null
+---
+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>
{ title: 'Articles', url: '/articles' },
{ title: 'Podcasts', url: '/podcasts' },
{ title: 'Reviews', url: '/reviews' },
+ { title: 'Search', url: '/search' },
];
export const socials: {