Move CodeBlockParse and MarkdownParser to service (#12112)
* Move remove_nested_linebreak_in_list to service * Move prefex_all_images to service * Major refactor :) - Move MarkdownParser to /services - Move CodeBlockParser to HtmlParser in /services - Update specs * Fix MarkdownParser * Fix parser again * Rename HtmlParser to Html::Parser - Fix Rubocop violations :/ - Fix conflict * Make html writer private * Fix codeclimate * Fix parser
This commit is contained in:
parent
dd67081e67
commit
ce4fe1aaf7
9 changed files with 753 additions and 266 deletions
|
|
@ -1,170 +0,0 @@
|
|||
module CodeBlockParser
|
||||
include InlineSvg::ActionView::Helpers
|
||||
include ApplicationHelper
|
||||
|
||||
def remove_nested_linebreak_in_list(html)
|
||||
html_doc = Nokogiri::HTML(html)
|
||||
html_doc.xpath("//*[self::ul or self::ol or self::li]/br").each(&:remove)
|
||||
html_doc.to_html
|
||||
end
|
||||
|
||||
def prefix_all_images(html, width = 880)
|
||||
# wrap with Cloudinary or allow if from giphy or githubusercontent.com
|
||||
doc = Nokogiri::HTML.fragment(html)
|
||||
doc.css("img").each do |img|
|
||||
src = img.attr("src")
|
||||
next unless src
|
||||
# allow image to render as-is
|
||||
next if allowed_image_host?(src)
|
||||
|
||||
img["loading"] = "lazy"
|
||||
img["src"] = if Giphy::Image.valid_url?(src)
|
||||
src.gsub("https://media.", "https://i.")
|
||||
else
|
||||
img_of_size(src, width)
|
||||
end
|
||||
end
|
||||
doc.to_html
|
||||
end
|
||||
|
||||
def wrap_all_images_in_links(html)
|
||||
doc = Nokogiri::HTML.fragment(html)
|
||||
doc.search("p img").each do |image|
|
||||
next if image.parent.name == "a"
|
||||
|
||||
image.swap("<a href='#{image.attr('src')}' class='article-body-image-wrapper'>#{image}</a>")
|
||||
end
|
||||
doc.to_html
|
||||
end
|
||||
|
||||
def add_control_class_to_codeblock(html)
|
||||
doc = Nokogiri::HTML.fragment(html)
|
||||
doc.search("div.highlight").each do |codeblock|
|
||||
codeblock.add_class("js-code-highlight")
|
||||
end
|
||||
doc.to_html
|
||||
end
|
||||
|
||||
def add_control_panel_to_codeblock(html)
|
||||
doc = Nokogiri::HTML.fragment(html)
|
||||
doc.search("div.highlight").each do |codeblock|
|
||||
codeblock.add_child('<div class="highlight__panel js-actions-panel"></div>')
|
||||
end
|
||||
doc.to_html
|
||||
end
|
||||
|
||||
def add_fullscreen_button_to_panel(html)
|
||||
on_title = "Enter fullscreen mode"
|
||||
on_cls = "highlight-action highlight-action--fullscreen-on"
|
||||
icon_fullscreen_on = inline_svg_tag("fullscreen-on.svg", class: on_cls, title: on_title)
|
||||
off_title = "Exit fullscreen mode"
|
||||
off_cls = "highlight-action highlight-action--fullscreen-off"
|
||||
icon_fullscreen_off = inline_svg_tag("fullscreen-off.svg", class: off_cls, title: off_title)
|
||||
doc = Nokogiri::HTML.fragment(html)
|
||||
doc.search("div.highlight__panel").each do |codeblock|
|
||||
fullscreen_action = <<~HTML
|
||||
<div class="highlight__panel-action js-fullscreen-code-action">
|
||||
#{icon_fullscreen_on}
|
||||
#{icon_fullscreen_off}
|
||||
</div>
|
||||
HTML
|
||||
|
||||
codeblock.add_child(fullscreen_action)
|
||||
end
|
||||
doc.to_html
|
||||
end
|
||||
|
||||
def wrap_all_tables(html)
|
||||
doc = Nokogiri::HTML.fragment(html)
|
||||
doc.search("table").each { |table| table.swap("<div class='table-wrapper-paragraph'>#{table}</div>") }
|
||||
doc.to_html
|
||||
end
|
||||
|
||||
def remove_empty_paragraphs(html)
|
||||
doc = Nokogiri::HTML.fragment(html)
|
||||
doc.css("p").select { |paragraph| all_children_are_blank?(paragraph) }.each(&:remove)
|
||||
doc.to_html
|
||||
end
|
||||
|
||||
def escape_colon_emojis_in_codeblock(html)
|
||||
html_doc = Nokogiri::HTML.fragment(html)
|
||||
|
||||
html_doc.children.each do |el|
|
||||
next if el.name == "code"
|
||||
|
||||
if el.search("code").empty?
|
||||
el.swap(Html::ParseEmoji.call(el.to_html)) if el.parent.present?
|
||||
else
|
||||
el.children = escape_colon_emojis_in_codeblock(el.children.to_html)
|
||||
end
|
||||
end
|
||||
html_doc.to_html
|
||||
end
|
||||
|
||||
def unescape_raw_tag_in_codeblocks(html)
|
||||
html.gsub!("{----% raw %----}", "{% raw %}")
|
||||
html.gsub!("{----% endraw %----}", "{% endraw %}")
|
||||
html_doc = Nokogiri::HTML(html)
|
||||
html_doc.xpath("//body/div/pre/code").each do |codeblock|
|
||||
next unless codeblock.content.include?("{----% raw %----}") || codeblock.content.include?("{----% endraw %----}")
|
||||
|
||||
children_content = codeblock.children.map(&:content)
|
||||
indices = children_content.size.times.select do |i|
|
||||
possibly_raw_tag_syntax?(children_content[i..i + 2])
|
||||
end
|
||||
indices.each do |i|
|
||||
codeblock.children[i].content = codeblock.children[i].content.delete("----")
|
||||
end
|
||||
end
|
||||
if html_doc.at_css("body")
|
||||
html_doc.at_css("body").inner_html
|
||||
else
|
||||
html_doc.to_html
|
||||
end
|
||||
end
|
||||
|
||||
def wrap_all_figures_with_tags(html)
|
||||
html_doc = Nokogiri::HTML(html)
|
||||
|
||||
html_doc.xpath("//figcaption").each do |caption|
|
||||
next if caption.parent.name == "figure"
|
||||
next unless caption.previous_element
|
||||
|
||||
fig = html_doc.create_element "figure"
|
||||
prev = caption.previous_element
|
||||
prev.replace(fig) << prev << caption
|
||||
end
|
||||
if html_doc.at_css("body")
|
||||
html_doc.at_css("body").inner_html
|
||||
else
|
||||
html_doc.to_html
|
||||
end
|
||||
end
|
||||
|
||||
def wrap_mentions_with_links!(html)
|
||||
html_doc = Nokogiri::HTML(html)
|
||||
|
||||
# looks for nodes that isn't <code>, <a>, and contains "@"
|
||||
targets = html_doc.xpath('//html/body/*[not (self::code) and not(self::a) and contains(., "@")]').to_a
|
||||
|
||||
# A Queue system to look for and replace possible usernames
|
||||
until targets.empty?
|
||||
node = targets.shift
|
||||
|
||||
# only focus on portion of text with "@"
|
||||
node.xpath("text()[contains(.,'@')]").each do |el|
|
||||
el.replace(el.text.gsub(/\B@[a-z0-9_-]+/i) { |text| user_link_if_exists(text) })
|
||||
end
|
||||
|
||||
# enqueue children that has @ in it's text
|
||||
children = node.xpath('*[not(self::code) and not(self::a) and contains(., "@")]').to_a
|
||||
targets.concat(children)
|
||||
end
|
||||
|
||||
if html_doc.at_css("body")
|
||||
html_doc.at_css("body").inner_html
|
||||
else
|
||||
html_doc.to_html
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -36,6 +36,6 @@ class DisplayAd < ApplicationRecord
|
|||
# attributes: %w[href target src height width style]
|
||||
stripped_html = initial_html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
html = stripped_html.delete("\n")
|
||||
self.processed_html = MarkdownParser.new(html).prefix_all_images(html, 350)
|
||||
self.processed_html = Html::Parser.new(html).prefix_all_images(350).html
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
module Html
|
||||
class ParseEmoji
|
||||
def self.call(html)
|
||||
return unless html
|
||||
|
||||
html.gsub!(/:([\w+-]+):/) do |match|
|
||||
emoji = Emoji.find_by_alias(Regexp.last_match(1)) # rubocop:disable Rails/DynamicFindBy
|
||||
emoji.present? ? emoji.raw : match
|
||||
end
|
||||
html
|
||||
end
|
||||
end
|
||||
end
|
||||
284
app/services/html/parser.rb
Normal file
284
app/services/html/parser.rb
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
module Html
|
||||
class Parser
|
||||
# Each of the instance methods should return self to support chaining of
|
||||
# methods
|
||||
# For example:
|
||||
# Html::Parser.
|
||||
# new(html).
|
||||
# remove_nested_linebreak_in_list.
|
||||
# prefix_all_images.
|
||||
# wrap_all_images_in_links.
|
||||
# html
|
||||
|
||||
include InlineSvg::ActionView::Helpers
|
||||
include ApplicationHelper
|
||||
|
||||
RAW_TAG_DELIMITERS = ["{", "}", "raw", "endraw", "----"].freeze
|
||||
RAW_TAG = "{----% raw %----}".freeze
|
||||
END_RAW_TAG = "{----% endraw %----}".freeze
|
||||
|
||||
attr_accessor :html
|
||||
private :html=
|
||||
|
||||
def initialize(html)
|
||||
@html = html
|
||||
end
|
||||
|
||||
def remove_nested_linebreak_in_list
|
||||
html_doc = Nokogiri::HTML(@html)
|
||||
html_doc.xpath("//*[self::ul or self::ol or self::li]/br").each(&:remove)
|
||||
@html = html_doc.to_html
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def prefix_all_images(width = 880)
|
||||
# wrap with Cloudinary or allow if from giphy or githubusercontent.com
|
||||
doc = Nokogiri::HTML.fragment(@html)
|
||||
|
||||
doc.css("img").each do |img|
|
||||
src = img.attr("src")
|
||||
next unless src
|
||||
# allow image to render as-is
|
||||
next if allowed_image_host?(src)
|
||||
|
||||
img["loading"] = "lazy"
|
||||
img["src"] = if Giphy::Image.valid_url?(src)
|
||||
src.gsub("https://media.", "https://i.")
|
||||
else
|
||||
img_of_size(src, width)
|
||||
end
|
||||
end
|
||||
|
||||
@html = doc.to_html
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def wrap_all_images_in_links
|
||||
doc = Nokogiri::HTML.fragment(@html)
|
||||
|
||||
doc.search("p img").each do |image|
|
||||
next if image.parent.name == "a"
|
||||
|
||||
image.swap("<a href='#{image.attr('src')}' class='article-body-image-wrapper'>#{image}</a>")
|
||||
end
|
||||
|
||||
@html = doc.to_html
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def add_control_class_to_codeblock
|
||||
doc = Nokogiri::HTML.fragment(@html)
|
||||
|
||||
doc.search("div.highlight").each do |codeblock|
|
||||
codeblock.add_class("js-code-highlight")
|
||||
end
|
||||
|
||||
@html = doc.to_html
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def add_control_panel_to_codeblock
|
||||
doc = Nokogiri::HTML.fragment(@html)
|
||||
|
||||
doc.search("div.highlight").each do |codeblock|
|
||||
codeblock.add_child('<div class="highlight__panel js-actions-panel"></div>')
|
||||
end
|
||||
|
||||
@html = doc.to_html
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def add_fullscreen_button_to_panel
|
||||
on_title = "Enter fullscreen mode"
|
||||
on_cls = "highlight-action highlight-action--fullscreen-on"
|
||||
icon_fullscreen_on = inline_svg_tag("fullscreen-on.svg", class: on_cls, title: on_title)
|
||||
off_title = "Exit fullscreen mode"
|
||||
off_cls = "highlight-action highlight-action--fullscreen-off"
|
||||
icon_fullscreen_off = inline_svg_tag("fullscreen-off.svg", class: off_cls, title: off_title)
|
||||
doc = Nokogiri::HTML.fragment(@html)
|
||||
doc.search("div.highlight__panel").each do |codeblock|
|
||||
fullscreen_action = <<~HTML
|
||||
<div class="highlight__panel-action js-fullscreen-code-action">
|
||||
#{icon_fullscreen_on}
|
||||
#{icon_fullscreen_off}
|
||||
</div>
|
||||
HTML
|
||||
|
||||
codeblock.add_child(fullscreen_action)
|
||||
end
|
||||
|
||||
@html = doc.to_html
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def wrap_all_tables
|
||||
doc = Nokogiri::HTML.fragment(@html)
|
||||
doc.search("table").each { |table| table.swap("<div class='table-wrapper-paragraph'>#{table}</div>") }
|
||||
@html = doc.to_html
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def remove_empty_paragraphs
|
||||
doc = Nokogiri::HTML.fragment(@html)
|
||||
doc.css("p").select { |paragraph| all_children_are_blank?(paragraph) }.each(&:remove)
|
||||
@html = doc.to_html
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def escape_colon_emojis_in_codeblock
|
||||
html_doc = Nokogiri::HTML.fragment(@html)
|
||||
|
||||
html_doc.children.each do |el|
|
||||
next if el.name == "code"
|
||||
|
||||
if el.search("code").empty?
|
||||
if el.parent.present?
|
||||
parsed_html = Html::Parser.new(el.to_html).parse_emojis.html
|
||||
el.swap(parsed_html)
|
||||
end
|
||||
else
|
||||
el.children = self.class.new(el.children.to_html)
|
||||
.escape_colon_emojis_in_codeblock
|
||||
.html
|
||||
end
|
||||
end
|
||||
|
||||
@html = html_doc.to_html
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def unescape_raw_tag_in_codeblocks
|
||||
return self if @html.blank?
|
||||
|
||||
@html.gsub!(RAW_TAG, "{% raw %}")
|
||||
@html.gsub!(END_RAW_TAG, "{% endraw %}")
|
||||
html_doc = Nokogiri::HTML(@html)
|
||||
html_doc.xpath("//body/div/pre/code").each do |codeblock|
|
||||
next unless codeblock.content.include?(RAW_TAG) || codeblock.content.include?(END_RAW_TAG)
|
||||
|
||||
children_content = codeblock.children.map(&:content)
|
||||
indices = children_content.size.times.select do |i|
|
||||
possibly_raw_tag_syntax?(children_content[i..i + 2])
|
||||
end
|
||||
indices.each do |i|
|
||||
codeblock.children[i].content = codeblock.children[i].content.delete("----")
|
||||
end
|
||||
end
|
||||
|
||||
@html =
|
||||
if html_doc.at_css("body")
|
||||
html_doc.at_css("body").inner_html
|
||||
else
|
||||
html_doc.to_html
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def wrap_all_figures_with_tags
|
||||
html_doc = Nokogiri::HTML(@html)
|
||||
|
||||
html_doc.xpath("//figcaption").each do |caption|
|
||||
next if caption.parent.name == "figure"
|
||||
next unless caption.previous_element
|
||||
|
||||
fig = html_doc.create_element "figure"
|
||||
prev = caption.previous_element
|
||||
prev.replace(fig) << prev << caption
|
||||
end
|
||||
|
||||
@html =
|
||||
if html_doc.at_css("body")
|
||||
html_doc.at_css("body").inner_html
|
||||
else
|
||||
html_doc.to_html
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def wrap_mentions_with_links
|
||||
html_doc = Nokogiri::HTML(@html)
|
||||
|
||||
# looks for nodes that isn't <code>, <a>, and contains "@"
|
||||
targets = html_doc.xpath('//html/body/*[not (self::code) and not(self::a) and contains(., "@")]').to_a
|
||||
|
||||
# A Queue system to look for and replace possible usernames
|
||||
until targets.empty?
|
||||
node = targets.shift
|
||||
|
||||
# only focus on portion of text with "@"
|
||||
node.xpath("text()[contains(.,'@')]").each do |el|
|
||||
el.replace(el.text.gsub(/\B@[a-z0-9_-]+/i) { |text| user_link_if_exists(text) })
|
||||
end
|
||||
|
||||
# enqueue children that has @ in it's text
|
||||
children = node.xpath('*[not(self::code) and not(self::a) and contains(., "@")]').to_a
|
||||
targets.concat(children)
|
||||
end
|
||||
|
||||
@html =
|
||||
if html_doc.at_css("body")
|
||||
html_doc.at_css("body").inner_html
|
||||
else
|
||||
html_doc.to_html
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def parse_emojis
|
||||
return self if @html.blank?
|
||||
|
||||
@html.gsub!(/:([\w+-]+):/) do |match|
|
||||
emoji = Emoji.find_by_alias(Regexp.last_match(1)) # rubocop:disable Rails/DynamicFindBy
|
||||
emoji.present? ? emoji.raw : match
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def img_of_size(source, width = 880)
|
||||
Images::Optimizer.call(source, width: width).gsub(",", "%2C")
|
||||
end
|
||||
|
||||
def all_children_are_blank?(node)
|
||||
node.children.all? { |child| blank?(child) }
|
||||
end
|
||||
|
||||
def blank?(node)
|
||||
(node.text? && node.content.strip == "") || (node.element? && node.name == "br")
|
||||
end
|
||||
|
||||
def allowed_image_host?(src)
|
||||
# GitHub camo image won't parse but should be safe to host direct
|
||||
src.start_with?("https://camo.githubusercontent.com")
|
||||
end
|
||||
|
||||
def user_link_if_exists(mention)
|
||||
username = mention.delete("@").downcase
|
||||
if User.find_by(username: username)
|
||||
<<~HTML
|
||||
<a class='comment-mentioned-user' href='#{ApplicationConfig['APP_PROTOCOL']}#{SiteConfig.app_domain}/#{username}'>@#{username}</a>
|
||||
HTML
|
||||
else
|
||||
mention
|
||||
end
|
||||
end
|
||||
|
||||
def possibly_raw_tag_syntax?(array)
|
||||
(RAW_TAG_DELIMITERS & array).any?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
class MarkdownParser
|
||||
include ApplicationHelper
|
||||
include CodeBlockParser
|
||||
|
||||
BAD_XSS_REGEX = [
|
||||
/src=["'](data|&)/i,
|
||||
|
|
@ -9,8 +8,6 @@ class MarkdownParser
|
|||
|
||||
WORDS_READ_PER_MINUTE = 275.0
|
||||
|
||||
RAW_TAG_DELIMITERS = ["{", "}", "raw", "endraw", "----"].freeze
|
||||
|
||||
def initialize(content, source: nil, user: nil)
|
||||
@content = content
|
||||
@source = source
|
||||
|
|
@ -32,18 +29,8 @@ class MarkdownParser
|
|||
rescue Liquid::SyntaxError => e
|
||||
html = e.message
|
||||
end
|
||||
html = remove_nested_linebreak_in_list(html)
|
||||
html = prefix_all_images(html)
|
||||
html = wrap_all_images_in_links(html)
|
||||
html = add_control_class_to_codeblock(html)
|
||||
html = add_control_panel_to_codeblock(html)
|
||||
html = add_fullscreen_button_to_panel(html)
|
||||
html = wrap_all_tables(html)
|
||||
html = remove_empty_paragraphs(html)
|
||||
html = escape_colon_emojis_in_codeblock(html)
|
||||
html = unescape_raw_tag_in_codeblocks(html)
|
||||
html = wrap_all_figures_with_tags(html)
|
||||
wrap_mentions_with_links!(html)
|
||||
|
||||
parse_html(html)
|
||||
end
|
||||
|
||||
def calculate_reading_time
|
||||
|
|
@ -121,11 +108,6 @@ class MarkdownParser
|
|||
raise ArgumentError, "Invalid markdown detected"
|
||||
end
|
||||
|
||||
def allowed_image_host?(src)
|
||||
# GitHub camo image won't parse but should be safe to host direct
|
||||
src.start_with?("https://camo.githubusercontent.com")
|
||||
end
|
||||
|
||||
def escape_liquid_tags_in_codeblock(content)
|
||||
# Escape codeblocks, code spans, and inline code
|
||||
content.gsub(/[[:space:]]*`{3}.*?`{3}|`{2}.+?`{2}|`{1}.+?`{1}/m) do |codeblock|
|
||||
|
|
@ -139,30 +121,25 @@ class MarkdownParser
|
|||
end
|
||||
end
|
||||
|
||||
def possibly_raw_tag_syntax?(array)
|
||||
(RAW_TAG_DELIMITERS & array).any?
|
||||
end
|
||||
private
|
||||
|
||||
def user_link_if_exists(mention)
|
||||
username = mention.delete("@").downcase
|
||||
if User.find_by(username: username)
|
||||
<<~HTML
|
||||
<a class='comment-mentioned-user' href='#{ApplicationConfig['APP_PROTOCOL']}#{SiteConfig.app_domain}/#{username}'>@#{username}</a>
|
||||
HTML
|
||||
else
|
||||
mention
|
||||
end
|
||||
end
|
||||
def parse_html(html)
|
||||
return html if html.blank?
|
||||
|
||||
def img_of_size(source, width = 880)
|
||||
Images::Optimizer.call(source, width: width).gsub(",", "%2C")
|
||||
end
|
||||
|
||||
def all_children_are_blank?(node)
|
||||
node.children.all? { |child| blank?(child) }
|
||||
end
|
||||
|
||||
def blank?(node)
|
||||
(node.text? && node.content.strip == "") || (node.element? && node.name == "br")
|
||||
Html::Parser
|
||||
.new(html)
|
||||
.remove_nested_linebreak_in_list
|
||||
.prefix_all_images
|
||||
.wrap_all_images_in_links
|
||||
.add_control_class_to_codeblock
|
||||
.add_control_panel_to_codeblock
|
||||
.add_fullscreen_button_to_panel
|
||||
.wrap_all_tables
|
||||
.remove_empty_paragraphs
|
||||
.escape_colon_emojis_in_codeblock
|
||||
.unescape_raw_tag_in_codeblocks
|
||||
.wrap_all_figures_with_tags
|
||||
.wrap_mentions_with_links
|
||||
.html
|
||||
end
|
||||
end
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<%= repo.name %>
|
||||
</p>
|
||||
<% if repo.description.present? %>
|
||||
<p class="fs-s color-base-80 mb-1"><%= Html::ParseEmoji.call(repo.description) %></p>
|
||||
<p class="fs-s color-base-80 mb-1"><%= Html::Parser.new(repo.description).parse_emojis.html %></p>
|
||||
<% end %>
|
||||
<p class="fs-s color-base-60 flex items-center">
|
||||
<% if repo.fork %>
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
require "rails_helper"
|
||||
|
||||
# rubocop:disable Rails/DynamicFindBy
|
||||
|
||||
RSpec.describe Html::ParseEmoji, type: :service do
|
||||
describe "#call" do
|
||||
it "converts emoji names wrapped in colons into unicode" do
|
||||
joy_emoji_unicode = Emoji.find_by_alias("joy").raw
|
||||
expect(described_class.call(":joy:")).to include(joy_emoji_unicode)
|
||||
end
|
||||
|
||||
it "converts disability emojis as well", :aggregate_failures do
|
||||
disability_emojis = %w[
|
||||
guide_dog service_dog person_with_probing_cane man_with_probing_cane woman_with_probing_cane probing_cane
|
||||
person_in_motorized_wheelchair man_in_motorized_wheelchair woman_in_motorized_wheelchair
|
||||
person_in_manual_wheelchair man_in_manual_wheelchair woman_in_manual_wheelchair manual_wheelchair
|
||||
motorized_wheelchair wheelchair
|
||||
]
|
||||
disability_emojis.each do |emoji|
|
||||
unicode = Emoji.find_by_alias(emoji).raw
|
||||
expect(described_class.call(":#{emoji}:")).to include(unicode)
|
||||
end
|
||||
end
|
||||
|
||||
it "leaves original text between colons when no emoji is found" do
|
||||
emoji_text = ":no_one_will_ever_create_an_emoji_with_this_alias:"
|
||||
expect(described_class.call(emoji_text)).to include(emoji_text)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:enable Rails/DynamicFindBy
|
||||
445
spec/services/html/parser_spec.rb
Normal file
445
spec/services/html/parser_spec.rb
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Html::Parser, type: :service do
|
||||
it "has the correct raw tag delimiters" do
|
||||
expect(described_class::RAW_TAG_DELIMITERS).to match_array(["{", "}", "raw", "endraw", "----"])
|
||||
end
|
||||
|
||||
describe "#remove_nested_linebreak_in_list" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).remove_nested_linebreak_in_list.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").remove_nested_linebreak_in_list.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").remove_nested_linebreak_in_list
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
it "renders nested lists without linebreaks" do
|
||||
html = "- [A](#a)\n - [B](#b)\n- [C](#c)"
|
||||
parsed_html = described_class.new(html).remove_nested_linebreak_in_list.html
|
||||
expect(parsed_html).not_to include("<br>")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#prefix_all_images" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).prefix_all_images.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").prefix_all_images.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").prefix_all_images
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
context "when using gifs from Giphy as images" do
|
||||
it "does not wrap giphy images with Cloudinary" do
|
||||
html = "<img src='https://media.giphy.com/media/3ow0TN2M8TH2aAn67F/giphy.gif'>"
|
||||
parsed_html = Nokogiri::HTML(described_class.new(html).prefix_all_images.html)
|
||||
img_src = parsed_html.search("img")[0]["src"]
|
||||
expect(img_src).not_to include("https://res.cloudinary.com")
|
||||
end
|
||||
|
||||
it "uses the raw gif from i.giphy.com" do
|
||||
html = "<img src='https://media.giphy.com/media/3ow0TN2M8TH2aAn67F/giphy.gif'>"
|
||||
parsed_html = Nokogiri::HTML(described_class.new(html).prefix_all_images.html)
|
||||
img_src = parsed_html.search("img")[0]["src"]
|
||||
expect(img_src).to start_with("https://i.giphy.com")
|
||||
end
|
||||
end
|
||||
|
||||
context "when an image is used" do
|
||||
it "wraps the image with Cloudinary" do
|
||||
html = "<img src='https://image.com/image.jpg'>"
|
||||
parsed_html = described_class.new(html).prefix_all_images.html
|
||||
expect(parsed_html).to include("https://res.cloudinary.com")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#wrap_all_images_in_links" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).wrap_all_images_in_links.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").wrap_all_images_in_links.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").wrap_all_images_in_links
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
it "wraps image in link" do
|
||||
html = "<p><img src='https://image.com/image.jpg'></p"
|
||||
parsed_html = described_class.new(html).wrap_all_images_in_links.html
|
||||
expect(parsed_html).to include("<a")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#add_control_class_to_codeblock" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).add_control_class_to_codeblock.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").add_control_class_to_codeblock.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").add_control_class_to_codeblock
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
context "when the highlight class is present" do
|
||||
it "adds the control panel" do
|
||||
html = "<div class='highlight'>Hello world!</div>"
|
||||
parsed_html = described_class.new(html).add_control_class_to_codeblock.html
|
||||
expect(parsed_html).to include("js-code-highlight")
|
||||
end
|
||||
end
|
||||
|
||||
context "when the highlight class is not present" do
|
||||
it "doesn't add the control panel" do
|
||||
html = "<div>Hello world!</div>"
|
||||
parsed_html = described_class.new(html).add_control_class_to_codeblock.html
|
||||
expect(parsed_html).not_to include("js-code-highlight")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#add_control_panel_to_codeblock" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).add_control_panel_to_codeblock.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").add_control_panel_to_codeblock.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").add_control_panel_to_codeblock
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
context "when the highlight class is present" do
|
||||
it "adds the control panel" do
|
||||
html = "<div class='highlight'>Hello world!</div>"
|
||||
parsed_html = described_class.new(html).add_control_panel_to_codeblock.html
|
||||
expect(parsed_html).to include("highlight__panel", "js-actions-panel")
|
||||
end
|
||||
end
|
||||
|
||||
context "when the highlight class is not present" do
|
||||
it "doesn't add the control panel" do
|
||||
html = "<div>Hello world!</div>"
|
||||
parsed_html = described_class.new(html).add_control_panel_to_codeblock.html
|
||||
expect(parsed_html).not_to include("highlight__panel", "js-actions-panel")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#add_fullscreen_button_to_panel" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).add_fullscreen_button_to_panel.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").add_fullscreen_button_to_panel.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").add_fullscreen_button_to_panel
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
it "adds the fullscreen button" do
|
||||
html = "<div class='highlight__panel'>Hello World</div>"
|
||||
parsed_html = described_class.new(html).add_fullscreen_button_to_panel.html
|
||||
expect(parsed_html).to include("Enter fullscreen mode")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#wrap_all_tables" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).wrap_all_tables.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").wrap_all_tables.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").wrap_all_tables
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
it "wraps all tables" do
|
||||
html = "<table><tr><th>Header</th></tr><tr><td>Data</td></tr></table>"
|
||||
parsed_html = described_class.new(html).wrap_all_tables.html
|
||||
expect(parsed_html).to include("table-wrapper-paragraph")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#remove_empty_paragraphs" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).remove_empty_paragraphs.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").remove_empty_paragraphs.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").remove_empty_paragraphs
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
context "when a paragraph is empty" do
|
||||
it "deletes the paragraph" do
|
||||
html = "<p></p>"
|
||||
parsed_html = described_class.new(html).remove_empty_paragraphs.html
|
||||
expect(parsed_html).not_to include("<p>")
|
||||
end
|
||||
end
|
||||
|
||||
context "when a paragraph is not empty" do
|
||||
it "doesn't delete the paragraph" do
|
||||
html = "<p>Hello World!</p>"
|
||||
parsed_html = described_class.new(html).remove_empty_paragraphs.html
|
||||
expect(parsed_html).to eq html
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#escape_colon_emojis_in_codeblock" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).escape_colon_emojis_in_codeblock.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").escape_colon_emojis_in_codeblock.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").escape_colon_emojis_in_codeblock
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
context "when a colon emoji is used" do
|
||||
it "doesn't change text in codeblock" do
|
||||
html = "<span>:o:<code>:o:</code>:o:<code>:o:</code>:o:<span>:o:</span>:o:</span>"
|
||||
parsed_html = described_class.new(html).escape_colon_emojis_in_codeblock.html
|
||||
expect(parsed_html).to include("<span>⭕<code>:o:</code>⭕<code>:o:</code>⭕<span>⭕</span>⭕</span>")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#unescape_raw_tag_in_codeblocks" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).unescape_raw_tag_in_codeblocks.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").unescape_raw_tag_in_codeblocks.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").unescape_raw_tag_in_codeblocks
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
it "escapes the `raw` Liquid tag in codeblocks" do
|
||||
code_block = "```\n{% raw %}some text{% endraw %}\n```"
|
||||
parsed_html = described_class.new(code_block).unescape_raw_tag_in_codeblocks.html
|
||||
expect(parsed_html).to include("{% raw %}", "{% endraw %}")
|
||||
end
|
||||
|
||||
it "does not render the escaped dashes when using a `raw` Liquid tag in codeblocks with syntax highlighting" do
|
||||
code_block = "```js\n{% raw %}some text{% endraw %}\n```"
|
||||
parsed_html = described_class.new(code_block).unescape_raw_tag_in_codeblocks.html
|
||||
expect(parsed_html).not_to include("----")
|
||||
end
|
||||
|
||||
it "does not remove the non-'raw tag related' four dashes" do
|
||||
code_block = "```\n----\n```"
|
||||
parsed_html = described_class.new(code_block).unescape_raw_tag_in_codeblocks.html
|
||||
expect(parsed_html).to include("----")
|
||||
end
|
||||
|
||||
it "escapes the `raw` Liquid tag in codespans" do
|
||||
code_block = "``{% raw %}some text{% endraw %}``"
|
||||
parsed_html = described_class.new(code_block).unescape_raw_tag_in_codeblocks.html
|
||||
expect(parsed_html).to include("{% raw %}", "{% endraw %}")
|
||||
end
|
||||
|
||||
it "escapes the `raw` Liquid tag in inline code" do
|
||||
code_block = "`{% raw %}some text{% endraw %}`"
|
||||
parsed_html = described_class.new(code_block).unescape_raw_tag_in_codeblocks.html
|
||||
expect(parsed_html).to include("{% raw %}", "{% endraw %}")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#wrap_all_figures_with_tags" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).wrap_all_figures_with_tags.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").wrap_all_figures_with_tags.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").wrap_all_figures_with_tags
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
it "wraps figcaptions with figures" do
|
||||
html = "<p>case: </p><p>Statement</p>\n<figcaption>A fig</figcaption>"
|
||||
parsed_html = described_class.new(html).wrap_all_figures_with_tags.html
|
||||
expect(parsed_html).to include("figure")
|
||||
end
|
||||
|
||||
it "does not wrap figcaptions already in figures" do
|
||||
html = "<figure><p>Statement</p>\n<figcaption>A fig</figcaption></figure>"
|
||||
parsed_html = described_class.new(html).wrap_all_figures_with_tags.html
|
||||
expect(parsed_html).to eq(html)
|
||||
end
|
||||
|
||||
it "does not wrap figcaptions without predecessors" do
|
||||
html = "<figcaption>A fig</figcaption>"
|
||||
parsed_html = described_class.new(html).wrap_all_figures_with_tags.html
|
||||
expect(parsed_html).to eq(html)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#wrap_mentions_with_links" do
|
||||
let(:user) { build_stubbed(:user) }
|
||||
|
||||
before { allow(User).to receive(:find_by).with(username: user.username).and_return(user) }
|
||||
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).wrap_mentions_with_links.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").wrap_mentions_with_links.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").wrap_mentions_with_links
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
it "links mentions" do
|
||||
html = "@#{user.username}"
|
||||
parsed_html = described_class.new(html).wrap_mentions_with_links.html
|
||||
expect(parsed_html).to include("<a")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#parse_emojis" do
|
||||
context "when html is nil" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new(nil).parse_emojis.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when html is empty" do
|
||||
it "doesn't raise an error" do
|
||||
expect { described_class.new("").parse_emojis.html }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an instance of Html::Parser" do
|
||||
html_parser = described_class.new("<div>Hello World</div>").parse_emojis
|
||||
expect(html_parser).to be_a described_class
|
||||
end
|
||||
|
||||
# rubocop:disable Rails/DynamicFindBy
|
||||
it "converts emoji names wrapped in colons into unicode" do
|
||||
joy_emoji_unicode = Emoji.find_by_alias("joy").raw
|
||||
parsed_html = described_class.new(":joy:").parse_emojis.html
|
||||
expect(parsed_html).to include(joy_emoji_unicode)
|
||||
end
|
||||
|
||||
it "converts disability emojis as well", :aggregate_failures do
|
||||
disability_emojis = %w[
|
||||
guide_dog service_dog person_with_probing_cane man_with_probing_cane woman_with_probing_cane probing_cane
|
||||
person_in_motorized_wheelchair man_in_motorized_wheelchair woman_in_motorized_wheelchair
|
||||
person_in_manual_wheelchair man_in_manual_wheelchair woman_in_manual_wheelchair manual_wheelchair
|
||||
motorized_wheelchair wheelchair
|
||||
]
|
||||
|
||||
disability_emojis.each do |emoji|
|
||||
unicode = Emoji.find_by_alias(emoji).raw
|
||||
parsed_html = described_class.new(":#{emoji}:").parse_emojis.html
|
||||
expect(parsed_html).to include(unicode)
|
||||
end
|
||||
end
|
||||
|
||||
it "leaves original text between colons when no emoji is found" do
|
||||
emoji_text = ":no_one_will_ever_create_an_emoji_with_this_alias:"
|
||||
parsed_html = described_class.new(emoji_text).parse_emojis.html
|
||||
expect(parsed_html).to include(emoji_text)
|
||||
end
|
||||
# rubocop:enable Rails/DynamicFindBy
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe MarkdownParser, type: :labor do
|
||||
RSpec.describe MarkdownParser, type: :service do
|
||||
let(:random_word) { Faker::Lorem.word }
|
||||
let(:basic_parsed_markdown) { described_class.new(random_word) }
|
||||
|
||||
|
|
@ -8,10 +8,6 @@ RSpec.describe MarkdownParser, type: :labor do
|
|||
described_class.new(raw_markdown).finalize
|
||||
end
|
||||
|
||||
it "has the correct raw tag delimiters" do
|
||||
expect(described_class::RAW_TAG_DELIMITERS).to match_array(["{", "}", "raw", "endraw", "----"])
|
||||
end
|
||||
|
||||
it "renders plain text as-is" do
|
||||
expect(basic_parsed_markdown.finalize).to include(random_word)
|
||||
end
|
||||
|
|
@ -132,7 +128,7 @@ RSpec.describe MarkdownParser, type: :labor do
|
|||
expect(result).to include "<a"
|
||||
end
|
||||
|
||||
it "works with undescore" do
|
||||
it "works with underscore" do
|
||||
mention = "what was found here _@#{user.username}_ let see"
|
||||
result = generate_and_parse_markdown(mention)
|
||||
expect(result).to include "<a", "<em"
|
||||
Loading…
Add table
Reference in a new issue