Pagefind は静的サイトに特化した軽量な検索ライブラリです。SSG サイト向けの Cloud CMS を提供する CloudCannon 社によって開発されています。

この Pagefind について、以前に Starlight の記事を作成したときに知ったのですが、いつのまにか安定バージョン(v1)になっていたので、試しにブログ記事の検索機能を実装してみました。

以下はライブデモで、本サイトのブログ記事のテキスト検索ができます。

Live Demo

Pagefind は静的サイトであれば(HTML ファイルさえあれば)、基本的にどのようなサイトであっても以下の 2 ステップで導入できます。

  1. Pagefind をインストールし、検索用ファイルを生成する
  2. Pagefind の UI を読み込み、インスタンスを作成する

1. Pagefind をインストールし、検索用ファイルを生成する

見出し「1. Pagefind をインストールし、検索用ファイルを生成する」

まず、検索用のインデックスファイルを生成します。本サイトのフレームワークは Astro を使用しているので、Astro のビルドと連携させるために npm パッケージをインストールします。

Pagefind の npm パッケージをインストールするコマンド
npm install pagefind

次に、Astro のビルド後に Pagefind CLI を実行するように、npm-scripts を記述します。

package.json に記述する npm-scripts の例
{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}

これで、npm run build コマンドを実行すると Astro によって生成された HTML ファイルの内容に基づいて、Pagefind で使用するインデックスファイルが生成されます。なお、生成されるインデックスは、HTML ファイルに指定した lang 属性の値に基づいて言語が判別されます。

コマンド実行後、CLI には以下のような結果が表示されます。

CLI に表示されるインデックス結果の例
Indexed 1 language
Indexed 38 pages
Indexed 4816 words
Indexed 0 filters
Indexed 0 sorts

Astro 以外の SSG でも、基本的にはこのような流れになると考えられます。

2. Pagefind の UI を読み込み、インスタンスを作成する

見出し「2. Pagefind の UI を読み込み、インスタンスを作成する」

続いてフロント側の指定です。Pagefind の UI を読み込んで検索フォームを表示します。

前述の「1」で Pagefind CLI を実行しましたが、そのときに --site で指定したディレクトリ以下に pagefind ディレクトリが生成されます。そこに Pagefind UI の CSS(pagefind-ui.css)と JS(pagefind-ui.js)が含まれています。

この CSS と JS を読み込んでから、new 演算子でインスタンスを作成するだけで完了です。

Pagefind UI のインスタンス作成
<link rel="stylesheet" href="/pagefind/pagefind-ui.css">
<script src="/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
  new PagefindUI({ element: "#search" });
});
</script>

これで、デフォルト状態の Pagefind の検索フォームが使用できるようになりました。

CLI で Pagefind のコマンド npx pagefind --site dist --serve、もしくは Astro であれば npx astro preview で、ローカルサーバを立ち上げて動作確認ができます。

Pagefind のインデックスを作成しローカルサーバを起動するコマンド
npx pagefind --site dist --serve

ここからは、Pagefind をカスタマイズしていきます。

Pagefind UI をインスタンス化する際にオプションを指定できますが、代表的な項目をいくつかピックアップします。

プロパティ値(例)内容
element#search検索フォームを表示するセレクタ(必須)
baseUrl/ベース URL
pageSize51 ページに表示する検索件数
showSubResultsfalseページ内の各見出しの検索結果の表示
showImagestrueサムネイル画像の表示
excerptLength30概要テキストの長さ
debounceTimeoutMs300ユーザが入力してから結果を表示するまでの待ち時間(ms)

また、translations オブジェクトで、各種ラベルの表記を変更することができます。日本語のデフォルトのラベルは以下のとおりです。

以下は現時点での本サイトでの設定ですが、各見出しの検索結果やサムネイル画像は無効にした、最小限の構成としています。

