本文へ移動
myowchick

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枚ぶん回して並べると、どのページにどのファセットが付いたかが一覧できる。

urlfiltersword_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抜きで測れる

索引はビルドの出力なので、目で見て確かめるものではないと思い込んでいた。開けるものは開いたほうが早い。

← 一覧へ戻る