no7.space
🔎

Pagefind でブログ記事を検索できるようにする

プロジェクトのリストも作れるようになったし、あと必要なのは…検索ですよね。
ということで今回はこのブログの記事を検索するための仕組みを導入します。

Pagefind

Pagefind は JavaScript で動作する静的検索ライブラリです。
静的サイトやSSGしたサイトのデータをビルド時にインデックスしてそのデータを使って高速な検索を実現します。

どんなフレームワーク上でも動作させることができるので一度覚えておくと結構便利かもしれないですね。
特に、Algolia とかを使うほどでもないけれども、検索はしたい、みたいなときは役に立つと思います。

ということで作っていきます。

Pagefind のインストール

npm -D install pagefind

そして、実行するための npm-scripts を設定します

{
  "scripts": {
    "build": "next build",
    "postbuild": "pagefind --site .next --output-path public/pagefind --glob **/blog/**/*.{html}",
  },
}

pre / post プレフィックス

pagefind をインストールすると、pagefind というコマンド(?)が使えるようになります。
pagefind は指定したディレクトリを検索してインデックスを作りますが、Next.js のサイトの場合その対象はビルド成果物である .next/ になりますが、これは一度でもビルドしないと存在しません。
そもそも最新の記事の状態を反映させるって意味でもビルドは必要なので、基本的にはビルドコマンドとセットで使うことになります。

そういう時に便利なのが npm-scriptspre / post というプレフィックスで、これともとのスクリプト名をつなげたスクリプト名を登録すると、対象のスクリプトの実行前(pre)や実行後(post)に自動的に実行してくれるようになります。

つまり、pagefind のインデックス作成処理を postbuild という名前で登録しておくと、npm run build が完了すると自動的に pagefind の処理までやってくれるようになります。

pagefind のオプション

今回は以下のように設定しました

optionexampledesc
--site.nextインデックスする対象のルートディレクトリ
--outputpublic/pagefindインデックスした結果を保存する先
--glob**/blog/**/*.{html}インデックス対象を glob パターンで指定

--site
検索対象のディレクトリ(ルート)を指定します。Next.js サイトの場合は上にも書いた通りで、ビルド成果物である .next/ を対象にします。

--output
pagefind は実行すると、インデックスデータと検索用UIを生成します。
そして Next.js は public に於いたファイルを静的アセットとしてホストできるので、インデックス結果は public に配置して利用するようにしています。
別に見られて困るデータでもないとおもうので。

--glob
デフォルトの設定だと、サイト内のページ全てを対象としてしまいますが、今回みたいにブログ記事だけを対象にしたい場合は、Glob パターンをつかって絞り込むことができます。
デフォルトだと **/*.{html} となっていて、--site で指定したフォルダ以下の全ての html ファイルを対象としていますが、ブログ記事だけを対象としたいので、今回は **/blog/**/*.{html} としました。

Note

それならはじめから --siteblog/ 以下を指定すればいいだろって感じるかもしれませんが、.next/ の中身はそこまで単純な構造じゃありませんでした 💦

とりあえずこれでビルドすれば pagefind の検索結果を使えるようになります。試しにここで

npm run build

してエラーが出ないでビルドが完了できることを確認しておくとよいです

サイトの修正

次にサイトのコンポーネントを修正していきます。

data-pagefind-body と data-pagefind-ignore

pagefind の挙動を制御するためのカスタムデータ属性です。
対象のページであっても data-pagefind-ignore が含まれていればインデックスされませんし、data-pagefind-body があればそれより内側だけを対象とします。
なので、一覧ページとかは ignore したり、記事本体を囲う div とか article とかに body を指定しておけばサイドバーなどのデータを含めなくなるのでノイズがなくなり、より検索の制度が上がる…んじゃないかなとおもいます。多分。

検索用コンポーネントの作成

pagefind には検索窓を生成するための UI コンポーネントが同梱されていますが、自前で創ることも可能です。
表示内容を細かくカスタマイズしたい場合はむしろこっちのほうが楽かも。

