From 2e8758b39c787622f4fb3ffd7d9a5bd16cc08d13 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 3 Sep 2026 19:53:17 +0200 Subject: [PATCH] FIX: Always escape setext heading underlines A text line that contains only `=` was escaped only when the escaper saw a paragraph line directly in front of it in the same text node. For the first line of a fragment it assumed there was no paragraph line before it. That assumption does not hold. The escaper works on one text fragment at a time and the renderer joins the fragments afterwards, so a fragment starting with `===` can end up right after a paragraph line: `Text("Body") LineBreak Text("===")` renders as `Body\n===`, and so does `[b]Body[/b]\n===`. Discourse cooks both as an `

`. Every other block construct is escaped without looking at the surrounding lines. The setext rule was the only exception, so this drops the paragraph tracking (`prev_was_paragraph`, `paragraph_line?`, `block_construct?`) and escapes a `=`-only line in every position. The `-` half of the guard was already dead code: a line with a single dash is caught by the bullet list rule, two dashes by the ndash pair in `escape_inline`, and three or more by the thematic break rule. The `SETEXT_UNDERLINE_DASH` regex is gone with it. The cost is cosmetic. I checked the over-escaped output in a Discourse instance: `\=` renders as a literal `=` at paragraph start, after a blank line, in list items and in blockquotes. --- UPGRADING.md | 23 ++ .../renderers/discourse/markdown_escaper.rb | 87 ++----- mutant.yml | 30 +-- spec/markbridge_spec.rb | 9 + spec/system/bbcode_to_markdown_spec.rb | 13 + .../markdown_escaper/setext_headings_spec.rb | 18 +- .../discourse/markdown_escaper_spec.rb | 244 ++++++------------ .../renderers/discourse/renderer_spec.rb | 15 ++ 8 files changed, 174 insertions(+), 265 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index 815eec0e..34e8fb57 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,5 +1,28 @@ # Upgrading Markbridge +## 0.4.1 — setext underlines are always escaped + +A text line that contains only `=` is now always escaped. Before, the +escaper escaped it only when it saw a paragraph line directly in front +of it inside the same text node. + +That check was too optimistic. The escaper works on one text fragment +at a time, and the renderer joins the fragments afterwards. +A fragment that starts with `===` can therefore end up right after a +paragraph line — after a line break, or after inline markup — and +Discourse cooks both lines as a heading: + +```ruby +Markbridge.html_to_markdown("

Body:{}
========

").markdown +# 0.4.0: "Body:{}\n========" → cooks as an

