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 => { :normalisation_rules => [{ :hosts => ["replace-me.example.com"], :host_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 => { :normalisation_rules => [{ :hosts => ["replace-me.example.com"], :host_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 => { :normalisation_rules => [{ :hosts => ["replace-me.example.com"], :host_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 => { :normalisation_rules => [{ :hosts => ["replace-me.example.com"], :host_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 => { :normalisation_rules => [{ :hosts => ["replace-me.example.com"] }] }) 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 => { :normalisation_rules => [{ :hosts => ["replace-me-wiki.example.com"], :host_replacement => "wiki.example.com", :optional_path_prefix => "^/wiki(?=/[A-Z])" }] }) do
278 r = RichText.new("text", "foo https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal bar")
280 assert_dom "a", :count => 1, :text => "wiki.example.com/Tag:surface%3Dmetal" do
281 assert_dom "> @href", "https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal"
282 assert_dom "> @rel", "nofollow noopener noreferrer"
288 def test_text_to_html_linkify_wiki_replace_prefix_undefined
289 with_settings(:linkify => { :normalisation_rules => [{ :hosts => ["replace-me-wiki.example.com"], :host_replacement => "wiki.example.com" }] }) do
290 r = RichText.new("text", "foo https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal bar")
292 assert_dom "a", :count => 1, :text => "wiki.example.com/wiki/Tag:surface%3Dmetal" do
293 assert_dom "> @href", "https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal"
294 assert_dom "> @rel", "nofollow noopener noreferrer"
300 def test_text_to_html_linkify_wiki_replace_undefined_prefix
301 with_settings(:linkify => { :normalisation_rules => [{ :hosts => ["replace-me-wiki.example.com"], :optional_path_prefix => "^/wiki(?=/[A-Z])" }] }) do
302 r = RichText.new("text", "foo https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal bar")
304 assert_dom "a", :count => 1, :text => "https://replace-me-wiki.example.com/Tag:surface%3Dmetal" do
305 assert_dom "> @href", "https://replace-me-wiki.example.com/wiki/Tag:surface%3Dmetal"
306 assert_dom "> @rel", "nofollow noopener noreferrer"
312 def test_text_to_html_linkify_wiki_replace_prefix_no_match
313 with_settings(:linkify => { :normalisation_rules => [{ :hosts => ["replace-me-wiki.example.com"], :host_replacement => "wiki.example.com", :optional_path_prefix => "^/wiki(?=/[A-Z])" }] }) do
314 r = RichText.new("text", "foo https://replace-me-wiki.example.com/wiki/w bar")
316 assert_dom "a", :count => 1, :text => "wiki.example.com/wiki/w" do
317 assert_dom "> @href", "https://replace-me-wiki.example.com/wiki/w"
318 assert_dom "> @rel", "nofollow noopener noreferrer"
324 def test_text_to_html_linkify_recognize_wiki
325 with_settings(:linkify => { :normalisation_rules => [{ :hosts => ["replace-me-wiki.example.com"], :host_replacement => "wiki.example.com", :optional_path_prefix => "^/wiki(?=/[A-Z])" }] }) do
326 r = RichText.new("text", "foo wiki.example.com/Tag:surface%3Dmetal bar")
328 assert_dom "a", :count => 1, :text => "wiki.example.com/Tag:surface%3Dmetal" do
329 assert_dom "> @href", "http://replace-me-wiki.example.com/Tag:surface%3Dmetal"
330 assert_dom "> @rel", "nofollow noopener noreferrer"
336 def test_text_to_html_linkify_idempotent
337 with_settings(:linkify => { :normalisation_rules => [{ :hosts => ["test.host"], :host_replacement => "test.host" }] }) do
338 t0 = "foo https://test.host/way/123456789 bar"
340 r1 = RichText.new("text", t0)
341 t1 = Nokogiri::HTML.fragment(r1.to_html).text
343 r2 = RichText.new("text", t1)
344 t2 = Nokogiri::HTML.fragment(r2.to_html).text
350 def test_text_to_html_linkify_recognize_path
351 with_settings(:linkify => { :detection_rules => [{ :patterns => ["@(?<username>\\w+)"], :path_template => "user/\\k<username>" }] }) do
352 r = RichText.new("text", "foo @example bar")
354 assert_dom "a", :count => 1, :text => "http://test.host/user/example" do
355 assert_dom "> @href", "http://test.host/user/example"
356 assert_dom "> @rel", "nofollow noopener noreferrer"
362 def test_text_to_html_linkify_recognize_path_no_partial_match
363 with_settings(:linkify => { :detection_rules => [{ :patterns => ["@(?<username>\\w+)"], :path_template => "user/\\k<username>" }] }) do
364 r = RichText.new("text", "foo example@example.com bar")
371 def test_text_to_html_linkify_recognize_wiki_path
372 with_settings(:linkify => { :detection_rules => [{ :patterns => ["(?<key>[^\"?#<>/\\s]+)=(?<value>[^\"?#<>\\s]+)"], :path_template => "Tag:\\k<key>=\\k<value>", :host => "http://example.wiki" }] }) do
373 r = RichText.new("text", "foo surface=metal bar")
375 assert_dom "a", :count => 1, :text => "http://example.wiki/Tag:surface=metal" do
376 assert_dom "> @href", "http://example.wiki/Tag:surface=metal"
377 assert_dom "> @rel", "nofollow noopener noreferrer"
381 with_settings(:linkify => { :detection_rules => [{ :patterns => ["(?<key>[^\"?#<>/\\s]+)=\\*?"], :path_template => "Key:\\k<key>", :host => "http://example.wiki" }] }) do
382 r = RichText.new("text", "foo surface=* bar")
384 assert_dom "a", :count => 1, :text => "http://example.wiki/Key:surface" do
385 assert_dom "> @href", "http://example.wiki/Key:surface"
386 assert_dom "> @rel", "nofollow noopener noreferrer"
392 def test_text_to_html_linkify_openstreetmap_links
393 with_settings(:server_url => "www.openstreetmap.org", :server_protocol => "https") do
395 "https://www.openstreetmap.org/note/4655490" =>
396 ["note/4655490", "https://www.openstreetmap.org/note/4655490"],
398 "https://www.openstreetmap.org/changeset/163353772" =>
399 ["changeset/163353772", "https://www.openstreetmap.org/changeset/163353772"],
401 "https://www.openstreetmap.org/way/1249366504" =>
402 ["way/1249366504", "https://www.openstreetmap.org/way/1249366504"],
404 "https://www.openstreetmap.org/way/1249366504/history" =>
405 ["way/1249366504/history", "https://www.openstreetmap.org/way/1249366504/history"],
407 "https://www.openstreetmap.org/way/1249366504/history/2" =>
408 ["way/1249366504/history/2", "https://www.openstreetmap.org/way/1249366504/history/2"],
410 "https://www.openstreetmap.org/node/12639964186" =>
411 ["node/12639964186", "https://www.openstreetmap.org/node/12639964186"],
413 "https://www.openstreetmap.org/relation/7876483" =>
414 ["relation/7876483", "https://www.openstreetmap.org/relation/7876483"],
416 "https://www.openstreetmap.org/user/aharvey" =>
417 ["@aharvey", "https://www.openstreetmap.org/user/aharvey"],
419 "https://wiki.openstreetmap.org/wiki/Key:boundary" =>
420 ["boundary=*", "https://wiki.openstreetmap.org/wiki/Key:boundary"],
422 "https://wiki.openstreetmap.org/wiki/Tag:boundary=place" =>
423 ["boundary=place", "https://wiki.openstreetmap.org/wiki/Tag:boundary=place"],
426 ["boundary=*", "https://wiki.openstreetmap.org/wiki/Key:boundary"],
429 ["boundary=place", "https://wiki.openstreetmap.org/wiki/Tag:boundary=place"],
431 "node/12639964186" =>
432 ["node/12639964186", "https://www.openstreetmap.org/node/12639964186"],
434 "node 12639964186" =>
435 ["node/12639964186", "https://www.openstreetmap.org/node/12639964186"],
438 ["node/12639964186", "https://www.openstreetmap.org/node/12639964186"],
441 ["way/1249366504", "https://www.openstreetmap.org/way/1249366504"],
444 ["way/1249366504", "https://www.openstreetmap.org/way/1249366504"],
447 ["way/1249366504", "https://www.openstreetmap.org/way/1249366504"],
449 "relation/7876483" =>
450 ["relation/7876483", "https://www.openstreetmap.org/relation/7876483"],
452 "relation 7876483" =>
453 ["relation/7876483", "https://www.openstreetmap.org/relation/7876483"],
456 ["relation/7876483", "https://www.openstreetmap.org/relation/7876483"],
458 "changeset/163353772" =>
459 ["changeset/163353772", "https://www.openstreetmap.org/changeset/163353772"],
461 "changeset 163353772" =>
462 ["changeset/163353772", "https://www.openstreetmap.org/changeset/163353772"],
465 ["note/4655490", "https://www.openstreetmap.org/note/4655490"],
468 ["note/4655490", "https://www.openstreetmap.org/note/4655490"]
471 cases.each do |input, (expected_text, expected_href)|
472 r = RichText.new("text", input)
474 assert_dom "a", :count => 1, :text => expected_text do
475 assert_dom "> @href", expected_href
482 def test_text_to_html_linkify_no_year_misinterpretation
483 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.")
489 def test_deactivated_linkify_expansion_in_markdown
490 t0 = "foo `surface=metal` bar"
492 r1 = RichText.new("markdown", t0)
493 t1 = Nokogiri::HTML.fragment(r1.to_html).text
495 assert_equal t0.delete("`"), t1.strip
498 def test_text_to_html_email
499 r = RichText.new("text", "foo example@example.com bar")
505 def test_text_to_html_escape
506 r = RichText.new("text", "foo < bar & baz > qux")
508 assert_select "p", "foo < bar & baz > qux"
512 def test_text_to_text
513 r = RichText.new("text", "foo http://example.com/ bar")
514 assert_equal "foo http://example.com/ bar", r.to_text
517 def test_text_no_opengraph_properties
518 r = RichText.new("text", "foo https://example.com/ bar")
520 assert_nil r.image_alt
521 assert_nil r.description
524 def test_html_no_opengraph_properties
525 r = RichText.new("html", "foo <a href='https://example.com/'>bar</a> baz")
527 assert_nil r.image_alt
528 assert_nil r.description
531 def test_markdown_no_image
532 r = RichText.new("markdown", "foo [bar](https://example.com/) baz")
534 assert_nil r.image_alt
537 def test_markdown_image
538 r = RichText.new("markdown", "foo  baz")
539 assert_equal "https://example.com/image.jpg", r.image
540 assert_equal "bar", r.image_alt
543 def test_markdown_first_image
544 r = RichText.new("markdown", "foo  baz\nfoo  baz")
545 assert_equal "https://example.com/image1.jpg", r.image
546 assert_equal "bar1", r.image_alt
549 def test_markdown_image_with_empty_src
550 r = RichText.new("markdown", "![invalid]()")
552 assert_nil r.image_alt
555 def test_markdown_skip_image_with_empty_src
556 r = RichText.new("markdown", "![invalid]() ")
557 assert_equal "https://example.com/valid.gif", r.image
558 assert_equal "valid", r.image_alt
561 def test_markdown_html_image
562 r = RichText.new("markdown", "<img src='https://example.com/img_element.png' alt='alt text here'>")
563 assert_equal "https://example.com/img_element.png", r.image
564 assert_equal "alt text here", r.image_alt
567 def test_markdown_html_image_without_alt
568 r = RichText.new("markdown", "<img src='https://example.com/img_element.png'>")
569 assert_equal "https://example.com/img_element.png", r.image
570 assert_nil r.image_alt
573 def test_markdown_html_image_with_empty_src
574 r = RichText.new("markdown", "<img src='' alt='forgot src'>")
576 assert_nil r.image_alt
579 def test_markdown_skip_html_image_with_empty_src
580 r = RichText.new("markdown", "<img src='' alt='forgot src'> <img src='https://example.com/next_img_element.png' alt='have src'>")
581 assert_equal "https://example.com/next_img_element.png", r.image
582 assert_equal "have src", r.image_alt
585 def test_markdown_html_image_without_src
586 r = RichText.new("markdown", "<img alt='totally forgot src'>")
588 assert_nil r.image_alt
591 def test_markdown_skip_html_image_without_src
592 r = RichText.new("markdown", "<img alt='totally forgot src'> <img src='https://example.com/next_img_element.png' alt='have src'>")
593 assert_equal "https://example.com/next_img_element.png", r.image
594 assert_equal "have src", r.image_alt
597 def test_markdown_no_description
598 r = RichText.new("markdown", "#Nope")
599 assert_nil r.description
602 def test_markdown_description
603 r = RichText.new("markdown", "This is an article about something.")
604 assert_equal "This is an article about something.", r.description
607 def test_markdown_description_after_heading
608 r = RichText.new("markdown", "#Heading\n\nHere starts the text.")
609 assert_equal "Here starts the text.", r.description
612 def test_markdown_description_after_image
613 r = RichText.new("markdown", "\n\nThis is below the image.")
614 assert_equal "This is below the image.", r.description
617 def test_markdown_description_only_first_paragraph
618 r = RichText.new("markdown", "This thing.\n\nMaybe also that thing.")
619 assert_equal "This thing.", r.description
622 def test_markdown_description_elements
623 r = RichText.new("markdown", "*Something* **important** [here](https://example.com/).")
624 assert_equal "Something important here.", r.description
627 def test_markdown_html_description
628 r = RichText.new("markdown", "<p>Can use HTML tags.</p>")
629 assert_equal "Can use HTML tags.", r.description
632 def test_markdown_description_max_length
633 m = RichText::DESCRIPTION_MAX_LENGTH
636 r = RichText.new("markdown", "x" * m)
637 assert_equal "x" * m, r.description
639 r = RichText.new("markdown", "y" * (m + 1))
640 assert_equal "#{'y' * (m - o)}...", r.description
642 r = RichText.new("markdown", "*zzzzzzzzz*z" * ((m + 1) / 10.0).ceil)
643 assert_equal "#{'z' * (m - o)}...", r.description
646 def test_markdown_description_word_break_threshold_length
647 m = RichText::DESCRIPTION_MAX_LENGTH
648 t = RichText::DESCRIPTION_WORD_BREAK_THRESHOLD_LENGTH
651 r = RichText.new("markdown", "#{'x' * (t - o - 1)} #{'y' * (m - (t - o - 1) - 1)}")
652 assert_equal "#{'x' * (t - o - 1)} #{'y' * (m - (t - o - 1) - 1)}", r.description
654 r = RichText.new("markdown", "#{'x' * (t - o - 1)} #{'y' * (m - (t - o - 1))}")
655 assert_equal "#{'x' * (t - o - 1)} #{'y' * (m - (t - o - 1) - 4)}...", r.description
657 r = RichText.new("markdown", "#{'x' * (t - o)} #{'y' * (m - (t - o) - 1)}")
658 assert_equal "#{'x' * (t - o)} #{'y' * (m - (t - o) - 1)}", r.description
660 r = RichText.new("markdown", "#{'x' * (t - o)} #{'y' * (m - (t - o))}")
661 assert_equal "#{'x' * (t - o)}...", r.description
664 def test_markdown_description_word_break_multiple_spaces
665 m = RichText::DESCRIPTION_MAX_LENGTH
666 t = RichText::DESCRIPTION_WORD_BREAK_THRESHOLD_LENGTH
669 r = RichText.new("markdown", "#{'x' * (t - o)} #{'y' * (m - (t - o - 1))}")
670 assert_equal "#{'x' * (t - o)}...", r.description
675 def assert_html(richtext, &block)
676 html = richtext.to_html
677 assert_predicate html, :html_safe?
678 root = Nokogiri::HTML::DocumentFragment.parse(html)
679 assert_select root, "*" do