Action TextにMarkdown変換機能を追加
Action Textがリッチテキストコンテンツを Markdown形式 で出力できるようになりました。既存の to_plain_text と同様のインターフェースで to_markdown メソッドが追加され、見出し・太字・斜体などの書式情報を保持したまま変換が可能になります。
背景
これまでAction Textのリッチテキストコンテンツは HTML と Plain text の2つの形式でしか出力できませんでした。HTMLは冗長でLLMエージェントによる処理コストが高く、Plain textは書式情報(見出し、太字、斜体、打ち消し線など)が失われるという課題がありました。
Markdown形式は人間にもエージェントにも扱いやすく、かつ元の書式情報を保持できる中間的な選択肢となります。#56858 がこの新しい出力形式を導入しています。
技術的な変更
変換メソッドの追加
Action Textの主要なクラスに to_markdown メソッドが追加されました。
RichText モデル:
def to_markdown
body&.to_markdown.to_s
end
Content クラス:
def to_markdown
render_attachments(with_full_attributes: false, &:to_markdown).fragment.to_markdown
end
Fragment クラス:
def to_markdown
@markdown ||= MarkdownConversion.node_to_markdown(source)
end
使用例は以下の通りです:
message = Message.create!(content: "<h1>Hello</h1><p>This is <strong>bold</strong></p>")
message.content.to_markdown # => "# Hello\n\nThis is **bold**"
MarkdownConversion モジュール
新しく追加された MarkdownConversion モジュールが、HTML DOMツリーをボトムアップで走査してMarkdownに変換します。このモジュールは PlainTextConversion と同様のアプローチを採用しています。
module ActionText
module MarkdownConversion
extend self
def node_to_markdown(node)
BottomUpReducer.new(node).reduce do |n, child_values|
markdown_for_node(n, child_values)
end.strip
end
private
def markdown_for_node(node, child_values)
if node.text?
node.content
elsif node.element?
method_name = :"visit_#{node.name.tr("-", "_")}"
if respond_to?(method_name, true)
send(method_name, node, child_values)
else
join_children(child_values).strip
end
else
join_children(child_values)
end
end
end
end
各HTML要素に対応する visit_* メソッドが変換ロジックを実装しています:
-
インライン書式:
<strong>→**bold**、<em>→*italic*、<s>→~~strikethrough~~、<code>→`code` -
ブロック要素:
<h1>→# heading、<blockquote>→> quote、<pre><code>→code -
リスト:
<ul>/<ol>のネスト構造を保持した変換 -
リンク:
<a>→[text](url)、ただしjavascript:などの危険なスキームはLoofah の許可プロトコルでチェック - テーブル: GitHub Flavored Markdown形式の表に変換
-
折りたたみ:
<details>/<summary>→ 対応するMarkdown拡張記法
BottomUpReducer の共通化
PlainTextConversion で使用されていた BottomUpReducer クラスが独立したファイルに抽出され、MarkdownConversion でも共用されるようになりました。
変更前(PlainTextConversionモジュール内):
module ActionText
module PlainTextConversion
class BottomUpReducer # :nodoc:
# ...
end
end
end
変更後(独立したファイル):
module ActionText
class BottomUpReducer # :nodoc:
def initialize(node)
@node = node
@values = {}
end
def reduce(&block)
traverse_bottom_up(@node) do |n|
child_values = @values.values_at(*n.children)
@values[n] = block.call(n, child_values)
end
@values[@node]
end
end
end
このリファクタリングにより、コードの重複を避けつつ、2つの変換モジュールが同じツリー走査ロジックを利用できるようになりました。
アタッチメントのMarkdown表現
各アタッチメント型に attachable_markdown_representation メソッドが実装され、添付ファイルのMarkdown表現をカスタマイズできるようになりました。
RemoteImage(リモート画像):
def attachable_markdown_representation(caption)
""
end
ActiveStorage::Blob(ファイル添付):
def attachable_markdown_representation(caption = nil)
"[#{caption || filename}]"
end
ContentAttachment(埋め込みHTML):
def attachable_markdown_representation(caption)
content_instance.fragment.to_markdown
end
MissingAttachable(削除済みアタッチメント):
def attachable_markdown_representation(caption = nil)
"☒"
end
MissingAttachableの実装は #56854 で提案されている to_plain_text での「☒」表示と統一されたものです。
アタッチメントを持つモデルで独自の表現を定義することも可能です:
class Person < ApplicationRecord
include ActionText::Attachable
def attachable_markdown_representation(caption)
"[@#{name}](#{Rails.application.routes.url_helpers.person_url(self)})"
end
end
設計判断
PlainTextConversionとの一貫性
to_markdown は to_plain_text と同じインターフェース設計 を採用しました。両メソッドは RichText → Content → Fragment と同じ委譲チェーンを辿り、アタッチメントの表現もそれぞれ attachable_plain_text_representation と attachable_markdown_representation で統一されています。
この一貫性により、既存の to_plain_text を使用しているコードベースに to_markdown を追加する際の学習コストが低減されます。
セキュリティへの配慮
Markdownのリンク変換では Loofahの許可プロトコルリスト を使用して、javascript: などの危険なURIスキームがMarkdown出力に含まれないようチェックしています。この判定により、XSS攻撃のベクターとなりうる不正なリンクを事前に排除できます。
パフォーマンス特性
PR内のベンチマーク結果によると、to_markdown は同サイズのHTML(約42KB)に対して to_plain_text より約35%高速 に動作しています。これは変換ロジックの実装方式の違いによるもので、将来的に to_plain_text も同様の最適化が適用できる可能性が示唆されています。
Comparison:
to_markdown: 70.9 i/s
to_plain_text: 51.8 i/s - 1.37x slower
Trix と Lexxy への対応
MarkdownConversion の実装は Trix と Lexxy の両エディタが生成するマークアップ に対して手動でテストされています。特にLexxyが生成する冗長な <b> タグのネストを適切に処理するため、祖先ノードを最大4階層まで走査して重複したボールド記号の出力を防ぐロジックが含まれています。
def visit_strong(node, child_values)
inner = join_children(child_values)
# lexxy redundantly wraps bold subtrees in `<b>`
if ancestor_named?(node, BOLD_TAGS, max_depth: 4)
inner
else
[ :bold, inner ]
end
end
まとめ
本PRは、Action TextにMarkdown変換機能を追加し、HTMLとPlain textの中間的な出力形式を提供します。to_plain_text と統一されたインターフェース設計により、既存のAction Textユーザーは直感的にMarkdown変換を利用できます。Loofahによるセキュリティチェック、TrixとLexxy両方への対応、to_plain_text を上回るパフォーマンス特性により、LLMエージェントとの統合や書式情報を保持したコンテンツ処理の選択肢が広がります。