Pagefindのfiltersに空文字を渡すと0件になる
サイト内検索に「すべて / ブログ / ツール」の絞り込みを付けた。Pagefindはファセットを持っているので、HTMLに属性を1つ足して、検索時にfiltersへ渡すだけで済む。
問題は「すべて」の表しかたで、Reactの状態を''で初期化して素直にfiltersへ流すと、検索結果が1件も出なくなる。Pagefindはこれを「絞り込みなし」ではなく「値が空文字のページを探せ」と読むから。
渡し方を4通り実測して、ついでに索引そのものを開いて中身を確かめた。以下はpagefind 1.5.2での結果。
種別ファセットは属性1つで足りる
ファセットはdata-pagefind-filterで作る。書き方が2つあって、要素の中身を値にする形と、属性の中に値まで書く形がある。
<span data-pagefind-filter="type">哺乳類</span>
<h1 data-pagefind-filter="type:ブログ">ラッコの生態</h1>
種別のようにページごとに固定の値なら、後者にすると値を画面に出さずに済む。見えない要素を足す必要がない。
記事側は記事レイアウトのh1に相乗りさせた。
<h1
data-pagefind-filter="type:ブログ"
class="font-display text-3xl leading-snug font-bold tracking-tight"
>
{title}
</h1>
ツール側は個別ページのh1にtype:ツールを、一覧ページとカテゴリページはh1ではなく中身を包むdivに同じ値を置いた。入口のページも「ツール」で引っかかってほしいので。
属性の置き場所は索引の範囲と関係なかった
data-pagefind-bodyを付けた要素の中身だけが索引に載る。ならファセットの属性も中に無いと拾われないはずだと思って、小さいHTMLを何枚か作って食わせてみた。
| 属性を置いた場所 | 拾われたfilters |
|---|---|
data-pagefind-bodyの中のh1 | {"type":["ナカ"]} |
data-pagefind-bodyの外のheader | {"type":["ソト"]} |
data-pagefind-ignoreを付けたheader | {"type":["ムシ"]} |
data-pagefind-ignoreの中のspan | {"type":["ムシノナカ"]} |
全部拾われた。本文の切り出しとファセットの収集は別の処理で、後者はページ全体を見ている。data-pagefind-bodyの中と外に1つずつ置いたページでは、{"type":["ソト","ナカ"]}と両方が載った。1ページが同じファセットの値を複数持てるので、うっかり2か所に書くと黙って両方に属する。
関係あるのはページが索引に載るかどうかだけ。このサイトだとdistのHTMLが42枚あって、索引に載ったのは40枚だった。落ちた2枚は404ページと検索ページで、前者はnoindexのときにdata-pagefind-bodyを付けない作りにしているから、後者は検索UIそのものが結果に混ざらないようページ全体をdata-pagefind-ignoreで包んでいるから。ページごと索引から消えれば、そこに書いた属性も一緒に消える。
「すべて」を空文字で表すと0件になる
絞り込みの状態をfiltersに載せるところ。search()の第2引数の形を変えて、同じ語で何件返るかを測った。
| 渡した形 | 件数 |
|---|---|
search("ツール") | 28 |
{} | 28 |
{ filters: {} } | 28 |
{ filters: { type: null } } | 28 |
{ filters: { type: '' } } | 0 |
{ filters: { type: 'ツール' } } | 22 |
{ filters: { type: 'ブログ' } } | 1 |
危ないのは空文字だけだった。filtersごと省いても、空オブジェクトを渡しても、値にnullを入れても全件返る。実装を読むと、search()は{ filters: {}, sort: {}, ...options }で既定値を埋めてからJSON.stringifyしているので、省略と{}は同じ文字列になる。nullはバックエンド側が無視する。''だけが「空文字という値で絞り込め」として通り、どのページも一致しない。
Reactのstateで「未選択」を''にするのはよくある書き方で、それをそのままfiltersに流すとこうなる。なので状態はstring | nullにして、渡す形に変換するところを関数として切り出した。
/** 種別の絞り込みを Pagefind の検索オプションに変換する。null は絞り込みなし */
export function buildSearchOptions(type: string | null): { filters?: { type: string } } {
return type === null ? {} : { filters: { type } }
}
たった1行だけど、切り出しておくとブラウザもWASMも要らずにテストできる。Pagefindの読み込みはimport()の動的解決で実行時に走るから、コンポーネントごと動かそうとすると途端に面倒になる。
索引はpf_fragmentを開けば読める
ここまでの数字は全部、UIを触らずに測っている。Pagefindが吐くdist/pagefind/の中身が素直な形をしているから。
ページ1枚ぶんの情報はdist/pagefind/fragment/*.pf_fragmentに入っている。中身はgzipで、展開すると先頭12バイトがpagefind_dcdというマジックバイト、その後ろがJSON。
import { readFileSync } from 'node:fs'
import { gunzipSync } from 'node:zlib'
const raw = gunzipSync(readFileSync('dist/pagefind/fragment/xxxxxxx.pf_fragment'))
console.log(raw.subarray(0, 12).toString('utf8')) // pagefind_dcd
const json = JSON.parse(raw.subarray(12).toString('utf8'))
url・filters・content・word_countが読める。40枚ぶん回して並べると、どのページにどのファセットが付いたかが一覧できる。
| url | filters | word_count |
|---|---|---|
/ | {} | 985 |
/blog/ | {} | 182 |
/blog/qr-code-tool/ | {"type":["ブログ"]} | 286 |
/blog/tags/WSL/ | {} | 68 |
/tools/ | {"type":["ツール"]} | 443 |
/tools/category/dev/ | {"type":["ツール"]} | 158 |
/tools/qr-code/ | {"type":["ツール"]} | 52 |
検索そのものもNode上で動く。pagefind.jsはただのESMで、fetchでしかdist/pagefind/を読まないので、globalThis.fetchをファイル読み込みに差し替えてbasePathを渡せば動く。上の件数の表はこれで測った。
globalThis.fetch = async (url) =>
new Response(readFileSync(toLocalPath(url)), { status: 200 })
const pagefind = await import(pathToFileURL('dist/pagefind/pagefind.js').href)
const pf = pagefind.createInstance({ basePath: 'https://local.test/pagefind/' })
await pf.init()
fetchのURLにはキャッシュ避けのクエリが付くので、?より後ろは落としてからパスに直す。ここで踏んでENOENTが出た。
開いてみたら2つ取りこぼしていた
読み上げ用の文が抜粋の先頭に出る
ツール一覧には絞り込みの件数を読み上げるためのsr-onlyの段落がある。画面には出ないが、HTMLとしては本文の先頭にある。contentを見たらそれが混ざっていて、検索結果の抜粋の頭に出ていた。
小さいHTMLで並べるとこうなる。同じ内容の2枚で、片方だけdata-pagefind-ignoreを付けた。
/a/ | 3件が該当した。 <mark>ラッコ</mark>の生態. <mark>ラッコ</mark>は貝を石で割る。
/b/ | <mark>ラッコ</mark>の食事. <mark>ラッコ</mark>は貝を石で割る。
検索結果に「3件が該当した。」から始まる抜粋が並ぶことになる。抜粋は一致箇所の周りを切り出すので、本文が短いページほど先頭が残りやすい。読み上げ専用の文と、絞り込みの操作そのものはdata-pagefind-ignoreで索引から外した。
ファセットの件数は属性を付けたページしか数えない
もう1つ。filtersが空のままのページは、どの絞り込みにも属さない。「すべて」でしか出てこないので、UIを触っているだけだと気づきにくい。
「変換」で引くと14件返る。「ツール」に絞ると11件で、「ブログ」に絞ると0件。残りの3件はトップページ・ブログ一覧・タグページで、どれもfiltersが{}だった。ツール側は一覧ページとカテゴリページにも属性を足してあるので落ちていない。
ファセットの件数はsearch()の戻り値のfiltersに入っている。ただしこれ、絞り込み無しでいきなり引くと空で返ってきた。
絞り込みなし hits=14 filters={}
ツールで絞る hits=11 filters={"type":{"ツール":11,"ブログ":0}}
先にpagefind.filters()を呼んでおくと、絞り込み無しでも件数が返るようになる。
filters() 先出し: {"type":{"ツール":23,"ブログ":3}}
絞り込みなし hits=14 filters={"type":{"ツール":11,"ブログ":0}}
ファセットの索引はdist/pagefind/filter/に別ファイルで置かれていて、必要になるまで取りに行かない作りになっている。絞り込みを渡さない検索はそれを読まないので、件数も出ない。ボタンに件数を出したいなら、初回にfilters()を1回呼んでおく必要がある。ちなみにこのサイトのファセット索引は98バイトしかない。
まとめ
- ファセットは
data-pagefind-filter="名前:値"で足せる。置き場所はdata-pagefind-bodyの内外を問わないが、ページ自体が索引に載っていることが条件 - 絞り込みなしは
filtersのキーごと省く。{}やnullは無害だが、''を渡すと0件になる dist/pagefind/fragment/*.pf_fragmentはgzip+pagefind_dcd+JSON。索引に何が入ったかはここを開けば全部見えるpagefind.jsはfetchを差し替えればNodeでも動くので、検索そのものの挙動もUI抜きで測れる
索引はビルドの出力なので、目で見て確かめるものではないと思い込んでいた。開けるものは開いたほうが早い。