Action TextにMarkdown変換機能を追加

rails/rails

Action Textがリッチテキストコンテンツを Markdown形式 で出力できるようになりました。既存の to_plain_text と同様のインターフェースで to_markdown メソッドが追加され、見出し・太字・斜体などの書式情報を保持したまま変換が可能になります。

背景

これまでAction Textのリッチテキストコンテンツは HTMLPlain 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)
  "![#{caption || "Image"}](#{url})"
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_markdownto_plain_text と同じインターフェース設計 を採用しました。両メソッドは RichTextContentFragment と同じ委譲チェーンを辿り、アタッチメントの表現もそれぞれ attachable_plain_text_representationattachable_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エージェントとの統合や書式情報を保持したコンテンツ処理の選択肢が広がります。

記事メタデータ

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

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

リード文(総論)、背景・技術詳細・設計判断(各論)、まとめ(結論)の3部構成が明確に適用されており、ガイドラインを完全に満たしています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```ruby:path/to/file.rb)とGitHubのPR番号リンク記法([#123](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Action Textの内部実装に関する詳細な解説であり、専門知識を持つエンジニアという対象読者に完全に適合しています。

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

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

各セクションが総論・各論で構成され、各パラグラフはトピックセンテンスで始まり、1段落1トピックの原則が守られており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事で引用されているすべてのコードブロックとファイル名は、提供されたDiff情報と正確に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「BottomUpReducer」「Loofah」「URIスキーム」などの技術用語や、PR固有のメソッド・クラス名が正確に使用されています。

説明の技術的正確性 ✓ PASS

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

BottomUpReducerの共通化や、Trix/Lexxyへの対応ロジックなど、技術的な説明はすべてDiffとPR情報によって裏付けられており、正確です。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのTitle, Description, Diffで裏付けられており、ハルシネーション(捏造)は検出されませんでした。

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

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

PR番号(#56858, #56854)、パフォーマンス向上率(約35%)、HTMLサイズ(約42KB)などの数値や固有名詞はすべて正確です。

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

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

記事タイトル「Action TextにMarkdown変換機能を追加」は、PRの主題を簡潔かつ正確に表現しています。

外部知識の正確性 ✓ PASS

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

記事の内容はPR情報に厳密に基づいており、バージョン情報やリリース予定など、PRに記載のない外部知識の追加はありません。

時間表現の正確性 ✓ PASS

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

「追加されました」「できるようになりました」といった完了形の表現や、「将来的に...可能性が示唆されています」といった推測の表現は、PRの文脈と正確に一致しています。