1 # frozen_string_literal: true
5 class RichTextTest < ActiveSupport::TestCase
6 include Rails::Dom::Testing::Assertions::SelectorAssertions
9 r = RichText.new("html", "foo http://example.com/ bar")
12 assert_select "a[href='http://example.com/']", 1
13 assert_select "a[rel='nofollow noopener noreferrer']", 1
16 r = RichText.new("html", "foo <a href='http://example.com/'>bar</a> baz")
19 assert_select "a[href='http://example.com/']", 1
20 assert_select "a[rel='nofollow noopener noreferrer']", 1
23 r = RichText.new("html", "foo <a rel='junk me trash' href='http://example.com/'>bar</a> baz")
26 assert_select "a[href='http://example.com/']", 1
27 assert_select "a[rel='me nofollow noopener noreferrer']", 1
30 r = RichText.new("html", "foo example@example.com bar")
35 r = RichText.new("html", "foo <a href='mailto:example@example.com'>bar</a> baz")
38 assert_select "a[href='mailto:example@example.com']", 1
39 assert_select "a[rel='nofollow noopener noreferrer']", 1
42 r = RichText.new("html", "foo <div>bar</div> baz")
44 assert_select "div", false
45 assert_select "p", /^foo *bar *baz$/
48 r = RichText.new("html", "foo <script>bar = 1;</script> baz")
50 assert_select "script", false
51 assert_select "p", /^foo *baz$/
54 r = RichText.new("html", "foo <style>div { display: none; }</style> baz")
56 assert_select "style", false
57 assert_select "p", /^foo *baz$/
60 r = RichText.new("html", "<table><tr><td>column</td></tr></table>")
62 assert_select "table[class='table table-sm w-auto']"
65 r = RichText.new("html", "<p class='btn btn-warning'>Click Me</p>")
67 assert_select "p[class='btn btn-warning']", false
68 assert_select "p", /^Click Me$/
71 r = RichText.new("html", "<p style='color:red'>Danger</p>")
73 assert_select "p[style='color:red']", false
74 assert_select "p", /^Danger$/
79 r = RichText.new("html", "foo <a href='http://example.com/'>bar</a> baz")
80 assert_equal "foo <a href='http://example.com/'>bar</a> baz", r.to_text
83 def test_markdown_to_html
84 r = RichText.new("markdown", "foo http://example.com/ bar")
87 assert_select "a[href='http://example.com/']", 1
88 assert_select "a[rel='nofollow noopener noreferrer']", 1
91 r = RichText.new("markdown", "foo [bar](http://example.com/) baz")
94 assert_select "a[href='http://example.com/']", 1
95 assert_select "a[rel='nofollow noopener noreferrer']", 1
98 r = RichText.new("markdown", "foo <a rel='junk me trash' href='http://example.com/'>bar</a>) baz")
101 assert_select "a[href='http://example.com/']", 1
102 assert_select "a[rel='me nofollow noopener noreferrer']", 1
105 r = RichText.new("markdown", "foo example@example.com bar")
108 assert_select "a[href='mailto:example@example.com']", 1
109 assert_select "a[rel='nofollow noopener noreferrer']", 1
112 r = RichText.new("markdown", "foo [bar](mailto:example@example.com) bar")
115 assert_select "a[href='mailto:example@example.com']", 1
116 assert_select "a[rel='nofollow noopener noreferrer']", 1
119 r = RichText.new("markdown", "foo  bar")
121 assert_select "img", 1
122 assert_select "img[alt='bar']", 1
123 assert_select "img[src='http://example.com/example.png']", 1
126 r = RichText.new("markdown", "# foo bar baz")
128 assert_select "h1", "foo bar baz"
131 r = RichText.new("markdown", "## foo bar baz")
133 assert_select "h2", "foo bar baz"
136 r = RichText.new("markdown", "### foo bar baz")
138 assert_select "h3", "foo bar baz"
141 r = RichText.new("markdown", "* foo bar baz")
143 assert_select "ul" do
144 assert_select "li", "foo bar baz"
148 r = RichText.new("markdown", "1. foo bar baz")
150 assert_select "ol" do
151 assert_select "li", "foo bar baz"
155 r = RichText.new("markdown", "foo *bar* _baz_ qux")
157 assert_select "em", "bar"
158 assert_select "em", "baz"
161 r = RichText.new("markdown", "foo **bar** __baz__ qux")
163 assert_select "strong", "bar"
164 assert_select "strong", "baz"
167 r = RichText.new("markdown", "foo `bar` baz")
169 assert_select "code", "bar"
172 r = RichText.new("markdown", " foo bar baz")
174 assert_select "pre", /^\s*foo bar baz\s*$/
177 r = RichText.new("markdown", "|column|column")
179 assert_select "table[class='table table-sm w-auto']"
182 r = RichText.new("markdown", "Click Me\n{:.btn.btn-warning}")
184 assert_select "p[class='btn btn-warning']", false
185 assert_select "p", /^Click Me$/
188 r = RichText.new("markdown", "<p style='color:red'>Danger</p>")
190 assert_select "p[style='color:red']", false
191 assert_select "p", /^Danger$/
195 def test_markdown_table_alignment
196 # Ensure that kramdown table alignment styles are converted to bootstrap classes
197 markdown_table = <<~MARKDOWN
202 r = RichText.new("markdown", markdown_table)
204 assert_select "td[style='text-align:center']", false
205 assert_select "td[class='text-center']", true
206 assert_select "td[style='text-align:right']", false
207 assert_select "td[class='text-end']", true
211 def test_markdown_to_text
212 r = RichText.new("markdown", "foo [bar](http://example.com/) baz")
213 assert_equal "foo [bar](http://example.com/) baz", r.to_text
216 def test_text_to_html_linkify
217 with_settings(:linkify_hosts => ["replace-me.example.com"], :linkify_hosts_replacement => "repl.example.com") do
218 r = RichText.new("text", "foo http://example.com/ bar")
220 assert_dom "a", :count => 1, :text => "http://example.com/" do
221 assert_dom "> @href", "http://example.com/"
222 assert_dom "> @rel", "nofollow noopener noreferrer"
228 def test_text_to_html_linkify_replace
229 with_settings(:linkify_hosts => ["replace-me.example.com"], :linkify_hosts_replacement => "repl.example.com") do
230 r = RichText.new("text", "foo https://replace-me.example.com/some/path?query=te<st&limit=20>10#result12 bar")
232 assert_dom "a", :count => 1, :text => "repl.example.com/some/path?query=te<st&limit=20>10#result12" do
233 assert_dom "> @href", "https://replace-me.example.com/some/path?query=te<st&limit=20>10#result12"
234 assert_dom "> @rel", "nofollow noopener noreferrer"
240 def test_text_to_html_linkify_recognize
241 with_settings(:linkify_hosts => ["replace-me.example.com"], :linkify_hosts_replacement => "repl.example.com") do
242 r = RichText.new("text", "foo repl.example.com/some/path?query=te<st&limit=20>10#result12 bar")
244 assert_dom "a", :count => 1, :text => "repl.example.com/some/path?query=te<st&limit=20>10#result12" do
245 assert_dom "> @href", "http://replace-me.example.com/some/path?query=te<st&limit=20>10#result12"
246 assert_dom "> @rel", "nofollow noopener noreferrer"
252 def test_text_to_html_linkify_replace_other_scheme
253 with_settings(:linkify_hosts => ["replace-me.example.com"], :linkify_hosts_replacement => "repl.example.com") do
254 r = RichText.new("text", "foo ftp://replace-me.example.com/some/path?query=te<st&limit=20>10#result12 bar")
256 assert_dom "a", :count => 1, :text => "ftp://replace-me.example.com/some/path?query=te<st&limit=20>10#result12" do
257 assert_dom "> @href", "ftp://replace-me.example.com/some/path?query=te<st&limit=20>10#result12"
258 assert_dom "> @rel", "nofollow noopener noreferrer"
264 def test_text_to_html_linkify_replace_undefined
265 with_settings(:linkify_hosts => ["replace-me.example.com"], :linkify_hosts_replacement => nil) do
266 r = RichText.new("text", "foo https://replace-me.example.com/some/path?query=te<st&limit=20>10#result12 bar")
268 assert_dom "a", :count => 1, :text => "https://replace-me.example.com/some/path?query=te<st&limit=20>10#result12" do
269 assert_dom "> @href", "https://replace-me.example.com/some/path?query=te<st&limit=20>10#result12"
270 assert_dom "> @rel", "nofollow noopener noreferrer"
276 def test_text_to_html_linkify_wiki_replace_prefix
277 with_settings(:linkify_wiki_hosts => ["replace-me-wiki.example.com"], :linkify_wiki_hosts_replacement => "wiki.example.com",
278 :linkify_wiki_optional_path_prefix => "^/wiki(?=/[A-Z])") do
279 r = RichText.new("text", "foo https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal bar")
281 assert_dom "a", :count => 1, :text => "wiki.example.com/Tag:surface%3Dmetal" do
282 assert_dom "> @href", "https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal"
283 assert_dom "> @rel", "nofollow noopener noreferrer"
289 def test_text_to_html_linkify_wiki_replace_prefix_undefined
290 with_settings(:linkify_wiki_hosts => ["replace-me-wiki.example.com"], :linkify_wiki_hosts_replacement => "wiki.example.com",
291 :linkify_wiki_optional_path_prefix => nil) do
292 r = RichText.new("text", "foo https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal bar")
294 assert_dom "a", :count => 1, :text => "wiki.example.com/wiki/Tag:surface%3Dmetal" do
295 assert_dom "> @href", "https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal"
296 assert_dom "> @rel", "nofollow noopener noreferrer"
302 def test_text_to_html_linkify_wiki_replace_undefined_prefix
303 with_settings(:linkify_wiki_hosts => ["replace-me-wiki.example.com"], :linkify_wiki_hosts_replacement => nil,
304 :linkify_wiki_optional_path_prefix => "^/wiki(?=/[A-Z])") do
305 r = RichText.new("text", "foo https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal bar")
307 assert_dom "a", :count => 1, :text => "https://replace-me-wiki.example.com/Tag:surface%3Dmetal" do
308 assert_dom "> @href", "https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal"
309 assert_dom "> @rel", "nofollow noopener noreferrer"
315 def test_text_to_html_linkify_wiki_replace_prefix_no_match
316 with_settings(:linkify_wiki_hosts => ["replace-me-wiki.example.com"], :linkify_wiki_hosts_replacement => "wiki.example.com",
317 :linkify_wiki_optional_path_prefix => "^/wiki(?=/[A-Z])") do
318 r = RichText.new("text", "foo https://replace-me-wiki.example.com/wiki/w bar")
320 assert_dom "a", :count => 1, :text => "wiki.example.com/wiki/w" do
321 assert_dom "> @href", "https://replace-me-wiki.example.com/wiki/w"
322 assert_dom "> @rel", "nofollow noopener noreferrer"
328 def test_text_to_html_linkify_recognize_wiki
329 with_settings(:linkify_wiki_hosts => ["replace-me-wiki.example.com"], :linkify_wiki_hosts_replacement => "wiki.example.com",
330 :linkify_wiki_optional_path_prefix => "^/wiki(?=/[A-Z])") do
331 r = RichText.new("text", "foo wiki.example.com/Tag:surface%3Dmetal bar")
333 assert_dom "a", :count => 1, :text => "wiki.example.com/Tag:surface%3Dmetal" do
334 assert_dom "> @href", "http://replace-me-wiki.example.com/Tag:surface%3Dmetal"
335 assert_dom "> @rel", "nofollow noopener noreferrer"
341 def test_text_to_html_linkify_idempotent
342 with_settings(:linkify_hosts => ["test.host"], :linkify_hosts_replacement => "test.host") do
343 t0 = "foo https://test.host/way/123456789 bar"
345 r1 = RichText.new("text", t0)
346 t1 = Nokogiri::HTML.fragment(r1.to_html).text
348 r2 = RichText.new("text", t1)
349 t2 = Nokogiri::HTML.fragment(r2.to_html).text
355 def test_text_to_html_linkify_recognize_path
356 with_settings(:linkify => { :detection_rules => [{ :patterns => ["@(?<username>\\w+)"], :path_template => "user/\\k<username>" }] }) do
357 r = RichText.new("text", "foo @example bar")
359 assert_dom "a", :count => 1, :text => "http://test.host/user/example" do
360 assert_dom "> @href", "http://test.host/user/example"
361 assert_dom "> @rel", "nofollow noopener noreferrer"
367 def test_text_to_html_linkify_recognize_path_no_partial_match
368 with_settings(:linkify => { :detection_rules => [{ :patterns => ["@(?<username>\\w+)"], :path_template => "user/\\k<username>" }] }) do
369 r = RichText.new("text", "foo example@example.com bar")
376 def test_text_to_html_linkify_recognize_wiki_path
377 with_settings(:linkify => { :detection_rules => [{ :patterns => ["(?<key>[^\"?#<>/\\s]+)=(?<value>[^\"?#<>\\s]+)"], :path_template => "Tag:\\k<key>=\\k<value>", :host => "http://example.wiki" }] }) do
378 r = RichText.new("text", "foo surface=metal bar")
380 assert_dom "a", :count => 1, :text => "http://example.wiki/Tag:surface=metal" do
381 assert_dom "> @href", "http://example.wiki/Tag:surface=metal"
382 assert_dom "> @rel", "nofollow noopener noreferrer"
386 with_settings(:linkify => { :detection_rules => [{ :patterns => ["(?<key>[^\"?#<>/\\s]+)=\\*?"], :path_template => "Key:\\k<key>", :host => "http://example.wiki" }] }) do
387 r = RichText.new("text", "foo surface=* bar")
389 assert_dom "a", :count => 1, :text => "http://example.wiki/Key:surface" do
390 assert_dom "> @href", "http://example.wiki/Key:surface"
391 assert_dom "> @rel", "nofollow noopener noreferrer"
397 def test_text_to_html_linkify_openstreetmap_links
398 with_settings(:server_url => "www.openstreetmap.org", :server_protocol => "https") do
400 "https://www.openstreetmap.org/note/4655490" =>
401 ["note/4655490", "https://www.openstreetmap.org/note/4655490"],
403 "https://www.openstreetmap.org/changeset/163353772" =>
404 ["changeset/163353772", "https://www.openstreetmap.org/changeset/163353772"],
406 "https://www.openstreetmap.org/way/1249366504" =>
407 ["way/1249366504", "https://www.openstreetmap.org/way/1249366504"],
409 "https://www.openstreetmap.org/way/1249366504/history" =>
410 ["way/1249366504/history", "https://www.openstreetmap.org/way/1249366504/history"],
412 "https://www.openstreetmap.org/way/1249366504/history/2" =>
413 ["way/1249366504/history/2", "https://www.openstreetmap.org/way/1249366504/history/2"],
415 "https://www.openstreetmap.org/node/12639964186" =>
416 ["node/12639964186", "https://www.openstreetmap.org/node/12639964186"],
418 "https://www.openstreetmap.org/relation/7876483" =>
419 ["relation/7876483", "https://www.openstreetmap.org/relation/7876483"],
421 "https://www.openstreetmap.org/user/aharvey" =>
422 ["@aharvey", "https://www.openstreetmap.org/user/aharvey"],
424 "https://wiki.openstreetmap.org/wiki/Key:boundary" =>
425 ["boundary=*", "https://wiki.openstreetmap.org/wiki/Key:boundary"],
427 "https://wiki.openstreetmap.org/wiki/Tag:boundary=place" =>
428 ["boundary=place", "https://wiki.openstreetmap.org/wiki/Tag:boundary=place"],
431 ["boundary=*", "https://wiki.openstreetmap.org/wiki/Key:boundary"],
434 ["boundary=place", "https://wiki.openstreetmap.org/wiki/Tag:boundary=place"],
436 "node/12639964186" =>
437 ["node/12639964186", "https://www.openstreetmap.org/node/12639964186"],
439 "node 12639964186" =>
440 ["node/12639964186", "https://www.openstreetmap.org/node/12639964186"],
443 ["node/12639964186", "https://www.openstreetmap.org/node/12639964186"],
446 ["way/1249366504", "https://www.openstreetmap.org/way/1249366504"],
449 ["way/1249366504", "https://www.openstreetmap.org/way/1249366504"],
452 ["way/1249366504", "https://www.openstreetmap.org/way/1249366504"],
454 "relation/7876483" =>
455 ["relation/7876483", "https://www.openstreetmap.org/relation/7876483"],
457 "relation 7876483" =>
458 ["relation/7876483", "https://www.openstreetmap.org/relation/7876483"],
461 ["relation/7876483", "https://www.openstreetmap.org/relation/7876483"],
463 "changeset/163353772" =>
464 ["changeset/163353772", "https://www.openstreetmap.org/changeset/163353772"],
466 "changeset 163353772" =>
467 ["changeset/163353772", "https://www.openstreetmap.org/changeset/163353772"],
470 ["note/4655490", "https://www.openstreetmap.org/note/4655490"],
473 ["note/4655490", "https://www.openstreetmap.org/note/4655490"]
476 cases.each do |input, (expected_text, expected_href)|
477 r = RichText.new("text", input)
479 assert_dom "a", :count => 1, :text => expected_text do
480 assert_dom "> @href", expected_href
487 def test_text_to_html_linkify_no_year_misinterpretation
488 r = RichText.new("text", "We thought there was no way 2020 could be worse than 2019. We were wrong. Please note 2025 is the first square year since OSM started. In that year, some osmlab repos switched from node 22 to bun 1.3.")
494 def test_text_to_html_email
495 r = RichText.new("text", "foo example@example.com bar")
501 def test_text_to_html_escape
502 r = RichText.new("text", "foo < bar & baz > qux")
504 assert_select "p", "foo < bar & baz > qux"
508 def test_text_to_text
509 r = RichText.new("text", "foo http://example.com/ bar")
510 assert_equal "foo http://example.com/ bar", r.to_text
513 def test_text_no_opengraph_properties
514 r = RichText.new("text", "foo https://example.com/ bar")
516 assert_nil r.image_alt
517 assert_nil r.description
520 def test_html_no_opengraph_properties
521 r = RichText.new("html", "foo <a href='https://example.com/'>bar</a> baz")
523 assert_nil r.image_alt
524 assert_nil r.description
527 def test_markdown_no_image
528 r = RichText.new("markdown", "foo [bar](https://example.com/) baz")
530 assert_nil r.image_alt
533 def test_markdown_image
534 r = RichText.new("markdown", "foo  baz")
535 assert_equal "https://example.com/image.jpg", r.image
536 assert_equal "bar", r.image_alt
539 def test_markdown_first_image
540 r = RichText.new("markdown", "foo  baz\nfoo  baz")
541 assert_equal "https://example.com/image1.jpg", r.image
542 assert_equal "bar1", r.image_alt
545 def test_markdown_image_with_empty_src
546 r = RichText.new("markdown", "![invalid]()")
548 assert_nil r.image_alt
551 def test_markdown_skip_image_with_empty_src
552 r = RichText.new("markdown", "![invalid]() ")
553 assert_equal "https://example.com/valid.gif", r.image
554 assert_equal "valid", r.image_alt
557 def test_markdown_html_image
558 r = RichText.new("markdown", "<img src='https://example.com/img_element.png' alt='alt text here'>")
559 assert_equal "https://example.com/img_element.png", r.image
560 assert_equal "alt text here", r.image_alt
563 def test_markdown_html_image_without_alt
564 r = RichText.new("markdown", "<img src='https://example.com/img_element.png'>")
565 assert_equal "https://example.com/img_element.png", r.image
566 assert_nil r.image_alt
569 def test_markdown_html_image_with_empty_src
570 r = RichText.new("markdown", "<img src='' alt='forgot src'>")
572 assert_nil r.image_alt
575 def test_markdown_skip_html_image_with_empty_src
576 r = RichText.new("markdown", "<img src='' alt='forgot src'> <img src='https://example.com/next_img_element.png' alt='have src'>")
577 assert_equal "https://example.com/next_img_element.png", r.image
578 assert_equal "have src", r.image_alt
581 def test_markdown_html_image_without_src
582 r = RichText.new("markdown", "<img alt='totally forgot src'>")
584 assert_nil r.image_alt
587 def test_markdown_skip_html_image_without_src
588 r = RichText.new("markdown", "<img alt='totally forgot src'> <img src='https://example.com/next_img_element.png' alt='have src'>")
589 assert_equal "https://example.com/next_img_element.png", r.image
590 assert_equal "have src", r.image_alt
593 def test_markdown_no_description
594 r = RichText.new("markdown", "#Nope")
595 assert_nil r.description
598 def test_markdown_description
599 r = RichText.new("markdown", "This is an article about something.")
600 assert_equal "This is an article about something.", r.description
603 def test_markdown_description_after_heading
604 r = RichText.new("markdown", "#Heading\n\nHere starts the text.")
605 assert_equal "Here starts the text.", r.description
608 def test_markdown_description_after_image
609 r = RichText.new("markdown", "\n\nThis is below the image.")
610 assert_equal "This is below the image.", r.description
613 def test_markdown_description_only_first_paragraph
614 r = RichText.new("markdown", "This thing.\n\nMaybe also that thing.")
615 assert_equal "This thing.", r.description
618 def test_markdown_description_elements
619 r = RichText.new("markdown", "*Something* **important** [here](https://example.com/).")
620 assert_equal "Something important here.", r.description
623 def test_markdown_html_description
624 r = RichText.new("markdown", "<p>Can use HTML tags.</p>")
625 assert_equal "Can use HTML tags.", r.description
628 def test_markdown_description_max_length
629 m = RichText::DESCRIPTION_MAX_LENGTH
632 r = RichText.new("markdown", "x" * m)
633 assert_equal "x" * m, r.description
635 r = RichText.new("markdown", "y" * (m + 1))
636 assert_equal "#{'y' * (m - o)}...", r.description
638 r = RichText.new("markdown", "*zzzzzzzzz*z" * ((m + 1) / 10.0).ceil)
639 assert_equal "#{'z' * (m - o)}...", r.description
642 def test_markdown_description_word_break_threshold_length
643 m = RichText::DESCRIPTION_MAX_LENGTH
644 t = RichText::DESCRIPTION_WORD_BREAK_THRESHOLD_LENGTH
647 r = RichText.new("markdown", "#{'x' * (t - o - 1)} #{'y' * (m - (t - o - 1) - 1)}")
648 assert_equal "#{'x' * (t - o - 1)} #{'y' * (m - (t - o - 1) - 1)}", r.description
650 r = RichText.new("markdown", "#{'x' * (t - o - 1)} #{'y' * (m - (t - o - 1))}")
651 assert_equal "#{'x' * (t - o - 1)} #{'y' * (m - (t - o - 1) - 4)}...", r.description
653 r = RichText.new("markdown", "#{'x' * (t - o)} #{'y' * (m - (t - o) - 1)}")
654 assert_equal "#{'x' * (t - o)} #{'y' * (m - (t - o) - 1)}", r.description
656 r = RichText.new("markdown", "#{'x' * (t - o)} #{'y' * (m - (t - o))}")
657 assert_equal "#{'x' * (t - o)}...", r.description
660 def test_markdown_description_word_break_multiple_spaces
661 m = RichText::DESCRIPTION_MAX_LENGTH
662 t = RichText::DESCRIPTION_WORD_BREAK_THRESHOLD_LENGTH
665 r = RichText.new("markdown", "#{'x' * (t - o)} #{'y' * (m - (t - o - 1))}")
666 assert_equal "#{'x' * (t - o)}...", r.description
671 def assert_html(richtext, &block)
672 html = richtext.to_html
673 assert_predicate html, :html_safe?
674 root = Nokogiri::HTML::DocumentFragment.parse(html)
675 assert_select root, "*" do