Back to posts

Pagefindで静的サイトに検索を足す手順と注意点

このサイトはAstroで作っている静的サイトで、記事検索にはPagefindを使っています。

導入した理由は単純で、記事が増えてきたからです。タグと時系列の一覧だけだと、「前に書いたあの記事」がなかなか見つからない。かといって、個人ブログのために検索サーバを立てる気にはなりません。静的サイトのまま、ビルド時にインデックスを作って配るだけ、というPagefindの設計がちょうど合いました。

Pagefindは、生成済みのHTMLを読み取って検索用インデックスを作るツールです。検索は読み手のブラウザ側で完結するので、バックエンドも外部APIもいりません。この記事では、導入手順と、インデックス範囲の指定まわりで押さえておきたい点を整理します。

導入の流れ

大まかな手順は次のとおりです。

  1. Pagefindをインストールする(npm install -D pagefind、または実行時に npx pagefind でもよい)
  2. 検索UIを置くページ(検索ページやレイアウト)を用意する
  3. サイトをビルドする(astro build)
  4. 生成された出力ディレクトリに対してPagefindを実行し、インデックスを作る
  5. ビルド済みの状態でプレビューして、検索が動くか確認する

ポイントは順番です。Pagefindが読むのは「ビルドされたHTML」であって、Markdownの原稿ではありません。開発サーバ(astro dev)ではインデックスが存在しないので、検索の動作確認は必ずビルド後のプレビューで行います。ここを取り違えると「検索が壊れている」のではなく「まだインデックスが無い」だけ、という状態で悩むことになります。このプレビューでの確認を自動化する話は、AstroサイトのE2EはPlaywrightの最小構成からにまとめています。

ビルドに組み込む

毎回手で実行するのは忘れるので、このサイトでは package.json の build スクリプトに組み込んでいます。

{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}

--site にはAstroの出力ディレクトリ(デフォルトは dist)を指定します。実行すると dist/pagefind/ の下にインデックス一式と、後述するUI用のアセットが生成されます。デプロイ先が何であれ、ビルドコマンドがこの形になっていれば、公開のたびにインデックスも一緒に更新されます。

検索UIの組み込み

Pagefindには、あらかじめ用意された検索UI(Pagefind UI)が同梱されています。インデックス生成時に /pagefind/pagefind-ui.css と /pagefind/pagefind-ui.js が出力されるので、検索を置きたいページで読み込んで初期化するだけです。

<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script>
  window.addEventListener("DOMContentLoaded", () => {
    new PagefindUI({ element: "#search", showSubResults: true });
  });
</script>

オプションは初期化時に渡せます。結果の件数(pageSize)、抜粋の長さ(excerptLength)、画像の表示・非表示(showImages)など。showSubResults: true にすると、ヒットしたページ内の見出し単位のサブ結果も出るようになり、長い記事では目当ての節へ直接飛べて便利です。UIの文言はページの lang を見て自動で切り替わるので、日本語サイトなら検索欄まわりも日本語で表示されます。

なお、最近のPagefindではWeb ComponentsベースのComponent UIも用意されています。どちらを使うかは好みですが、素のHTMLに置いてすぐ動かしたいなら、従来のPagefind UIが手早いと思います。

インデックス範囲の指定

ここが一番大事なところです。何も指定しないと、Pagefindは各ページの <body> 全体をインデックスします。するとヘッダーやサイドバー、フッターといった全ページ共通の文言まで引っかかるようになり、「どの記事にも出てくる言葉」で検索すると全記事がヒットする、という残念な結果になります。

範囲はHTML属性で制御します。主なものは次のとおりです。

属性役割
data-pagefind-bodyこの要素の中だけをインデックス対象にする。本文のラッパーに付ける
data-pagefind-ignoreこの要素と子要素をインデックスから外す
data-pagefind-ignore="all"インデックスだけでなく、タイトルやメタデータの検出からも完全に外す
data-pagefind-meta検索結果に添えるメタデータ(タイトル、日付など)を指定する
data-pagefind-index-attrsalt や title など、要素の属性値もインデックスに含める

