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