ということで今回は search API を利用して自前で検索コンポーネントを作ります。

pagefind.js の読み込み

Pagefind を動作させるには、pagefind コマンドで生成された pagefind.js を読み込む必要があります。
ですが、pagefind コマンドは postbuild つまり、Next.js のビルドが終わってから実行されるので、Next.js のビルド中はまだファイルがありません。
そうすると、pagefind.js が必要なのにその時点ではないため、ビルドが通らなくなってしまいます。

そのため、Pagefind の公式サイトからも紹介されている Next.js に実装するチュートリアルにもある通りに、 dynamic import と webpackIgnore を使ってロードします。

useEffect(() => {
  async function loadPagefind() {
    if (typeof window.pagefind === 'undefined') {
      try {
        window.pagefind = await import(
          // @ts-expect-error pagefind.js will generate after build
          /* webpackIgnore: true */ '/pagefind/pagefind.js'
        )
      } catch {
        window.pagefind = {
          search: () => ({ results: [] }),
        }
      }
    }
  }

  loadPagefind().catch((e) => {
    console.error('Failed to load pagefind:', e)
  })
}, [])

検索処理の実装

つづいて、実際に Pagefind API で検索を実行する部分ですが、まずは、フォームに入力されたクエリと、検索結果を格納する state を作りました。

const [query, setQuery] = useState('')
const [dataList, setDataList] = useState<ResultData[]>([])

続いて実際に検索を行う部分。

useEffect(() => {
  async function searchPagefind() {
    const search = await window.pagefind?.search(query)
    if (search && search.results.length > 0) {
      const dataArr = await Promise.all(
        search.results.map(async (r: Result) => {
          const data = await r.data()
          return {
            id: r.id,
            data,
          }
        })
      )
      setDataList(dataArr)
    } else {
      setDataList([])
    }
  }
  searchPagefind().catch((e) => {
    console.error('Failed to search with pagefind:', e)
  })
}, [query])

あと、Pagefind からのデータと、それを加工したデータの型定義も用意しました。
nとりあえず必要そうな要素だけ雑に…

type Result = {
  id: string
  data: () => Promise<ResultData>
}

type ResultData = {
  id: string
  data: {
    url: string
    meta: {
      title: string
    }
  }
}

そしたら入力フォームを作って、入力されるたびに query ステートを更新します。

<input
  type="search"
  placeholder="Search"
  value={query}
  onChange={(e) => setQuery(e.target.value)}
/>

query が更新されると searchPagefind() が呼ばれて結果が dataList に格納されるので、あとは適当にループでこれを表示すればとりあえず検索できるようになりました。

<div className={styles['search-results']}>
  {dataList.length > 0
    ? dataList.map((d) => {
        return (
          <div key={d.id} className={styles['search-result-item']}>
            <Link href={formatUrl(d.data.url)}>
              {formatPageTitle(d.data.meta.title)}
            </Link>
          </div>
        )
      })
    : query && (
        <div className={styles['search-result-item']}>
          No results found.
        </div>
      )}
</div>

URL を正しいものに書き換える

実はこれで完成っぽく見えますがこれだとまだ正しく動作しません。
というのも、Pagefind が見つけたページのパスというのは .nuxt/ 以下での話になり、実際にブラウザで見えてるパスとは若干異なるためです。
なので、最後にこの URL を書き換える処理を追加します。

pagefind が見つけてきた URL は、/server/app/blog/xxx.html というフォーマットになってますが、本来は /blog/xxx が正しいです。
つまり、

  • /server/app が不要
  • 拡張子(.html)が不要

ってだけなので、さくっと replace でこの辺を取り除いてあげればOKでした

const formatUrl = (url: string) => {
  return url.replace(/\/server\/app/, '').replace(/\.html$/, '')
}

あとは、適当にスタイルシートを整えたり、表示制御を行ったり、検索窓が開いたら自動的にフォーカスするようにして、完成です。

参考