+# 0.4.1: "Body:{}\n\\=\\=\\=\\=\\=\\=\\=\\=" → cooks as two lines of text +``` + +No public API changed. The cost is cosmetic: a separator line that has +no paragraph in front of it now reads `\=\=\=\=` in the raw Markdown. +Discourse renders `\=` as a literal `=`, so the result looks the same +as before. + ## 0.4.0 — ancestry matching and forced code blocks ### AST subclasses inherit rules and tags from their base class diff --git a/lib/markbridge/renderers/discourse/markdown_escaper.rb b/lib/markbridge/renderers/discourse/markdown_escaper.rb index da113680..367807c7 100644 --- a/lib/markbridge/renderers/discourse/markdown_escaper.rb +++ b/lib/markbridge/renderers/discourse/markdown_escaper.rb @@ -76,7 +76,6 @@ def initialize(escape_hard_line_breaks: false, allow: nil) FENCED_CODE_BACKTICK = /\A`{3,}[^`]*$/ FENCED_CODE_TILDE = /\A~{3,}/ SETEXT_UNDERLINE_EQUALS = /\A=+[ \t]*$/ - SETEXT_UNDERLINE_DASH = /\A-+[ \t]*$/ # Indented code: 4+ spaces, tab at start, or space+tab reaching column 4+ INDENTED_CODE = /\A(?: {4}|\t| {1,3}\t)/ @@ -182,11 +181,11 @@ def escape_text(text) # skip the split and its Array + line-String allocations. A lone # `\r` without `\n` stays on the line either way — `/\r?\n/` # needs the `\n` — so `include?("\n")` alone decides correctly. - return escape_line(text, false) unless text.include?("\n") + return escape_line(text) unless text.include?("\n") # On CRLF input, consume `\r` as part of the line terminator instead # of leaving it on the line. A trailing `\r` breaks line-end anchored - # regexes (e.g. SETEXT_UNDERLINE_*) and the `ws_end >= line_length` + # regexes (e.g. SETEXT_UNDERLINE_EQUALS) and the `ws_end >= line_length` # early-out in escape_indented_code, leaking NBSPs onto # whitespace-only CRLF lines. The `include?` guard keeps the # LF-only fast path on a string split (regex split is ~20% slower @@ -196,22 +195,19 @@ def escape_text(text) # Pre-allocate result buffer bytesize = text.bytesize result = String.new(capacity: bytesize + bytesize / 3, encoding: text.encoding) - prev_was_paragraph = false first = true lines.each do |line| result << "\n" unless first first = false - escaped = escape_line(line, prev_was_paragraph) - result << escaped - prev_was_paragraph = paragraph_line?(line) + result << escape_line(line) end result end - def escape_line(line, prev_was_paragraph) + def escape_line(line) # No `line.empty?` early-return: it's redundant with the # `line.getbyte(indent_len).nil?` guard below, which catches both # empty and whitespace-only lines while also preserving object @@ -229,7 +225,7 @@ def escape_line(line, prev_was_paragraph) has_indent = indent_len > 0 content = has_indent ? line[indent_len..] : line - escaped, skip_inline = escape_block_level(content, prev_was_paragraph) + escaped, skip_inline = escape_block_level(content) escaped = escape_inline(escaped) unless skip_inline if has_indent @@ -276,7 +272,7 @@ def escape_indented_code(line) "#{nbsp_indent}#{escape_inline(content)}" end - def escape_block_level(content, prev_was_paragraph) + def escape_block_level(content) first_byte = content.getbyte(0) case first_byte @@ -289,7 +285,7 @@ def escape_block_level(content, prev_was_paragraph) return pass_first_char_inline(content) if @allow.include?(:block_quote) return escape_first_char_inline(content, "\\>") when DASH - return escape_block_dash(content, prev_was_paragraph) + return escape_block_dash(content) when PLUS if BULLET_LIST.match?(content) return pass_first_char_inline(content) if @allow.include?(:bullet_list) @@ -302,7 +298,17 @@ def escape_block_level(content, prev_was_paragraph) return escape_all_chars(content, UNDERSCORE, "\\_"), true end when EQUALS - if prev_was_paragraph && SETEXT_UNDERLINE_EQUALS.match?(content) + # A line of only `=` is a setext heading underline when a + # paragraph line comes before it. The escaper sees a single + # text fragment, and the renderer can put that fragment + # right after a paragraph line — after a line break, or + # after inline markup like `[b]Body[/b]\n===` — so the + # previous line is not visible here. The line is therefore + # escaped in every position, like every other block + # construct. Where no paragraph line comes before it, + # Discourse renders `\=` as a literal `=`, so the result + # looks the same. + if SETEXT_UNDERLINE_EQUALS.match?(content) return escape_all_chars(content, EQUALS, "\\="), true end when BACKTICK @@ -327,11 +333,8 @@ def escape_first_char_inline(content, escaped_char) ["#{escaped_char}#{escape_inline(content[1..])}", true] end - def escape_block_dash(content, prev_was_paragraph) - if THEMATIC_BREAK_DASH.match?(content) || - (prev_was_paragraph && SETEXT_UNDERLINE_DASH.match?(content)) - return escape_all_chars(content, DASH, "\\-"), true - end + def escape_block_dash(content) + return escape_all_chars(content, DASH, "\\-"), true if THEMATIC_BREAK_DASH.match?(content) if BULLET_LIST.match?(content) return pass_first_char_inline(content) if @allow.include?(:bullet_list) return escape_first_char_inline(content, "\\-") @@ -562,56 +565,6 @@ def utf8_char_length(first_byte) 1 end end - - def paragraph_line?(line) - pos = 0 - line_len = line.bytesize - pos += 1 while pos < line_len && line.getbyte(pos) == SPACE - first_non_space = pos - - # Empty or whitespace-only lines: getbyte past the end returns nil. - return false if line.getbyte(first_non_space).nil? - - # Indented code (4+ spaces or any leading \t) is not a paragraph. - # INDENTED_CODE also catches lines where first_non_space > 3, so no - # separate numeric boundary check is needed. - return false if INDENTED_CODE.match?(line) - - content = first_non_space == 0 ? line : line[first_non_space..] - - # Lines starting with [ are paragraph content (the escaper rewrites [ - # to \[). block_construct? has no BRACKET_OPEN case arm, so such - # lines naturally fall through and !block_construct?(content) == true. - !block_construct?(content) - end - - # Checks whether content starts with a block-level markdown construct. - # Used by both escape_block_level (to decide what to escape) and - # paragraph_line? (to decide if setext underlines can follow). - def block_construct?(content) - case content.getbyte(0) - when HASH - ATX_HEADING.match?(content) - when GT - true - when DASH - BULLET_LIST.match?(content) || THEMATIC_BREAK_DASH.match?(content) - when STAR - BULLET_LIST.match?(content) || THEMATIC_BREAK_STAR.match?(content) - when PLUS - BULLET_LIST.match?(content) - when UNDERSCORE - THEMATIC_BREAK_UNDERSCORE.match?(content) - when BACKTICK - FENCED_CODE_BACKTICK.match?(content) - when TILDE - FENCED_CODE_TILDE.match?(content) - when DIGIT_0..DIGIT_9 - ORDERED_LIST.match?(content) - else - false - end - end end end end diff --git a/mutant.yml b/mutant.yml index 665109dc..54284428 100644 --- a/mutant.yml +++ b/mutant.yml @@ -115,7 +115,6 @@ matcher: # and surface as timeouts, never alive. All Bucket A. - Markbridge::Renderers::Discourse::MarkdownEscaper#escape_line - Markbridge::Renderers::Discourse::MarkdownEscaper#escape_indented_code - - Markbridge::Renderers::Discourse::MarkdownEscaper#paragraph_line? # escape_block_* fallthrough `[content, false]` returns. The `false` # → drop-second / `true` mutations on the non-match branch are @@ -401,20 +400,6 @@ mutation: - "send{receiver=send{receiver=self selector=class} selector=new}" - "index{receiver=lvar{value=new_cache}}" -# MarkdownEscaper#block_construct?'s `when DIGIT_0..DIGIT_9` range. - # Mutations `when DIGIT_0..nil` / `when nil..DIGIT_9` extend the - # range to unbounded, but the body is `ORDERED_LIST.match?(content)` - # which only matches content starting with `\d+\.` — non-digit bytes - # like `:` or `<` land in this arm but produce `false` identically - # to the `else false` arm. Dropping `else false` also fine: case - # returns nil, `!block_construct?(content)` becomes `!nil == true` - # — same as `!false == true` for the `paragraph_line?` caller. - # Bucket A. - - "case{value=send{receiver=lvar{value=content} selector=getbyte}}" - - - - # String.new(capacity:, encoding:) calls are preallocation hints — # capacity is a tuning knob with no observable effect on output, and @@ -464,15 +449,15 @@ mutation: - "lvasgn{name=has_indent}" - "if{condition=lvar{value=has_indent}}" - # Nested `line.getbyte(i).` guards in escape_line and - # paragraph_line? (`.nil?` / `!=`). Mutations on selector equality - # variants and drop-guard are equivalent because getbyte returns - # nil past the end and Integer equality is the same for Fixnum. + # Nested `line.getbyte(i).` guards in escape_line + # (`.nil?` / `!=`). Mutations on selector equality variants and + # drop-guard are equivalent because getbyte returns nil past the + # end and Integer equality is the same for Fixnum. - "if{condition=send{receiver=send{receiver=lvar{value=line} selector=getbyte} selector=(nil?,!=)}}" # Allocation-saving ternary `content = ? line[N..] : line` - # in escape_line and paragraph_line?. Output bytes identical; only - # object identity differs, which is an internal contract. + # in escape_line. Output bytes identical; only object identity + # differs, which is an internal contract. - "lvasgn{name=content value=if}" # escape_block_level's `case first_byte` dispatch. Mutations on @@ -480,8 +465,7 @@ mutation: # branch unreachable. The fallthrough `[content, false]` + inline # escaping produces byte-identical output for STAR/UNDERSCORE/ # BACKTICK/TILDE/BRACKET_OPEN/PIPE inputs because inline escape - # wraps the same characters. block_construct?'s different value - # shape (`case content.getbyte(0)`) is unaffected. + # wraps the same characters. - "case{value=lvar{value=first_byte}}" # escape_regular_char's `if byte < 128` ASCII fast-path. ASCII diff --git a/spec/markbridge_spec.rb b/spec/markbridge_spec.rb index 0579978b..e8772355 100644 --- a/spec/markbridge_spec.rb +++ b/spec/markbridge_spec.rb @@ -297,6 +297,15 @@ def render(_e, _i) expect(result.markdown).to eq("**hi** extra") end + + # The `=` line lands in its own text fragment after the
, so the + # escaper cannot see the paragraph line in front of it. Without the + # escape, Discourse cooks the two lines as an

