VitePress localSearchPlugin バグのデバッグ
Lee Dogeon @moreal@hackers.pub
原文は https://moreal.hashnode.dev/vitepress-localsearch-debugging にあります。内容は同じですが、画像ホスティング用に Hashnode を使用することになったため、礼儀として(?)リンクを残しておきます 🙏
昨日、Zennという日本の技術ブログサービスのトレンド記事を知らせるボットを計画し、実装しようとしていました。ActivityPub プロトコルを対象とするボットだったため、BotKitを調査し、BotKit が提供するメソッドが何をするのかを知るためにFedifyのドキュメントまで探すことになりました。そこで Fedify ドキュメントで検索機能が壊れていることに気づき、イシューを報告しました。VitePress にもイシューを報告してほしいという依頼(?)を受け、イシューの内容を書くための調査から PR を提出するまで、ヤクの毛刈りをしたというのがこの記事の内容です 🤣
バグの再現(原因変数の確認)
VitePress リポジトリにイシューを書く際、本文に再現方法と再現される状況の特定、発生理由を書く必要があったため、様々な試行を行い、問題が発生する状況を調べました。
最初は Fedify のイシューに報告したように、コードブロックがある地点から検索結果に表示されなくなったため、コードブロックが含まれていると検索結果に含まれないのではないかと推測しました。
Fedify ドキュメントは使用しているプラグインが多く、フィードバックを得るのに時間がかかりました。一時ディレクトリ(/tmp
)にプロジェクトを初期化し、Fedify で使用されている vitepress@1.6.3
パッケージをインストールしました。そして yarn vitepress init
コマンドを実行して基本テンプレートでドキュメントを生成し、.vitepress/config.mts
で search.provider
の値を "local"
に設定しました。Fedify ドキュメントにあるヘッディングを追加してみましたが、検索は正常に機能しました。
さらに環境を再現するため、マークダウン関連の設定を取り入れ、まず @shikijs/vitepress-twoslash
を適用してみましたが、バグは発生しませんでした。続いてマークダウン構文関連のプラグインも追加したところ、バグが再現されました。その後、マークダウンプラグインを一つずつ除外しながら、どのプラグインが影響を与えているかを確認し、markdown-it-jsr-ref プラグインを使用した時にバグが再現されることを確認しました。
markdown-it-jsr-ref プラグインは、`Type`
のように特定の型や関数を指すマークダウンを書いた時に、JSR にあるドキュメントにリンクさせるプラグインです。HTML 出力がどのように変わるかを考えると、<code>
タグだけで囲まれていた部分が <a>
タグでさらに囲まれるようになります。これに合わせて ## With <a>a tag</a> heading
と ## With <code>code tag</code> heading
という簡単な二つのテストケースを作成し、前者だけが "With " までしか検索結果に表示されないことを確認しました。
バグが再現されるテストケースを定義したことで、これが VitePress のバグであることが明確になりました。CommonMark の仕様にヘッディングに <a>
タグが入ってはいけないという部分は見つけられなかったからです。
バグの修正
VitePress のコードベースを理解していない状況だったので、どのコードが影響しているのかを最初に見つける必要がありました。search.provider
設定の値として "local"
を使用しているため、local というキーワードで検索してみました。すると localSearchPlugin.ts
というファイルが目に入りました。内容を見ると MiniSearch という検索ライブラリを活用していることがわかり、探していたものに間違いないと思いました。
まず clearHtmlTags
関数が検索結果として表示するテキストのために HTML タグをすべて削除しているようだったので、ここにブレークポイントを設定し、デバッガーを使いながら上位の splitPageIntoSections
関数を理解しました。
headingContentRegex
という正規表現を使用する行で、二つのテストケースの処理結果が異なっていました。headingContentRegex
正規表現を理解すると、ヘッディングを入力として受け取り、二つの部分をキャプチャしようとしていることがわかりました。一つ目は <a>
タグの前の内容で、変数名では title
、検索結果として表示される部分です。二つ目は <a>
タグの属性のうち href
の内容をキャプチャするもので、その値が #
で始まる必要がありました。
const headingContentRegex = /(.*?)<a.*? href="#(.*?)".*?>.*?<\/a>/i
#
で始まるものを取得するのはアンカーを抽出するためのもので、VitePressはヘッディングの末尾に任意でアンカーを挿入していました。FedifyドキュメントのヘッディングのHTML値を引用すると以下のようになります。
Implement the <a href="https://jsr.io/@fedify/fedify@1.6.2/doc/federation/~/KvStore"><code>KvStore</code></a> interface <a class="header-anchor" href="#implement-the-kvstore-interface" aria-label="Permalink to "Implement the `KvStore` interface""></a>
上記の正規表現にテストケースを当てはめると、以下のような結果が返されます。返された配列の2番目の値を見ると、With
までしか返されておらず、3番目の値を見るとアンカーは正しく取得できていることが確認できました。
>>> /(.*?)<a.*? href="#(.*?)".*?>.*?<\/a>/i.exec('With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>')
Array(3) [ 'With <a href="https://example.com/">a tag</a> heading <a href="#anchor"></a>', "With ", "anchor" ]
状況をまとめると、ヘッディングの末尾に付く<a>
タグのhref
値とその<a>
タグの前部分を取得することが本来の意図ですが、正しく動作していないと理解できました。
問題は最初のキャプチャグループ(.*?)
の?
記号でした。MDNの正規表現ドキュメントによると、?
記号は*
、+
、?
などの記号の後に使用されると、それらの記号をnon-greedy(最小一致)にすると説明されています。
If used immediately after any of the quantifiers
*
,+
,?
, or{}
, makes the quantifier non-greedy (matching the minimum number of times), as opposed to the default, which is greedy (matching the maximum number of times).
正規表現を完全に理解していなかったため確実な判断ではありませんが、上記の引用と合わせて理解すると、non-greedyであるため、最初のキャプチャグループが取得できる最大値の"With <a href="https://example.com">a tag</a> heading "
ではなく、最小値の"With "
だけを取得してしまい、この問題が発生したと理解しました。残りの部分は<a.*?
部分が取得することになります。括弧で囲んでキャプチャグループにしてテストすると、以下のように出力されるので正しいようです。
>>> /(.*?)<a(.*?) href="#(.*?)".*?>.*?<\/a>/i.exec('With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>')
Array(4) [ 'With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>', "With ", ' href="https://example.com">a tag</a> heading <a', "anchor" ]
そこで、最初のキャプチャグループを再度greedy(最大一致)にする必要があり、単純に?
記号を1つ削除しました。テストしてみると、うまく動作しているように見えました。バグ修正完了です!
>>> /(.*)<a.*? href="#(.*?)".*?>.*?<\/a>/i.exec('With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>')
Array(3) [ 'With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>', 'With <a href="https://example.com">a tag</a> heading ', "anchor" ]
PRの作成
PRを提出する時間です。PRを開く際には、メンテナーにどのようなバグなのかを十分に説明する必要があります。そのために、別々の2つのリポジトリを作成し、GitHub Pagesにデプロイする方法で2つのドキュメントを作成しました。1つは既存の1.6.3バージョンでバグを確認できるようにし、もう1つは?
記号を削除する変更を加えて、このPRの変更でバグを修正できることを確認できるようにしました。
スクリーンショットを添付しましたが、全体を撮るのが難しかったため、全文はイシューで確認してください 🙏
1時間ほど準備して提出したのですが、マージは瞬時に行われ、素早い確認に感謝しつつも、なんだか照れくさい気持ちになりました 😅
振り返り
うまくいった点
- ヤクの毛刈りではありましたが、楽しかったです。
- 最近登場したZed Debuggerを使用してデバッグしましたが、良い経験でした。
うまくいかなかった点(残念だった点)
- これに関連するバグを解決中のイシューやPRがすでに存在するかどうかを知るのが難しかったです。
- PR説明の準備にかなり多くの時間を費やしました。
- 再現環境の構築にもかなり時間がかかりました。
- この振り返り記事を書くのにも2時間ほどかかりました。
- 正規表現を完全に理解していなかったため、
<a>
タグについて正確に把握・判断できませんでした。
改善点
- 文章にしてみると、かなり手順的なプロセスなので、Agentでこのプロセスを自動化できないだろうか?
- 再現環境の構築とデプロイ、修正環境のデプロイをより簡単にできないだろうか?
- すでに誰かが取り組んでいる作業かどうかを簡単に確認する方法はないだろうか?
- 正規表現を完全に理解できるとよいでしょう。