【2023年6月版】

pagefindでブラウザだけのサイト内検索機能を追加

公開日: 2023/06/30
読了時間: 約 11分

6月最終日の本日はAstro SSGを使った静的サイトにpagefindAlpine.jsを使って外部の検索エンジンなしのサイト内検索機能を追加します。

pagefind について

pagefind の公式サイトは以下です。

Pagefind is a fully static search library that aims to perform well on large sites, while using as little of your users’ bandwidth as possible, and without hosting any infrastructure. Pagefind runs after Hugo, Eleventy, Jekyll, Next, Astro, SvelteKit, or any other SSG. The installation process is always the same: Pagefind only requires a folder containing the built static files of your website, so in most cases no configuration is needed to get started.

というわけで、Pagefind は外部の検索エンジンなしに、少ないダウンロードサイズで、大規模なサイトでも高速な検索ができるライブラリです。 Hugo、Celebrity、Jekyll、Next、Astro、SvelteKitなどのSSGに対応しているそうです。 サイトのビルド後にインデクス化を実行すると、pagefind.jsとサイトの検索インデクスを含む静的なファイルがいくつか生成されます。 あとはブラウザ上で pagefind.js を使用するだけです。 インデクスの読み込みや検索は pagefind.js がバッチリ行ってくれます。

今回は、本サイトで使っている SSG → Astro、UIフレームワーク → Alpine.js の環境での pagefind を使ったサイト内検索機能の実装方法をご紹介いたします。

インストール

まずはパッケージのインストールから。

shell
$ npm i -D pagefind

続いて、pagefind を pacage.json のpostbuildに追加します。

package.json
{
  ...,
  "scripts": {
    "dev": "astro dev",
    "start": "astro dev",
    "build": "astro build",
    "postbuild": "pagefind --source dist --bundle-dir pagefind",
    "preview": "astro preview",
    "astro": "astro"
  },
  ...
}

こんな感じで postbuildpagefind --source dist --bundle-dir pagefind とすると、dist/pagefind ディレクトリが作成され、この中に pagefind.js が生成されます。 つまり、実行時に pagefind/pagefind.js を読み込むようにサイト内検索機能を実装していきます。

Astro での pagefind の使用

上記を踏まえて Astro 用に開発時のエンドポイントを作成する必要があります。

  1. pagefind/pagefind.js が読み込めるようにすること。
  2. pagefind のインデクスはビルド時に作成されるので、開発時はダミーデータを返却すること
  3. https://pagefind.app/docs/api/ の API に従ってダミーデータを作成すること

というわけで、以下のようなエンドポイントを作成いたします。

src/pages/pagefind/pagefind.js.ts
import type { APIContext } from "astro"

export async function get({}: APIContext) {
  return {
    body: `export const search = () => {return {results: [
      {
        "data": () => ({
          "url": 'http://localhost:3000/',
          "excerpt": 'aaaa<mark>aaaaaaa</mark>aaaaaaaaaaaaaaaaaaaa',
          "meta": {
            "title": 'テストタイトルA',
          }
        })
      },
      ...
    ]}}`
  }
}

src/pages/pagefind/pagefind.js.ts は、URL上のパス pagefind/pagefind.js で開発時の js ファイルを返すエンドポイントです。 ここでダミーの検索結果を返す search 関数を export させます。 この関数で返却するダミーの検索結果の型は、https://pagefind.app/docs/api/ の以下の使い方から出てきます。

const search = await pagefind.search("static");
const oneResult = await search.results[0].data();