. + it "escapes a =-only line that follows a line break" do + result = described_class.html_to_markdown("

Body:{}
========

") + + expect(result.markdown).to eq("Body:{}\n\\=\\=\\=\\=\\=\\=\\=\\=") + end end describe ".parse_text_formatter_xml" do diff --git a/spec/system/bbcode_to_markdown_spec.rb b/spec/system/bbcode_to_markdown_spec.rb index 4fe6738f..496233dc 100644 --- a/spec/system/bbcode_to_markdown_spec.rb +++ b/spec/system/bbcode_to_markdown_spec.rb @@ -534,6 +534,19 @@ expect(result.markdown).to eq("**bold text**") end + # The `=` line starts its own text node, so the escaper cannot see the + # paragraph line that the inline markup produced before it. Without the + # escape, Discourse cooks both lines as an

. + it "escapes a =-only line after a bold tag" do + result = Markbridge.bbcode_to_markdown("[b]Body[/b]\n===") + expect(result.markdown).to eq("**Body**\n\\=\\=\\=") + end + + it "escapes a =-only line after text with inline markup" do + result = Markbridge.bbcode_to_markdown("Body [i]x[/i]\n===") + expect(result.markdown).to eq("Body *x*\n\\=\\=\\=") + end + it "inserts an HTML comment to break colliding emphasis delimiters between siblings" do # After reorder-with-reopen the Bold ends with *** and the reopened # Italic starts with * — adjacent they would form **** and parse diff --git a/spec/unit/markbridge/renderers/discourse/markdown_escaper/setext_headings_spec.rb b/spec/unit/markbridge/renderers/discourse/markdown_escaper/setext_headings_spec.rb index 4b4b8ace..fdb690a9 100644 --- a/spec/unit/markbridge/renderers/discourse/markdown_escaper/setext_headings_spec.rb +++ b/spec/unit/markbridge/renderers/discourse/markdown_escaper/setext_headings_spec.rb @@ -39,10 +39,20 @@ end end - context "when = is standalone (MAY escape - false positives OK)" do - it "may or may not escape standalone ===" do - result = escaper.escape("===") - expect(result).to eq("===").or eq("\\=\\=\\=") + context "when the = line has no paragraph before it (MUST escape)" do + # The escaper only sees one text fragment. The renderer can place + # that fragment after a paragraph line, so a `=`-only line is always + # escaped. Discourse shows `\=` as a literal `=`. + it "escapes a standalone ===" do + expect(escaper.escape("===")).to eq("\\=\\=\\=") + end + + it "escapes === after a blank line" do + expect(escaper.escape("Text\n\n===")).to eq("Text\n\n\\=\\=\\=") + end + + it "escapes === after a list item" do + expect(escaper.escape("- item\n===")).to eq("\\- item\n\\=\\=\\=") end end diff --git a/spec/unit/markbridge/renderers/discourse/markdown_escaper_spec.rb b/spec/unit/markbridge/renderers/discourse/markdown_escaper_spec.rb index ceec3f72..57b795ff 100644 --- a/spec/unit/markbridge/renderers/discourse/markdown_escaper_spec.rb +++ b/spec/unit/markbridge/renderers/discourse/markdown_escaper_spec.rb @@ -383,176 +383,87 @@ end end - # Exercises the private `paragraph_line?` and `block_construct?` helpers. - # `paragraph_line?` is called on line N to decide whether line N+1 is a - # setext heading underline (only `=+` or `-+` with `prev_was_paragraph` - # trigger setext escaping). `block_construct?` returns true when the line - # starts with a block-level marker (so it is NOT a paragraph). - describe "setext heading underline detection (via paragraph_line?/block_construct?)" do - # Paragraph ⇒ underline on next line must be escaped. - context "when preceded by a paragraph line" do - it "escapes = as setext underline" do - expect(escaper.escape("text\n=")).to eq("text\n\\=") - end - - it "escapes multiple = chars" do - expect(escaper.escape("text\n===")).to eq("text\n\\=\\=\\=") - end - - it "escapes - as setext underline" do - expect(escaper.escape("text\n-")).to eq("text\n\\-") - end - - it "escapes paragraph starting with [ (bracket case)" do - # content.getbyte(0) == BRACKET_OPEN short-circuits to true. - expect(escaper.escape("[link\n=")).to include("\\=") - end - - it "treats first line as not-a-paragraph (no prev line)" do - # First-line `=` has prev_was_paragraph == false, so stays bare. - expect(escaper.escape("=\nnext")).to eq("=\nnext") - end + # A line that contains only `=` is a setext heading underline when a + # paragraph line comes before it. The escaper only sees one text + # fragment and cannot know what the renderer puts in front of it, so + # it escapes such a line in every position. Discourse renders `\=` as + # a literal `=`, so the extra backslashes do not change the result. + describe "setext heading underline escaping" do + it "escapes a single = on the first line" do + expect(escaper.escape("=")).to eq("\\=") end - # Block constructs ⇒ next-line underline is NOT escaped (not a setext). - context "when preceded by a block construct" do - { - "ATX heading (HASH)" => "# title", - "blockquote (GT)" => "> quote", - "bullet list with - (DASH)" => "- item", - "bullet list with + (PLUS)" => "+ item", - "bullet list with * (STAR)" => "* item", - "thematic break --- (DASH+THEMATIC)" => "---", - "thematic break *** (STAR+THEMATIC)" => "***", - "thematic break ___ (UNDERSCORE)" => "___", - "fenced code ``` (BACKTICK)" => "```", - "fenced code ~~~ (TILDE)" => "~~~", - "ordered list starting with 0 (DIGIT_0 boundary)" => "0. zeroth", - "ordered list starting with 9 (DIGIT_9 boundary)" => "9. ninth", - "ordered list (DIGIT)" => "1. item", - }.each do |label, prev_line| - it "does NOT escape = after #{label}" do - result = escaper.escape("#{prev_line}\n=") - expect(result).to end_with("\n=") - end - end + it "escapes several = on the first line" do + expect(escaper.escape("===")).to eq("\\=\\=\\=") end - # First byte matches a `when` clause but the regex inside returns false - # ⇒ block_construct? returns false ⇒ treated as paragraph. - context "when first byte matches a case branch but no construct matches" do - { - "# without space (not ATX)" => "#foo", - "- without space (not bullet, not thematic)" => "-x", - "+ without space (not bullet)" => "+foo", - "* without space (not bullet, not thematic)" => "*foo", - "single _ (not thematic)" => "_foo", - "single ` (not fenced, needs 3+)" => "`foo", - "single ~ (not fenced, needs 3+)" => "~foo", - "digit without dot/paren (not ordered)" => "1foo", - }.each do |label, prev_line| - it "escapes = after paragraph starting with #{label}" do - result = escaper.escape("#{prev_line}\n=") - expect(result).to end_with("\n\\=") - end - end + it "escapes a =-only first line followed by text" do + expect(escaper.escape("===\nText")).to eq("\\=\\=\\=\nText") end - # paragraph_line? short-circuits (returns false) for: - # - empty lines - # - whitespace-only lines - # - lines whose non-space content starts with TAB (indented code) - # - lines matching INDENTED_CODE (4+ spaces / tab at start) - context "when preceded by non-paragraph whitespace/indented lines" do - it "does NOT escape = after empty line" do - expect(escaper.escape("\n=")).to eq("\n=") - end + it "escapes = after a paragraph line" do + expect(escaper.escape("text\n=")).to eq("text\n\\=") + end - it "does NOT escape = after whitespace-only line" do - expect(escaper.escape(" \n=")).to eq(" \n=") - end + it "escapes = after a paragraph line starting with [" do + expect(escaper.escape("[link\n=")).to eq("\\[link\n\\=") + end - it "does NOT escape = after tab-indented line (indented code)" do - # Tab-indented content is indented code per INDENTED_CODE. - result = escaper.escape("\tcode\n=") - expect(result).to end_with("\n=") + { + "an empty line" => "", + "a blank line with spaces" => " ", + "a bullet list item" => "- item", + "an ATX heading" => "# title", + "a blockquote line" => "> quote", + "a thematic break" => "---", + "a fenced code marker" => "```", + "an ordered list item" => "1. item", + "indented code" => " code", + "a tab-indented line" => "\tcode", + }.each do |label, prev_line| + it "escapes a =-only line after #{label}" do + expect(escaper.escape("#{prev_line}\n===")).to end_with("\n\\=\\=\\=") end + end - it "does NOT escape = after 4-space-indented line (indented code)" do - result = escaper.escape(" code\n=") - expect(result).to end_with("\n=") - end + it "escapes a =-only line with trailing spaces" do + expect(escaper.escape("=== ")).to eq("\\=\\=\\= ") + end - it "does NOT escape = after line with tab after spaces (paragraph_line? short-circuit)" do - # Content after spaces starts with TAB ⇒ return false directly. - # Uses 3 leading spaces (won't trigger INDENTED_CODE 4+ check first) - # then a tab ⇒ hits the `getbyte(first_non_space) == TAB` branch. - result = escaper.escape(" \tcode\n=") - expect(result).to end_with("\n=") - end + it "escapes a =-only line with a trailing tab" do + expect(escaper.escape("===\t")).to eq("\\=\\=\\=\t") end - context "with line having >3 spaces of indent (uses line, not content, for INDENTED_CODE check)" do - it "does NOT escape = after 5-space-indented text (indented code)" do - # Falls into INDENTED_CODE.match?(line) check at the end. - result = escaper.escape(" word\n=") - expect(result).to end_with("\n=") - end + it "escapes a =-only line indented by up to 3 spaces" do + expect(escaper.escape(" ===")).to eq(" \\=\\=\\=") end - context "with line having 1-3 spaces of indent" do - it "still escapes = after 2-space-indented paragraph (block_construct? false, not INDENTED_CODE)" do - result = escaper.escape(" text\n=") - expect(result).to end_with("\n\\=") - end + it "does not escape = followed by text" do + expect(escaper.escape("=foo")).to eq("=foo") + end - # Forces the space-skip loop to advance past exactly one space. - # Kills `first_non_space += 2` mutations: at n=1, content becomes - # "---" (block construct) vs "--" (paragraph) under the mutation. - it "does NOT escape = after 1-space-indented thematic break --- (space skip step=1)" do - result = escaper.escape(" ---\n=") - expect(result).to end_with("\n=") - end + it "does not escape = in the middle of a line" do + expect(escaper.escape("a === b")).to eq("a === b") + end - it "does NOT escape = after 2-space-indented thematic break" do - result = escaper.escape(" ---\n=") - expect(result).to end_with("\n=") - end + it "does not escape = when a word follows on the same line" do + expect(escaper.escape("=== alone")).to eq("=== alone") + end - it "does NOT escape = after 3-space-indented thematic break" do - result = escaper.escape(" ---\n=") - expect(result).to end_with("\n=") - end + # A `-`-only line never needed a setext rule of its own: a single + # dash is a bullet list marker, two dashes are the ndash pair that + # `escape_inline` handles, and three or more are a thematic break. + # All three rules escape the line already. + it "escapes a single - line as a bullet marker" do + expect(escaper.escape("text\n-")).to eq("text\n\\-") + end - # Kills `first_non_space -= 1` mutation: the wrong direction leaves - # first_non_space negative, which makes line[-1..] the last character. - # " # heading" should be detected as an indented ATX heading (block - # construct, not a paragraph) so the following = must stay bare. - # Under the mutation, content becomes "g" (not a block construct) - # ⇒ paragraph=true ⇒ = incorrectly escaped. - it "does NOT escape = after 2-space-indented ATX heading" do - result = escaper.escape(" # heading\n=") - expect(result).to end_with("\n=") - end + it "escapes a -- line as an ndash pair" do + expect(escaper.escape("text\n--")).to eq("text\n\\-\\-") + end - # Kills `content = if first_non_space == 0` mutations where the ternary - # always takes the else branch (line[first_non_space..]). When - # first_non_space is 0, this still returns `line` (identical slice), - # which is why the mutation survived — but slicing allocates a new - # string, so an identity check (same object) kills it. - it "does NOT allocate a new string for no-indent paragraphs (ternary optimization)" do - # First-line paragraph with no leading spaces. When first_non_space==0 - # original code uses `line` directly (no slice). Mutation forces a - # slice even for first_non_space==0. - # - # We verify behavior (not allocation): the [ fast-path must fire - # on content that == line for first_non_space==0. That's already - # covered by "[link\n=" escaping = after bracket paragraph. - # - # Here we also exercise a no-indent paragraph so content == line. - result = escaper.escape("[link\n=") - expect(result).to end_with("\n\\=") - end + it "escapes a --- line as a thematic break" do + expect(escaper.escape("text\n---")).to eq("text\n\\-\\-\\-") end end @@ -617,14 +528,6 @@ expect(escaper.escape(nil)).to eq("") end - # Kills the `escape_line(lines[0], false)` → `escape_line(lines[0], true)` - # mutation in escape_text. With prev_was_paragraph=true, a lone `=` is - # treated as a setext heading underline and gets escaped; original (false) - # leaves it bare because there's no previous paragraph. - it "treats first-line `=` as not-a-setext-underline (prev_was_paragraph=false)" do - expect(escaper.escape("=")).to eq("=") - end - # Same identity trick for the hard-line-break guard. When the option # is on but the text has no " \n" sequence, the gsub is skipped and # text stays the same object. Mutations that drop the include? guard @@ -660,13 +563,13 @@ expect(escaper.escape("-foo")).to eq("-foo") end - # Kills `prev_was_paragraph && SETEXT_UNDERLINE_DASH.match?` mutations - # that reduce to just `prev_was_paragraph` (drop regex / `&& true`). - # Input: paragraph + "-foo" ⇒ prev_was_paragraph=true, but SETEXT - # regex fails (SETEXT_UNDERLINE_DASH matches dashes+whitespace only). - # Under the mutation the `-foo` line would be force-escaped as a - # thematic break (`\-foo`) instead of the bare `-foo` passthrough. - it "does not setext-escape a dash-prefixed paragraph line (regex must still apply)" do + # Kills mutations that drop the `THEMATIC_BREAK_DASH.match?` guard in + # escape_block_dash (`if true`, `if content`). `-foo` is neither a + # thematic break nor a bullet list, so it has to reach the inline + # path and come out as a bare `-foo`; under the mutation it would be + # escaped as `\-foo`. The multi-line input also covers the branch on + # a line that is not the first one. + it "does not thematic-escape a dash-prefixed line (regex must still apply)" do expect(escaper.escape("text\n-foo")).to eq("text\n-foo") end @@ -674,12 +577,11 @@ expect(escaper.escape("*foo")).to eq("\\*foo") end - # Kills `prev_was_paragraph && SETEXT_UNDERLINE_EQUALS.match?` mutations - # (drop regex / `&& true` / `&& content`). Input: paragraph + "=foo" - # ⇒ prev_was_paragraph=true, regex fails. Under the mutation the - # `=foo` line would be force-escaped as a setext heading underline - # (`\=foo`). `=` is not in INLINE_SPECIAL so original output is bare. - it "does not setext-escape a =-prefixed paragraph line (regex must still apply)" do + # Kills mutations that drop the `SETEXT_UNDERLINE_EQUALS.match?` guard + # (`if true`, `if content`). `=foo` is not a setext underline, so it + # must stay bare — `=` is not in INLINE_SPECIAL either. Under the + # mutation the line would come out as `\=foo`. + it "does not setext-escape a =-prefixed line (regex must still apply)" do expect(escaper.escape("text\n=foo")).to eq("text\n=foo") end end diff --git a/spec/unit/markbridge/renderers/discourse/renderer_spec.rb b/spec/unit/markbridge/renderers/discourse/renderer_spec.rb index 61afb098..1c978028 100644 --- a/spec/unit/markbridge/renderers/discourse/renderer_spec.rb +++ b/spec/unit/markbridge/renderers/discourse/renderer_spec.rb @@ -304,6 +304,21 @@ expect(result).to eq("") end + # Each Text node is escaped on its own, so the escaper never sees the + # paragraph line in front of the `=` line. Discourse would cook the + # unescaped output as an

. + it "escapes a =-only text node that follows a line break" do + paragraph = Markbridge::AST::Paragraph.new + paragraph << Markbridge::AST::Text.new("Body") + paragraph << Markbridge::AST::LineBreak.new + paragraph << Markbridge::AST::Text.new("========") + + context = Markbridge::Renderers::Discourse::RenderContext.new + result = renderer.render_children(paragraph, context:) + + expect(result).to eq("Body\n\\=\\=\\=\\=\\=\\=\\=\\=") + end + it "checks against the part's FIRST char when deciding boundary insertion" do # Custom tag whose output starts with `*` but ends with non-delimiter `Z`. # Combined with a previous sibling ending in `*`, the boundary must be