Skip to content

Commit 8b39224

Browse files
committed
fix(markdown): unescape & last so math entities survive intact
The math pass unescaped &amp; before &lt; and &gt;. mdToHtml escapes the source first, so a literal "&lt;" typed inside a formula arrives here as "&amp;lt;", turns back into "&lt;" on the ampersand pass, and is then eaten by the very next one. Typing $a &lt; b$ rendered as "a < b" instead of the literal text. The code-block pass in the same function already unescapes &amp; last; only the math paths were the outlier, in all four of the copies this branch consolidated into pushMath(). Reordering to match makes them consistent and clears the js/double-escaping alert CodeQL raised on this PR. Math containing a genuinely typed "<" is unaffected, which is why this went unnoticed for so long. Covered by a regression test asserting both cases.
1 parent c7a65e7 commit 8b39224

2 files changed

Lines changed: 27 additions & 1 deletion

File tree

static/js/markdown.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,10 @@ export function mdToHtml(src, opts) {
727727
// Typeset straight away when KaTeX is already in, otherwise bank the source in
728728
// an inert placeholder for renderMath() to swap once the library lands.
729729
const pushMath = (math, displayMode) => {
730-
const raw = math.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim();
730+
// Unescape &amp; LAST, matching the code-block pass above. Doing it first
731+
// double-unescapes: a literal "&lt;" reaches here as "&amp;lt;", becomes
732+
// "&lt;" on the & pass, and then collapses to "<" on the next one.
733+
const raw = math.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
731734
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
732735
if (window.katex) {
733736
mathBlocks.push(katex.renderToString(raw, { displayMode, throwOnError: false }));

tests/test_markdown_lazy_lib_loading_js.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,29 @@ def test_deferred_math_schedules_a_katex_load(node_available):
292292
assert out["scriptSrcs"] == [KATEX_SRC]
293293

294294

295+
def test_math_entities_are_not_double_unescaped(node_available):
296+
"""A literal "&lt;" in math must survive as "&lt;", not collapse to "<".
297+
298+
mdToHtml escapes the source before the math pass, so the user's "&lt;"
299+
arrives as "&amp;lt;". Unescaping "&amp;" first turns it back into "&lt;"
300+
and the very next pass eats it, which is the same double-unescape the
301+
code-block pass already avoids by unescaping "&amp;" last.
302+
"""
303+
out = _run_node(
304+
"""
305+
globalThis.window.katex = { renderToString: (src) => `<K>${src}</K>` };
306+
globalThis.katex = globalThis.window.katex;
307+
emit({
308+
entity: mod.mdToHtml('Math: $a &lt; b$ done.'),
309+
realLt: mod.mdToHtml('Math: $a < b$ done.'),
310+
});
311+
"""
312+
)
313+
assert "<K>a &lt; b</K>" in out["entity"]
314+
# A genuinely typed "<" is untouched by the reorder.
315+
assert "<K>a < b</K>" in out["realLt"]
316+
317+
295318
def test_md_to_html_renders_inline_once_katex_is_loaded(node_available):
296319
"""After the first load mdToHtml goes back to typesetting synchronously."""
297320
out = _run_node(

0 commit comments

Comments
 (0)