Pagefind のオプション指定の例
new PagefindUI({
  element        : '#search',
  baseUrl        : '/blog/',
  pageSize       : 10,
  excerptLength  : 50,
  showSubResults : false,
  showImages     : false,
  translations   : {
    placeholder  : 'ブログ記事を検索',
    clear_search : 'クリア',
    load_more    : 'More',
    search_label : '',
    zero_results : '「[SEARCH_TERM]」に一致する記事は見つかりませんでした 🙇‍♂️',
    many_results : '「[SEARCH_TERM]」の検索結果([COUNT] 件)',
    one_result   : '「[SEARCH_TERM]」の検索結果([COUNT] 件)',
    searching    : '「[SEARCH_TERM]」を検索しています...',
  },
  debounceTimeoutMs: 400,
});

ちなみに、translationssearch_label<form> 要素に付与される aria-label の値として使われます。少し過剰に感じたので、別途 JS の処理で属性自体を取り除いているため、上記のコードでは空の値を指定しています。

検索対象・除外の指定

見出し「検索対象・除外の指定」

Pagefind では、HTML に特定の data-*(カスタムデータ属性)を使用することで、検索結果の細かな調整が可能です。

本サイトでは検索対象を明示する data-pagefind-body と、検索からの除外を明示する data-pagefind-ignore を使用しています。

