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."""