diff --git a/CHANGELOG.md b/CHANGELOG.md index 04cbdb5..6ac8768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Hermes Web UI -- Changelog +## [v0.50.102] — 2026-04-20 + +### Fixed +- **Code blocks no longer lose newlines when not preceded by a blank line** — `renderMd()` in `static/ui.js` now stashes `
` blocks (including language-labelled blocks with `` wrappers), mermaid diagrams, and katex blocks before the paragraph-splitting pass, then restores them. Previously, if a fenced code block was not separated from surrounding text by a double newline, all `\n` inside it were replaced with `
`, causing Prism.js to collapse the entire block to one line and misidentify everything after a `//` comment as a comment. (Fixes #745, reported by @qqxpee) + ## [v0.50.101] — 2026-04-20 ### Fixed diff --git a/static/ui.js b/static/ui.js index 08b7ad7..69c9434 100644 --- a/static/ui.js +++ b/static/ui.js @@ -646,8 +646,18 @@ function renderMd(raw){ } return `${esc(item.src)}`; }); + // Stash renderedblocks (with optional pre-header div) and mermaid/katex + // divs before paragraph splitting so \n inside code blocks is never replaced + // with
. Token \x00E (next free after B D F G L M C O A). + // Fixes #745: code blocks collapse to single line when not preceded by blank line. + const _pre_stash=[]; + s=s.replace(/([\s\S]*?<\/div>)?[\s\S]*?<\/pre>|/g,m=>{ + _pre_stash.push(m); + return '\x00E'+(_pre_stash.length-1)+'\x00'; + }); const parts=s.split(/\n{2,}/); - s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `${p.replace(/\n/g,'
`;}).join('\n'); + s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)|^\x00E/.test(p))return p;return `
')}${p.replace(/\n/g,'
`;}).join('\n'); + s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]); // ── Restore MEDIA stash → inline images or download links ───────────────── s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{ const ref=media_stash[+i]; diff --git a/tests/test_745_code_block_newlines.py b/tests/test_745_code_block_newlines.py new file mode 100644 index 0000000..de3f716 --- /dev/null +++ b/tests/test_745_code_block_newlines.py @@ -0,0 +1,104 @@ +""" +Tests for #745: code blocks losing newlines when not preceded by double blank line. + +Root cause: the paragraph-splitter in renderMd() replaced \n with
')}
inside +blocks when they were not separated by a double newline from surrounding +text. The fix stashesblocks (and pre-header divs, mermaid, katex) before +the paragraph split and restores them afterwards. +""" +import re +import subprocess +import sys +import os + +UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js') + + +def get_ui_js(): + return open(UI_JS, encoding='utf-8').read() + + +class TestCodeBlockNewlinePreservation: + + def test_pre_stash_present(self): + """The _pre_stash variable must exist in ui.js.""" + src = get_ui_js() + assert '_pre_stash' in src, "_pre_stash not found in ui.js" + + def test_pre_stash_token_E_used(self): + """Stash token \\x00E must be used for pre-block stashing.""" + src = get_ui_js() + assert r'\x00E' in src, r"\x00E stash token not found in ui.js" + + def test_stash_before_paragraph_split(self): + """_pre_stash must be populated BEFORE the parts=s.split line.""" + src = get_ui_js() + pre_stash_pos = src.index('_pre_stash=[]') + split_pos = src.index('const parts=s.split(/\\n{2,}/)') + assert pre_stash_pos < split_pos, \ + "_pre_stash must be initialised before the paragraph split" + + def test_restore_after_paragraph_split(self): + """_pre_stash restore must happen AFTER the paragraph map/join line.""" + src = get_ui_js() + restore_pos = src.index('_pre_stash[+i]') + split_pos = src.index("}).join('\\n');", src.index('const parts=s.split')) + assert restore_pos > split_pos, \ + "_pre_stash must be restored after the paragraph split/join" + + def test_paragraph_split_bypasses_stash_tokens(self): + """The paragraph map must bypass lines that start with \\x00E.""" + src = get_ui_js() + # The map line must check for \x00E in its bypass condition + map_line = next( + l for l in src.splitlines() + if 'parts.map' in l and '
' in l + ) + assert r'\x00E' in map_line, \ + r"paragraph map must bypass \x00E stash tokens" + + def test_pre_regex_covers_pre_header_div(self): + """The stash regex must matchbefore.""" + src = get_ui_js() + # Find the replacement regex used to populate _pre_stash + stash_block_idx = src.index('_pre_stash=[]') + stash_block = src[stash_block_idx:stash_block_idx + 400] + assert 'pre-header' in stash_block, \ + "pre-stash regex must matchwrappers" + + def test_mermaid_covered_by_stash(self): + """The stash regex must also cover mermaid-block divs.""" + src = get_ui_js() + stash_block_idx = src.index('_pre_stash=[]') + stash_block = src[stash_block_idx:stash_block_idx + 400] + assert 'mermaid-block' in stash_block, \ + "pre-stash regex must cover mermaid-block divs" + + def test_katex_covered_by_stash(self): + """The stash regex must also cover katex-block divs.""" + src = get_ui_js() + stash_block_idx = src.index('_pre_stash=[]') + stash_block = src[stash_block_idx:stash_block_idx + 400] + assert 'katex-block' in stash_block, \ + "pre-stash regex must cover katex-block divs" + + def test_js_syntax_valid(self): + """ui.js must pass node --check after the fix.""" + result = subprocess.run( + ['node', '--check', UI_JS], + capture_output=True, text=True + ) + assert result.returncode == 0, \ + f"node --check failed:\n{result.stderr}" + + def test_stash_token_e_not_used_elsewhere(self): + """\\x00E must only appear in the pre-stash section (not reused).""" + src = get_ui_js() + occurrences = [ + i for i in range(len(src)) + if src[i:i+4] == r'\x00' and i + 4 < len(src) and src[i+4] == 'E' + ] + # Allow 2 occurrences: the push token and the restore regex + # (may be 3 if there's also a comment mentioning it) + assert len(occurrences) >= 2, \ + r"Expected at least 2 uses of \x00E (push + restore)"