fix(renderer): drop empty trailing line from blockquote match

The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.

Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.

Reproducer:
  '> Hello\n\nThe rest of the message.'
    → '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
                          ^^^ phantom <br>

Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).

Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
  - input ending with \n
  - quote followed by paragraph (the real-world case)
  - multi-line quote ending with \n
  - quote with blank continuation + trailing \n (internal <br> stays,
    trailing <br> does not)

Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-25 19:50:08 -07:00
parent f6ea11d22e
commit 94d63d0215
2 changed files with 50 additions and 2 deletions
+10 -2
View File
@@ -769,8 +769,16 @@ function renderMd(raw){
// 3. After the fenced-code pass, lines of > preceding/following code
// blocks were left as literals because .+ didn't match empty lines
s=s.replace(/((?:^>[^\n]*(?:\n|$))+)/gm,block=>{
const inner=block.split('\n')
.filter((_,i,a)=>i<a.length-1||_.trim()!='>') // drop lone trailing '>' artifact
const lines=block.split('\n');
// Drop trailing artifacts: empty string from a trailing \n in the match
// (split adds '' after the final \n) and lone bare '>' lines that
// weren't intended as content. Without this, a blockquote whose source
// ends with \n (the common case — anything followed by another block)
// renders with a phantom <br> before </blockquote>.
while(lines.length&&(lines[lines.length-1].trim()===''||lines[lines.length-1].trim()==='>')){
lines.pop();
}
const inner=lines
.map(l=>l.replace(/^>[ \t]?/,'')) // strip "> " or ">"
.map(l=>l.trim()===''?'<br>':inlineMd(l)) // blank lines → <br>, text → inlineMd
.join('\n');
+40
View File
@@ -162,6 +162,46 @@ class TestInlineMarkdownInsideBlockquote:
assert "<blockquote>" in out
class TestNoPhantomTrailingBr:
"""The fix must drop both empty trailing lines (from a trailing \\n in the
match) and bare '>' artifacts. Without this, the common case — a blockquote
followed by another paragraph — renders with a phantom <br> right before
</blockquote>, leaving a visible blank line at the bottom of the quote.
"""
def test_input_ending_with_newline_no_trailing_br(self):
"""`> Hello\\n` must NOT produce `<blockquote>Hello\\n<br></blockquote>`."""
out = _apply_blockquote("> Hello\n")
assert "<br></blockquote>" not in out, (
f"Trailing <br> leaked inside the blockquote (phantom blank line): {out!r}"
)
def test_blockquote_followed_by_paragraph_no_trailing_br(self):
"""The common real-world shape: quote + blank line + paragraph."""
src = "> Quoted text\n\nNormal paragraph"
out = _apply_blockquote(src)
assert "<br></blockquote>" not in out, (
f"Trailing <br> leaked inside blockquote when followed by paragraph: {out!r}"
)
def test_multiline_quote_ending_with_newline_no_trailing_br(self):
out = _apply_blockquote("> Line one\n> Line two\n")
assert "<br></blockquote>" not in out, (
f"Multi-line quote ending with \\n must not leave a trailing <br>: {out!r}"
)
def test_quote_with_blank_continuation_then_newline(self):
"""`> A\\n>\\n> B\\n` — the internal `<br>` for the blank line stays,
but the trailing newline must not add a second `<br>` at the end."""
out = _apply_blockquote("> A\n>\n> B\n")
# Internal <br> for the blank-line continuation is intentional
assert "<br>" in out
# But there must not be a <br> immediately before the closing tag
assert "<br></blockquote>" not in out, (
f"Trailing <br> leaked at end of blockquote: {out!r}"
)
class TestBlockquoteFollowedByParagraph:
"""A blockquote followed by a normal paragraph must not bleed into each other."""