diff --git a/app/labor/code_block_parser.rb b/app/labor/code_block_parser.rb deleted file mode 100644 index 470c560c8..000000000 --- a/app/labor/code_block_parser.rb +++ /dev/null @@ -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("#{image}") - 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('
') - 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 -
- #{icon_fullscreen_on} - #{icon_fullscreen_off} -
- 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("
#{table}
") } - 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 , , 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 diff --git a/app/models/display_ad.rb b/app/models/display_ad.rb index 552437e26..e2c944449 100644 --- a/app/models/display_ad.rb +++ b/app/models/display_ad.rb @@ -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 diff --git a/app/services/html/parse_emoji.rb b/app/services/html/parse_emoji.rb deleted file mode 100644 index 1a48d2d6d..000000000 --- a/app/services/html/parse_emoji.rb +++ /dev/null @@ -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 diff --git a/app/services/html/parser.rb b/app/services/html/parser.rb new file mode 100644 index 000000000..02de9ea4e --- /dev/null +++ b/app/services/html/parser.rb @@ -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("#{image}") + 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('
') + 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 +
+ #{icon_fullscreen_on} + #{icon_fullscreen_off} +
+ 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("
#{table}
") } + @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 , , 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 + @#{username} + HTML + else + mention + end + end + + def possibly_raw_tag_syntax?(array) + (RAW_TAG_DELIMITERS & array).any? + end + end +end diff --git a/app/labor/markdown_parser.rb b/app/services/markdown_parser.rb similarity index 75% rename from app/labor/markdown_parser.rb rename to app/services/markdown_parser.rb index 0f2480db8..00feac7e9 100644 --- a/app/labor/markdown_parser.rb +++ b/app/services/markdown_parser.rb @@ -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 - @#{username} - 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 diff --git a/app/views/users/_github_repositories_area.html.erb b/app/views/users/_github_repositories_area.html.erb index d9d42cc95..ee240c3e4 100644 --- a/app/views/users/_github_repositories_area.html.erb +++ b/app/views/users/_github_repositories_area.html.erb @@ -11,7 +11,7 @@ <%= repo.name %>

<% if repo.description.present? %> -

<%= Html::ParseEmoji.call(repo.description) %>

+

<%= Html::Parser.new(repo.description).parse_emojis.html %>

<% end %>

<% if repo.fork %> diff --git a/spec/services/html/parse_emoji_spec.rb b/spec/services/html/parse_emoji_spec.rb deleted file mode 100644 index 489bb9b4c..000000000 --- a/spec/services/html/parse_emoji_spec.rb +++ /dev/null @@ -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 diff --git a/spec/services/html/parser_spec.rb b/spec/services/html/parser_spec.rb new file mode 100644 index 000000000..10ba0d191 --- /dev/null +++ b/spec/services/html/parser_spec.rb @@ -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("

Hello World
").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("
") + 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("
Hello World
").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 = "" + 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 = "" + 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 = "" + 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("
Hello World
").wrap_all_images_in_links + expect(html_parser).to be_a described_class + end + + it "wraps image in link" do + html = "

Hello World").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 = "

Hello world!
" + 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 = "
Hello world!
" + 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("
Hello World
").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 = "
Hello world!
" + 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 = "
Hello world!
" + 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("
Hello World
").add_fullscreen_button_to_panel + expect(html_parser).to be_a described_class + end + + it "adds the fullscreen button" do + html = "
Hello World
" + 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("
Hello World
").wrap_all_tables + expect(html_parser).to be_a described_class + end + + it "wraps all tables" do + html = "
Header
Data
" + 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("
Hello World
").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 = "

" + parsed_html = described_class.new(html).remove_empty_paragraphs.html + expect(parsed_html).not_to include("

") + end + end + + context "when a paragraph is not empty" do + it "doesn't delete the paragraph" do + html = "

Hello World!

" + 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("
Hello World
").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 = ":o::o::o::o::o::o::o:" + parsed_html = described_class.new(html).escape_colon_emojis_in_codeblock.html + expect(parsed_html).to include(":o::o:") + 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("
Hello World
").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("
Hello World
").wrap_all_figures_with_tags + expect(html_parser).to be_a described_class + end + + it "wraps figcaptions with figures" do + html = "

case:

Statement

\n
A fig
" + 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 = "

Statement

\n
A fig
" + 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 = "
A fig
" + 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("
Hello World
").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("Hello World").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 diff --git a/spec/labor/markdown_parser_spec.rb b/spec/services/markdown_parser_spec.rb similarity index 98% rename from spec/labor/markdown_parser_spec.rb rename to spec/services/markdown_parser_spec.rb index 92e5e8c5e..c7b714f15 100644 --- a/spec/labor/markdown_parser_spec.rb +++ b/spec/services/markdown_parser_spec.rb @@ -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 "