このサイトをリニューアルするとき、「検索機能は絶対に入れたい」と思っていました。

記事が増えてくると「あの記事どこだっけ」となる体験は自分でも嫌だし、せっかく書くなら読んでもらえる導線を作りたかったのです。問題は静的サイトに全文検索を実装する方法が意外と選択肢が多いこと。今回はその選定から変遷をお届けします。

なぜ Pagefind を選んだか

有名どころだと Algolia DocSearch があります。精度が高く、UIも洗練されていますが、オープンソース以外のプロジェクトだと有料プランが必要です。個人ブログに払うには気が引けました。

クライアントサイドで動くライブラリとしては Fuse.js も候補でしたが、日本語の形態素解析に対応していないため検索精度が心配でした。

Pagefind を知ったのはAstroのドキュメントを読んでいたときです。特徴は3点。

  • ビルド後のHTMLを静的解析してインデックスを自動生成する
  • Wasmベースの検索エンジンで日本語にも対応している
  • バンドルサイズが小さく、ページロードを圧迫しない

「Astroで静的サイトを作っているなら Pagefind が一番相性がいい」という結論に早々に至りました。

Phase 1: とりあえず動かす

最初の実装はかなりシンプルで、SearchToggle.astrosearch.astro の2ファイルを追加するだけでした。

src/pages/search.astro
<script src="/pagefind/pagefind-ui.js" is:inline></script>
<script is:inline>
window.addEventListener("DOMContentLoaded", () => {
const search = new PagefindUI({
element: "#search",
showSubResults: true,
pageSize: 10,
debounceTimeoutMs: 300,
translations: {
placeholder: "Search this site",
// ... 英語のままでとりあえず
},
});
});
</script>

/pagefind/pagefind-ui.jspnpm build 実行後に Pagefind CLI が自動生成するファイルで、開発中は存在しないという注意点があります。この「ビルドしないと動かない」仕様に最初は少し戸惑いました。

astro.config.mjsintegrations に何かを追加するわけではなく、pnpm build の後処理として pagefind --site dist を実行するだけでインデックスが生成されます。シンプルで気に入っています。

タグページについては、タグ一覧そのものがインデックスされると検索ノイズになるため data-pagefind-ignore="all" 属性で部分的に除外しています。

UIは Pagefind のデフォルトを使っている

Pagefind には PagefindUI という公式のUIコンポーネントが用意されています。このサイトではそれをそのまま使っていて、独自のUIは実装していません。

「デフォルトUIそのままで妥協したのか」というとそうではなくて、CSS変数と :global スタイルでかなり手を入れています。

src/styles/SearchPage.module.scss
.box {
/* Pagefind が公開している CSS 変数でベースを上書き */
--pagefind-ui-primary: var(--color-headline-blue);
--pagefind-ui-background: transparent;
--pagefind-ui-font: var(--font-family-base);
/* CSS変数で届かない部分は :global で直接上書き */
:global {
.pagefind-ui__search-input {
font-family: var(--font-handwrite);
font-size: 1.3rem;
}
}
}

結果として336行のSCSSになっていて、ノートの罫線背景やダークモード対応まで含まれています。デフォルトUIのベースに乗っかりながら見た目は好き勝手にできるのが Pagefind の良いところです。

Phase 2: A11y(アクセシビリティ) との格闘

動いているだけでは気になる点がいくつかありました。特にキーボード操作とスクリーンリーダー対応です。

検索トグルボタンを押すと入力欄が展開する UIですが、閉じているときも DOM には存在します。Tab キーでフォーカスが検索入力欄に当たってしまうのは困ります。

解決策として inert 属性を使いました。

src/scripts/SearchToggle.ts
function close() {
wrap.classList.remove(OPEN);
toggle.setAttribute("aria-expanded", "false");
// inert で閉じた入力欄へのフォーカスを完全にブロック
input.setAttribute("inert", "");
input.setAttribute("aria-hidden", "true");
input.setAttribute("tabindex", "-1");
toggle.focus(); // フォーカスをトグルボタンに戻す
}

