"@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=="],
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>
---
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>