fix: honor markdown fence lengths

This commit is contained in:
Michael Lam committed 2026-05-05 08:36:17 -07:00
1 parent 1cde702d47
commit f76921d322
8 files changed
+178 -52

No files matched your search

+31 -14
View File
@@ -50,6 +50,15 @@ function _setCompressionSessionLock(sid){
window._compressionLockSid=sid||null; window._compressionLockSid=sid||null;
} }
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function _matchBacktickFenceLine(line){
const m=String(line||'').match(/^[ ]{0,3}(`{3,})([^`]*)$/);
if(!m) return null;
return {fence:m[1],len:m[1].length,info:(m[2]||'').trim()};
}
function _isBacktickFenceClose(line,minLen){
const m=String(line||'').match(/^[ ]{0,3}(`{3,})[ \t]*$/);
return !!(m&&m[1].length>=minLen);
}
/** /**
* Render fenced code blocks inside user messages. * Render fenced code blocks inside user messages.
* Extracts ```…``` fences, replaces them with placeholders, * Extracts ```…``` fences, replaces them with placeholders,
@@ -62,9 +71,12 @@ function _renderUserFencedBlocks(text){
const stash=[]; const stash=[];
let s=String(text||''); let s=String(text||'');
// Extract fenced code blocks → stash, replace with null-token placeholder // Extract fenced code blocks → stash, replace with null-token placeholder
// CommonMark line-anchored fence (fixes #1438): inner ``` inside content no longer truncates the block. // CommonMark §4.5 line-anchored fence: the closing run must use at least
s=s.replace(/(^|\n)[ ]{0,3}```([a-zA-Z0-9_+-]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)/g,(_,lead,lang,code)=>{ // as many backticks as the opener, so inner triple-backtick fences remain content.
lang=(lang||'').trim().toLowerCase(); s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
const langInfo=(info||'').trim();
const langMatch=langInfo.match(/^(\w[\w+-]*)$/);
let lang=langMatch?(langMatch[1]||'').trim().toLowerCase():'';
code=code||''; code=code||'';
// Remove one trailing newline if present (the fence consumes its own) // Remove one trailing newline if present (the fence consumes its own)
if(code.endsWith('\n')) code=code.slice(0,-1); if(code.endsWith('\n')) code=code.slice(0,-1);
@@ -1736,7 +1748,8 @@ function renderMd(raw){
s=(function _applyBlockquotes(input){ s=(function _applyBlockquotes(input){
const lines=input.split('\n'); const lines=input.split('\n');
const out=[]; const out=[];
let inFence=false; // inside a non-blockquote ```...``` fence let inFence=false; // inside a non-blockquote backtick fence
let fenceLen=0;
let bqStart=-1; let bqStart=-1;
const flush=(end)=>{ const flush=(end)=>{
if(bqStart<0) return; if(bqStart<0) return;
@@ -1759,13 +1772,15 @@ function renderMd(raw){
const line=lines[i]; const line=lines[i];
if(inFence){ if(inFence){
out.push(line); out.push(line);
if(/^```/.test(line)) inFence=false; if(_isBacktickFenceClose(line,fenceLen)){inFence=false;fenceLen=0;}
continue; continue;
} }
if(/^```/.test(line)){ const fenceOpen=_matchBacktickFenceLine(line);
if(fenceOpen){
flush(i); flush(i);
out.push(line); out.push(line);
inFence=true; inFence=true;
fenceLen=fenceOpen.len;
continue; continue;
} }
if(/^>/.test(line)){ if(/^>/.test(line)){
@@ -1809,14 +1824,16 @@ function renderMd(raw){
const _preBlock_stash=[]; const _preBlock_stash=[];
const fence_stash=[]; const fence_stash=[];
// CommonMark §4.5: opening fence must start a line (with up to 3 spaces of indent) // CommonMark §4.5: opening fence must start a line (with up to 3 spaces of indent)
// and closing fence must also start a line. Without line anchoring, a literal ``` inside // and closing fence must start a line with the same backtick char and at least
// a code block (e.g. a regex pattern with ``` in a lookbehind, a script that documents // as many backticks as the opener. Without line/fence-length anchoring, a literal
// fences) terminates the outer block at the wrong place, leaking content into the // ``` inside a code block (e.g. a nested markdown example) terminates the outer
// markdown stream where bold/italic/inline-code passes corrupt it. Fixes #1438. // block at the wrong place, leaking content into the markdown stream where
s=s.replace(/(^|\n)[ ]{0,3}```(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)/g,(_,lead,raw)=>{ // bold/italic/inline-code passes corrupt it. Fixes #1438 and #1696.
const m=raw.match(/^(\w[\w+-]*)\n?([\s\S]*)$/); s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
const lang=m?(m[1]||'').trim().toLowerCase():''; const langInfo=(info||'').trim();
const code=m?m[2]:raw.replace(/^\n?/,''); const langMatch=langInfo.match(/^(\w[\w+-]*)$/);
const lang=langMatch?(langMatch[1]||'').trim().toLowerCase():'';
code=code||'';
const codeLines=code.split('\n'); const codeLines=code.split('\n');
const firstCodeLine=codeLines.find(line=>line.trim())||''; const firstCodeLine=codeLines.find(line=>line.trim())||'';
const firstMermaidLine=codeLines.map(line=>line.trim()).find(line=>line&&!line.startsWith('%%'))||''; const firstMermaidLine=codeLines.map(line=>line.trim()).find(line=>line&&!line.startsWith('%%'))||'';
+33 -16
View File
@@ -7,23 +7,30 @@ UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
def _extract_js_functions(): def _extract_js_functions():
"""Extract esc and _renderUserFencedBlocks from ui.js by line numbers.""" """Extract esc, fence helpers, and _renderUserFencedBlocks from ui.js."""
lines = open(UI_JS).read().split('\n') src = open(UI_JS).read()
# esc is on line 52 (0-indexed: 51)
esc_def = lines[51] def extract_function(name):
# _renderUserFencedBlocks starts at line 61 (0-indexed: 60) start = src.find(f"function {name}(")
# Find the end by matching closing brace at column 0 if start < 0:
fn_lines = [] raise AssertionError(f"{name} not found in ui.js")
i = 60 # 0-indexed i = src.find("{", start)
depth = 0 depth = 1
while i < len(lines):
fn_lines.append(lines[i])
depth += lines[i].count('{') - lines[i].count('}')
if depth <= 0:
break
i += 1 i += 1
fn_def = '\n'.join(fn_lines) while i < len(src) and depth:
return esc_def, fn_def if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
return src[start:i]
esc_line = next(line for line in src.split("\n") if line.startswith("const esc="))
helper_defs = "\n".join(
extract_function(name)
for name in ("_matchBacktickFenceLine", "_isBacktickFenceClose", "_renderUserFencedBlocks")
)
return esc_line, helper_defs
def _run_user_render(text_input): def _run_user_render(text_input):
@@ -116,6 +123,16 @@ class TestUserFencedBlocks:
assert '<a ' not in out assert '<a ' not in out
assert 'https://example.com' in out assert 'https://example.com' in out
def test_four_backtick_outer_fence_preserves_inner_triple_fence(self):
"""User-message code fences should follow CommonMark fence-length matching too."""
out = _run_user_render("````md\n```inner\nfoo\n```\n````")
assert out.count("<pre>") == 1
assert out.count("</pre>") == 1
assert '<div class="pre-header">md</div>' in out
assert "```inner" in out
assert "foo" in out
assert "<br>````" not in out
def test_inline_backticks_not_touched(self): def test_inline_backticks_not_touched(self):
"""Inline backticks (single backtick, not fenced block) should remain escaped as text.""" """Inline backticks (single backtick, not fenced block) should remain escaped as text."""
out = _run_user_render("use `var x = 1` here") out = _run_user_render("use `var x = 1` here")
+2
View File
@@ -43,6 +43,8 @@ function extractFunc(name) {
} }
return src.slice(start, i); return src.slice(start, i);
} }
eval(extractFunc('_matchBacktickFenceLine'));
eval(extractFunc('_isBacktickFenceClose'));
eval(extractFunc('renderMd')); eval(extractFunc('renderMd'));
let buf = ''; let buf = '';
+11 -17
View File
@@ -199,23 +199,15 @@ def test_inline_code_after_fence():
def test_renderMd_fence_regex_is_line_anchored(): def test_renderMd_fence_regex_is_line_anchored():
"""The fence regex in renderMd must include `(^|\\n)` opener and `(?=\\n|$)` closer. """The fence regex in renderMd must keep line anchoring and fence-length matching."""
pattern = r"s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g"
Pattern: (^|\\n)[ ]{0,3}```(?:([\\s\\S]*?)\\n)?[ ]{0,3}```(?=\\n|$) assert pattern in UI_JS, "renderMd fence regex lost line anchoring or #1696 fence-length matching"
The `(?:...\\n)?` makes the body optional so empty fences (```\\n```) still match.
"""
assert re.search(
r"s=s\.replace\(/\(\^\|\\n\)\[ \]\{0,3\}```\(\?:\(\[\\s\\S\]\*\?\)\\n\)\?\[ \]\{0,3\}```\(\?=\\n\|\$\)/g",
UI_JS,
), "renderMd fence regex is not line-anchored — regression of #1438"
def test_renderUserFencedBlocks_fence_regex_is_line_anchored(): def test_renderUserFencedBlocks_fence_regex_is_line_anchored():
"""The fence regex in _renderUserFencedBlocks must also be line-anchored.""" """The fence regex in _renderUserFencedBlocks must also be line-anchored."""
assert re.search( pattern = r"s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g"
r"s=s\.replace\(/\(\^\|\\n\)\[ \]\{0,3\}```\(\[a-zA-Z0-9_\+\-\]\*\)\\n\(\?:\(\[\\s\\S\]\*\?\)\\n\)\?\[ \]\{0,3\}```\(\?=\\n\|\$\)/g", assert UI_JS.count(pattern) >= 2, "render/user fence regexes lost line anchoring or #1696 fence-length matching"
UI_JS,
), "_renderUserFencedBlocks fence regex is not line-anchored — regression of #1438"
def test_stripForTTS_fence_regex_is_line_anchored(): def test_stripForTTS_fence_regex_is_line_anchored():
@@ -274,8 +266,10 @@ def test_diff_fence_with_inner_backticks_in_content():
# Pattern explanation: ui.js source contains literal backslash-n in regex literals # Pattern explanation: ui.js source contains literal backslash-n in regex literals
# (ONE backslash + 'n'). In a Python raw string, r"\\n" compiles to a regex pattern # (ONE backslash + 'n'). In a Python raw string, r"\\n" compiles to a regex pattern
# matching ONE literal backslash followed by 'n'. # matching ONE literal backslash followed by 'n'.
matches = re.findall(r"```\(\?=\\n\|\$\)", UI_JS) new_matches = UI_JS.count(r"[ ]{0,3}\2`*[ \t]*(?=\n|$)")
assert len(matches) >= 3, ( old_tts_matches = re.findall(r"```\(\?=\\n\|\$\)", UI_JS)
f"all 3 fence sites (renderMd, _renderUserFencedBlocks, _stripForTTS) " assert new_matches >= 2 and len(old_tts_matches) >= 1, (
f"must have line-anchored close fence; found {len(matches)} occurrences" f"renderMd/_renderUserFencedBlocks must have fence-length-aware line-anchored "
f"closers and _stripForTTS must keep a line-anchored closer; found "
f"new={new_matches}, tts={len(old_tts_matches)}"
) )
@@ -208,6 +208,8 @@ function extractFunc(name) {
} }
return src.slice(start, i); return src.slice(start, i);
} }
eval(extractFunc('_matchBacktickFenceLine'));
eval(extractFunc('_isBacktickFenceClose'));
eval(extractFunc('renderMd')); eval(extractFunc('renderMd'));
let buf = ''; let buf = '';
@@ -145,6 +145,8 @@ function extractFunc(name) {
} }
return src.slice(start, i); return src.slice(start, i);
} }
eval(extractFunc('_matchBacktickFenceLine'));
eval(extractFunc('_isBacktickFenceClose'));
eval(extractFunc('renderMd')); eval(extractFunc('renderMd'));
let buf = ''; let buf = '';
+45
View File
@@ -54,6 +54,8 @@ function extractFunc(name) {
} }
return src.slice(start, i); return src.slice(start, i);
} }
eval(extractFunc('_matchBacktickFenceLine'));
eval(extractFunc('_isBacktickFenceClose'));
eval(extractFunc('renderMd')); eval(extractFunc('renderMd'));
let buf = ''; let buf = '';
@@ -285,6 +287,49 @@ class TestBugFencedCodeInBlockquote:
assert "x = 1" in out assert "x = 1" in out
class TestFencedCodeFenceLength:
"""CommonMark §4.5 requires the closer to be at least as long as the opener."""
def test_five_backtick_outer_fence_preserves_inner_triple_fence(self, driver_path):
src = (
"- optionally also support fenced code blocks\n\n"
"`````md\n"
"`md\n"
"```novelcrafter\n"
"{#if novel.hasSeries}\n"
"...\n"
"{#endif}\n"
"```\n"
"`````\n\n"
"That is much more correct than pretending"
)
out = _render(driver_path, src)
assert out.count("<pre>") == 1
assert out.count("</pre>") == 1
assert '<div class="pre-header">md</div>' in out
assert "```novelcrafter" in out
assert "{#if novel.hasSeries}" in out
assert "That is much more correct than pretending" in out
assert "<p>`````" not in out
assert "<br>`````" not in out
def test_four_backtick_outer_fence_preserves_inner_triple_fence(self, driver_path):
out = _render(driver_path, "````md\n```inner\nfoo\n```\n````\n")
assert out.count("<pre>") == 1
assert out.count("</pre>") == 1
assert '<div class="pre-header">md</div>' in out
assert "```inner" in out
assert "foo" in out
assert "<p>````" not in out
def test_three_backtick_fence_still_renders_language_class(self, driver_path):
out = _render(driver_path, "```js\nconsole.log('ok')\n```")
assert out.count("<pre>") == 1
assert '<div class="pre-header">js</div>' in out
assert 'class="language-js"' in out
assert "console.log(&#39;ok&#39;)" in out
class TestBugBlankContinuationInBlockquote: class TestBugBlankContinuationInBlockquote:
"""Bug 2: blank > lines between paragraphs fragmented the blockquote into """Bug 2: blank > lines between paragraphs fragmented the blockquote into
separate elements with literal > characters between them.""" separate elements with literal > characters between them."""
+52 -5
View File
@@ -61,8 +61,8 @@ def render_md(raw):
fence_stash.append(m.group()) fence_stash.append(m.group())
return "\x00F" + str(len(fence_stash) - 1) + "\x00" return "\x00F" + str(len(fence_stash) - 1) + "\x00"
# Fence regex line-anchored to match JS fix for #1438 (allows empty fence) # Fence regex line-anchored to match JS fix for #1438 and fence-length fix for #1696
s = re.sub(r"(?:^|\n)[ ]{0,3}```(?:[\s\S]*?\n)?[ ]{0,3}```(?=\n|$)|`[^`\n]+`", stash, s) s = re.sub(r"(?:^|\n)[ ]{0,3}(`{3,})[^\n`]*\n(?:[\s\S]*?\n)?[ ]{0,3}\1`*(?=\n|$)|`[^`\n]+`", stash, s)
s = re.sub(r"<strong>([\s\S]*?)</strong>", lambda m: "**" + m.group(1) + "**", s, flags=re.I) s = re.sub(r"<strong>([\s\S]*?)</strong>", lambda m: "**" + m.group(1) + "**", s, flags=re.I)
s = re.sub(r"<b>([\s\S]*?)</b>", lambda m: "**" + m.group(1) + "**", s, flags=re.I) s = re.sub(r"<b>([\s\S]*?)</b>", lambda m: "**" + m.group(1) + "**", s, flags=re.I)
s = re.sub(r"<em>([\s\S]*?)</em>", lambda m: "*" + m.group(1) + "*", s, flags=re.I) s = re.sub(r"<em>([\s\S]*?)</em>", lambda m: "*" + m.group(1) + "*", s, flags=re.I)
@@ -77,11 +77,12 @@ def render_md(raw):
# Fenced code blocks # Fenced code blocks
def fenced(m): def fenced(m):
lang, code = m.group(1), (m.group(2) or "").rstrip("\n") info, code = (m.group(2) or "").strip(), (m.group(3) or "").rstrip("\n")
lang = info.lower() if re.match(r"^\w[\w+-]*$", info) else ""
h = f'<div class="pre-header">{esc(lang)}</div>' if lang else "" h = f'<div class="pre-header">{esc(lang)}</div>' if lang else ""
return h + "<pre><code>" + esc(code) + "</code></pre>" return h + "<pre><code>" + esc(code) + "</code></pre>"
# Fenced code blocks (line-anchored, fixes #1438; allows empty fence) # Fenced code blocks (line-anchored, fixes #1438; fence-length matching fixes #1696)
s = re.sub(r"(?:^|\n)[ ]{0,3}```([\w+-]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)", fenced, s) s = re.sub(r"(?:^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\1`*(?=\n|$)", fenced, s)
s = re.sub(r"`([^`\n]+)`", lambda m: "<code>" + esc(m.group(1)) + "</code>", s) s = re.sub(r"`([^`\n]+)`", lambda m: "<code>" + esc(m.group(1)) + "</code>", s)
# Inline formatting (top-level, outside list items) # Inline formatting (top-level, outside list items)
@@ -358,6 +359,52 @@ def test_render_md_fenced_code_protects_html(cleanup_test_sessions):
"Fenced code content was lost after stash/restore" "Fenced code content was lost after stash/restore"
def test_render_md_fenced_code_with_five_backtick_outer_preserves_inner_triples(cleanup_test_sessions):
"""CommonMark §4.5: a 5-backtick fence must not close at an inner triple fence."""
src = (
"- optionally also support fenced code blocks\n\n"
"`````md\n"
"`md\n"
"```novelcrafter\n"
"{#if novel.hasSeries}\n"
"...\n"
"{#endif}\n"
"```\n"
"`````\n\n"
"That is much more correct than pretending"
)
out = render_md(src)
assert out.count("<pre>") == 1
assert out.count("</pre>") == 1
assert '<div class="pre-header">md</div>' in out
assert "```novelcrafter" in out
assert "{#if novel.hasSeries}" in out
assert "That is much more correct than pretending" in out
assert "<p>`````" not in out
assert "<br>`````" not in out
def test_render_md_fenced_code_with_four_backtick_outer_preserves_inner_triples(cleanup_test_sessions):
"""A 4-backtick outer fence should also require a 4+ backtick closer."""
src = "````md\n```inner\nfoo\n```\n````\n"
out = render_md(src)
assert out.count("<pre>") == 1
assert out.count("</pre>") == 1
assert '<div class="pre-header">md</div>' in out
assert "```inner" in out
assert "foo" in out
assert "<p>````" not in out
def test_render_md_fenced_code_three_backtick_path_still_renders_language(cleanup_test_sessions):
"""The common 3-backtick path must keep rendering a single language-tagged block."""
src = "```js\nconsole.log('ok')\n```"
out = render_md(src)
assert out.count("<pre>") == 1
assert '<div class="pre-header">js</div>' in out
assert "console.log(&#39;ok&#39;)" in out or "console.log(&#x27;ok&#x27;)" in out
# ── Security: XSS must be blocked ───────────────────────────────────────────── # ── Security: XSS must be blocked ─────────────────────────────────────────────
def test_render_md_xss_img_tag_escaped(cleanup_test_sessions): def test_render_md_xss_img_tag_escaped(cleanup_test_sessions):