inert は2023年頃から主要ブラウザにサポートが揃い始め、現在はほぼ全環境で利用可能です(MDN Web Docs: inert)。tabindex="-1" との組み合わせで対処していた以前の実装より意図が明確になりました。

アニメーションについても prefers-reduced-motion に対応しています。展開アニメーションを clip-path で実装しつつ、モーション設定をオフにしているユーザーには即座に表示されるようにしてあります。

Phase 3: 543行から260行へ

気づいたら SearchToggle.ts が543行になっていました。

A11y(アクセシビリティ)の対応を重ねたり、モバイル向けのオーバーレイUIを追加したりしているうちに、1ファイルに責務が詰め込まれた状態になっていたのです。動作はしているのですが、読むのがつらい。

1日かけてリファクタリングしました。分割方針は責務ごとにモジュールを切り出すというシンプルなものです。

src/scripts/
├── SearchToggle.ts # エントリポイント(155行)
└── searchToggle/
├── domManager.ts # DOM要素取得・背景inert管理(60行)
├── focusManager.ts # フォーカストラップ・リトライ処理(178行)
└── uiState.ts # open/close/isOpen の状態管理(247行)
src/scripts/searchToggle/domManager.ts
/**
* SearchToggle DOM管理モジュール
*/
export function getSearchToggleDomElements(): SearchToggleDomElements | null {
const toggle = document.getElementById(
23 collapsed lines
"search-toggle",
) as HTMLButtonElement | null;
const input = document.getElementById(
"site-search-input",
) as HTMLInputElement | null;
const wrap = toggle?.parentElement;
const overlay = document.getElementById("mobile-search-overlay");
const overlayContent = document.getElementById("mobile-overlay-content");
const overlayClose = document.getElementById("mobile-search-close");
const mobileHint = document.getElementById("mobile-search-hint");
if (!toggle || !input || !wrap) return null;
return {
toggle,
input,
wrap,
overlay,
overlayContent,
overlayClose,
mobileHint,
};
}

分割後のトータル行数は640行で、むしろ増えています。ただ各ファイルの責務が明確になり、「フォーカス周りを触りたいときは focusManager.ts を開く」という作業になったのは体感的に大きな改善でした。

リファクタリング中も104件のテストがすべてパスし続けたのは、事前にテストを書いておいた自分を褒めたいところです。

Phase 4: UIの日本語化

ずっと英語のままになっていた検索UIのテキストをようやく日本語にしました。

src/pages/search.astro
translations: {
placeholder: "サイト内を検索...",
clear_search: "クリア",
load_more: "もっと見る",
zero_results: "「[SEARCH_TERM]」に一致するアイデアは見つかりませんでした 💦",
many_results: "「[SEARCH_TERM]」について [COUNT] 件のアイデアが見つかりました 🚀✨",
searching: "「[SEARCH_TERM]」を検索中...",
},

「アイデア」という言葉を使っているのは、このサイトのコンセプトに合わせた遊び心です。検索結果0件のときのメッセージが「アイデアは見つかりませんでした」になるのが気に入っています。

おまけ: robots.txt まで気が回った

検索結果ページ(/search/)はクロールされると SEO 的にノイズになります。Pagefind が動的に生成するページなので、コンテンツとしての価値もありません。

src/pages/robots.txt.ts
const getRobotsTxt = (site: URL | undefined) =>
`
User-agent: *
Allow: /
# インデックスを避けたいページ
Disallow: /search/
Sitemap: ${site ? new URL("sitemap-index.xml", site).toString() : "..."}
`.trim();

こういう細かいところを気にし始めると止まらなくなるのですが、やっておくと後で気持ちがいいです。

まだ手をつけていない課題もあって。

  • タグによるフィルタリングを Pagefind の Filter API で実装したい
  • 検索結果のスニペット表示がもう少し改善できそう

記事が増えるにつれて「検索できてよかった」という場面が増えてきています。個人ブログにしては少しやりすぎな気もしますが、やりすぎが楽しいのでよしとしています。