検索対象(data-pagefind-body

見出し「検索対象(」

data-pagefind-body を付けると、その要素以下のコンテンツが検索対象となります。

もし、この属性の指定が 1 つでも見つかると、すべてのページ内でこの data-pagefind-body 以下が検索の対象となります。つまり、属性の指定がないページは検索対象外になります。

このサイトでは、ブログ記事の <article> 要素以下を検索対象として指定しています。

data-pagefind-body の例
<main id="main" class="main">
  <!-- 検索対象 -->
  <article class="article" data-pagefind-body>
    <!-- ... -->
  </article>
</main>

検索除外(data-pagefind-ignore

見出し「検索除外(」

検索対象(data-pagefind-body)以下で、検索から除外したい要素がある場合には、data-pagefind-ignore を指定します。

data-pagefind-ignore の例
<main id="main" class="main">
  <!-- 検索対象 -->
  <article class="article" data-pagefind-body>
    <h1 class="article-header-title">CSS セレクタのレシピ - :has() 擬似クラス編</h1>

    <!-- 検索除外 -->
    <nav class="toc" aria-label="目次" data-pagefind-ignore="all">
      <h2 class="toc-hdg">Table of Contents</h2>
      <!-- ... -->
    </nav>

    <!-- 検索対象 -->
    <p>CSS の <code>:has()</code> 擬似クラスは、すでに多くのブラウザではサポートされていますが、これまでは Firefox が非対応のため、実案件で使用するには限定的でした。</p>
  </article>
</main>

この data-pagefind-ignore には indexall のいずれかの値が指定でき、デフォルトの値は index です。

index では、検索のインデックスの対象からは除外されますが、フィルタやメタデータ、タイトル、サムネイル画像の候補からは外れないため、これらも含めてすべて対象外にしたい場合には all を指定します。

本サイトでは、ブログ記事内の目次やシリーズ、コードブロック、ライブデモといった、検索結果のノイズになりそうな要素に data-pagefind-ignore="all" を指定しています。

Pagefind では以下のいずれかの方法でスタイルをカスタマイズすることも可能です。

  • CSS カスタム変数の値を変更する
  • オリジナルの CSS を適用する

CSS カスタム変数の値を変更する

見出し「CSS カスタム変数の値を変更する」

まずは、pagefind-ui.css を読み込んだまま、CSS のカスタム変数の値を変更して、テーマを調整する方法です。サイトのトーンにあわせてスタイルを微調整したい場合に適しています。

CSS カスタム変数の一覧は以下のページを参照してください。

オリジナルの CSS を適用する

見出し「オリジナルの CSS を適用する」

pagefind-ui.css を読み込まずに、オリジナルのスタイルを適用する方法です。本サイトではこの方法で実装しています。

ただ、この方法にはリスクがあり、今後のバージョンアップ次第では UI のマークアップが変わる可能性があるため、そのタイミングで CSS を書き換える必要が出てくるかもしれません。

Astro の開発モードで Pagefind を読み込む

見出し「Astro の開発モードで Pagefind を読み込む」

前述の導入方法では、ビルド後のファイルに対してプレビューをおこなっていました。

ただ、オリジナルの CSS を適用するといったように、UI の見た目を大きく変えたいときに、都度ビルドしてからプレビューするのは不便に感じます。

Astro の場合には、環境変数 import.meta.env.DEV で、開発モードかを判別できるので、これを使用して JS ファイルの読み込みを振り分けます。

Astro の環境変数で JS ファイルを読み込むパスを振り分ける例
---
let jsPath = '/pagefind/pagefind-ui.js';

// 開発モードでは `/dist` からパスを指定する
if (import.meta.env.DEV) {
  jsPath = '/dist' + jsPath;
}
---

<script src={jsPath}></script>

このように、少し強引ではありますが、開発モードではパスに dist を含めて読み込んでいます。これで、開発モードでも Pagefind が使えるようになります。

Pagefind は、今のところ通常の使用方法であれば大きな問題はなく、必要十分な機能を備えていますが、以下の問題を認識しています。

先頭の文字がマッチする

見出し「先頭の文字がマッチする」

例えば、現時点ではブログ記事で WebGL については言及していないので、結果はゼロになるはずですが、「WebGL」で検索すると先頭の「Web」の文字がマッチしてしまいます。

「WebGL」で検索したときのスクリーンショット
「WebGL」で検索すると「Web」の文字で 19 件の記事がヒットする

まったく関連のない「二十四節気」と検索しても「二」の文字がマッチしてしまいます。

「二十四節気」で検索したときのスクリーンショット
「二十四節気」で検索すると「二」の文字で 2 件の記事がヒットする

このように、マッチングの精度には改善の余地がありそうです。

なお、この記事もインデックス対象となるため、記事公開後は「WebGL」や「二十四節気」での検索結果は変わります。

Pagefind では、単語を空白で区切らない日本語や中国語でも単語の分割(Segmentation)には対応していますが、単語の語幹に一致させるステミング(Stemming)には非対応のようです。

また、検索する際には単語ごとに空白で区切る必要があるようです。

ただ、本サイトでは、高精度な検索機能を求めていないというのもありますが、現時点では日本語を使用した検索の使い勝手が悪いといった印象はありません。

インデックス内に存在しない絵文字を入力すると JS エラーが発生し、検索結果が表示されないバグがあるようです。これは公式サイトで試しても同様のようです。

例えば、本サイトの記事には絵文字「🚀(U+1F680)」が含まれているので検索しても問題ありませんが、公式サイトではエラーが発生して「Searching for 🚀…」で止まってしまいます。

Pagefind 公式サイトで「🚀」を検索したときのスクリーンショット

コンソールには以下のエラーメッセージが表示されます。

表示されるエラーメッセージ
Uncaught (in promise) RuntimeError: unreachable

ただ、すべての絵文字が対象ではなく、「⭐(U+2B50)」のようにエラーが発生せずに「No results for ⭐」と正しく検索結果が表示される絵文字もあるようです。

おそらく絵文字のコードポイントが関係していそうですが、こちらも通常の使用で問題になるとは考えられないので、これ以上は深入りせずに許容範囲とします。

この記事では、本サイトのブログ記事検索における、Pagefind の導入方法を説明しました。

アカウント登録を必要とせず、HTML ファイルさえあれば軽量で使いやすいサイト内検索の機能が実装できるのは魅力的です。

ほかにもフィルタ機能や重み付け(ウェイト)の指定、サムネイル画像の指定、ソートの指定などさまざまなカスタマイズが可能です。JS 経由で API にアクセスすることもできるので、より高度な使い方もできます。詳しくは公式サイトを確認してみてください。

本サイトでもひとまず試験的に導入しているので、様子を見ながら、また少しずつチューニングしていければと考えています。