Class: ProseMirror::Serializers::MarkdownSerializer

Inherits:
Object
  • Object
show all
Defined in:
lib/prose_mirror/serializers/markdown_serializer.rb

Overview

A specification for serializing a ProseMirror document as Markdown/CommonMark text.

Constant Summary collapse

DEFAULT_NODE_SERIALIZERS =

Default serializers for various node types

{
  blockquote: ->(state, node, parent = nil, index = nil) {
    # Track that we're in a blockquote to handle lists within blockquotes properly
    old_in_blockquote = state.instance_variable_get(:@in_blockquote) || false
    state.instance_variable_set(:@in_blockquote, true)

    # Use the standard blockquote prefix for all content
    state.wrap_block("> ", nil, node) { state.render_content(node) }

    # Restore the blockquote state
    state.instance_variable_set(:@in_blockquote, old_in_blockquote)
  },

  code_block: ->(state, node, parent = nil, index = nil) {
    # Make sure fence is longer than any dash sequence within content
    backticks = node.text_content.scan(/`{3,}/m)
    fence = backticks.empty? ? "```" : (backticks.sort.last + "`")

    state.write(fence + (node.attrs[:params] || "") + "\n")
    state.text(node.text_content, false)
    # Add newline before closing marker
    state.write("\n")
    state.write(fence)
    state.close_block(node)
  },

  heading: ->(state, node, parent = nil, index = nil) {
    level = node.attrs[:level].to_i.clamp(1, 6)
    state.write(state.repeat("#", level) + " ")
    state.render_inline(node, false)
    state.close_block(node)
  },

  horizontal_rule: ->(state, node, parent = nil, index = nil) {
    state.write(node.attrs[:markup] || "---")
    state.close_block(node)
  },

  bullet_list: ->(state, node, parent = nil, index = nil) {
    # Special handling for lists inside blockquotes
    if state.instance_variable_get(:@in_blockquote)
      # For lists in blockquotes, add the blockquote prefix to each line
      # Each list item should start with "> * "
      state.render_list(node, "", ->(_) { "* " }, true)
    else
      # Standard list rendering
      state.render_list(node, "  ", ->(_) { "* " })
    end
  },

  ordered_list: ->(state, node, parent = nil, index = nil) {
    start = node.attrs[:order] || 1

    # Special handling for ordered lists inside blockquotes
    if state.instance_variable_get(:@in_blockquote)
      # For lists in blockquotes, add the blockquote prefix to each line
      # Each list item should start with "> 1. " etc.
      state.render_list(node, "", ->(i) {
        "#{start + i}. "
      }, true)
    else
      # Standard ordered list rendering
      state.render_list(node, "  ", ->(i) {
        "#{start + i}. "
      })
    end
  },

  list_item: ->(state, node, parent = nil, index = nil) {
    # Track that we're processing a list item to handle nested lists
    old_in_list_item = state.instance_variable_get(:@in_list_item) || false
    state.instance_variable_set(:@in_list_item, true)

    # Special handling for list items in blockquotes
    if state.instance_variable_get(:@in_blockquote)
      # Process the list item content with special handling
      node.each_with_index do |child, i|
        if child.type.name == "paragraph"
          # Render paragraph content directly
          state.render_inline(child)
        else
          # Render other content normally
          state.render(child, node, i)
        end
      end
    else
      # Process the list item content normally
      state.render_content(node)
    end

    # Restore the previous state
    state.instance_variable_set(:@in_list_item, old_in_list_item)
  },

  paragraph: ->(state, node, parent = nil, index = nil) {
    # Special handling for paragraphs inside list items to avoid extra whitespace
    if state.instance_variable_get(:@in_list_item)
      # Create a clean paragraph renderer for list items
      old_at_block_start = state.instance_variable_get(:@at_block_start)

      # Render the paragraph content directly with minimal whitespace
      state.render_inline(node)

      # Restore state
      state.instance_variable_set(:@at_block_start, old_at_block_start)
    elsif state.instance_variable_get(:@in_blockquote) && parent&.type&.name == "bullet_list"
      # Special handling for paragraphs in bullet lists inside blockquotes
      old_at_block_start = state.instance_variable_get(:@at_block_start)

      # Render with blockquote prefix
      state.render_inline(node)

      # Restore state
      state.instance_variable_set(:@at_block_start, old_at_block_start)
    else
      # Normal paragraph rendering for non-list items
      state.render_inline(node)
      state.close_block(node)
    end
  },

  image: ->(state, node, parent = nil, index = nil) {
    state.write("![" + state.esc(node.attrs[:alt] || "") + "](" +
              node.attrs[:src].gsub(/[\(\)]/, "\\\\\\&") +
              (node.attrs[:title] ? ' "' + node.attrs[:title].gsub('"', '\\"') + '"' : "") +
              ")")
  },

  hard_break: ->(state, node, parent, index) {
    (index + 1...parent.child_count).each do |i|
      if parent.child(i).type != node.type
        state.write("\\\n")
        return
      end
    end
  },

  text: ->(state, node, parent = nil, index = nil) {
    state.text(node.text, !state.in_autolink)
  },

  # Table serialization support
  table: ->(state, node, parent = nil, index = nil) {
    # Track that we're in a table
    old_in_table = state.instance_variable_get(:@in_table) || false
    state.instance_variable_set(:@in_table, true)

    # Render table content
    state.render_content(node)

    # Restore table state
    state.instance_variable_set(:@in_table, old_in_table)

    # Only add newline if not at the end of the document
    if parent && index < parent.child_count - 1
      state.close_block(node)
    end
  },

  table_row: ->(state, node, parent = nil, index = nil) {
    # Write row separator after headers
    if index == 1 && parent && parent.child(0).content.any? { |cell| cell.type.name == "table_header" }
      state.write("|")
      node.content.each do |_|
        state.write(" --- |")
      end
      state.write("\n")
    end

    # Write row content
    state.write("|")
    state.render_content(node)

    # Add newline unless this is the last row
    if parent && index < parent.child_count - 1
      state.write("\n")
    end
  },

  table_header: ->(state, node, parent = nil, index = nil) {
    state.write(" ")
    # Render content with marks
    node.content.each do |cell_content|
      state.render_inline(cell_content)
    end
    state.write(" |")
  },

  table_cell: ->(state, node, parent = nil, index = nil) {
    state.write(" ")
    # Render content with marks
    node.content.each do |cell_content|
      state.render_inline(cell_content)
    end
    state.write(" |")
  }
}
DEFAULT_MARK_SERIALIZERS =

Default serializers for various mark types

{
  em: {
    open: "*",
    close: "*",
    mixable: true,
    expel_enclosing_whitespace: true
  },

  italic: {
    open: "*",
    close: "*",
    mixable: true,
    expel_enclosing_whitespace: true
  },

  text_style: {
    open: "",
    close: "",
    mixable: true,
    expel_enclosing_whitespace: false
  },

  inline_thread: {
    open: "",
    close: "",
    mixable: true,
    expel_enclosing_whitespace: false
  },

  strong: {
    open: "**",
    close: "**",
    mixable: true,
    expel_enclosing_whitespace: true
  },

  link: {
    open: ->(state, mark, parent, index) {
      state.in_autolink = ProseMirror::Serializers.is_plain_url(mark, parent, index)
      state.in_autolink ? "<" : "["
    },
    close: ->(state, mark, parent, index) {
      in_autolink = state.in_autolink
      state.in_autolink = nil

      if in_autolink
        ">"
      else
        "](" + mark.attrs[:href].gsub(/[\(\)"]/, "\\\\\\&") +
          (mark.attrs[:title] ? ' "' + mark.attrs[:title].gsub('"', '\\"') + '"' : "") + ")"
      end
    },
    mixable: true
  },

  code: {
    open: ->(_, mark, parent, index) { ProseMirror::Serializers.backticks_for(parent.child(index), -1) },
    close: ->(_, mark, parent, index) { ProseMirror::Serializers.backticks_for(parent.child(index - 1), 1) },
    escape: false
  },

  strikethrough: {
    open: "~~",
    close: "~~",
    mixable: true,
    expel_enclosing_whitespace: true
  }
}

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(nodes, marks, options = {}) ⇒ MarkdownSerializer

Constructor with node serializers, mark serializers, and options

Parameters:

  • nodes (Hash)

    Node serializer functions

  • marks (Hash)

    Mark serializer specifications

  • options (Hash) (defaults to: {})

    Configuration options



285
286
287
288
289
# File 'lib/prose_mirror/serializers/markdown_serializer.rb', line 285

def initialize(nodes, marks, options = {})
  @nodes = nodes
  @marks = marks
  @options = options
end

Instance Attribute Details

#marksObject (readonly)

Returns the value of attribute marks.



8
9
10
# File 'lib/prose_mirror/serializers/markdown_serializer.rb', line 8

def marks
  @marks
end

#nodesObject (readonly)

Returns the value of attribute nodes.



8
9
10
# File 'lib/prose_mirror/serializers/markdown_serializer.rb', line 8

def nodes
  @nodes
end

#optionsObject (readonly)

Returns the value of attribute options.



8
9
10
# File 'lib/prose_mirror/serializers/markdown_serializer.rb', line 8

def options
  @options
end

Instance Method Details

#serialize(content, options = {}) ⇒ String

Serialize the content of the given node to CommonMark

Parameters:

  • content (Node)

    The node to serialize

  • options (Hash) (defaults to: {})

    Serialization options

Returns:

  • (String)

    The markdown output



295
296
297
298
299
300
# File 'lib/prose_mirror/serializers/markdown_serializer.rb', line 295

def serialize(content, options = {})
  options = @options.merge(options)
  state = MarkdownSerializerState.new(@nodes, @marks, options)
  state.render_content(content)
  state.out
end