HTML出力から冗長なbold/italicタグを除去するカスタムexportDOMを追加
LexicalのTextNode.exportDOM()が生成する<b><strong>text</strong></b>や<i><em>text</em></i>という二重タグ問題を、カスタムexport関数で解消しました。これにより、HTML出力のセマンティクスが正しい形に整理されます。
背景
LexicalのTextNodeはHTML出力時に、セマンティクスタグと表示用タグを二重に付与する問題を抱えていました。具体的には、createDOM()が<strong>(bold)や<em>(italic)といったセマンティクスタグを生成した上で、exportDOM()がさらに<b>や<i>といった表示用タグで無条件にラップするため、<b><strong>text</strong></b>という冗長なマークアップが出力されていました。
この問題はPlaywrightのテストやCapybaraのシステムテストにも反映されており、たとえばtest/browser/tests/formatting/inline_formatting.test.jsでは"<p>Hello <b><strong>everyone</strong></b></p>"が期待値として記述されていました。出力HTMLをそのままTrixなど他のエディタに渡す際や、アクセシビリティの観点から、冗長なタグは除去されるべきものでした。
技術的な変更
新たに追加されたsrc/helpers/text_node_export_helper.jsの exportTextNodeDOM 関数が、Lexical組み込みのTextNode.exportDOM()を置き換えます。この関数はcreateDOM()が生成した要素を起点とし、各フォーマットについてセマンティクスタグの重複をチェックしてからラップするかどうかを判断します。
export function exportTextNodeDOM(editor, textNode) {
const element = textNode.createDOM(editor._config, editor)
element.style.whiteSpace = "pre-wrap"
// ...(textTransformの処理)...
let result = element
if (textNode.hasFormat("bold") && !containsTag(element, "strong")) {
result = wrapWith(result, "b")
}
if (textNode.hasFormat("italic") && !containsTag(element, "em")) {
result = wrapWith(result, "i")
}
if (textNode.hasFormat("strikethrough")) {
result = wrapWith(result, "s")
}
if (textNode.hasFormat("underline")) {
result = wrapWith(result, "u")
}
return { element: result }
}
containsTag()ヘルパーは、要素自身のタグ名とquerySelector()による子孫検索の両方で対象タグの存在を確認します。<strong>が既に存在する場合は<b>を追加せず、<em>が既に存在する場合は<i>を追加しません。一方、<s>(strikethrough)と<u>(underline)はcreateDOM()が対応するセマンティクスタグを生成しないため、チェックなしでラップされます。
この関数はsrc/elements/editor.jsのエディタ初期化時に、Lexicalの html.export 設定としてMap形式で登録されます。
html: {
export: new Map([ [ TextNode, exportTextNodeDOM ] ])
}
テストの変更は変更の広がりを示しています。Playwrightテスト(inline_formatting.test.js、markdown.test.js、links.test.js、highlights.test.js)とCapybaraシステムテスト(trix_html_test.rb、color_highlighter_test.rb、prompts_test.rb)の期待値が一斉に更新されており、いずれも<b><strong>の二重タグから<strong>単体へ修正されています。
設計判断
Lexicalのコアを変更せずに上書きするアプローチが採用されました。Lexicalはhtml.export設定でノード型ごとにexport関数を登録できる拡張ポイントを提供しており、今回はこれを活用してTextNodeの出力処理のみを差し替えています。
関数の実装では、createDOM()を起点とすることで、Lexicalがテーマや設定に応じてDOMを構築するロジックを再利用しています。フォーマット判定の順序(bold→italic→strikethrough→underline)は、<strong>が最内層になるよう設計されており、test/dummy/app/views/sandbox/_default.html.erbで確認できる<i><strong>styles</strong></i>のように、italic(<i>)とbold(<strong>)が共存する場合は意図的に<i>が外側に残ります。これはboldにはセマンティクスタグ<strong>が存在する一方、italicが<em>で既にカバーされていれば<i>を省略し、そうでなければ<i>で囲む、という非対称な処理の結果です。
まとめ
本PRは、Lexicalのhtml.export拡張ポイントを活用することで、ライブラリ本体に手を入れずにHTML出力の品質を修正した変更です。セマンティクスタグと表示用タグの重複チェックという単純なロジックで、広範なテストにわたる冗長なマークアップを一掃しており、出力HTMLをそのまま他システムに渡す際の信頼性が向上します。