await search.results[0].data() ってのがちょっと厄介で、resultsの配列の中身が data() というメソッドを持つオブジェクトであるのがちょいとめんどい感じです。 というわけで、{results: [{"data": () => ({ みたいなややこしい構造になっております。

また、ここで返却するのは自サイトの検索機能で使いたいデータだけでOKです。 検索結果全体としては、以下のような構造となっております。

{
  "url": "/url-of-the-page/",
  "excerpt": "A small snippet of the <mark>static</mark> content, with the search term(s) highlighted in &lt;mark&gt; elements.",
  "filters": {
    "author": "CloudCannon"
  },
  "meta": {
    "title": "The title from the first h1 element on the page",
    "image": "/weka.png"
  },
  "content": "The full content of the page, formatted as text. <html> will not be escaped. ...",
  "word_count": 242
}

今回は、{"url":'' "excerpt":'', "meta":{"title":'',}} のデータだけを使用しております。

alpine.js で UIの実装

本サイトでは画面上部のナビゲーションバーで検索機能を提供しておりますのでそのソースの概要をご紹介いたします。

src/components/Navigation.astro
...
  <span x-data=`{
      result_data :[],
      loaded: false,
      async search(e) {
        if (this.loaded !== 'true') {
          this.loaded = 'true'
          window.pagefind = await import("/pagefind/pagefind.js");
        }
        const search = await window.pagefind.search(e.target.value);
        this.result_data = await Promise.all(search.results.map(async (result) => await result.data()));
      }
    }`
    >
    <input @input="search" type="text" placeholder="Search..." />
    <div id="results" >
      <template x-for="result in result_data">
        <a :href="result.url" >
          <h3 class="font-bold" x-text="result.meta.title" />
          <p x-html="result.excerpt"></p>
        </a>
      </template>
    </div>
...

検索機能だけの箇所を、アイコンやCSSを取り除いて機能のjsとHTMLの構造のみ抜き出したものが上記です。

では、以下で3つのポイントについて解説いたします。

1. x-data で “検索ステート(状態)” の定義

まずは alpine.js のトリガーとなる x-data を定義します。 ちなみにデータが必要なくイベントハンドラだけ使いたい場合でも、x-data がalpine.js のトリガーとなるので、引数を取らずに <div x-data>...</div> などと定義する必要があります。

再度、x-data の定義を抜き出してみます。

{
  result_data :[],
  loaded: false,
  async search(e) {
    if (this.loaded !== 'true') {
      this.loaded = 'true'
      window.pagefind = await import("/pagefind/pagefind.js");
    }
    const search = await window.pagefind.search(e.target.value);
    this.result_data = await Promise.all(search
      .results
      .map(async (result) => await result.data()));
  }
}

処理のメインは以下の流れです。

  1. await import("/pagefind/pagefind.js")/pagefind/pagefind.js を読み込む
  2. const search = await window.pagefind.search(e.target.value) で検索を実行する
  3. search.results.map(async (result) => await result.data()) で検索結果のデータを取得する

です。 loaded/pagefind/pagefind.jsの読み込みをこのページで1回だけにするためのフラグです。2回目から読み込み処理をスキップし、window.pagefind だけが実行されます。 result_data はただの配列にしたいので例によって Promise.all() でPromiseの配列(Promise<T>[])を配列のPromise(Promise<T[]>)にしています。

2. 検索 input のイベントハンドラ

検索処理のトリガーとなるイベントハンドラは以下の定義です。

<input @input="search" type="text" placeholder="Search..." />

Alpine.js はシンプルに @input="search" でOKなのがいいですね。 これでinput type=textタグのinputイベントで、上記の x-data で定義した async search(e) {...} メソッドが呼び出だされます。

3. 検索結果表示のテンプレート

検索結果表示のテンプレート箇所は以下の通りです。

<div id="results" >
  <template x-for="result in result_data">
    <a :href="result.url" >
      <h3 x-text="result.meta.title" />
      <p x-html="result.excerpt"></p>
    </a>
  </template>
</div>

x-for="result in result_data" でなんとなくお分かりのように x-data:{result_data:[] で定義した配列の各要素を result に入れて中身をイテレーションしてます。

後は、result の中身を使って宜しく表示を整えております。 <a :href="result.url" > は、<a x-bind:href="result.url" > の省略形で、HTMLタグの属性にオブジェクトの値をマッピングするx-bindを使ってます。

タイトルはそのまま表示するため x-text="result.meta.title" としています。

また、検索結果がヒットした強調表示付き本文は <mark> タグがついたHTMLのため、x-html="result.excerpt"> としております。

機能の実装としては以上です。

日本語の検索について

検索インデクスといえば当然、トークナイザーの話が出てきますが、今のところ pagefind.js では外部から日本語トークナイザーを追加することはできないようです。 本物の日本語トークナイザーを使用したい場合はやはり Elasticsearch などトークナイザーを指定できる製品が必要ですね。

ただし、一応、言語に応じてトークナイズの方式はいくつかアルゴリズムを分けているようです。

ここに記載のあるとおり、HTMLのlang属性をちゃんと見ている模様です。 なのでサイトの言語設定をちゃんとします。

src/layouts/Base.astro
---
<!DOCTYPE html>
<html lang="ja">
...

layouts/Base.astro<html lang="ja"> で、ちゃんとサイトが日本語である、という宣言をいたしました。 これで一応、日本語でもそこそこの検索精度になります。

テストと本番環境について

ローカルでのAstro開発サーバーの起動時には上記で定義した pagefind/pagefind.js.ts エンドポイントが有効になるので、ダミーデータでのHTML表示のテストが行えます。

デプロイ後の環境に対して、サイトがビルドされると、postbuild のスクリプトによりページの検索インデクスと本物の pagedind.js が生成されるので、ブラウザ上ではちゃんと検索結果が返ってきます。

まとめ

サイト検索を自前でやろうとすると検索エンジンを作るか、Google の検索窓を設置するか、しかなかったとおもいますが、pagefind のおかげで自前で本格的なサイト内検索機能が実現できるようになりました。

実装方法もそんなに難しくはないですし、とくに Astro はエンドポイントが定義できるのでローカルの開発環境でもダミーデータで動作確認が行えるのが素晴らしいですね。

またこういった機能をサクッと組み込める Alpine.js も素晴らしいです。こういった小回りの利くフレームワーク、待ってましたよ。ホントに。。。(jQueryいつの間にかめっちゃ巨大なんだもん。。。)

本日は以上です! (そして 2023年、半分が終了です。。。)