コードブロックのインデント保持とハイライト位置の同期

basecamp/lexxy

コードブロック内の先頭空白がエディタから保存、再レンダリングの往復で失われ、ハイライトの <mark> が誤った文字列に付与されていた問題を解決しました。highlightElement が行っていたテキスト抽出とハイライト抽出が分離していたために起きていた同期ずれを、単一走査で統一的に取得する実装に置き換えています。これにより、インデントや ASCII テーブルの形状が維持され、ハイライトが正しい位置に描画されます。

背景

コードブロックのリードインデントや箱形テーブルが、エディタ → 保存 → レンダリングのサイクルで行頭空白が削除され、ハイライトの色付けが別の文字にずれる事象が報告されていました。根本原因は highlightElementDOMParser().parseFromString(...).body.textContent でコード文字列を取得していたことで、HTML パーサが空白を正規化し、先頭空白が失われていた点にあります。さらにハイライト範囲は extractHighlightRanges が元の DOM で計算していたため、短縮された文字列に対してオフセットが適用され、結果としてハイライトがずれていました。

この問題はインデントが必須となるコード例や表形式のテキストで顕著に現れ、ユーザー体験を損なうため修正が必要でした。コードブロックの内容とビジュアルハイライトの整合性を保つことは、エディタ機能の信頼性に直結します。したがって、テキスト抽出とハイライト抽出を同一の文字列基盤で行う方針が採られました。

技術的な変更

highlightElement は、コード文字列とハイライト情報を同時に取得する新しいヘルパー extractCodeAndHighlights を呼び出すように変更されました。従来は code = preElement.innerHTML.replace(/<br\s*\/?>(?i)/gi, "\n") でコードだけを取得し、別途 extractHighlightRanges でハイライトを抽出していました。

@@ -12,16 +12,16 @@ export function highlightElement(preElement) {
   if (preElement.dataset.highlighted === "true") return

   const language = preElement.getAttribute("data-language")
-  let code = preElement.innerHTML.replace(/<br\s*\/??>/gi, "\n")
@@
-  // Extract highlight ranges before Prism destroys <mark> elements
-  const highlights = extractHighlightRanges(preElement)
-
-  // unescape HTML entities in the code block
-  code = new DOMParser().parseFromString(code, "text/html").body.textContent || ""
+  // Read the source text and <mark> ranges in a single walk, before Prism
+  // rewrites the element. Sharing one traversal keeps the highlight offsets
+  // aligned with the code string and preserves leading whitespace — deriving
+  // either of them separately (e.g. textContent through DOMParser) collapses
+  // leading whitespace and shifts every range, re-indenting the rendered block.
+  const { code, highlights } = extractCodeAndHighlights(preElement)

extractCodeAndHighlights<pre> 内を再帰的に走査し、テキストノードはそのまま文字列に、<br> 要素は改行文字として処理し、
の位置で <mark> の開始・終了を記録します。これにより、コード文字列とハイライト範囲が同一の文字ストリーム上のオフセットで表現され、空白が保持されたまま正確にマッピングされます。

function extractCodeAndHighlights(preElement) {
  const root = preElement.querySelector("code") || preElement
  const highlights = []
  let code = ""

  function walk(node) {
    if (node.nodeType === Node.TEXT_NODE) {
      code += node.textContent
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      if (node.tagName === "BR") {
        code += "\n"
      } else if (node.tagName === "MARK") {
        const start = code.length
        walk(node.firstChild) // recurse into mark's children
        const end = code.length
        highlights.push({ start, end, style: node.getAttribute("style") })
      } else {
        node.childNodes.forEach(walk)
      }
    }
  }

  walk(root)
  return { code, highlights }
}

この変更に合わせて、インデント保持とハイライト位置の検証を行うテストが 3 件追加されました。テストはインデントされたコード、インデント付き ASCII テーブル、インデント内のハイライトの三種を対象とし、pre.textContent<mark> の内容・スタイルが期待通りであることを確認します。回帰テストの導入により、将来的な変更で同様の問題が再発しにくくなります。

設計判断

テキスト抽出とハイライト抽出を 単一走査 に統合した設計は、空白正規化という副作用を排除しつつ、アルゴリズムの複雑性を増やさないことを目的としています。DOMParser を使用しないことで HTML パーサの暗黙的な正規化を回避し、コードブロックの リテラル な表現を保持できる点が重要でした。既存の API (highlightElement) は変更せず内部実装だけを差し替えることで、呼び出し側への互換性を確保しています。

このアプローチは、機能追加よりも既存挙動の正確性向上に重点を置く設計方針と一致します。新しいヘルパーは内部利用に留め、外部から直接呼び出す必要がないため、将来的に実装をさらにリファクタリングしても公開インターフェースは安定したままです。結果として、エディタのレンダリングロジックが決定論的に動作し、バグ再発リスクが低減しました。

まとめ

本PRは、コードブロックの先頭空白が失われる問題とハイライトオフセットのずれを根本的に解消し、インデント保持とハイライト位置の同期 を実現しました。単一走査でテキストとハイライト範囲を取得することで、HTML パーサの副作用を排除し、既存 API 互換性を維持しつつ信頼性を向上させています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
c00dad08

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文→背景→技術的な変更→設計判断→まとめの流れが明確で、各セクションに必須要素が揃っている。まとめはリード文の単なる繰り返しではなく、変更の意義を再提示している。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックのファイル名付きハイライトは正しく `javascript:src/helpers/code_highlighting_helper.js` と記述されている。GitHubリンクは `[PR #1157](URL)` ではなく `[PR #1157](URL)` と記載されており、要件のリンク形式に完全には合致していない。

対象読者への適合性 ✓ PASS

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

専門エンジニア向けの技術的記述で、初心者向けの余計な説明はない。

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

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

各セクションは総論パラグラフで要旨を示し、以降は1トピック1段落で構成され、段落は6文未満で空行で区切られている。

Diff内容との照合 ⚠ WARNING

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

Diffブロックは概ね正しいが、 `replace(/<br\s*\/??>/gi, "\n")` の正規表現がオリジナルの `replace(/<br\s*\/? > /gi, "\n")` と異なり、余分な `??` が入っている。また、前後のコンテキスト行(grammar 取得等)が省略されているため、完全な照合とは言えない。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`highlightElement`、`extractCodeAndHighlights`、`DOMParser` などの用語は PR と一致しており、誤用は見られない。

説明の技術的正確性 ✓ PASS

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

変更の根本原因と解決策が PR のサマリと合致しており、技術的に正確。

事実の突合 ✓ PASS

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

記事内の全主張は PR の Title、Description、Diff で裏付けられている。数値や外部情報の無根拠な記載はなし。

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

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

PR 番号 #1157、テスト件数「3 件」など、数値は正確に記載されている。

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

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

記事タイトルは PR の趣旨「コードブロックのインデント保持とハイライトオフセットの同期」を日本語で適切に表現している。

外部知識の正確性 ✓ PASS

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

バージョンサポートや LTS など、PR に記載のない外部知識は含まれていない。

時間表現の正確性 ✓ PASS

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

時間に関する表現はなく、PR のタイムラインと食い違う記載もない。