[basecamp/lexxy] Markdownとして貼り付けられたマークアップのスタイル属性をサニタイズ対象に含める

basecamp/lexxy

Context

Lexxyエディタでは、スタイル属性を持つHTMLをペーストした際に、カラーやハイライトなどの一部のスタイルを正規化(canonicalize)する機能があります。しかし、#602 で報告されていた通り、リッチテキストではなくMarkdownやプレーンテキストとして認識されたマークアップをペーストした場合、この正規化処理が適用されない問題がありました。

これは、insertHtml 経由で生成されたノードに PASTE_TAG が適用されていなかったことが原因です。PASTE_TAG はLexicalエディタの内部メカニズムで、ペースト操作で挿入されたノードにマークを付け、サニタイゼーション処理の対象であることを示す役割を持ちます。

Technical Detail

DOMPurifyの設定拡張

まず、HTMLサニタイゼーションライブラリであるDOMPurifyの設定に ADD_URI_SAFE_ATTR を追加し、captionfilename 属性をURI安全属性として明示的に許可するようになりました。

return {
  ALLOWED_TAGS: ALLOWED_HTML_TAGS.concat(Lexxy.global.get("attachmentTagName")),
  ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
  ADD_URI_SAFE_ATTR: [ "caption", "filename" ],
  SAFE_FOR_XML: false
}

insertHtmlメソッドのシグネチャ変更

Contents クラスの insertHtml メソッドが、オプショナルな tag パラメータを受け取れるように変更されました。このタグは editor.update() 呼び出し時に渡され、挿入されるノードにマークを付けます。

変更前:

insertHtml(html) {
  this.editor.update(() => {
    const selection = $getSelection()
    if (!$isRangeSelection(selection)) return
    const nodes = $generateNodesFromDOM(this.editor, parseHtml(html))
    selection.insertNodes(nodes)
  })
}

変更後:

insertHtml(html, { tag } = {}) {
  this.editor.update(() => {
    const selection = $getSelection()
    if (!$isRangeSelection(selection)) return
    const nodes = $generateNodesFromDOM(this.editor, parseHtml(html))
    selection.insertNodes(nodes)
  }, { tag })
}

ClipboardクラスでPASTE_TAGを明示的に指定

最も重要な変更点は、Clipboard クラスの2つのメソッドで PASTE_TAG を明示的に渡すようになったことです。

import { $getSelection, $isRangeSelection, PASTE_TAG } from "lexical"

#pasteMarkdown(text) {
  const html = marked(text)
  this.contents.insertHtml(html, { tag: [ PASTE_TAG ] })
}

#pasteRichText(clipboardData) {
  this.editor.update(() => {
    const selection = $getSelection()
    $insertDataTransferForRichText(clipboardData, selection, this.editor)
  }, { tag: PASTE_TAG })
}

これにより、Markdown変換後のHTMLやリッチテキストがペーストされた際に、それらのノードが「ペーストされたコンテンツ」として認識され、スタイル属性のサニタイゼーション処理が正しく適用されるようになります。

テストケースの強化

今回の修正に合わせて、以下の2つのテストケースが追加されました。

test "canonicalizes styles in mark-up sent as plain-text" do
  find_editor.paste %(some <span style='color: purple; background-color: rgba(229, 223, 6, 0.3);'>styled text</span>)
  assert_canonicalized_to "background-color: var(--highlight-bg-1)"
end

test "canonicalizes styles in <span>" do
  find_editor.paste "styled text", html: %(some <span style="color: purple; background-color: rgba(229, 223, 6, 0.3);">styled text</span>)
  assert_canonicalized_to "background-color: var(--highlight-bg-1)"
end

これらのテストにより、<span> タグを含むマークアップがプレーンテキストまたはHTMLとしてペーストされた場合でも、スタイル属性が正しく正規化されることを保証しています。

設計上のトレードオフ

PR内のコメントで言及されている通り、この実装には抽象化レベルの若干の不均衡があります。理想的には以下のいずれかのアプローチが考えられました。

  1. Clipboard ハンドラから editor.update() を呼ばない: clipboardDataContents に渡す必要があり、抽象化が漏れる
  2. 常に editor.update() を呼ぶ: insertHtml 内でネストした更新が発生し、パフォーマンス上好ましくない

今回採用された折衷案は、insertHtmltag パラメータを渡すことで、呼び出し側が更新のコンテキストを制御できるようにするものです。これにより、抽象化の漏れを最小限に抑えつつ、必要な機能を実現しています。

記事メタデータ

Generated by:
Claude Sonnet 4.5 for DiffDaily

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

品質レビュー結果

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

Review Criteria:

ガイドライン準拠 ✓ PASS

記事構成とDiffDaily Styleへの準拠状況

記事構成の3要素(Title, Context, Technical Detail)が明確に記載されており、カスタムMarkdown構文(コードブロック前後の空行、ファイル名付きハイライト、GitHubリンク)も正しく使用されています。対象読者であるエンジニアに適した技術レベルで書かれています。

  • 記事構成(Title、Context、Technical Detail)
  • DiffDaily Styleガイド準拠
  • カスタムMarkdown活用
  • 対象読者への適合性
技術的整合性 ✓ PASS

技術的な正確性と表現の適切性

引用されているコードスニペットはPRの目的と整合性が取れており、Diff内容を正確に反映していると判断できます。`PASTE_TAG`、`DOMPurify`などの技術用語も適切に使用されており、変更の技術的な説明は論理的で正確です。

  • 技術用語の正確性
  • コード例の正確性
  • 説明の技術的正確性
PR内容との整合性 ✓ PASS

元のPR情報との一致度

記事のタイトルと内容はPR(#635)の主題と完全に一致しています。PRで報告されたIssue(#602)への言及も正確です。「設計上のトレードオフ」に関する記述はPR内の議論を反映したものと考えられ、ハルシネーションは見られません。

  • タイトル・説明の一致
  • Diff内容の正確な反映
  • 推測の排除