# frozen_string_literal: true require "pretty_text" RSpec.describe PrettyText do fab!(:user) fab!(:post) before { SiteSetting.enable_markdown_typographer = false } def n(html) html.strip end def cook(*args) PrettyText.cook(*args) end let(:wrapped_image) do "<div class=\"lightbox-wrapper\"><a href=\"//localhost:3000/uploads/default/4399/33691397e78b4d75.png\" class=\"lightbox\" title=\"Screen Shot 2014-04-14 at 9.47.10 PM.png\"><img src=\"//localhost:3000/uploads/default/_optimized/bd9/b20/bbbcd6a0c0_655x500.png\" width=\"655\" height=\"500\"><div class=\"meta\">\n<span class=\"filename\">Screen Shot 2014-04-14 at 9.47.10 PM.png</span><span class=\"informations\">966x737 1.47 MB</span><span class=\"expand\"></span>\n</div></a></div>" end describe "Quoting" do context "with avatar" do let(:default_avatar) do "//test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/{size}.png" end before { User.stubs(:default_template).returns(default_avatar) } it "correctly extracts usernames from the new quote format" do topic = Fabricate(:topic, title: "this is a test topic :slight_smile:") expected = <<~HTML <aside class="quote no-group" data-username="codinghorror" data-post="2" data-topic="#{topic.id}"> <div class="title"> <div class="quote-controls"></div> <a href="http://test.localhost/t/this-is-a-test-topic/#{topic.id}/2">This is a test topic <img width="20" height="20" src="/images/emoji/twitter/slight_smile.png?v=#{Emoji::EMOJI_VERSION}" title="slight_smile" loading="lazy" alt="slight_smile" class="emoji"></a></div> <blockquote> <p>ddd</p> </blockquote> </aside> HTML expect( cook( "[quote=\"Jeff, post:2, topic:#{topic.id}, username:codinghorror\"]\nddd\n[/quote]", topic_id: 1, ), ).to eq(n(expected)) end it "do off topic quoting with emoji unescape" do topic = Fabricate(:topic, title: "this is a test topic :slight_smile:") expected = <<~HTML <aside class="quote no-group" data-username="EvilTrout" data-post="2" data-topic="#{topic.id}"> <div class="title"> <div class="quote-controls"></div> <a href="http://test.localhost/t/this-is-a-test-topic/#{topic.id}/2">This is a test topic <img width="20" height="20" src="/images/emoji/twitter/slight_smile.png?v=#{Emoji::EMOJI_VERSION}" title="slight_smile" loading="lazy" alt="slight_smile" class="emoji"></a></div> <blockquote> <p>ddd</p> </blockquote> </aside> HTML expect( cook("[quote=\"EvilTrout, post:2, topic:#{topic.id}\"]\nddd\n[/quote]", topic_id: 1), ).to eq(n(expected)) end context "with emojis" do let(:md) { <<~MD } > This is a quote with a regular emoji :upside_down_face: > This is a quote with an emoji shortcut :) > This is a quote with a Unicode emoji ๐ MD it "does not unescape emojis when emojis are disabled" do SiteSetting.enable_emoji = false html = <<~HTML <blockquote> <p>This is a quote with a regular emoji :upside_down_face:</p> </blockquote> <blockquote> <p>This is a quote with an emoji shortcut :)</p> </blockquote> <blockquote> <p>This is a quote with a Unicode emoji ๐</p> </blockquote> HTML expect(cook(md)).to eq(html.strip) end it "does not convert emoji shortcuts when emoji shortcuts are disabled" do SiteSetting.enable_emoji_shortcuts = false html = <<~HTML <blockquote> <p>This is a quote with a regular emoji <img src="/images/emoji/twitter/upside_down_face.png?v=#{Emoji::EMOJI_VERSION}" title=":upside_down_face:" class="emoji" alt=":upside_down_face:" loading="lazy" width="20" height="20"></p> </blockquote> <blockquote> <p>This is a quote with an emoji shortcut :)</p> </blockquote> <blockquote> <p>This is a quote with a Unicode emoji <img src="/images/emoji/twitter/sunglasses.png?v=#{Emoji::EMOJI_VERSION}" title=":sunglasses:" class="emoji" alt=":sunglasses:" loading="lazy" width="20" height="20"></p> </blockquote> HTML expect(cook(md)).to eq(html.strip) end it "unescapes all emojis" do html = <<~HTML <blockquote> <p>This is a quote with a regular emoji <img src="/images/emoji/twitter/upside_down_face.png?v=#{Emoji::EMOJI_VERSION}" title=":upside_down_face:" class="emoji" alt=":upside_down_face:" loading="lazy" width="20" height="20"></p> </blockquote> <blockquote> <p>This is a quote with an emoji shortcut <img src="/images/emoji/twitter/slight_smile.png?v=#{Emoji::EMOJI_VERSION}" title=":slight_smile:" class="emoji" alt=":slight_smile:" loading="lazy" width="20" height="20"></p> </blockquote> <blockquote> <p>This is a quote with a Unicode emoji <img src="/images/emoji/twitter/sunglasses.png?v=#{Emoji::EMOJI_VERSION}" title=":sunglasses:" class="emoji" alt=":sunglasses:" loading="lazy" width="20" height="20"></p> </blockquote> HTML expect(cook(md)).to eq(html.strip) end it "adds an only-emoji class when a line has only one emoji" do md = <<~MD โน๏ธ foo ๐ foo ๐ bar :smile_cat: :smile_cat: :smile_cat: :smile_cat: :smile_cat: :smile_cat: :smile_cat: baz? :smile_cat: ๐ ๐ foo ๐ ๐ ๐ ๐ ๐ ๐ ๐ ๐๐๐ ๐ ๐ ๐ ๐d๐ ๐ ๐ ๐ ๐d ๐๐๐๐ MD html = <<~HTML <p><img src="/images/emoji/twitter/frowning.png?v=#{Emoji::EMOJI_VERSION}" title=":frowning:" class="emoji only-emoji" alt=":frowning:" loading="lazy" width="20" height="20"><br> foo <img src="/images/emoji/twitter/grinning.png?v=#{Emoji::EMOJI_VERSION}" title=":grinning:" class="emoji" alt=":grinning:" loading="lazy" width="20" height="20"><br> foo <img src="/images/emoji/twitter/grinning.png?v=#{Emoji::EMOJI_VERSION}" title=":grinning:" class="emoji" alt=":grinning:" loading="lazy" width="20" height="20"> bar<br> <img src="/images/emoji/twitter/smile_cat.png?v=#{Emoji::EMOJI_VERSION}" title=":smile_cat:" class="emoji only-emoji" alt=":smile_cat:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/smile_cat.png?v=#{Emoji::EMOJI_VERSION}" title=":smile_cat:" class="emoji only-emoji" alt=":smile_cat:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/smile_cat.png?v=#{Emoji::EMOJI_VERSION}" title=":smile_cat:" class="emoji only-emoji" alt=":smile_cat:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/smile_cat.png?v=#{Emoji::EMOJI_VERSION}" title=":smile_cat:" class="emoji" alt=":smile_cat:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/smile_cat.png?v=#{Emoji::EMOJI_VERSION}" title=":smile_cat:" class="emoji" alt=":smile_cat:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/smile_cat.png?v=#{Emoji::EMOJI_VERSION}" title=":smile_cat:" class="emoji" alt=":smile_cat:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/smile_cat.png?v=#{Emoji::EMOJI_VERSION}" title=":smile_cat:" class="emoji" alt=":smile_cat:" loading="lazy" width="20" height="20"><br> baz? <img src="/images/emoji/twitter/smile_cat.png?v=#{Emoji::EMOJI_VERSION}" title=":smile_cat:" class="emoji" alt=":smile_cat:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/grinning.png?v=#{Emoji::EMOJI_VERSION}" title=":grinning:" class="emoji only-emoji" alt=":grinning:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20"> foo<br> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"><img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"><img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji only-emoji" alt=":wink:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20">dโ:wink: <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20"><br> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20"> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20">d<br> <img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20"><img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20"><img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20"><img src="/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}" title=":wink:" class="emoji" alt=":wink:" loading="lazy" width="20" height="20"></p> HTML expect(cook(md)).to eq(html.strip) end it "does use emoji CDN when enabled" do SiteSetting.external_emoji_url = "https://emoji.cdn.com" html = <<~HTML <blockquote> <p>This is a quote with a regular emoji <img src="https://emoji.cdn.com/twitter/upside_down_face.png?v=#{Emoji::EMOJI_VERSION}" title=":upside_down_face:" class="emoji" alt=":upside_down_face:" loading="lazy" width="20" height="20"></p> </blockquote> <blockquote> <p>This is a quote with an emoji shortcut <img src="https://emoji.cdn.com/twitter/slight_smile.png?v=#{Emoji::EMOJI_VERSION}" title=":slight_smile:" class="emoji" alt=":slight_smile:" loading="lazy" width="20" height="20"></p> </blockquote> <blockquote> <p>This is a quote with a Unicode emoji <img src="https://emoji.cdn.com/twitter/sunglasses.png?v=#{Emoji::EMOJI_VERSION}" title=":sunglasses:" class="emoji" alt=":sunglasses:" loading="lazy" width="20" height="20"></p> </blockquote> HTML expect(cook(md)).to eq(html.strip) end it "does use emoji CDN when others CDNs are also enabled" do set_cdn_url("https://cdn.com") setup_s3 SiteSetting.s3_cdn_url = "https://s3.cdn.com" SiteSetting.external_emoji_url = "https://emoji.cdn.com" html = <<~HTML <blockquote> <p>This is a quote with a regular emoji <img src="https://emoji.cdn.com/twitter/upside_down_face.png?v=#{Emoji::EMOJI_VERSION}" title=":upside_down_face:" class="emoji" alt=":upside_down_face:" loading="lazy" width="20" height="20"></p> </blockquote> <blockquote> <p>This is a quote with an emoji shortcut <img src="https://emoji.cdn.com/twitter/slight_smile.png?v=#{Emoji::EMOJI_VERSION}" title=":slight_smile:" class="emoji" alt=":slight_smile:" loading="lazy" width="20" height="20"></p> </blockquote> <blockquote> <p>This is a quote with a Unicode emoji <img src="https://emoji.cdn.com/twitter/sunglasses.png?v=#{Emoji::EMOJI_VERSION}" title=":sunglasses:" class="emoji" alt=":sunglasses:" loading="lazy" width="20" height="20"></p> </blockquote> HTML expect(cook(md)).to eq(html.strip) end end it "do off topic quoting of posts from secure categories" do category = Fabricate(:category, read_restricted: true) topic = Fabricate(:topic, title: "this is topic with secret category", category: category) expected = <<~HTML <aside class="quote no-group" data-username="maja" data-post="3" data-topic="#{topic.id}"> <div class="title"> <div class="quote-controls"></div> <a href="/t/#{topic.id}/3">#{I18n.t("on_another_topic")}</a></div> <blockquote> <p>I have nothing to say.</p> </blockquote> </aside> HTML expect( cook( "[quote=\"maja, post:3, topic:#{topic.id}\"]\nI have nothing to say.\n[/quote]", topic_id: 1, ), ).to eq(n(expected)) end it "do off topic quoting with the force_quote_link opt and no topic_id opt provided" do topic = Fabricate(:topic, title: "This is an off-topic topic") expected = <<~HTML <aside class="quote no-group" data-username="maja" data-post="3" data-topic="#{topic.id}"> <div class="title"> <div class="quote-controls"></div> <a href="http://test.localhost/t/this-is-an-off-topic-topic/#{topic.id}/3">#{topic.title}</a></div> <blockquote> <p>I have nothing to say.</p> </blockquote> </aside> HTML cooked = cook( "[quote=\"maja, post:3, topic:#{topic.id}\"]\nI have nothing to say.\n[/quote]", force_quote_link: true, ) expect(cooked).to eq(n(expected)) end it "indifferent about missing quotations" do md = <<~MD [quote=#{user.username}, post:123, topic:456, full:true] ddd [/quote] MD html = <<~HTML <aside class="quote no-group" data-username="#{user.username}" data-post="123" data-topic="456" data-full="true"> <div class="title"> <div class="quote-controls"></div> <img loading="lazy" alt="" width="24" height="24" src="//test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/48.png" class="avatar"> #{user.username}:</div> <blockquote> <p>ddd</p> </blockquote> </aside> HTML expect(PrettyText.cook(md)).to eq(html.strip) end it "indifferent about curlies and no curlies" do md = <<~MD [quote=โ#{user.username}, post:123, topic:456, full:trueโ] ddd [/quote] MD html = <<~HTML <aside class="quote no-group" data-username="#{user.username}" data-post="123" data-topic="456" data-full="true"> <div class="title"> <div class="quote-controls"></div> <img loading="lazy" alt="" width="24" height="24" src="//test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/48.png" class="avatar"> #{user.username}:</div> <blockquote> <p>ddd</p> </blockquote> </aside> HTML expect(PrettyText.cook(md)).to eq(html.strip) end it "trims spaces on quote params" do md = <<~MD [quote="#{user.username}, post:555, topic: 666"] ddd [/quote] MD html = <<~HTML <aside class="quote no-group" data-username="#{user.username}" data-post="555" data-topic="666"> <div class="title"> <div class="quote-controls"></div> <img loading="lazy" alt="" width="24" height="24" src="//test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/48.png" class="avatar"> #{user.username}:</div> <blockquote> <p>ddd</p> </blockquote> </aside> HTML expect(PrettyText.cook(md)).to eq(html.strip) end end context "with primary user group" do let(:default_avatar) do "//test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/{size}.png" end fab!(:group) fab!(:user) { Fabricate(:user, primary_group: group) } before { User.stubs(:default_template).returns(default_avatar) } it "adds primary group class to referenced users quote" do topic = Fabricate(:topic, title: "this is a test topic") expected = <<~HTML <aside class="quote group-#{group.name}" data-username="#{user.username}" data-post="2" data-topic="#{topic.id}"> <div class="title"> <div class="quote-controls"></div> <img loading="lazy" alt="" width="24" height="24" src="//test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/48.png" class="avatar"><a href="http://test.localhost/t/this-is-a-test-topic/#{topic.id}/2">This is a test topic</a></div> <blockquote> <p>ddd</p> </blockquote> </aside> HTML expect( cook( "[quote=\"#{user.username}, post:2, topic:#{topic.id}\"]\nddd\n[/quote]", topic_id: 1, ), ).to eq(n(expected)) end end it "can handle inline block bbcode" do cooked = PrettyText.cook("[quote]te **s** t[/quote]") html = <<~HTML <aside class="quote no-group"> <blockquote> <p>te <strong>s</strong> t</p> </blockquote> </aside> HTML expect(cooked).to eq(html.strip) end it "handles bbcode edge cases" do expect(PrettyText.cook "[constructor]\ntest").to eq("<p>[constructor]<br>\ntest</p>") end it "can handle quote edge cases" do expect(PrettyText.cook("[quote]abc\ntest\n[/quote]")).not_to include("aside") expect(PrettyText.cook("[quote] \ntest\n[/quote] ")).to include("aside") expect(PrettyText.cook("a\n[quote]\ntest\n[/quote]\n\n\na")).to include("aside") expect(PrettyText.cook("- a\n[quote]\ntest\n[/quote]\n\n\na")).to include("aside") expect(PrettyText.cook("[quote]\ntest")).not_to include("aside") expect(PrettyText.cook("[quote]\ntest\n[/quote]z")).not_to include("aside") nested = <<~MD [quote] a [quote] b [/quote] c [/quote] MD cooked = PrettyText.cook(nested) expect(cooked.scan("aside").length).to eq(4) expect(cooked.scan("quote]").length).to eq(0) end context "with letter avatar" do context "with subfolder" do it "should have correct avatar url" do set_subfolder "/forum" md = <<~MD [quote="#{user.username}, post:123, topic:456, full:true"] ddd [/quote] MD expect(PrettyText.cook(md)).to include("/forum/letter_avatar_proxy") end end end end describe "Mentions" do it "can handle mentions after abbr" do expect(PrettyText.cook("test <abbr>test</abbr>\n\n@bob")).to eq( "<p>test <abbr>test</abbr></p>\n<p><span class=\"mention\">@bob</span></p>", ) end it "should handle 3 mentions in a row" do expect( PrettyText.cook("@hello @hello @hello"), ).to match_html "<p><span class=\"mention\">@hello</span> <span class=\"mention\">@hello</span> <span class=\"mention\">@hello</span></p>" end it "can handle mention edge cases" do expect(PrettyText.cook("hi\n@s")).to eq("<p>hi<br>\n<span class=\"mention\">@s</span></p>") expect(PrettyText.cook("hi\n@ss")).to eq("<p>hi<br>\n<span class=\"mention\">@ss</span></p>") expect(PrettyText.cook("hi\n@s.")).to eq("<p>hi<br>\n<span class=\"mention\">@s</span>.</p>") expect(PrettyText.cook("hi\n@s.s")).to eq( "<p>hi<br>\n<span class=\"mention\">@s.s</span></p>", ) expect(PrettyText.cook("hi\n@.s.s")).to eq("<p>hi<br>\n@.s.s</p>") end it "handles user and group mentions correctly" do %w[User user2].each { |username| Fabricate(:user, username: username) } Fabricate(:group, name: "Group", mentionable_level: Group::ALIAS_LEVELS[:everyone]) Fabricate( :group, name: "Group2", mentionable_level: Group::ALIAS_LEVELS[:members_mods_and_admins], ) [ [ "hi @uSer! @user2 hi", '<p>hi <a class="mention" href="/u/user">@uSer</a>! <a class="mention" href="/u/user2">@user2</a> hi</p>', ], [ "hi\n@user. @GROUP @somemention @group2", %Q|<p>hi<br>\n<a class="mention" href="/u/user">@user</a>. <a class="mention-group notify" href="/groups/group">@GROUP</a> <span class="mention">@somemention</span> <a class="mention-group" href="/groups/group2">@group2</a></p>|, ], ].each { |input, expected| expect(PrettyText.cook(input)).to eq(expected) } end context "with subfolder" do it "handles user and group mentions correctly" do set_subfolder "/forum" Fabricate(:user, username: "user1") Fabricate(:group, name: "groupA", mentionable_level: Group::ALIAS_LEVELS[:everyone]) input = "hi there @user1 and @groupA" expected = '<p>hi there <a class="mention" href="/forum/u/user1">@user1</a> and <a class="mention-group notify" href="/forum/groups/groupa">@groupA</a></p>' expect(PrettyText.cook(input)).to eq(expected) end end it "does not assign the notify class to a group that can't be mentioned" do group = Fabricate( :group, visibility_level: Group.visibility_levels[:members], mentionable_level: Group::ALIAS_LEVELS[:nobody], ) expect(PrettyText.cook("test @#{group.name} test")).to eq( %Q|<p>test <a class="mention-group" href="/groups/#{group.name}">@#{group.name}</a> test</p>|, ) end it "assigns the notify class if the user can mention" do group = Fabricate( :group, visibility_level: Group.visibility_levels[:members], mentionable_level: Group::ALIAS_LEVELS[:members_mods_and_admins], ) expect(PrettyText.cook("test @#{group.name} test", user_id: Fabricate(:admin).id)).to eq( %Q|<p>test <a class="mention-group notify" href="/groups/#{group.name}">@#{group.name}</a> test</p>|, ) end it "does not mention staged users" do user = Fabricate(:user, staged: true) expect(PrettyText.cook("something @#{user.username} something")).to eq( %Q|<p>something <span class="mention">@#{user.username}</span> something</p>|, ) end context "when mentions are disabled" do before { SiteSetting.enable_mentions = false } it "should not convert mentions to links" do expect(PrettyText.cook("hi @user")).to eq("<p>hi @user</p>") end end it "can handle mentions inside a hyperlink" do expect(PrettyText.cook("<a> @inner</a> ")).to match_html "<p><a> @inner</a></p>" end it "can handle mentions inside a hyperlink" do expect( PrettyText.cook("[link @inner](http://site.com)"), ).to match_html '<p><a href="http://site.com" rel="noopener nofollow ugc">link @inner</a></p>' end it "can handle a list of mentions" do expect(PrettyText.cook("@a,@b")).to match_html( '<p><span class="mention">@a</span>,<span class="mention">@b</span></p>', ) end it "should handle group mentions with a hyphen and without" do expect( PrettyText.cook("@hello @hello-hello"), ).to match_html "<p><span class=\"mention\">@hello</span> <span class=\"mention\">@hello-hello</span></p>" end it "should allow for @mentions to have punctuation" do expect(PrettyText.cook("hello @bob's @bob,@bob; @bob\"")).to match_html( "<p>hello <span class=\"mention\">@bob</span>'s <span class=\"mention\">@bob</span>,<span class=\"mention\">@bob</span>; <span class=\"mention\">@bob</span>\"</p>", ) end it "should not treat a medium link as a mention" do expect(PrettyText.cook(". http://test/@sam")).not_to include("mention") end context "with Unicode usernames disabled" do before { SiteSetting.unicode_usernames = false } it "does not detect mention" do expect(PrettyText.cook("Hello @็ฎๅญ")).to_not include("mention") end end context "with Unicode usernames enabled" do before { SiteSetting.unicode_usernames = true } it "does detect mention" do expect( PrettyText.cook("Hello @็ฎๅญ"), ).to match_html '<p>Hello <span class="mention">@็ฎๅญ</span></p>' end end context "with pretty_text_extract_mentions modifier" do it "allows changing the mentions extracted" do cooked_html = <<~HTML <p> <a class="mention" href="/u/test">@test</a>, <a class="mention-group" href="/g/test-group">@test-group</a>, <a class="custom-mention" href="/custom-mention">@test-custom</a>, this is a test </p> HTML extracted_mentions = PrettyText.extract_mentions(Nokogiri::HTML5.fragment(cooked_html)) expect(extracted_mentions).to include("test", "test-group") expect(extracted_mentions).not_to include("test-custom") Plugin::Instance .new .register_modifier(:pretty_text_extract_mentions) do |mentions, cooked_text| custom_mentions = cooked_text .css(".custom-mention") .map do |e| if (name = e.inner_text) name = name[1..-1] name = User.normalize_username(name) name end end mentions + custom_mentions end extracted_mentions = PrettyText.extract_mentions(Nokogiri::HTML5.fragment(cooked_html)) expect(extracted_mentions).to include("test", "test-group", "test-custom") ensure DiscoursePluginRegistry.clear_modifiers! end end end describe "code fences" do it "indents code correctly" do code = <<~MD X ``` # x ``` MD cooked = PrettyText.cook(code) html = <<~HTML <p>X</p> <pre><code class="lang-auto"> # x </code></pre> HTML expect(cooked).to eq(html.strip) end it "doesn't replace emoji in code blocks with our emoji sets if emoji is enabled" do expect(PrettyText.cook("```\n๐ฃ`\n```\n")).not_to match(/\:bomb\:/) end it "can include code class correctly" do SiteSetting.highlighted_languages += "|c++|structured-text|p21" # keep in mind spaces should be trimmed per spec expect(PrettyText.cook("``` ruby the mooby\n`````")).to eq( '<pre data-code-wrap="ruby"><code class="lang-ruby"></code></pre>', ) expect(PrettyText.cook("```cpp\ncpp\n```")).to match_html( "<pre data-code-wrap=\"cpp\"><code class='lang-cpp'>cpp\n</code></pre>", ) expect(PrettyText.cook("```\ncpp\n```")).to match_html( "<pre><code class='lang-auto'>cpp\n</code></pre>", ) expect(PrettyText.cook("```text\ncpp\n```")).to match_html( "<pre><code class='lang-plaintext'>cpp\n</code></pre>", ) expect(PrettyText.cook("```custom\ncustom content\n```")).to match_html( "<pre data-code-wrap='custom'><code class='lang-custom'>custom content\n</code></pre>", ) expect(PrettyText.cook("```custom foo=bar\ncustom content\n```")).to match_html( "<pre data-code-foo='bar' data-code-wrap='custom'><code class='lang-custom'>custom content</code></pre>", ) expect(PrettyText.cook("```INVALID a=1, foo=bar , baz=2\n```")).to match_html( "<pre data-code-a='1' data-code-foo='bar' data-code-baz='2' data-code-wrap='INVALID'><code class='lang-INVALID'>\n</code></pre>", ) expect(PrettyText.cook("```text\n```")).to match_html( "<pre><code class='lang-plaintext'>\n</code></pre>", ) expect(PrettyText.cook("```auto\n```")).to match_html( "<pre><code class='lang-auto'>\n</code></pre>", ) expect(PrettyText.cook("```ruby startline=3 $%@#\n```")).to match_html( "<pre data-code-startline='3' data-code-wrap='ruby'><code class='lang-ruby'>\n</code></pre>", ) expect(PrettyText.cook("```mermaid a_-ไฝ =17\n```")).to match_html( "<pre data-code-a_-='17' data-code-wrap='mermaid'><code class='lang-mermaid'>\n</code></pre>", ) expect( PrettyText.cook("```mermaid foo=<script>alert(document.cookie)</script>\n```"), ).to match_html( "<pre data-code-foo='<script>alert(document.cookie)</script>' data-code-wrap='mermaid'><code class='lang-mermaid'>\n</code></pre>", ) # Check unicode bidi characters are stripped: expect(PrettyText.cook("```mermaid foo=\u202E begin admin o\u001C\n```")).to match_html( "<pre data-code-wrap='mermaid'><code class='lang-mermaid'>\n</code></pre>", ) expect(PrettyText.cook("```c++\nc++\n```")).to match_html( "<pre data-code-wrap='c++'><code class='lang-c++'>c++\n</code></pre>", ) expect(PrettyText.cook("```structured-text\nstructured-text\n```")).to match_html( "<pre data-code-wrap='structured-text'><code class='lang-structured-text'>structured-text\n</code></pre>", ) expect(PrettyText.cook("```p21\np21\n```")).to match_html( "<pre data-code-wrap='p21'><code class='lang-p21'>p21\n</code></pre>", ) expect( PrettyText.cook("<pre data-code='3' data-code-foo='1' data-malicous-code='2'></pre>"), ).to match_html("<pre data-code-foo='1'></pre>") end it "indents code correctly" do code = "X\n```\n\n #\n x\n```" cooked = PrettyText.cook(code) expect(cooked).to match_html( "<p>X</p>\n<pre><code class=\"lang-auto\">\n #\n x\n</code></pre>", ) end it "does censor code fences" do begin %w[apple banana].each do |w| Fabricate(:watched_word, word: w, action: WatchedWord.actions[:censor]) end expect(PrettyText.cook("# banana")).not_to include("banana") ensure Discourse.redis.flushdb end end it "strips out unicode bidirectional (bidi) override characters and replaces with a highlighted span" do code = <<~MD X ```auto var isAdmin = false; /*โฎ begin admin only */โฆ if (isAdmin) โฉ โฆ { console.log("You are an admin."); /* end admins only โฎ*/โฆ } ``` MD cooked = PrettyText.cook(code) hidden_bidi_title = I18n.t("post.hidden_bidi_character") html = <<~HTML <p>X</p> <pre><code class="lang-auto">var isAdmin = false; /*<span class="bidi-warning" title="#{hidden_bidi_title}"><U+202E></span> begin admin only */<span class="bidi-warning" title="#{hidden_bidi_title}"><U+2066></span> if (isAdmin) <span class="bidi-warning" title="#{hidden_bidi_title}"><U+2069></span> <span class="bidi-warning" title="#{hidden_bidi_title}"><U+2066></span> { console.log("You are an admin."); /* end admins only <span class="bidi-warning" title="#{hidden_bidi_title}"><U+202E></span>*/<span class="bidi-warning" title="#{hidden_bidi_title}"><U+2066></span> } </code></pre> HTML expect(cooked).to eq(html.strip) end it "fuzzes all possible dangerous unicode bidirectional (bidi) override characters, making sure they are replaced" do bad_bidi = [ "\u202A", "\u202B", "\u202C", "\u202D", "\u202E", "\u2066", "\u2067", "\u2068", "\u2069", ] bad_bidi.each do |bidi| code = <<~MD ``` #{bidi} ``` MD cooked = PrettyText.cook(code) formatted_bidi = format("<U+%04X>", bidi.ord) html = <<~HTML <pre><code class="lang-auto"><span class="bidi-warning" title="#{I18n.t("post.hidden_bidi_character")}">#{formatted_bidi}</span> </code></pre> HTML expect(cooked).to eq(html.strip) end end it "fuzzes all possible dangerous unicode bidirectional (bidi) override characters in solo code and pre nodes, making sure they are replaced" do bad_bidi = [ "\u202A", "\u202B", "\u202C", "\u202D", "\u202E", "\u2066", "\u2067", "\u2068", "\u2069", ] bad_bidi.each do |bidi| code = <<~MD <code>#{bidi}</code> MD cooked = PrettyText.cook(code) formatted_bidi = format("<U+%04X>", bidi.ord) html = <<~HTML <p><code><span class="bidi-warning" title="#{I18n.t("post.hidden_bidi_character")}">#{formatted_bidi}</span></code></p> HTML expect(cooked).to eq(html.strip) end bad_bidi.each do |bidi| code = <<~MD <pre>#{bidi}</pre> MD cooked = PrettyText.cook(code) formatted_bidi = format("<U+%04X>", bidi.ord) html = <<~HTML <pre><span class="bidi-warning" title="#{I18n.t("post.hidden_bidi_character")}">#{formatted_bidi}</span></pre> HTML expect(cooked).to eq(html.strip) end end end describe "rel attributes" do before do SiteSetting.add_rel_nofollow_to_user_content = true SiteSetting.exclude_rel_nofollow_domains = "foo.com|bar.com" end it "should inject nofollow in all user provided links" do expect(PrettyText.cook('<a href="http://cnn.com">cnn</a>')).to match(/noopener nofollow ugc/) end it "should not inject nofollow in all local links" do expect( PrettyText.cook("<a href='#{Discourse.base_url}/test.html'>cnn</a>") !~ /nofollow ugc/, ).to eq(true) end it "should not inject nofollow in all subdomain links" do expect( PrettyText.cook( "<a href='#{Discourse.base_url.sub("http://", "http://bla.")}/test.html'>cnn</a>", ) !~ /nofollow ugc/, ).to eq(true) end it "should inject nofollow in all non subdomain links" do expect( PrettyText.cook( "<a href='#{Discourse.base_url.sub("http://", "http://bla")}/test.html'>cnn</a>", ), ).to match(/nofollow ugc/) end it "should not inject nofollow for foo.com" do expect(PrettyText.cook("<a href='http://foo.com/test.html'>cnn</a>") !~ /nofollow ugc/).to eq( true, ) end it "should inject nofollow for afoo.com" do expect(PrettyText.cook("<a href='http://afoo.com/test.html'>cnn</a>")).to match( /nofollow ugc/, ) end it "should not inject nofollow for bar.foo.com" do expect( PrettyText.cook("<a href='http://bar.foo.com/test.html'>cnn</a>") !~ /nofollow ugc/, ).to eq(true) end it "should not inject nofollow if omit_nofollow option is given" do expect( PrettyText.cook('<a href="http://cnn.com">cnn</a>', omit_nofollow: true) !~ /nofollow ugc/, ).to eq(true) end it "adds the noopener attribute even if omit_nofollow option is given" do raw_html = '<a href="https://www.mysite.com/" target="_blank">Check out my site!</a>' expect(PrettyText.cook(raw_html, omit_nofollow: true)).to match(/noopener/) end it "adds the noopener attribute even if omit_nofollow option is given" do raw_html = '<a href="https://www.mysite.com/" target="_blank">Check out my site!</a>' expect(PrettyText.cook(raw_html, omit_nofollow: false)).to match(/noopener nofollow ugc/) end end describe "Excerpt" do it "sanitizes attempts to inject invalid attributes" do spinner = "<a href=\"http://thedailywtf.com/\" data-bbcode=\"' class='fa fa-spin\">WTF</a>" expect(PrettyText.excerpt(spinner, 20)).to match_html spinner spinner = %q{<a href="http://thedailywtf.com/" title="' class="fa fa-spin"><img src='http://thedailywtf.com/Resources/Images/Primary/logo.gif"></a>} expect(PrettyText.excerpt(spinner, 20)).to match_html spinner end context "with images" do it "should dump images" do expect(PrettyText.excerpt("<img src='http://cnn.com/a.gif'>", 100)).to eq("[image]") end context "with alt tags" do it "should keep alt tags" do expect( PrettyText.excerpt( "<img src='http://cnn.com/a.gif' alt='car' title='my big car'>", 100, ), ).to eq("[car]") end describe "when alt tag is empty" do it "should not keep alt tags" do expect(PrettyText.excerpt("<img src='http://cnn.com/a.gif' alt>", 100)).to eq( "[#{I18n.t("excerpt_image")}]", ) end end end context "with title tags" do it "should keep title tags" do expect(PrettyText.excerpt("<img src='http://cnn.com/a.gif' title='car'>", 100)).to eq( "[car]", ) end describe "when title tag is empty" do it "should not keep title tags" do expect(PrettyText.excerpt("<img src='http://cnn.com/a.gif' title>", 100)).to eq( "[#{I18n.t("excerpt_image")}]", ) end end end it "should convert images to markdown if the option is set" do expect( PrettyText.excerpt( "<img src='http://cnn.com/a.gif' title='car'>", 100, markdown_images: true, ), ).to eq("![car](http://cnn.com/a.gif)") end it "should keep details if too long" do expect( PrettyText.excerpt("<details><summary>expand</summary><p>hello</p></details>", 6), ).to match_html "<details class='disabled'><summary>expand</summary></details>" end it "doesn't disable details if short enough" do expect( PrettyText.excerpt("<details><summary>expand</summary><p>hello</p></details>", 60), ).to match_html "<details><summary>expand</summary>hello</details>" end it "should remove meta information" do expect( PrettyText.excerpt(wrapped_image, 100), ).to match_html "<a href='//localhost:3000/uploads/default/4399/33691397e78b4d75.png' class='lightbox' title='Screen Shot 2014-04-14 at 9.47.10 PM.png'>[image]</a>" end it "should strip images when option is set" do expect( PrettyText.excerpt("<img src='http://cnn.com/a.gif'>", 100, strip_images: true), ).to be_blank expect( PrettyText.excerpt( "<img src='http://cnn.com/a.gif'> Hello world!", 100, strip_images: true, ), ).to eq("Hello world!") end it "should strip images, but keep emojis when option is set" do emoji_image = "<img src='/images/emoji/twitter/heart.png?v=#{Emoji::EMOJI_VERSION}' title=':heart:' class='emoji' alt=':heart:' loading='lazy' width='20' height='20'>" html = "<img src='http://cnn.com/a.gif'> Hello world #{emoji_image}" expect(PrettyText.excerpt(html, 100, strip_images: true)).to eq("Hello world :heart:") expect( PrettyText.excerpt(html, 100, strip_images: true, keep_emoji_images: true), ).to match_html("Hello world #{emoji_image}") end end context "with emojis" do it "should remove broken emoji" do html = <<~HTML <img src=\"//localhost:3000/images/emoji/twitter/bike.png?v=#{Emoji::EMOJI_VERSION}\" title=\":bike:\" class=\"emoji\" alt=\":bike:\" loading=\"lazy\" width=\"20\" height=\"20\"> <img src=\"//localhost:3000/images/emoji/twitter/cat.png?v=#{Emoji::EMOJI_VERSION}\" title=\":cat:\" class=\"emoji\" alt=\":cat:\" loading=\"lazy\" width=\"20\" height=\"20\"> <img src=\"//localhost:3000/images/emoji/twitter/discourse.png?v=#{Emoji::EMOJI_VERSION}\" title=\":discourse:\" class=\"emoji\" alt=\":discourse:\" loading=\"lazy\" width=\"20\" height=\"20\"> HTML expect(PrettyText.excerpt(html, 7)).to eq(":bike: …") expect(PrettyText.excerpt(html, 8)).to eq(":bike: …") expect(PrettyText.excerpt(html, 9)).to eq(":bike: …") expect(PrettyText.excerpt(html, 10)).to eq(":bike: …") expect(PrettyText.excerpt(html, 11)).to eq(":bike: …") expect(PrettyText.excerpt(html, 12)).to eq(":bike: :cat: …") expect(PrettyText.excerpt(html, 13)).to eq(":bike: :cat: …") expect(PrettyText.excerpt(html, 14)).to eq(":bike: :cat: …") end end it "should have an option to strip links" do expect(PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>", 100, strip_links: true)).to eq( "cnn", ) end it "should preserve links" do expect( PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>", 100), ).to match_html "<a href='http://cnn.com'>cnn</a>" end it "should deal with special keys properly" do expect(PrettyText.excerpt("<pre><b></pre>", 100)).to eq("") end it "should truncate stuff properly" do expect(PrettyText.excerpt("hello world", 5)).to eq("hello…") expect(PrettyText.excerpt("<p>hello</p><p>world</p>", 6)).to eq("hello w…") end it "should insert a space between to Ps" do expect(PrettyText.excerpt("<p>a</p><p>b</p>", 5)).to eq("a b") end it "should strip quotes" do expect(PrettyText.excerpt("<aside class='quote'><p>a</p><p>b</p></aside>boom", 5)).to eq( "boom", ) end it "should not count the surrounds of a link" do expect( PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>", 3), ).to match_html "<a href='http://cnn.com'>cnn</a>" end it "uses an ellipsis instead of html entities if provided with the option" do expect( PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>", 2, text_entities: true), ).to match_html "<a href='http://cnn.com'>cn...</a>" end it "should truncate links" do expect( PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>", 2), ).to match_html "<a href='http://cnn.com'>cn…</a>" end it "doesn't extract empty quotes as links" do expect( PrettyText.extract_links("<aside class='quote'>not a linked quote</aside>\n").to_a, ).to be_empty end it "doesn't extract links from elided parts" do expect( PrettyText.extract_links( "<details class='elided'><a href='http://cnn.com'>cnn</a></details>\n", ).to_a, ).to be_empty end def extract_urls(text) PrettyText.extract_links(text).map(&:url).to_a end it "should be able to extract links" do expect(extract_urls("<a href='http://cnn.com'>http://bla.com</a>")).to eq(["http://cnn.com"]) end it "should extract links to topics" do expect(extract_urls("<aside class=\"quote\" data-topic=\"321\">aside</aside>")).to eq( ["/t/321"], ) end it "does not extract links from hotlinked images" do html = <<~HTML <p> <a href="https://example.com">example</a> <a href="https://images.pexels.com/photos/1525041/pexels-photo-1525041.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2" target="_blank" rel="noopener" class="onebox"> <img src="https://images.pexels.com/photos/1525041/pexels-photo-1525041.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2" width="690" height="459"> </a> </p> HTML expect(extract_urls(html)).to eq(["https://example.com"]) end context "when lazy-videos" do it "should extract youtube url" do expect( extract_urls( "<div class=\"lazy-video-container\" data-video-id=\"yXEuEUQIP3Q\" data-video-title=\"Mister Rogers defending PBS to the US Senate\" data-provider-name=\"youtube\"></div>", ), ).to eq(["https://www.youtube.com/watch?v=yXEuEUQIP3Q"]) end it "should extract vimeo url" do expect( extract_urls( "<div class=\"lazy-video-container\" data-video-id=\"786646692\" data-video-title=\"Dear Rich\" data-provider-name=\"vimeo\"></div>", ), ).to eq(["https://vimeo.com/786646692"]) end it "should extract tiktok url" do expect( extract_urls( "<div class=\"lazy-video-container\" data-video-id=\"6718335390845095173\" data-video-title=\"Scramble up ur name &amp; Iโll try to guess it๐โค๏ธ #foryoupage #petsoftiktok...\" data-provider-name=\"tiktok\"></div>", ), ).to eq(["https://m.tiktok.com/v/6718335390845095173"]) end end it "should extract links to posts" do expect( extract_urls("<aside class=\"quote\" data-topic=\"1234\" data-post=\"4567\">aside</aside>"), ).to eq(["/t/1234/4567"]) end it "should not extract links to anchors" do expect(extract_urls("<a href='#tos'>TOS</a>")).to eq([]) end it "should not extract links inside quotes" do links = PrettyText.extract_links( " <a href='http://body_only.com'>http://useless1.com</a> <aside class=\"quote\" data-topic=\"1234\"> <a href='http://body_and_quote.com'>http://useless3.com</a> <a href='http://quote_only.com'>http://useless4.com</a> </aside> <a href='http://body_and_quote.com'>http://useless2.com</a> ", ) expect(links.map { |l| [l.url, l.is_quote] }.sort).to eq( [ ["http://body_only.com", false], ["http://body_and_quote.com", false], ["/t/1234", true], ].sort, ) end it "should not extract links inside oneboxes" do onebox = <<~HTML <aside class="onebox twitterstatus" data-onebox-src="https://twitter.com/EDBPostgres/status/1402528437441634306"> <header class="source"> <a href="https://twitter.com/EDBPostgres/status/1402528437441634306" target="_blank" rel="noopener">twitter.com</a> <a href="https://twitter.com/EDBPostgres/status/1402528437441634306" target="_blank" rel="noopener">twitter.com</a> </header> <article class="onebox-body"> <div class="tweet">Example URL: <a target="_blank" href="https://example.com" rel="noopener">example.com</a></div> </article> </aside> HTML expect(PrettyText.extract_links(onebox).map(&:url)).to contain_exactly( "https://twitter.com/EDBPostgres/status/1402528437441634306", ) end it "should not preserve tags in code blocks" do expect( PrettyText.excerpt( "<pre><code class='handlebars'><h3>Hours</h3></code></pre>", 100, ), ).to eq("<h3>Hours</h3>") end it "should handle nil" do expect(PrettyText.excerpt(nil, 100)).to eq("") end it "handles custom bbcode excerpt" do raw = <<~MD [excerpt] hello [site](https://site.com) [/excerpt] more stuff MD post = Fabricate(:post, raw: raw) expect(post.excerpt).to eq( "hello <a href=\"https://site.com\" rel=\"noopener nofollow ugc\">site</a>", ) end it "handles div excerpt at the beginning of a post" do expect(PrettyText.excerpt("<div class='excerpt'>hi</div> test", 100)).to eq("hi") end it "handles span excerpt at the beginning of a post" do expect(PrettyText.excerpt("<span class='excerpt'>hi</span> test", 100)).to eq("hi") end it "ignores max excerpt length if a div excerpt is specified" do two_hundred = "123456789 " * 20 + "." text = two_hundred + "<div class='excerpt'>#{two_hundred}</div>" + two_hundred expect(PrettyText.excerpt(text, 100)).to eq(two_hundred) end it "ignores max excerpt length if a span excerpt is specified" do two_hundred = "123456789 " * 20 + "." text = two_hundred + "<span class='excerpt'>#{two_hundred}</span>" + two_hundred expect(PrettyText.excerpt(text, 100)).to eq(two_hundred) end it "unescapes html entities when we want text entities" do expect(PrettyText.excerpt("'", 500, text_entities: true)).to eq("'") end it "should have an option to preserve emoji images" do emoji_image = "<img src='/images/emoji/twitter/heart.png?v=#{Emoji::EMOJI_VERSION}' title=':heart:' class='emoji' alt=':heart:' loading='lazy' width='20' height='20'>" expect(PrettyText.excerpt(emoji_image, 100, keep_emoji_images: true)).to match_html( emoji_image, ) end it "should have an option to remap emoji to code points" do emoji_image = "I <img src='/images/emoji/twitter/heart.png?v=#{Emoji::EMOJI_VERSION}' title=':heart:' class='emoji' alt=':heart:' loading='lazy' width='20' height='20'> you <img src='/images/emoji/twitter/heart.png?v=#{Emoji::EMOJI_VERSION}' title=':unknown:' class='emoji' alt=':unknown:' loading='lazy' width='20' height='20'> " expect(PrettyText.excerpt(emoji_image, 100, remap_emoji: true)).to match_html( "I โค you :unknown:", ) end it "should have an option to preserve emoji codes" do emoji_code = "<img src='/images/emoji/twitter/heart.png?v=#{Emoji::EMOJI_VERSION}' title=':heart:' class='emoji' alt=':heart:' loading='lazy' width='20' height='20'>" expect(PrettyText.excerpt(emoji_code, 100)).to eq(":heart:") end context "with option to preserve onebox source" do it "should return the right excerpt" do onebox = "<aside class=\"onebox allowlistedgeneric\">\n <header class=\"source\">\n <a href=\"https://meta.discourse.org/t/infrequent-translation-updates-in-stable-branch/31213/9\">meta.discourse.org</a>\n </header>\n <article class=\"onebox-body\">\n <img src=\"https://cdn-enterprise.discourse.org/meta/user_avatar/meta.discourse.org/gerhard/200/70381_1.png\" width=\"\" height=\"\" class=\"thumbnail\">\n\n<h3><a href=\"https://meta.discourse.org/t/infrequent-translation-updates-in-stable-branch/31213/9\">Infrequent translation updates in stable branch</a></h3>\n\n<p>Well, there's an Italian translation for \"New Topic\" in beta, it's been there since November 2014 and it works here on meta. Do you have any plugins installed? Try disabling them. I'm quite confident that it's either a plugin or a site...</p>\n\n </article>\n <div class=\"onebox-metadata\">\n \n \n </div>\n <div style=\"clear: both\"></div>\n</aside>\n\n\n" expected = "<a href=\"https://meta.discourse.org/t/infrequent-translation-updates-in-stable-branch/31213/9\">meta.discourse.org</a>" expect(PrettyText.excerpt(onebox, 100, keep_onebox_source: true)).to eq(expected) expect( PrettyText.excerpt("#{onebox}\n \n \n \n\n\n #{onebox}", 100, keep_onebox_source: true), ).to eq("#{expected}\n\n#{expected}") end it "should continue to strip quotes" do expect( PrettyText.excerpt( "<aside class='quote'><p>a</p><p>b</p></aside>boom", 100, keep_onebox_source: true, ), ).to eq("boom") end end it "should strip audio/video" do html = <<~HTML <audio controls> <source src="https://awebsite.com/audio.mp3"><a href="https://awebsite.com/audio.mp3">https://awebsite.com/audio.mp3</a></source> </audio> <p>Listen to this!</p> HTML expect(PrettyText.excerpt(html, 100)).to eq("Listen to this!") html = <<~HTML <div class="onebox video-onebox"> <video controlslist="nodownload" width="100%" height="100%" controls=""> <source src="http://videosource.com/running.mp4"> <a href="http://videosource.com/running.mp4">http://videosource.com/running.mp4</a> </video> </div> <p>Watch this, but do not include the video in the excerpt.</p> HTML ellipsis = "…" excerpt_size = 40 excerpt = PrettyText.excerpt(html, excerpt_size) expect(excerpt.size).to eq(excerpt_size + ellipsis.size) expect(excerpt).to eq("Watch this, but do not include the video#{ellipsis}") end end describe "strip links" do it "returns blank for blank input" do expect(PrettyText.strip_links("")).to be_blank end it "does nothing to a string without links" do expect(PrettyText.strip_links("I'm the <b>batman</b>")).to eq("I'm the <b>batman</b>") end it "strips links but leaves the text content" do expect( PrettyText.strip_links( "I'm the linked <a href='http://en.wikipedia.org/wiki/Batman'>batman</a>", ), ).to eq("I'm the linked batman") end it "escapes the text content" do expect( PrettyText.strip_links( "I'm the linked <a href='http://en.wikipedia.org/wiki/Batman'><batman></a>", ), ).to eq("I'm the linked <batman>") end end describe "strip_image_wrapping" do def strip_image_wrapping(html) doc = Nokogiri::HTML5.fragment(html) described_class.strip_image_wrapping(doc) doc.to_html end it "doesn't change HTML when there's no wrapped image" do html = "<img src=\"wat.png\">" expect(strip_image_wrapping(html)).to eq(html) end it "strips the metadata" do expect( strip_image_wrapping(wrapped_image), ).to match_html "<div class=\"lightbox-wrapper\"><a href=\"//localhost:3000/uploads/default/4399/33691397e78b4d75.png\" class=\"lightbox\" title=\"Screen Shot 2014-04-14 at 9.47.10 PM.png\"><img src=\"//localhost:3000/uploads/default/_optimized/bd9/b20/bbbcd6a0c0_655x500.png\" width=\"655\" height=\"500\"></a></div>" end end describe "format_for_email" do let(:base_url) { "http://baseurl.net" } before { Discourse.stubs(:base_url).returns(base_url) } it "does not crash" do PrettyText.format_for_email( '<a href="mailto:michael.brown@discourse.org?subject=Your%20post%20at%20http://try.discourse.org/t/discussion-happens-so-much/127/1000?u=supermathie">test</a>', post, ) end it "adds base url to relative links" do html = "<p><a class=\"mention\" href=\"/u/wiseguy\">@wiseguy</a>, <a class=\"mention\" href=\"/u/trollol\">@trollol</a> what do you guys think? </p>" output = described_class.format_for_email(html, post) expect(output).to eq( "<p><a class=\"mention\" href=\"#{base_url}/u/wiseguy\">@wiseguy</a>, <a class=\"mention\" href=\"#{base_url}/u/trollol\">@trollol</a> what do you guys think? </p>", ) end it "doesn't change external absolute links" do html = "<p>Check out <a href=\"http://mywebsite.com/users/boss\">this guy</a>.</p>" expect(described_class.format_for_email(html, post)).to eq(html) end it "doesn't change internal absolute links" do html = "<p>Check out <a href=\"#{base_url}/users/boss\">this guy</a>.</p>" expect(described_class.format_for_email(html, post)).to eq(html) end it "can tolerate invalid URLs" do html = "<p>Check out <a href=\"not a real url\">this guy</a>.</p>" expect { described_class.format_for_email(html, post) }.to_not raise_error end it "doesn't change mailto" do html = "<p>Contact me at <a href=\"mailto:username@me.com\">this address</a>.</p>" expect(PrettyText.format_for_email(html, post)).to eq(html) end it "prefers data-original-href attribute to get Vimeo iframe link and escapes it" do html = "<p>Check out this video โ <iframe src='https://player.vimeo.com/video/329875646' data-original-href='https://vimeo.com/329875646/> <script>alert(1)</script>'></iframe>.</p>" expect(PrettyText.format_for_email(html, post)).to match( Regexp.escape("https://vimeo.com/329875646/%3E%20%3Cscript%3Ealert(1)%3C/script%3E"), ) end it "creates a valid URL when data-original-href is missing from Vimeo link" do html = '<iframe src="https://player.vimeo.com/video/508864124?h=fcbbcc92fa" width="640" height="360" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>' expect(PrettyText.format_for_email(html, post)).to match( "https://vimeo.com/508864124/fcbbcc92fa", ) end describe "#convert_vimeo_iframes" do it "converts <iframe> to <a>" do html = <<~HTML <p>This is a Vimeo link:</p> <iframe width="640" height="360" src="https://player.vimeo.com/video/1" data-original-href="https://vimeo.com/1" frameborder="0" allowfullscreen="" seamless="seamless" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation"></iframe> HTML md = PrettyText.format_for_email(html, post) expect(md).not_to include("<iframe") expect(md).to match_html(<<~HTML) <p>This is a Vimeo link:</p> <p><a href="https://vimeo.com/1">https://vimeo.com/1</a></p> HTML end end describe "#strip_secure_uploads" do before do setup_s3 SiteSetting.s3_cdn_url = "https://s3.cdn.com" SiteSetting.secure_uploads = true SiteSetting.login_required = true end it "replaces secure video content" do html = <<~HTML <video width="100%" height="100%" controls=""> <source src="#{base_url}/secure-uploads/original/1X/some-video.mp4"> <a href="#{base_url}/secure-uploads/original/1X/some-video.mp4">Video label</a> </source> </video> HTML md = PrettyText.format_for_email(html, post) expect(md).not_to include("<video") expect(md.to_s).to match(I18n.t("emails.secure_uploads_placeholder")) expect(md.to_s).not_to match(SiteSetting.Upload.s3_cdn_url) end it "replaces secure audio content" do html = <<~HTML <audio controls> <source src="#{base_url}/secure-uploads/original/1X/some-audio.mp3"> <a href="#{base_url}/secure-uploads/original/1X/some-audio.mp3">Audio label</a> </source> </audio> HTML md = PrettyText.format_for_email(html, post) expect(md).not_to include("<audio") expect(md.to_s).to match(I18n.t("emails.secure_uploads_placeholder")) expect(md.to_s).not_to match(SiteSetting.Upload.s3_cdn_url) end it "replaces secure uploads within a link with a placeholder, keeping the url in an attribute" do url = "#{Discourse.base_url}\/secure-uploads/original/1X/testimage.png" html = <<~HTML <a href=\"#{url}\"><img src=\"/secure-uploads/original/1X/testimage.png\"></a> HTML md = PrettyText.format_for_email(html, post) expect(md).not_to include("<img") expect(md).to include("Redacted") expect(md).to include("data-stripped-secure-upload=\"#{url}\"") end it "does not create nested redactions from double processing because of the view media link" do url = "#{Discourse.base_url}\/secure-uploads/original/1X/testimage.png" html = <<~HTML <a href=\"#{url}\"><img src=\"/secure-uploads/original/1X/testimage.png\"></a> HTML md = PrettyText.format_for_email(html, post) md = PrettyText.format_for_email(md, post) expect(md.scan(/stripped-secure-view-upload/).length).to eq(1) expect(md.scan(/Redacted/).length).to eq(1) end it "replaces secure images with a placeholder, keeping the url in an attribute" do url = "/secure-uploads/original/1X/testimage.png" html = <<~HTML <img src=\"#{url}\" width=\"20\" height=\"20\"> HTML md = PrettyText.format_for_email(html, post) expect(md).not_to include("<img") expect(md).to include("Redacted") expect(md).to include("data-stripped-secure-upload=\"#{url}\"") expect(md).to include("data-width=\"20\"") expect(md).to include("data-height=\"20\"") end end end it "Is smart about linebreaks and IMG tags" do raw = <<~MD a <img> <img> <img> <img> <img> a <img> - li <img> ``` test ``` ``` test ``` MD html = <<~HTML <p>a <img><br> <img></p> <p><img><br> <img></p> <p><img></p> <p>a</p> <p><img></p> <ul> <li>li</li> </ul> <p><img></p> <pre><code class="lang-auto">test </code></pre> <pre><code class="lang-auto">test </code></pre> HTML expect(PrettyText.cook(raw)).to eq(html.strip) end describe "emoji" do it "replaces unicode emoji with our emoji sets if emoji is enabled" do expect(PrettyText.cook("๐ฃ")).to match(/\:bomb\:/) end it "does not replace left right arrow" do expect(PrettyText.cook("↔")).to eq("<p>โ</p>") end it "doesn't replace emoji in inline code blocks with our emoji sets if emoji is enabled" do expect(PrettyText.cook("`๐ฃ`")).not_to match(/\:bomb\:/) end it "replaces some glyphs that are not in the emoji range" do expect(PrettyText.cook("โน")).to match(/\:frowning\:/) expect(PrettyText.cook("โบ")).to match(/\:smiling_face\:/) expect(PrettyText.cook("โป")).to match(/\:slight_smile\:/) expect(PrettyText.cook("โก")).to match(/\:heart\:/) expect(PrettyText.cook("โค")).to match(/\:heart\:/) expect(PrettyText.cook("โค๏ธ")).to match(/\:heart\:/) # in emoji range but ensure it works along others end it "replaces digits" do expect(PrettyText.cook("๐ข")).to match(/\:1234\:/) expect(PrettyText.cook("1๏ธโฃ")).to match(/\:one\:/) expect(PrettyText.cook("#๏ธโฃ")).to match(/\:hash\:/) expect(PrettyText.cook("*๏ธโฃ")).to match(/\:asterisk\:/) end it "doesn't replace unicode emoji if emoji is disabled" do SiteSetting.enable_emoji = false expect(PrettyText.cook("๐ฃ")).not_to match(/\:bomb\:/) end it "doesn't replace emoji if emoji is disabled" do SiteSetting.enable_emoji = false expect(PrettyText.cook(":bomb:")).to eq("<p>:bomb:</p>") end it "doesn't replace shortcuts if disabled" do SiteSetting.enable_emoji_shortcuts = false expect(PrettyText.cook(":)")).to eq("<p>:)</p>") end it "does replace shortcuts if enabled" do expect(PrettyText.cook(":)")).to match("smile") end it "replaces skin toned emoji" do expect(PrettyText.cook("hello ๐ฑ๐ฟโโ๏ธ")).to eq( "<p>hello <img src=\"/images/emoji/twitter/blonde_woman/6.png?v=#{Emoji::EMOJI_VERSION}\" title=\":blonde_woman:t6:\" class=\"emoji\" alt=\":blonde_woman:t6:\" loading=\"lazy\" width=\"20\" height=\"20\"></p>", ) expect(PrettyText.cook("hello ๐ฉโ๐ค")).to eq( "<p>hello <img src=\"/images/emoji/twitter/woman_singer.png?v=#{Emoji::EMOJI_VERSION}\" title=\":woman_singer:\" class=\"emoji\" alt=\":woman_singer:\" loading=\"lazy\" width=\"20\" height=\"20\"></p>", ) expect(PrettyText.cook("hello ๐ฉ๐พโ๐")).to eq( "<p>hello <img src=\"/images/emoji/twitter/woman_student/5.png?v=#{Emoji::EMOJI_VERSION}\" title=\":woman_student:t5:\" class=\"emoji\" alt=\":woman_student:t5:\" loading=\"lazy\" width=\"20\" height=\"20\"></p>", ) expect(PrettyText.cook("hello ๐คทโโ๏ธ")).to eq( "<p>hello <img src=\"/images/emoji/twitter/woman_shrugging.png?v=#{Emoji::EMOJI_VERSION}\" title=\":woman_shrugging:\" class=\"emoji\" alt=\":woman_shrugging:\" loading=\"lazy\" width=\"20\" height=\"20\"></p>", ) end it "correctly strips VARIATION SELECTOR-16 character (ufe0f) from some emojis" do expect(PrettyText.cook("โค๏ธ๐ฃ")).to match(/<img src[^>]+bomb[^>]+>/) end it "replaces Emoji from Unicode 14.0" do expect(PrettyText.cook("๐ซฃ")).to match(/\:face_with_peeking_eye\:/) end context "with subfolder" do it "prepends the subfolder path to the emoji url" do set_subfolder "/forum" expected = "src=\"/forum/images/emoji/twitter/grinning.png?v=#{Emoji::EMOJI_VERSION}\"" expect(PrettyText.cook("๐")).to include(expected) expect(PrettyText.cook(":grinning:")).to include(expected) end it "prepends the subfolder path even if it is part of the emoji url" do set_subfolder "/info" expected = "src=\"/info/images/emoji/twitter/information_source.png?v=#{Emoji::EMOJI_VERSION}\"" expect(PrettyText.cook("โน๏ธ")).to include(expected) expect(PrettyText.cook(":information_source:")).to include(expected) end end end describe "custom emoji" do it "replaces the custom emoji" do CustomEmoji.create!(name: "trout", upload: Fabricate(:upload)) Emoji.clear_cache expect(PrettyText.cook("hello :trout:")).to match(/<img src[^>]+trout[^>]+>/) end end describe "custom emoji translation" do before do PrettyText.reset_translations SiteSetting.enable_emoji = true SiteSetting.enable_emoji_shortcuts = true plugin = Plugin::Instance.new plugin.translate_emoji "0:)", "otter" end after do Plugin::CustomEmoji.clear_cache PrettyText.reset_translations end it "sets the custom translation" do expect(PrettyText.cook("hello 0:)")).to match(/otter/) end end it "replaces skin toned emoji" do expect(PrettyText.cook("hello ๐ฑ๐ฟโโ๏ธ")).to eq( "<p>hello <img src=\"/images/emoji/twitter/blonde_woman/6.png?v=#{Emoji::EMOJI_VERSION}\" title=\":blonde_woman:t6:\" class=\"emoji\" alt=\":blonde_woman:t6:\" loading=\"lazy\" width=\"20\" height=\"20\"></p>", ) expect(PrettyText.cook("hello ๐ฉโ๐ค")).to eq( "<p>hello <img src=\"/images/emoji/twitter/woman_singer.png?v=#{Emoji::EMOJI_VERSION}\" title=\":woman_singer:\" class=\"emoji\" alt=\":woman_singer:\" loading=\"lazy\" width=\"20\" height=\"20\"></p>", ) expect(PrettyText.cook("hello ๐ฉ๐พโ๐")).to eq( "<p>hello <img src=\"/images/emoji/twitter/woman_student/5.png?v=#{Emoji::EMOJI_VERSION}\" title=\":woman_student:t5:\" class=\"emoji\" alt=\":woman_student:t5:\" loading=\"lazy\" width=\"20\" height=\"20\"></p>", ) expect(PrettyText.cook("hello ๐คทโโ๏ธ")).to eq( "<p>hello <img src=\"/images/emoji/twitter/woman_shrugging.png?v=#{Emoji::EMOJI_VERSION}\" title=\":woman_shrugging:\" class=\"emoji\" alt=\":woman_shrugging:\" loading=\"lazy\" width=\"20\" height=\"20\"></p>", ) end it "should not treat a non emoji as an emoji" do expect(PrettyText.cook(":email,class_name:")).not_to include("emoji") end it "supports href schemes" do SiteSetting.allowed_href_schemes = "macappstore|steam" cooked = cook("[Steam URL Scheme](steam://store/452530)") expected = '<p><a href="steam://store/452530" rel="noopener nofollow ugc">Steam URL Scheme</a></p>' expect(cooked).to eq(n expected) end it "supports forbidden schemes" do SiteSetting.allowed_href_schemes = "macappstore|itunes" cooked = cook("[Steam URL Scheme](steam://store/452530)") expected = "<p><a>Steam URL Scheme</a></p>" expect(cooked).to eq(n expected) end it "applies scheme restrictions to img[src] attributes" do SiteSetting.allowed_href_schemes = "steam" cooked = cook "![Steam URL Image](steam://store/452530) ![Other scheme image](itunes://store/452530)" expected = '<p><img src="steam://store/452530" alt="Steam URL Image"> <img src="" alt="Other scheme image"></p>' expect(cooked).to eq(n expected) end it "applies scheme restrictions to track[src] and source[src]" do SiteSetting.allowed_href_schemes = "steam" cooked = cook <<~MD <video> <source src="steam://store/452530"><source src="itunes://store/452530"><track src="steam://store/452530"><track src="itunes://store/452530"> </video> MD expect(cooked).to include <<~HTML <source src="steam://store/452530"><source src=""><track src="steam://store/452530"><track src=""> HTML end it "applies scheme restrictions to source[srcset]" do SiteSetting.allowed_href_schemes = "steam" cooked = cook <<~MD <video> <source srcset="steam://store/452530 1x,itunes://store/123 2x"><source srcset="steam://store/452530"><source srcset="itunes://store/452530"> </video> MD expect(cooked).to include <<~HTML <source srcset="steam://store/452530 1x,"><source srcset="steam://store/452530"><source srcset=""> HTML end it "allows only tel URL scheme to start with a plus character" do SiteSetting.allowed_href_schemes = "tel|steam" cooked = cook("[Tel URL Scheme](tel://+452530579785)") expected = '<p><a href="tel://+452530579785" rel="noopener nofollow ugc">Tel URL Scheme</a></p>' expect(cooked).to eq(n expected) cooked2 = cook("[Steam URL Scheme](steam://+store/452530)") expected2 = "<p><a>Steam URL Scheme</a></p>" expect(cooked2).to eq(n expected2) end it "produces hashtag links" do user = Fabricate(:user) category = Fabricate(:category, name: "testing", slug: "testing") category2 = Fabricate(:category, name: "known", slug: "known") group = Fabricate(:group) private_category = Fabricate(:private_category, name: "secret", group: group, slug: "secret") tag = Fabricate(:tag, name: "known") Fabricate(:topic, tags: [tag]) cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id) expect(cooked).to have_tag("span", text: "#unknown::tag", with: { class: "hashtag-raw" }) expect(cooked).to have_tag( "a", with: { class: "hashtag-cooked", href: category2.url, "data-type": "category", "data-slug": category2.slug, "data-id": category2.id, }, ) do with_tag("span", with: { class: "hashtag-icon-placeholder" }) end expect(cooked).to have_tag( "a", with: { class: "hashtag-cooked", href: category.url, "data-type": "category", "data-slug": category.slug, "data-id": category.id, }, ) do with_tag("span", with: { class: "hashtag-icon-placeholder" }) end expect(cooked).to have_tag( "a", with: { class: "hashtag-cooked", href: tag.url, "data-type": "tag", "data-slug": tag.name, "data-id": tag.id, }, ) do with_tag("span", with: { class: "hashtag-icon-placeholder" }) end expect(cooked).to have_tag("span", text: "#secret", with: { class: "hashtag-raw" }) # If the user hash access to the private category it should be cooked with the details + icon group.add(user) cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id) expect(cooked).to have_tag( "a", with: { class: "hashtag-cooked", href: private_category.url, "data-type": "category", "data-slug": private_category.slug, "data-id": private_category.id, }, ) do with_tag("span", with: { class: "hashtag-icon-placeholder" }) end cooked = PrettyText.cook("[`a` #known::tag here](http://example.com)", user_id: user.id) html = <<~HTML <p><a href="http://example.com" rel="noopener nofollow ugc"><code>a</code> #known::tag here</a></p> HTML expect(cooked).to eq(html.strip) cooked = PrettyText.cook("<a href='http://example.com'>`a` #known::tag here</a>", user_id: user.id) expect(cooked).to eq(html.strip) cooked = PrettyText.cook("<A href='/a'>test</A> #known::tag", user_id: user.id) expect(cooked).to have_tag( "a", with: { class: "hashtag-cooked", href: tag.url, "data-type": "tag", "data-slug": tag.name, "data-id": tag.id, }, ) do with_tag("span", with: { class: "hashtag-icon-placeholder" }) end # ensure it does not fight with the autolinker expect(PrettyText.cook(" http://somewhere.com/#known")).not_to include("hashtag") expect(PrettyText.cook(" http://somewhere.com/?#known")).not_to include("hashtag") expect(PrettyText.cook(" http://somewhere.com/?abc#known")).not_to include("hashtag") end it "can handle mixed lists" do # known bug in old md engine cooked = PrettyText.cook("* a\n\n1. b") expect(cooked).to match_html("<ul>\n<li>a</li>\n</ul>\n<ol>\n<li>b</li>\n</ol>") end it "can handle traditional vs non traditional newlines" do SiteSetting.traditional_markdown_linebreaks = true expect(PrettyText.cook("1\n2")).to match_html "<p>1 2</p>" SiteSetting.traditional_markdown_linebreaks = false expect(PrettyText.cook("1\n2")).to match_html "<p>1<br>\n2</p>" end it "can handle emoji by name" do expected = <<HTML <p><img src="/images/emoji/twitter/smile.png?v=#{Emoji::EMOJI_VERSION}\" title=":smile:" class="emoji only-emoji" alt=":smile:" loading="lazy" width="20" height="20"><img src="/images/emoji/twitter/sunny.png?v=#{Emoji::EMOJI_VERSION}" title=":sunny:" class="emoji only-emoji" alt=":sunny:" loading="lazy" width="20" height="20"></p> HTML expect(PrettyText.cook(":smile::sunny:")).to eq(expected.strip) end it "handles emoji boundaries correctly" do cooked = PrettyText.cook("a,:man:t2:,b") expected = "<p>a,<img src=\"/images/emoji/twitter/man/2.png?v=#{Emoji::EMOJI_VERSION}\" title=\":man:t2:\" class=\"emoji\" alt=\":man:t2:\" loading=\"lazy\" width=\"20\" height=\"20\">,b</p>" expect(cooked).to match(expected.strip) end it "can handle emoji by translation" do expected = "<p><img src=\"/images/emoji/twitter/wink.png?v=#{Emoji::EMOJI_VERSION}\" title=\":wink:\" class=\"emoji only-emoji\" alt=\":wink:\" loading=\"lazy\" width=\"20\" height=\"20\"></p>" expect(PrettyText.cook(";)")).to eq(expected) end it "can handle multiple emojis by translation" do cooked = PrettyText.cook(":) ;) :)") expect(cooked.split("img").length - 1).to eq(3) end it "handles emoji boundaries correctly" do expect(PrettyText.cook(",:)")).to include("emoji") expect(PrettyText.cook(":-)\n")).to include("emoji") expect(PrettyText.cook("a :)")).to include("emoji") expect(PrettyText.cook(":),")).not_to include("emoji") expect(PrettyText.cook("abcde ^:;-P")).to include("emoji") end describe "censoring" do after { Discourse.redis.flushdb } def expect_cooked_match(raw, expected_cooked) expect(PrettyText.cook(raw)).to eq(expected_cooked) end context "with basic words" do fab!(:watched_words) do %w[shucks whiz whizzer a**le badword* shuck$ cafรฉ $uper].each do |word| Fabricate(:watched_word, action: WatchedWord.actions[:censor], word: word) end end it "works correctly" do expect_cooked_match("aw shucks, golly gee whiz.", "<p>aw โ โ โ โ โ โ , golly gee โ โ โ โ .</p>") end it "doesn't censor words unless they have boundaries." do expect_cooked_match( "you are a whizzard! I love cheesewhiz. Whiz.", "<p>you are a whizzard! I love cheesewhiz. โ โ โ โ .</p>", ) end it "censors words even if previous partial matches exist." do expect_cooked_match( "you are a whizzer! I love cheesewhiz. Whiz.", "<p>you are a โ โ โ โ โ โ โ ! I love cheesewhiz. โ โ โ โ .</p>", ) end it "won't break links by censoring them." do expect_cooked_match( "The link still works. [whiz](http://www.whiz.com)", '<p>The link still works. <a href="http://www.whiz.com" rel="noopener nofollow ugc">โ โ โ โ </a></p>', ) end it "escapes regexp characters" do expect_cooked_match("I have a pen, I have an a**le", "<p>I have a pen, I have an โ โ โ โ โ </p>") end it "works for words ending in non-word characters" do expect_cooked_match( "Aw shuck$, I can't fix the problem with money", "<p>Aw โ โ โ โ โ โ , I can't fix the problem with money</p>", ) end it "works for words ending in accented characters" do expect_cooked_match("Let's go to a cafรฉ today", "<p>Let's go to a โ โ โ โ today</p>") end it "works for words starting with non-word characters" do expect_cooked_match("Discourse is $uper amazing", "<p>Discourse is โ โ โ โ โ amazing</p>") end it "handles * as wildcard" do expect_cooked_match("No badword or apple here plz.", "<p>No โ โ โ โ โ โ โ or โ โ โ โ โ here plz.</p>") end end context "with watched words as regular expressions" do before { SiteSetting.watched_words_regular_expressions = true } it "supports words as regular expressions" do %w[xyz* plee+ase].each do |word| Fabricate(:watched_word, action: WatchedWord.actions[:censor], word: word) end expect_cooked_match( "Pleased to meet you, but pleeeease call me later, xyz123", "<p>Pleased to meet you, but โ โ โ โ โ โ โ โ โ call me later, โ โ โ 123</p>", ) end it "supports custom boundaries" do Fabricate(:watched_word, action: WatchedWord.actions[:censor], word: "\\btown\\b") expect_cooked_match( "Meet downtown in your town at the townhouse on Main St.", "<p>Meet downtown in your โ โ โ โ at the townhouse on Main St.</p>", ) end end end describe "watched words - replace & link" do after { Discourse.redis.flushdb } it "replaces words with other words" do Fabricate( :watched_word, action: WatchedWord.actions[:replace], word: "dolor sit*", replacement: "something else", ) expect(PrettyText.cook("Lorem ipsum dolor sit amet")).to match_html(<<~HTML) <p>Lorem ipsum something else amet</p> HTML expect(PrettyText.cook("Lorem ipsum dolor sits amet")).to match_html(<<~HTML) <p>Lorem ipsum something else amet</p> HTML expect(PrettyText.cook("Lorem ipsum dolor sittt amet")).to match_html(<<~HTML) <p>Lorem ipsum something else amet</p> HTML expect(PrettyText.cook("Lorem ipsum xdolor sit amet")).to match_html(<<~HTML) <p>Lorem ipsum xdolor sit amet</p> HTML end it "replaces words with wildcards" do Fabricate( :watched_word, action: WatchedWord.actions[:replace], word: "*dolor*", replacement: "something else", ) expect(PrettyText.cook("Lorem ipsum xdolorx sit amet")).to match_html(<<~HTML) <p>Lorem ipsum something else sit amet</p> HTML end it "replaces words with links" do Fabricate( :watched_word, action: WatchedWord.actions[:link], word: "meta", replacement: "https://meta.discourse.org", ) expect(PrettyText.cook("Meta is a Discourse forum")).to match_html(<<~HTML) <p> <a href=\"https://meta.discourse.org\" rel=\"noopener nofollow ugc\">Meta</a> is a Discourse forum </p> HTML end it "works with regex" do Fabricate( :watched_word, action: WatchedWord.actions[:replace], word: "f.o", replacement: "test", ) expect(PrettyText.cook("foo")).to match_html("<p>foo</p>") expect(PrettyText.cook("f.o")).to match_html("<p>test</p>") SiteSetting.watched_words_regular_expressions = true expect(PrettyText.cook("foo")).to match_html("<p>test</p>") expect(PrettyText.cook("f.o")).to match_html("<p>test</p>") end it "does not replace hashtags and mentions" do Fabricate(:user, username: "test") category = Fabricate(:category, slug: "test", name: "test") Fabricate( :watched_word, action: WatchedWord.actions[:replace], word: "test", replacement: "discourse", ) cooked = PrettyText.cook("@test #test test") expect(cooked).to have_tag("a", text: "@test", with: { class: "mention", href: "/u/test" }) expect(cooked).to have_tag( "a", text: "test", with: { class: "hashtag-cooked", href: "/c/test/#{category.id}", "data-type": "category", "data-slug": category.slug, "data-id": category.id, }, ) do with_tag("span", with: { class: "hashtag-icon-placeholder" }) end expect(cooked).to include("discourse") end it "does not replace hashtags and mentions when watched words are regular expressions" do SiteSetting.watched_words_regular_expressions = true Fabricate(:user, username: "test") category = Fabricate(:category, slug: "test", name: "test") Fabricate( :watched_word, action: WatchedWord.actions[:replace], word: "es", replacement: "discourse", ) cooked = PrettyText.cook("@test #test test") expect(cooked).to have_tag("a", text: "@test", with: { class: "mention", href: "/u/test" }) expect(cooked).to have_tag( "a", text: "test", with: { class: "hashtag-cooked", href: "/c/test/#{category.id}", "data-type": "category", "data-slug": category.slug, "data-id": category.id, }, ) do with_tag("span", with: { class: "hashtag-icon-placeholder" }) end expect(cooked).to include("tdiscourset") end it "supports overlapping words" do Fabricate( :watched_word, action: WatchedWord.actions[:link], word: "meta", replacement: "https://meta.discourse.org", ) Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "iz", replacement: "is") Fabricate( :watched_word, action: WatchedWord.actions[:link], word: "discourse", replacement: "https://discourse.org", ) expect(PrettyText.cook("Meta iz a Discourse forum")).to match_html(<<~HTML) <p> <a href="https://meta.discourse.org" rel="noopener nofollow ugc">Meta</a> is a <a href="https://discourse.org" rel="noopener nofollow ugc">Discourse</a> forum </p> HTML end end it "supports typographer" do SiteSetting.enable_markdown_typographer = true expect(PrettyText.cook("->")).to eq("<p> โ </p>") SiteSetting.enable_markdown_typographer = false expect(PrettyText.cook("->")).to eq("<p>-></p>") end it "uses quotation marks from site settings" do SiteSetting.enable_markdown_typographer = true expect(PrettyText.cook(%q|"Do you know," he said, "what 'Discourse' is?"|)).to eq( "<p>โDo you know,โ he said, โwhat โDiscourseโ is?โ</p>", ) SiteSetting.markdown_typographer_quotation_marks = "โ|โ|โ|โ" expect(PrettyText.cook(%q|"Weiรt du", sagte er, "was 'Discourse' ist?"|)).to eq( "<p>โWeiรt duโ, sagte er, โwas โDiscourseโ ist?โ</p>", ) end it "handles onebox correctly" do expect(PrettyText.cook("http://a.com\nhttp://b.com").split("onebox").length).to eq(3) expect(PrettyText.cook("http://a.com\n\nhttp://b.com").split("onebox").length).to eq(3) expect(PrettyText.cook("a\nhttp://a.com")).to include("onebox") expect(PrettyText.cook("> http://a.com")).not_to include("onebox") expect(PrettyText.cook("a\nhttp://a.com a")).not_to include("onebox") expect(PrettyText.cook("a\nhttp://a.com\na")).to include("onebox") expect(PrettyText.cook("http://a.com")).to include("onebox") expect(PrettyText.cook("http://a.com ")).to include("onebox") expect(PrettyText.cook("http://a.com a")).not_to include("onebox") expect(PrettyText.cook("- http://a.com")).not_to include("onebox") expect(PrettyText.cook("<http://a.com>")).not_to include("onebox") expect(PrettyText.cook(" http://a.com")).not_to include("onebox") expect(PrettyText.cook("a\n http://a.com")).not_to include("onebox") expect(PrettyText.cook("sam@sam.com")).not_to include("onebox") expect(PrettyText.cook("<img src='a'>\nhttp://a.com")).to include("onebox") end it "can handle bbcode" do expect(PrettyText.cook("a[b]b[/b]c")).to eq('<p>a<span class="bbcode-b">b</span>c</p>') expect(PrettyText.cook("a[i]b[/i]c")).to eq('<p>a<span class="bbcode-i">b</span>c</p>') end it "supports empty inline BBCode" do expect(PrettyText.cook("a[b][/b]c")).to eq('<p>a<span class="bbcode-b"></span>c</p>') end it "can handle bbcode after a newline" do # this is not 100% ideal cause we get an extra p here, but this is pretty rare expect(PrettyText.cook("a\n[code]code[/code]")).to eq( "<p>a</p>\n<pre><code class=\"lang-auto\">code</code></pre>", ) # this is fine expect(PrettyText.cook("a\na[code]code[/code]")).to eq("<p>a<br>\na<code>code</code></p>") end it "can onebox local topics" do op = post reply = Fabricate(:post, topic_id: op.topic_id) url = Discourse.base_url + reply.url quote = create_post(topic_id: op.topic.id, raw: "This is a sample reply with a quote\n\n#{url}") quote.reload expect(quote.cooked).not_to include("[quote") end it "supports tables" do markdown = <<~MD | Tables | Are | Cool | | ------------- |:-------------:| -----:| | col 3 is | right-aligned | $1600 | MD expected = <<~HTML <div class="md-table"> <table> <thead> <tr> <th>Tables</th> <th style="text-align:center">Are</th> <th style="text-align:right">Cool</th> </tr> </thead> <tbody> <tr> <td>col 3 is</td> <td style="text-align:center">right-aligned</td> <td style="text-align:right">$1600</td> </tr> </tbody> </table> </div> HTML expect(PrettyText.cook(markdown)).to eq(expected.strip) end it "supports img bbcode" do cooked = PrettyText.cook "[img]http://www.image/test.png[/img]" html = "<p><img src=\"http://www.image/test.png\" alt=\"\" role=\"presentation\"></p>" expect(cooked).to eq(html) end it "supports img bbcode entities in attributes" do actual = PrettyText.cook "[img]http://aaa.com/?a=1&b=<script>alert(1);</script>[/img]" expected = '<p><img src="http://aaa.com/?a=1&b=<script>alert(1);</script>" alt="" role="presentation"></p>' expect(expected).to be_same_dom(actual) end it "supports email bbcode" do cooked = PrettyText.cook "[email]sam@sam.com[/email]" html = '<p><a href="mailto:sam@sam.com" data-bbcode="true">sam@sam.com</a></p>' expect(cooked).to eq(html) end it "supports url bbcode" do cooked = PrettyText.cook "[url]http://sam.com[/url]" html = '<p><a href="http://sam.com" data-bbcode="true" rel="noopener nofollow ugc">http://sam.com</a></p>' expect(cooked).to eq(html) end it "supports nesting tags in url" do cooked = PrettyText.cook("[url=http://sam.com][b]I am sam[/b][/url]") html = '<p><a href="http://sam.com" data-bbcode="true" rel="noopener nofollow ugc"><span class="bbcode-b">I am sam</span></a></p>' expect(cooked).to eq(html) end it "supports query params in bbcode url" do cooked = PrettyText.cook( "[url=https://www.amazon.com/Camcorder-Hausbell-302S-Control-Infrared/dp/B01KLOA1PI/?tag=discourse]BBcode link[/url]", ) html = '<p><a href="https://www.amazon.com/Camcorder-Hausbell-302S-Control-Infrared/dp/B01KLOA1PI/?tag=discourse" data-bbcode="true" rel="noopener nofollow ugc">BBcode link</a></p>' expect(cooked).to eq(html) end it "supports inline code bbcode" do cooked = PrettyText.cook "Testing [code]codified **stuff** and `more` stuff[/code]" html = "<p>Testing <code>codified **stuff** and `more` stuff</code></p>" expect(cooked).to eq(html) end it "supports block code bbcode" do cooked = PrettyText.cook "[code]\ncodified\n\n\n **stuff** and `more` stuff\n[/code]" html = "<pre><code class=\"lang-auto\">codified\n\n\n **stuff** and `more` stuff</code></pre>" expect(cooked).to eq(html) end it "support special handling for space in urls" do cooked = PrettyText.cook "http://testing.com?a%20b" html = '<p><a href="http://testing.com?a%20b" class="onebox" target="_blank" rel="noopener nofollow ugc">http://testing.com?a%20b</a></p>' expect(cooked).to eq(html) end it "supports onebox for decoded urls" do cooked = PrettyText.cook "http://testing.com?a%50b" html = '<p><a href="http://testing.com?a%50b" class="onebox" target="_blank" rel="noopener nofollow ugc">http://testing.com?aPb</a></p>' expect(cooked).to eq(html) end it "should sanitize the html" do expect(PrettyText.cook("<test>alert(42)</test>")).to eq "<p>alert(42)</p>" end it "should not onebox magically linked urls" do expect(PrettyText.cook("[url]site.com[/url]")).not_to include("onebox") end it "should sanitize the html" do expect(PrettyText.cook("<p class='hi'>hi</p>")).to eq "<p>hi</p>" end it "should strip SCRIPT" do expect(PrettyText.cook("<script>alert(42)</script>")).to eq "" expect(PrettyText.cook("<div><script>alert(42)</script></div>")).to eq "<div></div>" end it "strips script regardless of sanitize" do expect( PrettyText.cook("<div><script>alert(42)</script></div>", sanitize: false), ).to eq "<div></div>" end it "should allow sanitize bypass" do expect( PrettyText.cook("<test>alert(42)</test>", sanitize: false), ).to eq "<p><test>alert(42)</test></p>" end # custom rule used to specify image dimensions via alt tags describe "image dimensions" do it "allows title plus dimensions" do cooked = PrettyText.cook <<~MD ![title with | title|220x100](http://png.com/my.png) ![](http://png.com/my.png) ![|220x100](http://png.com/my.png) ![stuff](http://png.com/my.png) ![|220x100,50%](http://png.com/my.png "some title") MD html = <<~HTML <p><img src="http://png.com/my.png" alt="title with | title" width="220" height="100"><br> <img src="http://png.com/my.png" alt="" role="presentation"><br> <img src="http://png.com/my.png" alt="" width="220" height="100" role="presentation"><br> <img src="http://png.com/my.png" alt="stuff"><br> <img src="http://png.com/my.png" alt="" title="some title" width="110" height="50" role="presentation"></p> HTML expect(cooked).to eq(html.strip) end it "ignores whitespace and allows scaling by percent, width, height" do cooked = PrettyText.cook <<~MD ![|220x100, 50%](http://png.com/my.png) ![|220x100 , 50%](http://png.com/my.png) ![|220x100 ,50%](http://png.com/my.png) ![|220x100,150x](http://png.com/my.png) ![|220x100, x50](http://png.com/my.png) MD html = <<~HTML <p><img src="http://png.com/my.png" alt="" width="110" height="50" role="presentation"><br> <img src="http://png.com/my.png" alt="" width="110" height="50" role="presentation"><br> <img src="http://png.com/my.png" alt="" width="110" height="50" role="presentation"><br> <img src="http://png.com/my.png" alt="" width="150" height="68" role="presentation"><br> <img src="http://png.com/my.png" alt="" width="110" height="50" role="presentation"></p> HTML expect(cooked).to eq(html.strip) end end describe "upload decoding" do it "can decode upload:// for default setup" do set_cdn_url("https://cdn.com") upload = Fabricate(:upload) raw = <<~RAW ![upload](#{upload.short_url}) ![upload](#{upload.short_url} "some title to test") - ![upload](#{upload.short_url}) - test - ![upload](#{upload.short_url}) ![upload](#{upload.short_url.gsub(".png", "")}) Inline img <img src="#{upload.short_url}"> <div> Block img <img src="#{upload.short_url}"> </div> [some attachment](#{upload.short_url}) [some attachment|attachment](#{upload.short_url}) [some attachment|random](#{upload.short_url}) RAW cdn_url = Discourse.store.cdn_url(upload.url) cooked = <<~HTML <p><img src="#{cdn_url}" alt="upload" data-base62-sha1="#{upload.base62_sha1}"></p> <p><img src="#{cdn_url}" alt="upload" title="some title to test" data-base62-sha1="#{upload.base62_sha1}"></p> <ul> <li> <p><img src="#{cdn_url}" alt="upload" data-base62-sha1="#{upload.base62_sha1}"></p> </li> <li> <p>test</p> <ul> <li><img src="#{cdn_url}" alt="upload" data-base62-sha1="#{upload.base62_sha1}"></li> </ul> </li> </ul> <p><img src="#{cdn_url}" alt="upload" data-base62-sha1="#{upload.base62_sha1}"></p> <p>Inline img <img src="#{cdn_url}" data-base62-sha1="#{upload.base62_sha1}"></p> <div> Block img <img src="#{cdn_url}" data-base62-sha1="#{upload.base62_sha1}"> </div> <p><a href="#{upload.short_path}">some attachment</a></p> <p><a class="attachment" href="#{upload.short_path}">some attachment</a></p> <p><a href="#{upload.short_path}">some attachment|random</a></p> HTML expect(PrettyText.cook(raw)).to eq(cooked.strip) end it "can place a blank image if we can not find the upload" do raw = <<~MD ![upload](upload://abcABC.png) [some attachment|attachment](upload://abcdefg.png) MD cooked = <<~HTML <p><img src="/images/transparent.png" alt="upload" data-orig-src="upload://abcABC.png"></p> <p><a class="attachment" href="/404" data-orig-href="upload://abcdefg.png">some attachment</a></p> HTML expect(PrettyText.cook(raw)).to eq(cooked.strip) end end it "can properly allowlist iframes" do SiteSetting.allowed_iframes = "https://bob.com/a|http://silly.com?EMBED=" raw = <<~HTML <iframe src='https://www.google.com/maps/Embed?testing'></iframe> <iframe src='https://bob.com/a?testing'></iframe> <iframe src='HTTP://SILLY.COM?EMBED=111'></iframe> HTML # we require explicit HTTPS here html = <<~HTML <iframe src="https://bob.com/a?testing"></iframe> <iframe src="HTTP://SILLY.COM?EMBED=111"></iframe> HTML cooked = PrettyText.cook(raw).strip expect(cooked).to eq(html.strip) end it "can skip relative paths in allowlist iframes" do SiteSetting.allowed_iframes = "https://bob.com/abc/def" raw = <<~HTML <iframe src='https://bob.com/abc/def'></iframe> <iframe src='https://bob.com/abc/def/../ghi'></iframe> <iframe src='https://bob.com/abc/def/ghi/../../jkl'></iframe> HTML html = <<~HTML <iframe src="https://bob.com/abc/def"></iframe> HTML expect(PrettyText.cook(raw).strip).to eq(html.strip) end it "You can disable linkify" do md = "www.cnn.com test.it http://test.com https://test.ab https://a" cooked = PrettyText.cook(md) html = <<~HTML <p><a href="http://www.cnn.com" rel="noopener nofollow ugc">www.cnn.com</a> test.it <a href="http://test.com" rel="noopener nofollow ugc">http://test.com</a> <a href="https://test.ab" rel="noopener nofollow ugc">https://test.ab</a> <a href="https://a" rel="noopener nofollow ugc">https://a</a></p> HTML expect(cooked).to eq(html.strip) # notice how cnn.com is no longer linked but it is SiteSetting.markdown_linkify_tlds = "not_com|it" cooked = PrettyText.cook(md) html = <<~HTML <p>www.cnn.com <a href="http://test.it" rel="noopener nofollow ugc">test.it</a> <a href="http://test.com" rel="noopener nofollow ugc">http://test.com</a> <a href="https://test.ab" rel="noopener nofollow ugc">https://test.ab</a> <a href="https://a" rel="noopener nofollow ugc">https://a</a></p> HTML expect(cooked).to eq(html.strip) # no tlds anymore SiteSetting.markdown_linkify_tlds = "" cooked = PrettyText.cook(md) html = <<~HTML <p>www.cnn.com test.it <a href="http://test.com" rel="noopener nofollow ugc">http://test.com</a> <a href="https://test.ab" rel="noopener nofollow ugc">https://test.ab</a> <a href="https://a" rel="noopener nofollow ugc">https://a</a></p> HTML expect(cooked).to eq(html.strip) # lastly ... what about no linkify SiteSetting.enable_markdown_linkify = false cooked = PrettyText.cook(md) html = <<~HTML <p>www.cnn.com test.it http://test.com https://test.ab https://a</p> HTML end it "has a proper data whitelist on div" do cooked = PrettyText.cook("<div data-theme-a='a'>test</div>") expect(cooked).to include("data-theme-a") end it "allowlists lang attribute" do cooked = PrettyText.cook( "<p lang='fr'>tester</p><div lang='fr'>tester</div><span lang='fr'>tester</span>", ) expect(cooked).to eq( "<p lang=\"fr\">tester</p><div lang=\"fr\">tester</div><span lang=\"fr\">tester</span>", ) end it "allowlists ruby tags" do # read all about ruby chars at: https://en.wikipedia.org/wiki/Ruby_character # basically it is super hard to remember every single rare letter when there are # so many, so ruby tags provide a hint. # html = (<<~MD).strip <ruby lang="je"> <rb lang="je">X</rb> ๆผข <rp>(</rp><rt lang="je"> ใใขห </rt><rp>)</rp> </ruby> MD cooked = PrettyText.cook html expect(cooked).to eq(html) end describe "d-wrap" do it "wraps the [wrap] tag inline" do cooked = PrettyText.cook("[wrap=toc]taco[/wrap]") html = <<~HTML <div class="d-wrap" data-wrap="toc"> <p>taco</p> </div> HTML expect(cooked).to eq(html.strip) cooked = PrettyText.cook("Hello [wrap=toc id=1]taco[/wrap] world") html = <<~HTML <p>Hello <span class="d-wrap" data-wrap="toc" data-id="1">taco</span> world</p> HTML expect(cooked).to eq(html.strip) end it "wraps the [wrap] tag in block" do # can interfere with parsing SiteSetting.enable_markdown_typographer = true md = <<~MD [wrap=toc id="aโ aa='b"' bb="f'"] taco1 [/wrap] MD cooked = PrettyText.cook(md) html = <<~HTML <div class="d-wrap" data-wrap="toc" data-id="a" data-aa="b&quot;" data-bb="f'"> <p>taco1</p> </div> HTML expect(cooked).to eq(html.strip) end it "wraps the [wrap] tag without content" do md = <<~MD [wrap=toc] [/wrap] MD cooked = PrettyText.cook(md) html = <<~HTML <div class="d-wrap" data-wrap="toc"></div> HTML expect(cooked).to eq(html.strip) end it "adds attributes as data-attributes" do cooked = PrettyText.cook("[wrap=toc name=\"single quote's\" id='1\"2']taco[/wrap]") html = <<~HTML <div class="d-wrap" data-wrap="toc" data-name="single quote's" data-id="1&quot;2"> <p>taco</p> </div> HTML expect(cooked).to eq(html.strip) end it "prevents xss" do cooked = PrettyText.cook('[wrap=toc foo="<script>console.log(1)</script>"]taco[/wrap]') html = <<~HTML <div class="d-wrap" data-wrap="toc" data-foo="&lt;script&gt;console.log(1)&lt;/script&gt;"> <p>taco</p> </div> HTML expect(cooked).to eq(html.strip) end it "allows a limited set of attributes chars" do cooked = PrettyText.cook('[wrap=toc fo@"รจk-"!io=bar]taco[/wrap]') html = <<~HTML <div class=\"d-wrap\" data-wrap=\"toc\" data-io=\"bar\"> <p>taco</p> </div> HTML expect(cooked).to eq(html.strip) end end it "adds anchor links to headings" do cooked = PrettyText.cook("# Hello world") html = <<~HTML <h1> <a name="hello-world-1" class="anchor" href="#hello-world-1"></a> Hello world </h1> HTML expect(cooked).to match_html(html) end describe "customizing markdown-it rules" do it "customizes the markdown-it rules correctly" do cooked = PrettyText.cook("This is some text **bold**", markdown_it_rules: []) expect(cooked).to eq("<p>This is some text **bold**</p>") cooked = PrettyText.cook("This is some text **bold**", markdown_it_rules: ["emphasis"]) expect(cooked).to eq("<p>This is some text <strong>bold</strong></p>") end end describe "enabling/disabling features" do it "allows features to be overridden" do cooked = PrettyText.cook(":grin: @mention", features_override: []) expect(cooked).to eq("<p>:grin: @mention</p>") cooked = PrettyText.cook(":grin: @mention", features_override: ["emoji"]) expect(cooked).to eq( "<p><img src=\"/images/emoji/twitter/grin.png?v=#{Emoji::EMOJI_VERSION}\" title=\":grin:\" class=\"emoji\" alt=\":grin:\" loading=\"lazy\" width=\"20\" height=\"20\"> @mention</p>", ) cooked = PrettyText.cook(":grin: @mention", features_override: %w[mentions text-post-process]) expect(cooked).to eq("<p>:grin: <span class=\"mention\">@mention</span></p>") end end it "does not amend HTML when scrubbing" do md = <<~MD <s>\n\nhello\n\n</s> MD html = <<~HTML <s>\n<p>hello</p>\n</s> HTML cooked = PrettyText.cook(md) expect(cooked.strip).to eq(html.strip) end it "handles deprecations correctly" do Rails .logger .expects(:warn) .once .with("[PrettyText] Deprecation notice: Some deprecation message") PrettyText.v8.eval <<~JS require("discourse-common/lib/deprecated").default("Some deprecation message"); JS end end