123456789_123456789_123456789_123456789_123456789_

Module: ActionText::MarkdownConversion

Relationships & Source Files
Defined in: actiontext/lib/action_text/markdown_conversion.rb

Overview

Converts an HTML fragment into a Markdown string. Used by Content#to_markdown and Fragment#to_markdown to produce Markdown representations of rich text.

Example:

Release Notes

=> # Release Notes, a markdown heading.

Note that this converter escapes text nodes so it won't render as markdown.

Example:

# Release Notes

=> # Release Notes, not a heading.

Constant Summary

Instance Method Summary

Instance Method Details

#ancestor_named?(node, names, max_depth:) ⇒ Boolean (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 433

def ancestor_named?(node, names, max_depth:)
  current = node.parent
  max_depth.times do
    break unless current&.element?
    return true if current.name.in?(names)
    current = current.parent
  end
  false
end

#block_value?(value) ⇒ Boolean (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 295

def block_value?(value)
  stringify(value).end_with?("\n\n")
end

#child_values_for_elements(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 366

def child_values_for_elements(node, child_values)
  node.children.zip(child_values).filter_map do |child, value|
    value if child.element?
  end
end

#code_fence(content) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 394

def code_fence(content)
  max_run = content.scan(/`{3,}/).map(&:length).max || 0
  "`" * [3, max_run + 1].max
end

#encode_href(href) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 443

def encode_href(href)
  URI::RFC2396_PARSER.escape(href, ENCODE_HREF_CHARS)
end

#escape_markdown_text(text)

Backslash-escapes CommonMark metacharacters in text so they are treated as literal characters by Markdown renderers.

MarkdownConversion.escape_markdown_text("**Important**")
# => "\\*\\*Important\\*\\*"
[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 92

def escape_markdown_text(text)
  text.gsub(MARKDOWN_METACHARACTERS) { |c| "\\#{c}" }
end

#flatten_to_inline(text) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 230

def flatten_to_inline(text)
  return text unless text.match?(/[\r\n]/)

  text.gsub(/[\r\n]+/, " ")
end

#format_list_item(lines, bullet) (private)

A list item's later lines have to be indented to the width of its marker. Indent them less and the item ends there, which for a fenced code block means the fence closes early and the rest of the pre content #markdown_for_node emits unescaped is released as Markdown source. - happens to be as wide as LIST_INDENT; 1. is not.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 329

def format_list_item(lines, bullet)
  first, *rest = lines
  leader = first.match?(LIST_BULLET) ? LIST_INDENT : bullet
  indent = " " * leader.length
  ([ leader + first ] + rest.map { |line| indent + line }).join("\n")
end

#fragment_by_unwrapping_raw_markdown_tags(fragment)

Returns a copy of fragment with elements replaced by their children, leaving the text to be escaped like any other.

#render_attachment wraps already-rendered Markdown in that element so #node_to_markdown emits it without escaping. Only Action Text may do that, so Content unwraps the element while canonicalizing: anything carrying it at that point came from outside the framework.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 44

def fragment_by_unwrapping_raw_markdown_tags(fragment)
  ActionText::Fragment.wrap(fragment).update do |source|
    source.css(RAW_MARKDOWN_TAG_NAME).each do |node|
      node.replace(node.children)
    end
  end
end

#inline_code(content) (private)

Two things break a code span's delimiter. A blank line closes the paragraph before the closing backtick string arrives -- Markdown turns the line endings inside a code span into spaces anyway, so collapse them and the span always closes. And a lone backtick followed by whitespace does not open a span in every renderer (kramdown refuses it), so widen the delimiter when the content leads with whitespace.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 404

def inline_code(content)
  content = flatten_to_inline(content)
  max_run = content.scan(/`+/).map(&:length).max || 0
  fence = "`" * [content.match?(/\A\s/) ? 2 : 1, max_run + 1].max
  if content.start_with?("`") || content.end_with?("`")
    "#{fence} #{content} #{fence}"
  else
    "#{fence}#{content}#{fence}"
  end
end

#inline_sibling?(sibling) ⇒ Boolean (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 429

def inline_sibling?(sibling)
  sibling&.text? || sibling&.name&.in?(INLINE_ELEMENTS)
end

#join_children(child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 336

def join_children(child_values)
  merged = []

  child_values.each do |value|
    # Merge adjacent bold/italic runs which Lexxy emits
    if value.is_a?(Array) && (value[0] == :bold || value[0] == :italic)
      if merged.last.is_a?(Array) && merged.last[0] == value[0]
        merged.last[1] = merged.last[1] + value[1]
      else
        merged << [ value[0], value[1] ]
      end
    else
      merged << value
    end
  end

  parts = merged.map { |v| stringify(v) }
  result = +""
  parts.each do |part|
    # A block child has to begin its own block. Renderers disagree about whether a fence or
    # a list may interrupt a paragraph, and one that says no releases the content the fence
    # was holding, so separate with a blank line rather than a single newline.
    if !result.empty? && part.end_with?("\n\n")
      result << "\n" until result.end_with?("\n\n")
    end
    result << part
  end
  result
end

#list_item_lines(list_node, child_values, prefix:) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 313

def list_item_lines(list_node, child_values, prefix:)
  element_values = child_values_for_elements(list_node, child_values)
  element_values.each_with_index.filter_map do |value, index|
    text = stringify(value)
    lines = text.split("\n").reject(&:blank?)
    next if lines.empty?

    bullet = prefix.respond_to?(:call) ? prefix.call(index) : prefix
    format_list_item(lines, bullet)
  end.join("\n")
end

#markdown_for_node(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 122

def markdown_for_node(node, child_values)
  if node.text?
    if node.content.blank? && !significant_whitespace?(node)
      ""
    elsif skip_markdown_escaping?(node)
      node.content
    else
      escape_markdown_text(strip_pretty_print_indentation(node))
    end
  elsif node.element?
    method_name = :"visit_#{node.name.tr("-", "_")}"
    if respond_to?(method_name, true)
      send(method_name, node, child_values)
    else
      visit__container(node, child_values)
    end
  else
    join_children(child_values)
  end
end

#node_to_markdown(node)

Converts a Nokogiri HTML node into a Markdown string.

node = Nokogiri::HTML4.fragment("<p>Hello <strong>world</strong></p>")
MarkdownConversion.node_to_markdown(node) # => "Hello **world**"

NOTE: text inside elements is emitted without escaping, so this method is not safe for untrusted content. Convert user-supplied markup through Content, which strips those elements while canonicalizing.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 31

def node_to_markdown(node)
  BottomUpReducer.new(node).reduce do |n, child_values|
    markdown_for_node(n, child_values)
  end.strip
end

#normalize_line_endings(text) (private)

Markdown ends a line at a bare CR, but String#lines and String#split("\n") do not, so a CR inside a fence would slip past the indentation #format_list_item and #visit_blockquote add to each line and land outside the block.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 239

def normalize_line_endings(text)
  text.gsub(/\r\n?/, "\n")
end

#render_attachment(attachment, attachment_links: false)

Returns an element holding attachment's Markdown, for Content#to_markdown to substitute in place of the attachment. #node_to_markdown emits the element's text verbatim rather than escaping it as ordinary Markdown source.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 55

def render_attachment(attachment, attachment_links: false)
  ActionText::HtmlConversion.create_element(RAW_MARKDOWN_TAG_NAME).tap do |node|
    node.content = attachment.to_markdown(attachment_links: attachment_links)
  end
end

#significant_whitespace?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 424

def significant_whitespace?(node)
  inline_sibling?(node.previous_sibling) &&
    inline_sibling?(node.next_sibling)
end

#single_line_context?(node) ⇒ Boolean (private)

A fenced code block opens only at the start of a line. A link, a heading, a summary and a table row or cell each splice their descendants into a line they have already begun, and Markdown cannot hold a block inside an inline element at all, so under any of them the fence never opens and the pre content #markdown_for_node emits unescaped is released as Markdown source. See SKIP_ESCAPING_PARENTS.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 452

def single_line_context?(node)
  node.ancestors.any? { |ancestor| ancestor.element? && ancestor.name.in?(SINGLE_LINE_ANCESTORS) }
end

#skip_markdown_escaping?(node) ⇒ Boolean (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 456

def skip_markdown_escaping?(node)
  node.parent&.name.in?(SKIP_ESCAPING_PARENTS)
end

#stringify(value) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 372

def stringify(value)
  case value
  when Array
    case value[0]
    when :bold then wrap_emphasis(value[1], "**")
    when :italic then wrap_emphasis(value[1], "*")
    else value.join
    end
  else
    value.to_s
  end
end

#strip_pretty_print_indentation(node) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 415

def strip_pretty_print_indentation(node)
  content = node.content
  return content unless content.include?("\n")

  content
    .sub(LEADING_PRETTY_PRINT_WHITESPACE, inline_sibling?(node.previous_sibling) ? " " : "")
    .sub(TRAILING_PRETTY_PRINT_WHITESPACE, inline_sibling?(node.next_sibling) ? " " : "")
end

#visit__container(_node, child_values) (private) Also known as: #visit_div, #visit_li, #visit_td, #visit_th, #visit_thead, #visit_tbody

A container contributes no Markdown of its own, and neither does an element with no visitor at all. #join_children reads a trailing blank line as "this value is a block" and uses it to keep the next value off the same line, so a container holding a block has to report one too. Flatten that away and a fence inside the container lands mid-line, releasing the pre content #markdown_for_node emits unescaped.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 276

def visit__container(_node, child_values)
  inner = join_children(child_values)

  if child_values.any? { |value| block_value?(value) }
    "#{inner.rstrip}\n\n"
  else
    inner
  end
end

#visit__heading(_node, child_values, level) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 196

def visit__heading(_node, child_values, level)
  "#{"#" * level} #{join_children(child_values)}\n\n"
end

#visit__table_header_row(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 306

def visit__table_header_row(node, child_values)
  cells = child_values_for_elements(node, child_values).map { |v| stringify(v).strip }
  row = "| #{cells.join(" | ")} |\n"
  separator = "| #{Array.new(cells.size, "---").join(" | ")} |\n"
  "#{row}#{separator}"
end

#visit__unsupported(_node, _child_values) (private) Also known as: #visit_script, #visit_style

Avoid including content from elements that aren't meaningful for markdown output

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 300

def visit__unsupported(_node, _child_values)
  ""
end

#visit_a(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 221

def visit_a(node, child_values)
  inner = join_children(child_values)
  if (href = node["href"]) && Rails::HTML::Sanitizer.allowed_uri?(href)
    "[#{flatten_to_inline(inner)}](#{encode_href(href)})"
  else
    inner
  end
end

#visit_action_text_markdown(_node, child_values) (private)

Attachment markdown is wrapped in by Content#to_markdown so it passes through without text escaping.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 267

def visit_action_text_markdown(_node, child_values)
  join_children(child_values)
end

#visit_b(node, child_values) (private)

Alias for #visit_strong.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 153

alias_method :visit_b, :visit_strong

#visit_blockquote(_node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 206

def visit_blockquote(_node, child_values)
  quoted = join_children(child_values).strip.lines.map { |line| "> #{line}" }.join
  "#{quoted}\n\n"
end

#visit_br(_node, _child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 257

def visit_br(_node, _child_values)
  "\n"
end

#visit_code(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 172

def visit_code(node, child_values)
  inner = join_children(child_values)
  if node.parent&.name == "pre"
    inner
  else
    inline_code(inner)
  end
end

#visit_del(_node, child_values) (private)

Alias for #visit_s.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 170

alias_method :visit_del, :visit_s

#visit_div(_node, child_values) (private)

Alias for #visit__container.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 288

alias_method :visit_div, :visit__container

#visit_em(node, child_values) (private) Also known as: #visit_i

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 155

def visit_em(node, child_values)
  inner = join_children(child_values)

  # lexxy redundantly wraps emphasized subtrees in `<i>`
  if ancestor_named?(node, ITALIC_TAGS, max_depth: 4)
    inner
  else
    [ :italic, inner ]
  end
end

#visit_h1(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 199

def visit_h1(node, child_values) = visit__heading(node, child_values, 1)

#visit_h2(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 200

def visit_h2(node, child_values) = visit__heading(node, child_values, 2)

#visit_h3(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 201

def visit_h3(node, child_values) = visit__heading(node, child_values, 3)

#visit_h4(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 202

def visit_h4(node, child_values) = visit__heading(node, child_values, 4)

#visit_h5(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 203

def visit_h5(node, child_values) = visit__heading(node, child_values, 5)

#visit_h6(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 204

def visit_h6(node, child_values) = visit__heading(node, child_values, 6)

#visit_hr(_node, _child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 261

def visit_hr(_node, _child_values)
  "---\n\n"
end

#visit_i(node, child_values) (private)

Alias for #visit_em.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 165

alias_method :visit_i, :visit_em

#visit_li(_node, child_values) (private)

Alias for #visit__container.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 289

alias_method :visit_li, :visit__container

#visit_ol(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 216

def visit_ol(node, child_values)
  items = list_item_lines(node, child_values, prefix: ->(i) { "#{i + 1}. " })
  "#{items}\n\n"
end

#visit_p(_node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 192

def visit_p(_node, child_values)
  "#{join_children(child_values)}\n\n"
end

#visit_pre(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 181

def visit_pre(node, child_values)
  inner = normalize_line_endings(join_children(child_values)).delete_prefix("\n").delete_suffix("\n")

  if single_line_context?(node)
    inline_code(inner)
  else
    fence = code_fence(inner)
    "#{fence}\n#{inner}\n#{fence}\n\n"
  end
end

#visit_s(_node, child_values) (private) Also known as: #visit_del

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 167

def visit_s(_node, child_values)
  "~~#{join_children(child_values)}~~"
end

#visit_script(_node, _child_values) (private)

Alias for #visit__unsupported.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 303

alias_method :visit_script, :visit__unsupported

#visit_strong(node, child_values) (private) Also known as: #visit_b

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 143

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

#visit_style(_node, _child_values) (private)

Alias for #visit__unsupported.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 304

alias_method :visit_style, :visit__unsupported

#visit_summary(_node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 253

def visit_summary(_node, child_values)
  "**#{join_children(child_values)}**\n\n"
end

#visit_tbody(_node, child_values) (private)

Alias for #visit__container.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 293

alias_method :visit_tbody, :visit__container

#visit_td(_node, child_values) (private)

Alias for #visit__container.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 290

alias_method :visit_td, :visit__container

#visit_th(_node, child_values) (private)

Alias for #visit__container.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 291

alias_method :visit_th, :visit__container

#visit_thead(_node, child_values) (private)

Alias for #visit__container.

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 292

alias_method :visit_thead, :visit__container

#visit_tr(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 243

def visit_tr(node, child_values)
  # lexxy does not emit `thead`, so we need to infer header rows from `tr` contents
  if node.element_children.all? { |cell| cell.name == "th" }
    visit__table_header_row(node, child_values)
  else
    cells = child_values_for_elements(node, child_values).map { |v| stringify(v).strip }
    "| #{cells.join(" | ")} |\n"
  end
end

#visit_ul(node, child_values) (private)

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 211

def visit_ul(node, child_values)
  items = list_item_lines(node, child_values, prefix: "- ")
  "#{items}\n\n"
end

#wrap_emphasis(text, marker) (private)

Make sure hello becomes hello and not ** hello ** (the latter is not valid markdown).

[ GitHub ]

  
# File 'actiontext/lib/action_text/markdown_conversion.rb', line 387

def wrap_emphasis(text, marker)
  leading = text[/\A\s*/]
  trailing = text[/\s*\z/]
  inner = text.strip
  "#{leading}#{marker}#{inner}#{marker}#{trailing}"
end