feat(chat): render fenced code blocks in user messages (#1325)
From PR #1335. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
This commit is contained in:
1 parent
fbe84d26e6
commit
1fa740d32f
2 files changed
+161
-1
No files matched your search
+38
-1
@@ -51,6 +51,43 @@ function _setCompressionSessionLock(sid){
|
||||
}
|
||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
/**
|
||||
* Render fenced code blocks inside user messages.
|
||||
* Extracts ```…``` fences, replaces them with placeholders,
|
||||
* escapes remaining text as plain HTML, then restores code blocks
|
||||
* with the same <pre><code> pipeline used by renderMd().
|
||||
* All non-fenced text stays escaped (no bold/italic/link interpretation).
|
||||
*/
|
||||
function _renderUserFencedBlocks(text){
|
||||
const stash=[];
|
||||
let s=String(text||'');
|
||||
// Extract fenced code blocks → stash, replace with null-token placeholder
|
||||
s=s.replace(/```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g,(_,lang,code)=>{
|
||||
lang=(lang||'').trim().toLowerCase();
|
||||
// Remove one trailing newline if present (the fence consumes its own)
|
||||
if(code.endsWith('\n')) code=code.slice(0,-1);
|
||||
const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';
|
||||
const langAttr=lang?` class="language-${esc(lang)}"`:'';
|
||||
if(lang==='diff'||lang==='patch'){
|
||||
const colored=esc(code).split('\n').map(line=>{
|
||||
if(line.startsWith('@@')) return `<span class="diff-line diff-hunk">${line}</span>`;
|
||||
if(line.startsWith('+')) return `<span class="diff-line diff-plus">${line}</span>`;
|
||||
if(line.startsWith('-')) return `<span class="diff-line diff-minus">${line}</span>`;
|
||||
return `<span class="diff-line">${line}</span>`;
|
||||
}).join('\n');
|
||||
stash.push(`${h}<pre class="diff-block"><code${langAttr}>${colored}</code></pre>`);
|
||||
} else {
|
||||
stash.push(`${h}<pre><code${langAttr}>${esc(code)}</code></pre>`);
|
||||
}
|
||||
return '\x00UF'+(stash.length-1)+'\x00';
|
||||
});
|
||||
// Escape remaining plain text and convert newlines to <br>
|
||||
s=esc(s).replace(/\n/g,'<br>');
|
||||
// Restore stashed code blocks
|
||||
s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]);
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */
|
||||
function _openImgLightbox(src, alt) {
|
||||
const lb = document.createElement('div');
|
||||
@@ -3047,7 +3084,7 @@ function renderMessages(){
|
||||
return _renderAttachmentHtml(fname,fileUrl);
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(_stripXmlToolCallsDisplay(String(content)));
|
||||
const bodyHtml = isUser ? _renderUserFencedBlocks(content) : renderMd(_stripXmlToolCallsDisplay(String(content)));
|
||||
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
|
||||
const editBtn = isEditableUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
|
||||
const undoBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('undo_exchange')}" onclick="undoLastExchange()">${li('undo',13)}</button>` : '';
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for issue #1325 — fenced code blocks in user message bubbles."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
||||
|
||||
|
||||
def _extract_js_functions():
|
||||
"""Extract esc and _renderUserFencedBlocks from ui.js by line numbers."""
|
||||
lines = open(UI_JS).read().split('\n')
|
||||
# esc is on line 52 (0-indexed: 51)
|
||||
esc_def = lines[51]
|
||||
# _renderUserFencedBlocks starts at line 61 (0-indexed: 60)
|
||||
# Find the end by matching closing brace at column 0
|
||||
fn_lines = []
|
||||
i = 60 # 0-indexed
|
||||
depth = 0
|
||||
while i < len(lines):
|
||||
fn_lines.append(lines[i])
|
||||
depth += lines[i].count('{') - lines[i].count('}')
|
||||
if depth <= 0:
|
||||
break
|
||||
i += 1
|
||||
fn_def = '\n'.join(fn_lines)
|
||||
return esc_def, fn_def
|
||||
|
||||
|
||||
def _run_user_render(text_input):
|
||||
"""Return the HTML output of _renderUserFencedBlocks for the given input text."""
|
||||
import json
|
||||
esc_def, fn_def = _extract_js_functions()
|
||||
js_code = esc_def + '\n' + fn_def + '\n'
|
||||
js_code += 'var input = JSON.parse(process.argv[2]);\n'
|
||||
js_code += 'process.stdout.write(_renderUserFencedBlocks(input));\n'
|
||||
tf = tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False, encoding='utf-8')
|
||||
tf.write(js_code)
|
||||
tf.close()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['node', tf.name, json.dumps(text_input)],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"node error: {result.stderr}")
|
||||
return result.stdout
|
||||
finally:
|
||||
os.unlink(tf.name)
|
||||
|
||||
|
||||
class TestUserFencedBlocks:
|
||||
"""Fenced code blocks in user messages should render as <pre><code>."""
|
||||
|
||||
def test_simple_fenced_block(self):
|
||||
out = _run_user_render("hello\n```python\nprint(1)\n```\nworld")
|
||||
assert '<pre><code class="language-python">' in out
|
||||
assert 'print(1)' in out
|
||||
# Newlines around the fenced block become <br> (same as original plain-text path)
|
||||
assert 'hello<br>' in out
|
||||
assert '<br>world' in out
|
||||
|
||||
def test_fenced_block_escaped_html(self):
|
||||
"""HTML in code blocks should be escaped."""
|
||||
out = _run_user_render("```html\n<div>hi</div>\n```")
|
||||
assert '<div>' in out
|
||||
# No raw <div> in code content
|
||||
assert '<div>' not in out.replace('<div>', '').replace('>', '')
|
||||
|
||||
def test_plain_text_not_interpreted_as_markdown(self):
|
||||
"""Bold/italic/links in non-fenced text should stay escaped."""
|
||||
out = _run_user_render("**bold** and *italic* and <script>alert(1)</script>")
|
||||
assert '**bold**' in out
|
||||
assert '*italic*' in out
|
||||
assert '<script>' in out
|
||||
assert '<strong>' not in out
|
||||
|
||||
def test_language_header_shown(self):
|
||||
out = _run_user_render("```javascript\nconst x = 1;\n```")
|
||||
assert 'class="pre-header"' in out
|
||||
assert 'javascript' in out
|
||||
|
||||
def test_no_language_no_header(self):
|
||||
out = _run_user_render("```\nsome code\n```")
|
||||
assert 'class="pre-header"' not in out
|
||||
assert '<pre><code>' in out
|
||||
assert 'some code' in out
|
||||
|
||||
def test_diff_block_colored(self):
|
||||
out = _run_user_render("```diff\n+added\n-removed\n```")
|
||||
assert 'diff-block' in out
|
||||
assert 'diff-plus' in out
|
||||
assert 'diff-minus' in out
|
||||
|
||||
def test_multiple_fenced_blocks(self):
|
||||
out = _run_user_render("first\n```python\n1\n```\nmiddle\n```js\n2\n```\nlast")
|
||||
assert 'language-python' in out
|
||||
assert 'language-js' in out
|
||||
assert 'first<br>' in out
|
||||
assert '<br>last' in out
|
||||
|
||||
def test_fenced_block_with_ampersand(self):
|
||||
out = _run_user_render("```python\nx & y\n```")
|
||||
assert 'x & y' in out
|
||||
|
||||
def test_empty_code_block(self):
|
||||
out = _run_user_render("```\n```")
|
||||
assert '<pre><code>' in out
|
||||
|
||||
def test_special_chars_outside_blocks_escaped(self):
|
||||
out = _run_user_render("a < b > c & d")
|
||||
assert 'a < b > c & d' in out
|
||||
|
||||
def test_links_not_rendered_in_plain_text(self):
|
||||
"""URLs in plain text should NOT become clickable links."""
|
||||
out = _run_user_render("Check https://example.com for details")
|
||||
assert '<a ' not in out
|
||||
assert 'https://example.com' in out
|
||||
|
||||
def test_inline_backticks_not_touched(self):
|
||||
"""Inline backticks (single backtick, not fenced block) should remain escaped as text."""
|
||||
out = _run_user_render("use `var x = 1` here")
|
||||
assert '`var x = 1`' in out
|
||||
assert '<code>' not in out
|
||||
Reference in new issue
Block a user