はじめに
先日公開した Lang x Lang は、現時点で 500 ページくらいあるので、検索機能が欲しいと思った。Lang x Lang は Next.js
の ver.14.2.4
の App Router
を使っており、Production は SSG
で動いている。なんか良いのないかなと探してみたところ、pagefind
がお手軽で良さそうだったので、試しに導入してみた。
結論としては、導入は簡単だったが、工夫しないと検索精度は微妙だった。
微妙だったのは、「技術ドキュメントサイトの検索精度として」という意味で。
API Reference に頻出の関数名で検索したいなど場合、例えば generateMetadata
と検索したら、そのページが一番上に出てきて欲しいが、generateMetadata
のような複合語(generate + metadata)などと相性が悪い。そういう意味で微妙だった、というより技術ドキュメントの検索としては、致命的だった。
せっかく設置したけど、これならない方が良いかな?とも検討したが、解決できたので公開した。満足している。
pagefind とは
pagefind は大規模サイトでもできるだけ少ない帯域幅で、静的サイトの検索を実現する(目指す)ライブラリ。のようだ。2023年9月に安定版がリリース。以降、いまのところアクティブに開発が行われている。今回使ったのは、v1.1.0
。
pagefind はどんな風に動くかというと、ざっくり
- build した静的なページを対象に
- 検索用の fragment, index, filter などのファイルを生成
- ブラウザに 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 で良い。
$ 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
で実装してみる。
<!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>
なるほど… 一通り揃った検索ページで、CSS の調整で見た目は変更が可能なようだが、今回はヘッダーに窓を置いて検索を行い、検索ページは作らないつもりだし、 Filter したいけど、画面には出したくない。など、ここから取り除いていくのは逆に面倒そうなので pagefind.js
を使って検索だけを行う方を使うことにする。
検索窓のコンポーネントを作る
方針としては、
- まずは
useEffect
で、pagefind.js
を Dynamic import する -
input
フィールドに文字が入力されたら検索を行う - 検索結果は、人間フレンドリーでないので、読めるようにして結果として表示する
See More などは今回は省略。
pagefind.js をロードする
'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
を参照するようになる。
{
"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 で管理したくないので、
# 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 を出しつつ良い感じにやってくれたので、今回はこれで。
検索する
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 を元に設定した。このあと出てくるそんな type ないけどってのは、全部同様に。
読めるようにして表示する
まず、読めるようにしないといけないので、Result コンポーネントを作る。ファイルを分けても良いが、ここでしか使わないし、同一ファイル内に作ることにする。
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
{
"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 を参照していただくか、あんまり親切ではないけど、公式ドキュメント を参照。
これを呼び出して結果を表示する。
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 は、ざっくり以下のような作りになっている。
src/app
[locale] ← 日本語とかベトナム語とか言語ごとのトップ郡
[language] ← JavaScript とか PHP とかのトップ郡
[software] ← Next.js とか Laravel とかのトップ郡(今は最新バージョンページにリダイレクト)
[...pageId] ← 翻訳ドキュメント本体(submodule から取得したページいっぱい)
legal ← ポリシー系のページ郡
terms
...
top ← トップページ
この中で、検索対象にしたいのは、翻訳ドキュメント本体だけで、それ以外のページは検索したくなく、また、ヘッダー、フッター、サイドメニュー等も検索対象にしたくない。何も設定しないと、全部インデクスしてしまうので、設定が必要だ。
Configuring what content is indexed にある通り、data-pagefind-body
, data-pagefind-ignore="all"
を使えばそれが実現可能である。画像の title, alt などもインデクシングできるが、今回は必要ないのでやっていない。data-pagefind-ignore
は all
としないと、その子要素はインデックスされてしまう。default 値を all にしないのは、どういう使い方想定なんだろ?
...
return (
<main id="main" data-pagefind-body>
...
<aside data-pagefind-ignore="all">
...
</aside>
...
</main>
)
インデックスさせたくないページは、data-pagefind-ignore="all"
を入れる必要があるのかないのかよく分からなかったので、念の為入れておいたが、インデクシングを実行すると、
[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 を参考に、.../[...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
などの取得件数のオプションは機能しないようなので、そういうのをやりたい場合は、取ってきたあとに加工する必要がある。
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, 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
<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
通常の100倍…!
generate で検索:
generate を含む諸々が検索される。
更に Camel case で Metadata を続け関数名のまま検索
無事に絞り込むことができた。
まとめ
pagefind を使って、Next.js ver.14 App Router を使った SSG サイトに、検索機能を簡単に実装することができた。実際の動作は、公式ドキュメントの AI 翻訳サイト Lang x Lang の右上の検索窓で確かめられる。スマホのUIはまだ考えていない(ので、出てこない)。
検索対象や、フィルター機能なども簡単にカスタマイズ可能だ。Sort や 重み付けなどの Scoring なども調整できる。ユーザーの検索シナリオを想定し、検索対象やフィルタリングを実施することで、検索精度を上げることができる。少々強引なハックだったが、技術ドキュメント独特の関数名表記にも対応できた。想像力と実装されている機能の組み合わせ。
動作もサクサクしているし、満足した。
宣伝
ググっても出てこない(探すのが難しい)ことも、機能があれば、公式ドキュメントに書いてあって、それを工夫すればやりたいことはできる。公式ドキュメントの読み込みは、改めて重要だと再認識。ググって悩んでる間に読み込める。英語さえ読めれば。
そんな英語が苦手なデベロッパー向けに Lang x Lang では、Next.js や Laravel などの公式ドキュメントの翻訳を提供している。他言語、コンテンツは随時追加中。ソフトウェア理解の一助になれば幸いだ。
なお、pagefind
のドキュメントは、まだソース読めな部分が多かったので、翻訳しなかった。