From d703959b743dbb1db895c84d3b6c24b5dd9b5caa Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Thu, 7 May 2026 14:03:04 -0700 Subject: [PATCH] fix(user-bubble): stash code fences before math to keep code-blocks literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1854 added a math stash to _renderUserFencedBlocks so backslash LaTeX delimiters (\[..\], \(..\)) survive esc() and reach the KaTeX renderer in user bubbles. The stash ran BEFORE the existing code-fence stash, so a user-typed code block containing LaTeX-like syntax was extracted as KaTeX and rendered as math inside
:

    ```
    \[ a + b \] is wrong
    ```
  → 
a + b
is wrong
renderMd() (assistant path) handles this correctly by running fence_stash before math_stash. The user-bubble path got the order inverted. Fix: stash code fences first, then run the math regexes on the outside-of-fence text only. Both top-level math and code-fenced literals now render correctly: - "math: \[ x + y \]" → KaTeX block - "```\n\[ a + b \]\n```" → literal
\[ a + b \]
Adds two regression tests: - test_user_code_block_with_latex_syntax_renders_as_literal_code (fails pre-fix, asserts no KaTeX wrappers inside
)
  - test_user_bubble_top_level_latex_still_renders_after_fence_reorder
    (sibling guard against over-correcting and disabling math entirely)

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 static/ui.js           | 16 ++++++++++------
 tests/test_issue347.py | 35 +++++++++++++++++++++++++++++++++++
 2 files changed, 45 insertions(+), 6 deletions(-)

diff --git a/static/ui.js b/static/ui.js
index 8eed5ab7..a7128bfc 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -81,12 +81,10 @@ function _renderUserFencedBlocks(text){
     return `${esc(item.src)}`;
   });
   let s=String(text||'');
-  // Stash math before escaping plain text; display delimiters must run before inline.
-  s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>stashMath('display',m));
-  s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>stashMath('display',m));
-  s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>stashMath('inline',m));
-  s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>stashMath('inline',m));
-  // Extract fenced code blocks → stash, replace with null-token placeholder
+  // Extract fenced code blocks FIRST so math regexes never run inside fenced
+  // content. If math were stashed first, a user-typed code block containing
+  // \[..\] / \(..\) / $$..$$ would be rendered as a KaTeX block inside
+  // 
 instead of as literal source. Mirrors renderMd()'s ordering.
   // CommonMark §4.5 line-anchored fence: the closing run must use at least
   // as many backticks as the opener, so inner triple-backtick fences remain content.
   s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
@@ -111,6 +109,12 @@ function _renderUserFencedBlocks(text){
     }
     return lead+'\x00UF'+(stash.length-1)+'\x00';
   });
+  // Now stash math from the OUTSIDE-of-fence text. Display delimiters must
+  // run before inline so $$..$$ isn't mis-parsed as $..$..$..$.
+  s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>stashMath('display',m));
+  s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>stashMath('display',m));
+  s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>stashMath('inline',m));
+  s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>stashMath('inline',m));
   // Escape remaining plain text and convert newlines to 
s=esc(s).replace(/\n/g,'
'); // Restore stashed code blocks, then math placeholders as KaTeX targets. diff --git a/tests/test_issue347.py b/tests/test_issue347.py index 756bcd20..4513139d 100644 --- a/tests/test_issue347.py +++ b/tests/test_issue347.py @@ -115,6 +115,41 @@ where \\(L_i(f)\\) = SPL at angle \\(i\\).""" assert "\\(" not in html and "\\)" not in html, html +def test_user_code_block_with_latex_syntax_renders_as_literal_code(): + """User-bubble code blocks containing \\[..\\] / \\(..\\) / $$..$$ must + render as literal code source, not as KaTeX. _renderUserFencedBlocks + must stash code fences BEFORE math (mirroring renderMd's ordering); if + math is stashed first, a user-typed code block containing LaTeX-like + syntax gets a `
` placeholder dropped INSIDE + `
`, and the user's literal source is silently replaced by
+    rendered math.
+    """
+    sample = "```\n\\[ a + b \\] is wrong\n\\(L_i\\) too\n$$matrix$$\n```"
+    rendered = _run_renderers(sample)
+    user_html = rendered["user"]
+    # The whole code block should remain literal, no KaTeX wrappers inside.
+    assert "
" in user_html, user_html
+    assert "katex-block" not in user_html, user_html
+    assert "katex-inline" not in user_html, user_html
+    # Backslashes survive HTML escape unchanged; the user's source is intact.
+    assert "\\[ a + b \\]" in user_html, user_html
+    assert "\\(L_i\\)" in user_html, user_html
+    assert "$$matrix$$" in user_html, user_html
+
+
+def test_user_bubble_top_level_latex_still_renders_after_fence_reorder():
+    """Sibling regression: top-level math (outside any code fence) must
+    still render through KaTeX in user bubbles after the fence-first
+    reorder. Guards against an over-correction that disables user-bubble
+    math rendering entirely.
+    """
+    sample = "math: \\[ x + y \\]\n\nand inline \\(L_i\\)"
+    rendered = _run_renderers(sample)
+    user_html = rendered["user"]
+    assert 'class="katex-block" data-katex="display"' in user_html, user_html
+    assert 'class="katex-inline" data-katex="inline"' in user_html, user_html
+
+
 def test_katex_inline_placeholder_emitted():
     """renderMd restore pass must emit .katex-inline spans for inline math."""
     assert 'katex-inline' in UI_JS, \