コードブロックで複数のハイライトが共存できなかったバグを修正

basecamp/lexxy

Lexxyのコードブロックにおいて、2つ目以降のハイライト適用が1つ目を上書きしてしまう不具合が修正されました。原因はリトークナイザーが生成する TextNode を複数の関数が考慮していなかったことにあり、3箇所の修正によって複数ハイライトの共存が可能になりました。

背景

コードブロックへの連続したハイライト適用が正常に機能しない問題が発生していました。1つ目のハイライトを「hello」に適用した後、2つ目を「goodbye」に適用しようとすると、1つ目のハイライトが失われるという挙動です。

根本的な原因は、ハイライト適用後にリトークナイザーが走るタイミングにあります。$patchStyleText が内部で splitText を呼び出すと、CodeHighlightNode ではなく生の TextNode が一時的に CodeNode の子として存在する状態になります。その状態で2つ目のハイライト適用が始まると、TextNode を認識できない処理が誤った判断を下し、コードブロック専用のスタイリングパスをバイパスしてしまっていました。

技術的な変更

今回の修正は src/extensions/highlight_extension.js の3箇所に集中しています。それぞれが独立した役割を持ち、組み合わせることで問題を解消します。

1. $buildChildRanges の修正: 文字オフセット計算に TextNode を含めるよう拡張しました。

変更前:

for (const child of codeNode.getChildren()) {
  if ($isCodeHighlightNode(child)) {
    const text = child.getTextContent()
    childRanges.push({ node: child, start: charOffset, end: charOffset + text.length })
    charOffset += text.length
  }
}

変更後:

for (const child of codeNode.getChildren()) {
  if ($isCodeHighlightNode(child) || $isTextNode(child)) {
    const text = child.getTextContent()
    childRanges.push({ node: child, start: charOffset, end: charOffset + text.length })
    charOffset += text.length
  }
}

これにより、TextNode が混在していても各ノードの文字オフセットが正しく計算されます。保存済みのハイライト範囲との整合性を維持するためにも、TextNode をカウントに含めることが必要でした。

2. $applyHighlightRangesToCodeNode の修正: スタイル適用のループで TextNode をスキップするガード節を追加しました。TextNodesplitText で生成された一時的な存在であり、スタイル置換の対象は CodeHighlightNode のみです。文字オフセットの計算には参加させつつ、実際のスタイル書き換えからは除外することで、位置計算の正確さとスタイル適用の安全性を両立しています。

// Skip plain TextNodes: only CodeHighlightNodes can be split into
// styled replacements here.
if (!$isCodeHighlightNode(node)) continue

3. $extractHighlightRangesFromCodeNode の新規追加: ライブな CodeHighlightNode のツリーからハイライト範囲を読み出す関数が追加されました。HTMLインポート時に使われていた extractHighlightRanges(DOM要素から読む)に対応する、Lexicalノードツリーから読む版です。ハイライト適用後にこの関数で範囲を保存することで、リトークナイザーが走った後も既存のハイライトが失われず、保留ハイライト機構(HTMLインポートで既に使われていたもの)を通じて再適用されます。

function $extractHighlightRangesFromCodeNode(codeNode) {
  const ranges = []
  const childRanges = $buildChildRanges(codeNode)

  for (const { node, start, end } of childRanges) {
    const style = node.getStyle()
    if (style && hasHighlightStyles(style)) {
      ranges.push({ start, end, style })
    }
  }

  return ranges
}

また、$isTextNodelexical パッケージからインポートされるようになっており、型チェックの一貫性も確保されています。

設計判断

既存の保留ハイライト機構を再利用するアプローチが採られました。HTMLインポート時にコードブロックのハイライトをリトークナイザー後に再適用するために使われていた pending-highlights 機構が、インタラクティブな操作時にも同じ問題を抱えていたことになります。新たな状態管理を導入せず、既存のメカニズムを活用したことで、コードの一貫性と変更の局所性が保たれています。

$buildChildRangesTextNode を含めつつ、$applyHighlightRangesToCodeNode ではスキップするという分離も意図的な設計です。文字オフセットの整合性を保つためには全ノードをカウントする必要がある一方、スタイル書き換えは CodeHighlightNode だけに限定したい。この2つの要求を関心の分離で解決しています。

回帰テストとして test/browser/tests/formatting/bug_code_block_multiple_highlights.test.js が追加されており、「hello」と「goodbye」への順次ハイライト適用後に両方の <mark> 要素が存在することをPlaywrightで検証します。リトークナイザーの非同期サイクルを考慮し、toPass でポーリングしている点も実装の複雑さを反映しています。

まとめ

本PRは、splitText が生む一時的な TextNode の存在を複数の処理関数が見落としていたという根本原因を特定し、文字オフセット計算・スタイル適用・ハイライト永続化の3層を整合的に修正することで解決しました。既存のHTMLインポート用機構を転用してハイライト範囲を保存・再適用する設計は、リトークナイザーが引き起こす中間状態の問題に対する汎用的なパターンとして機能しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
d1d760e2

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「総論→各論→結論」の構成が明確です。リード文で要旨を述べ、背景、技術詳細、設計判断、まとめの各セクションが論理的に配置されています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```javascript:ファイルパス)とGitHubのPRリンク記法([#955](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

「リトークナイザー」「TextNode」などの専門用語を前提としており、対象読者であるエンジニアに適した技術レベルと情報密度で書かれています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論から始まり、各パラグラフもトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、非常に読みやすい構成です。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているすべてのコードブロック(変更前・変更後)は、提供されたDiffの内容と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`TextNode`, `CodeHighlightNode`, `$buildChildRanges`などのLexical関連の技術用語が、PRの文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

「`splitText`によって生成される一時的な`TextNode`が原因」という根本原因の説明から、3つの修正がどう連携して問題を解決するかの解説まで、技術的に正確かつ論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべてPRのTitle, Description, Diffの内容で裏付けられています。「既存の保留ハイライト機構を再利用」という設計判断もPR Descriptionに記載があり、ハルシネーションは認められません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#955)、ファイルパス、関数名などの数値・固有名詞はすべて正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトル「コードブロックで複数のハイライトが共存できなかったバグを修正」は、PRの主題「Fix code blocks only allowing one highlight at a time」を正確に反映しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事の内容は提供されたPR情報に限定されており、バージョンサポート情報など、根拠のない外部知識の追加はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「一時的に」「既存の」といった時間や状態に関する表現が、PRの情報と正確に一致しています。