Next.js（ver.14, App Router, SSG）に pagefind を追加して関数名なども検索できるようにする

## はじめに

先日公開した [Lang x Lang](https://langxlang.com) は、現時点で 500 ページくらいあるので、検索機能が欲しいと思った。Lang x Lang は `Next.js` の `ver.14.2.4` の `App Router` を使っており、Production は `SSG` で動いている。なんか良いのないかなと探してみたところ、[`pagefind`](https://pagefind.app/) がお手軽で良さそうだったので、試しに導入してみた。

![Screenshot 2024-06-21 at 16.19.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/6a3d1f20-d445-cfc1-af8d-50d50b5aa433.png)

結論としては、導入は簡単だったが、工夫しないと検索精度は微妙だった。
微妙だったのは、「*技術ドキュメントサイトの検索精度として*」という意味で。

API Reference に頻出の関数名で検索したいなど場合、例えば `generateMetadata` と検索したら、そのページが一番上に出てきて欲しいが、`generateMetadata` のような複合語（generate + metadata）などと相性が悪い。そういう意味で微妙だった、というより技術ドキュメントの検索としては、致命的だった。

せっかく設置したけど、これならない方が良いかな？とも検討したが、解決できたので公開した。満足している。

## pagefind とは

pagefind は大規模サイトでもできるだけ少ない帯域幅で、静的サイトの検索を実現する（目指す）ライブラリ。のようだ。2023年9月に安定版がリリース。以降、いまのところアクティブに開発が行われている。今回使ったのは、`v1.1.0`。

pagefind はどんな風に動くかというと、ざっくり

1. build した静的なページを対象に
1. 検索用の fragment, index, filter などのファイルを生成
1. ブラウザに pagefind.js（これも一緒に生成される）をロードして、検索を行う

というものだ。

> Pagefind runs after Hugo, Eleventy, Jekyll, Next, Astro, SvelteKit, or any other website framework. 

と言っているように、Static build ができれば、どんなものにでも導入できる。10,000 ページでもペイロードは 300kB 以下とサクサクなようだ。（10,000ページ Static build するのは途方もない時間がかかりそうだけど…）


## pagefind の導入

まずはインストール。devDependencies で良い。

```bash
$ yarn add pagefind --dev
```

試しにインデクシング（local で static build 済み前提）

```
npx pagefind --site out
```

この場合、`out` ディレクトリに吐き出されている静的ファイルを読み込み、`out/pagefind` に諸々のファイルが吐き出される。
pagefind では、UI もセットになった `pagefind-ui.js` と検索だけの `pagefind.js` を使った方法が提供されているので、試しに、静的な html に `pagefind-ui.js` で実装してみる。

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link href="./pagefind/pagefind-ui.css" rel="" stylesheet />
    <script src="./pagefind/pagefind-ui.js"></script>
  </head>
  <body>
    <div id="search"></div>
    <script>
      window.addEventListener("DOMContentLoaded", (event) => {
        new PagefindUI({ element: "#search", showSubResults: true });
      });
    </script>
  </body>
</html>
```

![Screenshot 2024-06-21 at 17.18.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/5fc46bb9-4c79-2ad4-66b2-f84142d89110.png)

なるほど… 一通り揃った検索ページで、CSS の調整で見た目は変更が可能なようだが、今回はヘッダーに窓を置いて検索を行い、検索ページは作らないつもりだし、 Filter したいけど、画面には出したくない。など、ここから取り除いていくのは逆に面倒そうなので `pagefind.js` を使って検索だけを行う方を使うことにする。

## 検索窓のコンポーネントを作る

方針としては、

1. まずは `useEffect` で、`pagefind.js` を Dynamic import する
1. `input` フィールドに文字が入力されたら検索を行う
1. 検索結果は、人間フレンドリーでないので、読めるようにして結果として表示する

See More などは今回は省略。

### pagefind.js をロードする

```ts:src/app/components/layouts/Search.tsx
'use client';
import { useEffect } from 'react';

// interface を定義
declare global {
  interface Window {
    pagefind: any;
  }
}

export default function Search() {

  // pagefind.js をロード
  useEffect(() => {
    async function loadPagefind() {
      if (typeof window.pagefind === 'undefined') {
        try {
          window.pagefind = await import(
            // @ts-ignore
            /* webpackIgnore: true */ '/search/pagefind.js'
          );
        } catch (e) {
          window.pagefind = { search: () => ({ results: [] }) };
        }
      }
    }
    loadPagefind();
  }, []);
  ...
}
```

`useEffect` を使うのは、SSG の場合、`export default async function Search()` とできないので、`useEffect` 内で `await` させている。ロードに失敗した場合は、検索結果は常に空になるようにセット。

ここで、`/search/pagefind.js` をロードしているが、前述の通り、`npx pagefind --site out` とすると、pagefind.js 含めた諸々は `out/pagefind` に吐き出されるため、開発中はそんなファイルないと言われてしまう。そのため、エラーを握りつぶしているが、開発中に検索できないのは不便。そして、pagefind/pagefind と少しうるさい。

というわけで、build 時は、`out/search` に、dev 時は、`public/search` にファイルを吐き出すようにする。これで、dev/build ともに `/search/pagefind.js` を参照するようになる。

```json:package.json
{
  "scripts": {
    "dev": "next dev",
    "dev:indexing": "pagefind --site out --output-path public/search",
    "build": "next build && pagefind --site out --output-path out/search",
  }
}
```

Production のインデクシングは、build 時に一緒にやって欲しいので、上記のような script にした。こうしないと、deploy 前に local でインデクシングのための build をしてインデクシングをしておかないといけないので、ちょっとな…と。（build には数十分かかるので）

開発用の `public/search` は pagefind.js だけ確実にあれば良く（なくても良いが…チームに新人が入ったときに、実行前のおまじないを教える必要があるなどを考えて）、他のファイルは Git で管理したくないので、

```text:.gitignore
# pagefind
/public/search/*
!/public/search/pagefind.js
```

として除外する。build すると、public/search/pagefind.js は out/search/pagefind.js に build されるが、そのあとのインデクシングで上書きされる。

なお、pagefind をインストールすると、インストールしたマシンのプラットフォームにあったものがインストールされる。私の場合、Apple Silicon の MBP を使っており、`node_modules/@pagefind/darwin-arm64` がインストールされる。pagefind の仕組み的に、これで正しいのだが、ローカル以外で build する場合、その builder の Platform と合わず、うまくインデクシングできない場合がある。その場合は、面倒だけど、インデクシングした諸々のファイルを Git 管理するなど、なんか考えてください。GitHub Actions は、package のインストール時に、Warning を出しつつ良い感じにやってくれたので、今回はこれで。

### 検索する

```ts:src/app/components/layouts/Search.tsx
export default function Search() {
  const [searchQuery, setSearchQuery] = useState<string>('');
  const [results, setResults] = useState<PagefindSearchResult[]>([]);

  // 検索
  const handleSearch = async (query: string) => {
    setSearchQuery(query);
    if (window.pagefind) {
      const search = await window.pagefind.search(searchQuery);
      setResults(search.results);
    }
  };
  
  return (
    <div>
      <input
        type="text"
        placeholder="Search"
        value={searchQuery}
        onChange={(e) => handleSearch(e.target.value)}
      />
    </div>
  )
}
```

`window.pagefind.search()` で検索できる。検索結果は、results state に入れ、後で表示する。
`PagefindSearchResult` type は、`@types/pagefind` などがないので、`any` でも良いが、気持ち悪かったので、[pagefind_web_js/types/index.d.ts](https://github.com/CloudCannon/pagefind/blob/production-docs/pagefind_web_js/types/index.d.ts) を元に設定した。このあと出てくるそんな type ないけどってのは、全部同様に。

### 読めるようにして表示する

まず、読めるようにしないといけないので、Result コンポーネントを作る。ファイルを分けても良いが、ここでしか使わないし、同一ファイル内に作ることにする。

```ts
function Result({
  result
}: {
  result: PagefindSearchResult;
}) {
  const [data, setData] = useState<PagefindSearchFragment>();

  useEffect(() => {
    async function fetchData() {
      let data = await result.data();
      setData(data);
    }
    fetchData();
  }, [result]);

  if (!data) return <></>;

  return (
    <Link href={data.url.replace(/.html$/, '').replace(/\/$/, '')}>
      <h3 className={styles.searchTitle}>{data.meta.title}</h3>
      <p
        dangerouslySetInnerHTML={{ __html: data.excerpt }}
        className={styles.searchContents}
      />
    </Link>
  );
}
```

html の組み方はお好きに。検索が何件ヒットしたとか、ありませんでしたとかの処理は今回はしないので、そのあたりは適当だ。`data.excerpt` にはヒットした部分の `<mark>` タグが含まれるので、dangerously した。

`data.url` には、`.html` の拡張子がついてくる。いらないので、除去。それだと `index.html` の場合 `index` になってしまうので、インデクシングのオプションで `--keep-index-url` をつけて `index` も消す。`dirname/index.html` を `dirname/` にしてくれる。Trailing Slash はなしにしているので、`/` も除去。

via. [Keep Index URL](https://pagefind.app/docs/config-options/#keep-index-url)

```json:package.json
{
  "scripts": {
    "dev": "next dev",
    "dev:indexing": "pagefind --site out --output-path public/search --keep-index-url",
    "build": "next build && pagefind --site out --output-path out/search --keep-index-url",
  }
}
```

検索結果で何が取れるかは、前述の type の PafefindSearchFragment を参照していただくか、あんまり親切ではないけど、[公式ドキュメント](https://pagefind.app/docs/api/#loading-a-result) を参照。

これを呼び出して結果を表示する。

```tsx
export default function Search() {
  ...
  return (
    <div>
      <input ... />
      {results && (
        <div className={styles.searchResults}>
          {results.map((result, index) => (
            <Result
              key={`searchResult-${index}`}
              result={result}
            />
          ))}
        </div>
     }
    </div>
  )
}
function Result(...){...}
```

これで検索機能実装完了だ。あとは、好きにデザインやギミックを仕込めば良い。

## 検索対象を調整する

[Lang x Lang](https://langxlang.com) は、ざっくり以下のような作りになっている。

```text
src/app
  [locale] ← 日本語とかベトナム語とか言語ごとのトップ郡
    [language] ← JavaScript とか PHP とかのトップ郡
      [software] ← Next.js とか Laravel とかのトップ郡（今は最新バージョンページにリダイレクト）
        [...pageId] ← 翻訳ドキュメント本体（submodule から取得したページいっぱい）
  legal ← ポリシー系のページ郡
    terms
    ...
  top ← トップページ
```

この中で、検索対象にしたいのは、翻訳ドキュメント本体だけで、それ以外のページは検索したくなく、また、ヘッダー、フッター、サイドメニュー等も検索対象にしたくない。何も設定しないと、全部インデクスしてしまうので、設定が必要だ。

![Screenshot 2024-06-23 at 17.10.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/e0dbf65c-c435-dd01-121e-e71b841a9fb7.png)


[Configuring what content is indexed](https://pagefind.app/docs/indexing/) にある通り、`data-pagefind-body`, `data-pagefind-ignore="all"` を使えばそれが実現可能である。画像の title, alt などもインデクシングできるが、今回は必要ないのでやっていない。`data-pagefind-ignore` は `all` としないと、その子要素はインデックスされてしまう。default 値を all にしないのは、どういう使い方想定なんだろ？

```tsx:src/app/.../[...pageId]/page.tsx
...
  return (
    <main id="main" data-pagefind-body>
      ...
      <aside data-pagefind-ignore="all">
        ...
      </aside>
      ...
    </main>
  )
```

インデックスさせたくないページは、`data-pagefind-ignore="all"` を入れる必要があるのかないのかよく分からなかったので、念の為入れておいたが、インデクシングを実行すると、

```terminal
[Parsing files]
Found a data-pagefind-body element on the site.
↳ Ignoring pages without this tag.
```

と出てくるので、必要なさそうだ。

これで、プライバシーポリシーなどのページが検索から除外され、必要なものだけが検索対象になった。



## フィルタリングする

インデクシングの対象は絞れたが、検索結果はもうちょっとフィルターしたい。
具体的には、

- 日本語のページにいるときは、日本語のページだけを検索したい
- Next.js ver.14 のページにいるときは、Next.js ver.14 のページだけを検索したい

など、現在地によってフィルタリングしたい。
まずは、[Setting up filters](https://pagefind.app/docs/filtering/) を参考に、`.../[...pageId]/page.tsx` を以下のように設定する。

```tsx:src/app/[locale]/[language]/[software]/[...pageId]/page.tsx
...
  return (
    <main
      id="main"
      data-pagefind-body
      data-locale={locale}
      data-lang={language}
      data-software={software}
      data-version={version}
      data-pagefind-filter="category[data-locale], category[data-lang], category[data-software], category[data-version]"
    >
      ...
    </main>
  )
```

このページには、必ず local, language, software, version のパラメータが存在し（version はpageId から取り出す）、かつ今回はこのページ郡だけが検索対象なので、こうなっているが、他のページも検索対象にしたい場合は、設定できる・したいデータを登録すれば良い。フィルターの設計は、そのサイトの構造ややりたいことによる。

設定ができたら、実際の検索でフィルタリングする。なお、こういう設定をしたら、build してインデクシングしないと、結果は確認できないので、注意。`pagefind-ui.js` で使える `pageSize` などの取得件数のオプションは機能しないようなので、そういうのをやりたい場合は、取ってきたあとに加工する必要がある。

```tsx:Search.tsx
  let searchFilter: string[] = [];
  // usePathname などを使って、良い感じに searchFilter を組み立てる（省略）
  
  const handleSearch = async (query: string) => {
    setSearchQuery(query);
    if (window.pagefind) {
      const search = await window.pagefind.search(searchQuery, {
        filters: {
          category: searchFilter,
        },
      });
      setResults(search.results);
    }
  };
```

via. [Filtering as part of a search](https://pagefind.app/docs/js-api-filtering/#filtering-as-part-of-a-search), [Using compound filters](https://pagefind.app/docs/js-api-filtering/#using-compound-filters)

## 関数名などに使われている複合語をヒットさせる

組み込み関数を API References などで検索したい。というのは、技術ドキュメントサイトで一番多い検索？ではなかろうか。一番多いというか唯一？かもしれない。（唯一なら pagefind 使う必要ないような…）
例えば、Next.js だと、

- generateImageMetadata
- generateMetadata
- NextRequest
- revalidatePath
- unstable_noStore

というような Camel/Pascal/Snake case で関数名が定義されている。pagefind のコードを読み込んだわけではなく、実際の動きから察するに、pagefind は、上記単語を、

- generateImageMetadata → generate, image, metadata
- generateMetadata → generate, metadata
- NextRequest → next, request
- revalidatePath → revalidate, path
- unstable_noStore → unstable, no, store

とバラしてインデックスしているようだ。入力された検索ワードは、インデックスされたワードにヒットするものがあれば、検索できるが、ない場合前方一致であるところまでと解釈するようだ。

例えば、`generateMetadata` と検索した場合、`generateMetadata` というワードはインデックスに存在しないが、`generate` は存在するため、検索語 `generateMetadata` は `generate` と解釈され、前方一致で `generateImageMetadata`, `generateMetadata`, `generateStaticParams` など余計なものまでヒットし、諸々のスコアリングによって、`generateMetada` が一番上に出てこない。なお、大文字小文字は区別していない模様。

ということは、`generateMetadata` を `generate + metadata` にバラさずにインデックスできれば検索にヒットさせることができそう。バラすルールは、動きを見るに、Camel/Pascal/Snake case などを元にバラしているっぽいので、インデクシング用のデータとして、LowerCase にしたものを用意すれば良さそう。

LowerCase にしたテキストは画面には表示したくないので、HTML の attribute をインデックスさせるようにする。

via. [Adding HTML attributes to the index](https://pagefind.app/docs/indexing/#adding-html-attributes-to-the-index)

```tsx
<h1
  className={styles.articleTitle}
  data-title={meta.title.toLowerCase()}
  data-pagefind-index-attrs="data-title"
  data-pagefind-weight="10"
>
  {meta.title}
</h1>
```

ページの h1 に `data-title` という attr を用意し、`title.toLowerCase()` と全部小文字にして `data-pagefind-index-attrs="data-title"` でインデックスさせる。

`h1` の重みは、default で 7.0 だが、技術ドキュメントではタイトルが最重要なことが多いので、最大値の 10 にしておいた。

via. [Weighting sections of the page higher or lower](https://pagefind.app/docs/weighting/)

通常の100倍…！

### generate で検索:

![Screenshot 2024-06-23 at 16.56.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/dc38282b-3ba7-ca00-44cf-2854d49faf63.png)

generate を含む諸々が検索される。

### 更に Camel case で Metadata を続け関数名のまま検索

![Screenshot 2024-06-23 at 16.55.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/f0284aeb-5f4e-b608-2f59-e9557fe8171a.png)

無事に絞り込むことができた。

## まとめ

pagefind を使って、Next.js ver.14 App Router を使った SSG サイトに、検索機能を簡単に実装することができた。実際の動作は、公式ドキュメントの AI 翻訳サイト Lang x Lang の右上の検索窓で確かめられる。スマホのUIはまだ考えていない（ので、出てこない）。

https://langxlang.com

検索対象や、フィルター機能なども簡単にカスタマイズ可能だ。Sort や 重み付けなどの Scoring なども調整できる。ユーザーの検索シナリオを想定し、検索対象やフィルタリングを実施することで、検索精度を上げることができる。少々強引なハックだったが、技術ドキュメント独特の関数名表記にも対応できた。想像力と実装されている機能の組み合わせ。

動作もサクサクしているし、満足した。

### 宣伝

ググっても出てこない（探すのが難しい）ことも、機能があれば、公式ドキュメントに書いてあって、それを工夫すればやりたいことはできる。公式ドキュメントの読み込みは、改めて重要だと再認識。ググって悩んでる間に読み込める。英語さえ読めれば。

そんな英語が苦手なデベロッパー向けに [Lang x Lang](https://langxlang.com) では、Next.js や Laravel などの公式ドキュメントの翻訳を提供している。他言語、コンテンツは随時追加中。ソフトウェア理解の一助になれば幸いだ。

なお、`pagefind` のドキュメントは、まだソース読めな部分が多かったので、翻訳しなかった。

## 参考
- [pagefind](https://pagefind.app/)
- [Add search to your Next.js static site with Pagefind](https://www.petemillspaugh.com/nextjs-search-with-pagefind)