記事ページなら、本文を包む要素に data-pagefind-body を付けるのが基本形です。

<article data-pagefind-body>
  <h1>{title}</h1>
  <p data-pagefind-meta="date:{date}">公開日: {date}</p>
  <!-- 本文 -->
  <aside data-pagefind-ignore>
    <!-- 関連記事へのリンクなど、本文ではないもの -->
  </aside>
</article>

メタデータは、指定しなければ最初の h1 がタイトル、その後に続く最初の画像がサムネイルとして自動で拾われます。日付のような「本文には出てくるけれど構造化したい」情報は、data-pagefind-meta で明示しておくと、結果の並び替えや表示に使えます。土台になる記事側のメタデータは frontmatter で管理していて、その形式の事故を防ぐ話はContent Collectionsの型でfrontmatterの事故を防ぐに書いています。

落とし穴:data-pagefind-body はサイト全体に効きます

公式ドキュメントにも明記されていますが、サイト内のどこか1ページでも data-pagefind-body を使うと、その属性が無いページはインデックス対象から外れます。記事レイアウトにだけ付けて、トップページや固定ページに付け忘れると、それらのページは検索に出てこなくなります。「記事だけ検索できればいい」なら好都合ですが、そうでないページにも忘れずに付ける必要があります。

除外の使いどころと、検索ページ自体の扱い

data-pagefind-ignore は、本文の中にある「本文ではないもの」を落とすのに使います。関連記事の見出しリスト、前後記事へのナビゲーション、目次、タグの羅列あたりが典型です。これらが入っていると、他の記事のタイトルで検索したときに無関係の記事までヒットしてしまいます。

もう一つ、忘れやすいのが検索ページ自体です。

検索ページをインデックスに入れない

検索UIを置いたページも、ほかのページと同じくビルドされてHTMLとして出力されます。範囲指定のやり方によっては、そのページ自体がインデックスに含まれて、検索結果に「検索ページ」がヒットする間抜けな状態になります。検索ページには data-pagefind-body を付けない、あるいは data-pagefind-ignore="all" を付けて、対象から外しておきます。

ちなみに、<nav> や <footer>、<script>、<form> といった要素は、Pagefindが標準でインデックスから外してくれます。ただし <header> や汎用の <div> は対象に含まれるので、共通パーツを div で組んでいるサイトほど、範囲指定の効果が出ます。

日本語検索の挙動

日本語は単語の区切りに空白を使わないので、英語圏向けの検索ツールだと分かち書きまわりが心配になります。Pagefindの公式ドキュメントによると、日本語は「特殊な言語」として扱われ、拡張版(npx pagefind で取得されるデフォルトの版)では空白なしのテキストを単語に分割(セグメンテーション)してインデックスする、とされています。検索クエリ側も同様に分割されるので、文章の途中にある単語でも引っかかる、という動きです。

一方で、語幹をそろえるステミングは日本語では効きません。「書いた」「書ける」を同じ語として扱うような融通は期待できないので、送り仮名や表記ゆれの違いは、そのまま別物として検索される前提でいた方がよさそうです。

このあたりの挙動は、公式ドキュメントの記述をもとにした整理です。自分のサイトでも長文の途中にある語で検索できることは確認していますが、分割の精度を体系的に検証したわけではありません。「日本語でもだいたい効くが、完全ではない」くらいの期待値で導入するのが安全だと思います。

前提として、ページの <html lang="ja"> は正しく付けておいてください。Pagefindは lang 属性を見て言語ごとのインデックスを分ける仕組みなので、ここが間違っていると日本語向けの処理自体が働きません。

おわりに

Pagefindの導入でやることは、言ってしまえば三つだけです。ビルドの後にインデックス生成を挟むこと、本文の範囲を data-pagefind-body で示すこと、入れたくない部分を data-pagefind-ignore で外すこと。凝った設定をする前に、この三つを押さえる方が結果が安定します。

静的サイトの「検索は付けられない」という弱点は、思ったより安く埋められます。記事を書き溜めるタイプのサイトなら、早めに入れておいて損はないはずです。

参考