diff --git a/config/initializers/markdown.rb b/config/initializers/markdown.rb index 92b21b273..57e7c4384 100644 --- a/config/initializers/markdown.rb +++ b/config/initializers/markdown.rb @@ -1,4 +1,4 @@ ActiveSupport.on_load :action_text_markdown do require "markdown_renderer" - ActionText::Markdown.renderer = -> { MarkdownRenderer.build } + ActionText::Markdown.renderer = -> { MarkdownRenderer.new } end diff --git a/config/initializers/sanitization.rb b/config/initializers/sanitization.rb index 961a62083..8b602bcb4 100644 --- a/config/initializers/sanitization.rb +++ b/config/initializers/sanitization.rb @@ -1,3 +1,3 @@ Rails.application.config.after_initialize do - Rails::HTML5::SafeListSanitizer.allowed_tags.merge(%w[ table tr td th thead tbody details summary ]) + Rails::HTML5::SafeListSanitizer.allowed_tags.merge(%w[ s table tr td th thead tbody details summary ]) end diff --git a/lib/markdown_renderer.rb b/lib/markdown_renderer.rb index 97c496856..1205e6d6d 100644 --- a/lib/markdown_renderer.rb +++ b/lib/markdown_renderer.rb @@ -1,33 +1,40 @@ -require "rouge/plugins/redcarpet" +class MarkdownRenderer + require_relative "markdown_renderer/parsing" + require_relative "markdown_renderer/markup" -class MarkdownRenderer < Redcarpet::Render::HTML - include Rouge::Plugins::Redcarpet + include Parsing, Markup - def self.build - renderer = MarkdownRenderer.new(ActionText::Markdown::DEFAULT_RENDERER_OPTIONS) - Redcarpet::Markdown.new(renderer, ActionText::Markdown::DEFAULT_MARKDOWN_EXTENSIONS) - end - - def initialize(*args) - super + def initialize @id_counts = Hash.new(0) end - def header(text, header_level) - unique_id(text).then do |id| - "#{text} " - end - end - - def image(url, title, alt_text) - %(#{alt_text}) + def render(content) + content + .then { |c| parse_paragraphs(c) } + .then { |c| parse_bold_italics(c) } + .then { |c| parse_bold(c) } + .then { |c| parse_italics(c) } + .then { |c| parse_strikethrough(c) } + .then { |c| parse_highlight(c) } + .then { |c| parse_headers(c) } + .then { |c| parse_tables(c) } + .then { |c| parse_ordered_lists(c) } + .then { |c| parse_unordered_lists(c) } + .then { |c| parse_block_quotes(c) } + .then { |c| parse_horizontal_rules(c) } + .then { |c| parse_images(c) } + .then { |c| parse_links(c) } + .then { |c| parse_code_blocks(c) } + .then { |c| parse_code_spans(c) } end private + attr_reader :id_counts + def unique_id(text) text.parameterize.then do |base_id| - @id_counts[base_id] += 1 - @id_counts[base_id] > 1 ? "#{base_id}-#{@id_counts[base_id]}" : base_id + id_counts[base_id] += 1 + id_counts[base_id] > 1 ? "#{base_id}-#{id_counts[base_id]}" : base_id end end end diff --git a/lib/markdown_renderer/markup.rb b/lib/markdown_renderer/markup.rb new file mode 100644 index 000000000..2e2753c5e --- /dev/null +++ b/lib/markdown_renderer/markup.rb @@ -0,0 +1,117 @@ +require "rouge/plugins/redcarpet" + +module MarkdownRenderer::Markup + include Rouge::Plugins::Redcarpet + + TABLE_ROW_INDENT = " " * 4 + LIST_ITEM_INDENT = " " * 2 + BLOCK_QUOTE_INDENT = " " * 2 + TABLE_HEADER_INDENT = " " * 6 + + # FIXME: the attributes suggest this should be an app-level override instead + def header(text, header_level) + unique_id(text).then do |id| + <<~HTML.chomp + + #{text} + + HTML + end + end + + def bold(text) + "#{text}" + end + + def italics(text) + "#{text}" + end + + def strikethrough(text) + "#{text}" + end + + def highlight(text) + "#{text}" + end + + def table(headers, rows) + <<~HTML + + + + #{headers.map { |header| "" }.join("\n" + TABLE_HEADER_INDENT)} + + + + #{rows.map { |row| "#{row.map { |cell| "" }.join}" }.join("\n" + TABLE_ROW_INDENT)} + +
#{header}
#{cell}
+ HTML + end + + def ordered_list(contents) + <<~HTML +
    + #{contents.split("\n").join("\n" + LIST_ITEM_INDENT)} +
+ HTML + end + + def unordered_list(contents) + <<~HTML + + HTML + end + + def list_item(text) + "
  • #{text}
  • " + end + + def block_quote(text) + <<~HTML +
    + #{text.gsub(/^>\s?/, '').split("\n").join("\n" + BLOCK_QUOTE_INDENT)} +
    + HTML + end + + def horizontal_rule + "
    " + end + + def link(text, href) + "#{text}" + end + + def code_block(code, language) + block_code(code, language) # call Rouge Redcarpet plugin + end + + def code_span(text) + "#{text}" + end + + # FIXME: the attributes suggest this should be an app-level override instead + def image(url, alt_text) + <<~HTML.chomp + + #{alt_text} + + HTML + end + + def paragraph(text) + "

    #{text}

    " + end + + def soft_line_break + "\n#{line_break}\n" + end + + def line_break + "
    " + end +end diff --git a/lib/markdown_renderer/parsing.rb b/lib/markdown_renderer/parsing.rb new file mode 100644 index 000000000..9c31bfe31 --- /dev/null +++ b/lib/markdown_renderer/parsing.rb @@ -0,0 +1,135 @@ +module MarkdownRenderer::Parsing + HR = /^---$/ + INLINE_HTML_BLOCK_START = /^\s?/ + BLOCK_QUOTE_CONTENT = /#{BLOCK_QUOTE_START}[^\n]*#{NEWLINE_OR_EOF}/ + + TABLE_START = /^\|/ + TABLE_HEADER_ROW = /^\|(.+)\|/ + TABLE_SEPARATOR = /^\|[-|]+\|/ + TABLE_DATA_ROW = /^\|.+\|/ + TABLE = /#{TABLE_HEADER_ROW}\r?\n#{TABLE_SEPARATOR}\r?\n((?:#{TABLE_DATA_ROW}\r?\n)*)/ + + BLOCK_STARTERS = Regexp.union [ + HR, + TABLE_START, + HEADING_START, + CODE_BLOCK_START, + BLOCK_QUOTE_START, + NUMBERED_LIST_START, + UNORDERED_LIST_START, + INLINE_HTML_BLOCK_START ] + + private + def parse_paragraphs(content) + content.split(/\n\n+/).map do |text| + if text.match?(BLOCK_STARTERS) + text + else + paragraph text.gsub(SOFT_LINE_BREAK, soft_line_break) + end + end.join("\n\n") + end + + def parse_bold_italics(content) + transform = ->(match) { bold(italics(match)) } + + content + .gsub(/(?(match) { bold(match) } + + content + .gsub(/(?(match) { italics(match) } + + content + .gsub(/(? + Welcome to My Document + + +

    This is an introduction paragraph with some text.

    + +
    + +

    Hello, world! Hello, world! Hello, world! Hello, world! Hello, world!

    + +

    Hello, world! +
    + Hello, world! +
    + Hello, world! +
    + Hello, world! +
    + Hello, world!

    + +

    + Getting Started +

    + +

    Here’s what you need to know:

    + +
      +
    1. First important point
    2. +
    3. Second crucial thing
    4. +
    5. Don’t forget this
    6. +
    + +

    + Key Features +

    + +

    Our product offers:

    + + + +

    Get Basecamp

    + +

    + Important Note +

    + +

    Here’s some +
    + multiline text. +
    + It has line breaks +
    + in-between.

    + +
    + Please read this carefully + It contains vital information + That you shouldn’t miss +
    + +

    + Placeholder image +

    + +

    + Important Note +

    + +

    + Placeholder image +

    + +
      +
    1. Remember to save
    2. +
    3. Back up your work
    4. +
    + +

    Get Basecamp

    + + + + + + + + + + + + +
    Table 1 Header 1Table 1 Header 2
    Table 1 Data 1Table 1 Data 2
    Table 1 Data 3Table 1 Data 4
    + +

    Some content between tables...

    + + + + + + + + + + + + +
    Table 2 Header 1Table 2 Header 2
    Table 2 Data 1Table 2 Data 2
    Table 2 Data 3Table 2 Data 4
    + +
    class Post < ApplicationRecord
    +          def title
    +            "foo"
    +          end
    +        end
    +        
    + +

    puts "Hello, world!"

    + +

    Thanks for reading!

    + +
    + Summary + Details +
    + HTML + end + + def markdown_content + <<~MARKDOWN + # Welcome to My Document + + This is an introduction paragraph with some text. + + --- + + ***Hello, world!*** **Hello, world!** *Hello, world!* ~~Hello, world!~~ ==Hello, world!== + + ***Hello, world!*** + **Hello, world!** + *Hello, world!* + ~~Hello, world!~~ + ==Hello, world!== + + ## Getting Started + + Here’s what you need to know: + + 1. First important point + 2. Second crucial thing + 3. Don’t forget this + + ## Key Features + + Our product offers: + + - Simple interface + - Powerful features + - Great documentation + + [Get Basecamp](https://basecamp.com) + + ### Important Note + + Here’s some + multiline text. + It has line breaks + in-between. + + > Please read this carefully + > It contains vital information + > That you shouldn’t miss + + ![Placeholder image](https://placehold.co/600x400) + + ## Important Note + + ![Placeholder image](https://placehold.co/400x400) + + 1. Remember to save + 2. Back up your work + + [Get Basecamp](https://basecamp.com) + + | Table 1 Header 1 | Table 1 Header 2 | + |-|-| + | Table 1 Data 1 | Table 1 Data 2 | + | Table 1 Data 3 | Table 1 Data 4 | + + Some content between tables... + + | Table 2 Header 1 | Table 2 Header 2 | + |------------------|------------------| + | Table 2 Data 1 | Table 2 Data 2 | + | Table 2 Data 3 | Table 2 Data 4 | + + ```rb + class Post < ApplicationRecord + def title + "foo" + end + end + ``` + + `puts "Hello, world!"` + + Thanks for reading! + +
    + Summary + Details +
    + MARKDOWN + end +end