Spike potential redcarpet replacement

This commit is contained in:
Jose Farias
2024-12-17 00:32:58 -06:00
parent 326bdc74cf
commit 4fc7ab1582
6 changed files with 511 additions and 22 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+27 -20
View File
@@ -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|
"<h#{header_level} id='#{id}'>#{text} <a href='##{id}' class='heading__link' aria-hidden='true'>#</a></h#{header_level}>"
end
end
def image(url, title, alt_text)
%(<a title="#{title}" data-action="lightbox#open:prevent" data-lightbox-target="image" data-lightbox-url-value="#{url}?disposition=attachment" href="#{url}"><img src="#{url}" alt="#{alt_text}"></a>)
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
+117
View File
@@ -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
<h#{header_level} id="#{id}">
#{text} <a href="##{id}" class="heading__link" aria-hidden="true">#</a>
</h#{header_level}>
HTML
end
end
def bold(text)
"<strong>#{text}</strong>"
end
def italics(text)
"<em>#{text}</em>"
end
def strikethrough(text)
"<s>#{text}</s>"
end
def highlight(text)
"<mark>#{text}</mark>"
end
def table(headers, rows)
<<~HTML
<table>
<thead>
<tr>
#{headers.map { |header| "<th>#{header}</th>" }.join("\n" + TABLE_HEADER_INDENT)}
</tr>
</thead>
<tbody>
#{rows.map { |row| "<tr>#{row.map { |cell| "<td>#{cell}</td>" }.join}</tr>" }.join("\n" + TABLE_ROW_INDENT)}
</tbody>
</table>
HTML
end
def ordered_list(contents)
<<~HTML
<ol>
#{contents.split("\n").join("\n" + LIST_ITEM_INDENT)}
</ol>
HTML
end
def unordered_list(contents)
<<~HTML
<ul>
#{contents.split("\n").join("\n" + LIST_ITEM_INDENT)}
</ul>
HTML
end
def list_item(text)
"<li>#{text}</li>"
end
def block_quote(text)
<<~HTML
<blockquote>
#{text.gsub(/^>\s?/, '').split("\n").join("\n" + BLOCK_QUOTE_INDENT)}
</blockquote>
HTML
end
def horizontal_rule
"<hr>"
end
def link(text, href)
"<a href=\"#{href}\">#{text}</a>"
end
def code_block(code, language)
block_code(code, language) # call Rouge Redcarpet plugin
end
def code_span(text)
"<code>#{text}</code>"
end
# FIXME: the attributes suggest this should be an app-level override instead
def image(url, alt_text)
<<~HTML.chomp
<a href="#{url}" data-action="lightbox#open:prevent" data-lightbox-target="image" data-lightbox-url-value="#{url}?disposition=attachment">
<img src="#{url}" alt="#{alt_text}">
</a>
HTML
end
def paragraph(text)
"<p>#{text}</p>"
end
def soft_line_break
"\n#{line_break}\n"
end
def line_break
"<br>"
end
end
+135
View File
@@ -0,0 +1,135 @@
module MarkdownRenderer::Parsing
HR = /^---$/
INLINE_HTML_BLOCK_START = /^</
HEADING_START = /^#/
CODE_BLOCK_START = /^```/
NEWLINE_OR_EOF = /\n|$/
SOFT_LINE_BREAK = /(?<=[^\n])\n(?=[^\n])/
NUMBERED_LIST_START = /^\d+\.\s/
NUMBERED_LIST_ITEM = /#{NUMBERED_LIST_START}[^\n]*#{NEWLINE_OR_EOF}/
UNORDERED_LIST_START = /^-\s/
UNORDERED_LIST_ITEM = /#{UNORDERED_LIST_START}[^\n]*#{NEWLINE_OR_EOF}/
BLOCK_QUOTE_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(/(?<!\*)\*\*\*([^*]+)\*\*\*(?!\*)/) { transform.($1) }
.gsub(/(?<!_)___([^_]+)___(?!_)/) { transform.($1) }
end
def parse_bold(content)
transform = ->(match) { bold(match) }
content
.gsub(/(?<!\*)\*\*([^*]+)\*\*(?!\*)/) { transform.($1) }
.gsub(/(?<!_)__([^_]+)__(?!_)/) { transform.($1) }
end
def parse_italics(content)
transform = ->(match) { italics(match) }
content
.gsub(/(?<!\*)\*([^*]+)\*(?!\*)/) { transform.($1) }
.gsub(/(?<!_)_([^_]+)_(?!_)/) { transform.($1) }
end
def parse_strikethrough(content)
content.gsub(/~~(.*?)~~/) { strikethrough($1) }
end
def parse_highlight(content)
content.gsub(/==(.*?)==/) { highlight($1) }
end
def parse_headers(content)
content
.gsub(/^# (.*)$/) { header($1, 1) }
.gsub(/^## (.*)$/) { header($1, 2) }
.gsub(/^### (.*)$/) { header($1, 3) }
.gsub(/^#### (.*)$/) { header($1, 4) }
.gsub(/^##### (.*)$/) { header($1, 5) }
.gsub(/^###### (.*)$/) { header($1, 6) }
end
def parse_tables(content)
content.gsub(TABLE) do
headers = $1.split("|").map(&:strip).compact_blank
rows = $2.split("\n").map { |row| row.split("|").map(&:strip).compact_blank unless row.blank? }.compact
table(headers, rows) if rows.map(&:size).all?(headers.size)
end
end
def parse_ordered_lists(content)
content.gsub(/(?:#{NUMBERED_LIST_ITEM})+/) { |list| ordered_list(parse_ordered_list_items(list.strip)) }
end
def parse_ordered_list_items(content)
content.gsub(/#{NUMBERED_LIST_START}(.*)$/) { list_item($1) }
end
def parse_unordered_lists(content)
content.gsub(/(?:#{UNORDERED_LIST_ITEM})+/) { |list| unordered_list(parse_unordered_list_items(list.strip)) }
end
def parse_unordered_list_items(content)
content.gsub(/#{UNORDERED_LIST_START}(.*)$/) { list_item($1) }
end
def parse_block_quotes(content)
content.gsub(/(?:#{BLOCK_QUOTE_CONTENT})+/) { |text| block_quote(text.strip) }
end
def parse_horizontal_rules(content)
content.gsub(HR) { horizontal_rule }
end
def parse_images(content)
content.gsub(/!\[(.*?)\]\((.*?)\)/) { image($2, $1) }
end
def parse_links(content)
content.gsub(/\[(.*?)\]\((.*?)\)/) { link($1, $2) }
end
def parse_code_blocks(content)
content.gsub(/#{CODE_BLOCK_START}(\w*)\n(.*?)```$/m) { code_block($2, $1) }
end
def parse_code_spans(content)
content.gsub(/`([^`\n]+)`/) { code_span($1) }
end
end
+230
View File
@@ -0,0 +1,230 @@
require "test_helper"
class MarkdownRendererTest < ActiveSupport::TestCase
setup do
@renderer = MarkdownRenderer.new
end
test "renders a complete markdown document" do
assert_equal expected_html, @renderer.render(markdown_content)
end
private
def expected_html
<<~HTML
<h1 id="welcome-to-my-document">
Welcome to My Document <a href="#welcome-to-my-document" class="heading__link" aria-hidden="true">#</a>
</h1>
<p>This is an introduction paragraph with some text.</p>
<hr>
<p><strong><em>Hello, world!</em></strong> <strong>Hello, world!</strong> <em>Hello, world!</em> <s>Hello, world!</s> <mark>Hello, world!</mark></p>
<p><strong><em>Hello, world!</em></strong>
<br>
<strong>Hello, world!</strong>
<br>
<em>Hello, world!</em>
<br>
<s>Hello, world!</s>
<br>
<mark>Hello, world!</mark></p>
<h2 id="getting-started">
Getting Started <a href="#getting-started" class="heading__link" aria-hidden="true">#</a>
</h2>
<p>Heres what you need to know:</p>
<ol>
<li>First important point</li>
<li>Second crucial thing</li>
<li>Dont forget this</li>
</ol>
<h2 id="key-features">
Key Features <a href="#key-features" class="heading__link" aria-hidden="true">#</a>
</h2>
<p>Our product offers:</p>
<ul>
<li>Simple interface</li>
<li>Powerful features</li>
<li>Great documentation</li>
</ul>
<p><a href="https://basecamp.com">Get Basecamp</a></p>
<h3 id="important-note-2">
Important Note <a href="#important-note-2" class="heading__link" aria-hidden="true">#</a>
</h3>
<p>Heres some
<br>
multiline text.
<br>
It has line breaks
<br>
in-between.</p>
<blockquote>
Please read this carefully
It contains vital information
That you shouldnt miss
</blockquote>
<p><a href="https://placehold.co/600x400" data-action="lightbox#open:prevent" data-lightbox-target="image" data-lightbox-url-value="https://placehold.co/600x400?disposition=attachment">
<img src="https://placehold.co/600x400" alt="Placeholder image">
</a></p>
<h2 id="important-note">
Important Note <a href="#important-note" class="heading__link" aria-hidden="true">#</a>
</h2>
<p><a href="https://placehold.co/400x400" data-action="lightbox#open:prevent" data-lightbox-target="image" data-lightbox-url-value="https://placehold.co/400x400?disposition=attachment">
<img src="https://placehold.co/400x400" alt="Placeholder image">
</a></p>
<ol>
<li>Remember to save</li>
<li>Back up your work</li>
</ol>
<p><a href="https://basecamp.com">Get Basecamp</a></p>
<table>
<thead>
<tr>
<th>Table 1 Header 1</th>
<th>Table 1 Header 2</th>
</tr>
</thead>
<tbody>
<tr><td>Table 1 Data 1</td><td>Table 1 Data 2</td></tr>
<tr><td>Table 1 Data 3</td><td>Table 1 Data 4</td></tr>
</tbody>
</table>
<p>Some content between tables...</p>
<table>
<thead>
<tr>
<th>Table 2 Header 1</th>
<th>Table 2 Header 2</th>
</tr>
</thead>
<tbody>
<tr><td>Table 2 Data 1</td><td>Table 2 Data 2</td></tr>
<tr><td>Table 2 Data 3</td><td>Table 2 Data 4</td></tr>
</tbody>
</table>
<div class="highlight"><pre class="highlight ruby"><code><span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
<span class="k">def</span> <span class="nf">title</span>
<span class="s2">"foo"</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
<p><code>puts "Hello, world!"</code></p>
<p>Thanks for reading!</p>
<details>
<summary>Summary</summary>
Details
</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
Heres what you need to know:
1. First important point
2. Second crucial thing
3. Dont forget this
## Key Features
Our product offers:
- Simple interface
- Powerful features
- Great documentation
[Get Basecamp](https://basecamp.com)
### Important Note
Heres some
multiline text.
It has line breaks
in-between.
> Please read this carefully
> It contains vital information
> That you shouldnt 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!
<details>
<summary>Summary</summary>
Details
</details>
MARKDOWN
end
end