From 94d63d0215583ea3b9dcbdded74c9883d192a225 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sat, 25 Apr 2026 19:50:08 -0700 Subject: [PATCH] fix(renderer): drop empty trailing line from blockquote match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 → '
') step turned it into a phantom
immediately before . 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.' → '
Hello\n
\nThe rest of the message.' ^^^ phantom
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-
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
stays, trailing
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) --- static/ui.js | 12 +++++++-- tests/test_blockquote_rendering.py | 40 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/static/ui.js b/static/ui.js index 2c871365..9c90e526 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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') // 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
before . + 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()===''?'
':inlineMd(l)) // blank lines →
, text → inlineMd .join('\n'); diff --git a/tests/test_blockquote_rendering.py b/tests/test_blockquote_rendering.py index cd494fde..fad580ad 100644 --- a/tests/test_blockquote_rendering.py +++ b/tests/test_blockquote_rendering.py @@ -162,6 +162,46 @@ class TestInlineMarkdownInsideBlockquote: assert "
" 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
right before +
, 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 `
Hello\\n
`.""" + out = _apply_blockquote("> Hello\n") + assert "
" not in out, ( + f"Trailing
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 "
" not in out, ( + f"Trailing
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 "
" not in out, ( + f"Multi-line quote ending with \\n must not leave a trailing
: {out!r}" + ) + + def test_quote_with_blank_continuation_then_newline(self): + """`> A\\n>\\n> B\\n` — the internal `
` for the blank line stays, + but the trailing newline must not add a second `
` at the end.""" + out = _apply_blockquote("> A\n>\n> B\n") + # Internal
for the blank-line continuation is intentional + assert "
" in out + # But there must not be a
immediately before the closing tag + assert "
" not in out, ( + f"Trailing
leaked at end of blockquote: {out!r}" + ) + + class TestBlockquoteFollowedByParagraph: """A blockquote followed by a normal paragraph must not bleed into each other."""