diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts index cb700c3..3367969 100644 --- a/src/core/math-delims.ts +++ b/src/core/math-delims.ts @@ -129,6 +129,48 @@ function closeEndsLine(text: string, afterClose: number): boolean { * Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`. * Everything else passes through byte-for-byte. */ +/** The escaped-dollar-aware inline closer (owner, 2026-10-12): micromark's + * mathText closer is code-span logic — ANY size-matched `$` run closes, + * `\$` included, so `$x = \$5$` closes at the `\$` and errors. KaTeX's + * own auto-render scanner skips escaped dollars, and KaTeX renders `\$` + * fine — the author's intent is unambiguous: an escaped dollar (odd number + * of backslashes before it) never closes the span. Mirrors findDollarClose's + * inline rules otherwise (exact size match, whole-run skip). */ +function findInlineDollarCloseEscapeAware(text: string, from: number): number { + for (let j = from; j < text.length;) { + if (text[j] !== "$") { + j += 1; + continue; + } + if (isEscaped(text, j)) { + j += 1; + continue; // `\$` never closes + } + const count = dollarRun(text, j); + if (count === 1) return j; + j += count; + } + return -1; +} + +/** Replace `\$` (odd-backslash dollars) inside a math span with the + * dollar-free `\text{\textdollar}` — KaTeX-safe in math mode (bare + * `\textdollar` is text-mode-only there). Even-backslash dollars (`\\$`, + * row break + dollar) are left alone. */ +function replaceEscapedDollars(content: string): string { + let replaced = ""; + for (let j = 0; j < content.length;) { + if (content[j] === "\\" && content[j + 1] === "$" && !isEscaped(content, j)) { + replaced += "\\text{\\textdollar}"; + j += 2; + } else { + replaced += content[j]; + j += 1; + } + } + return replaced; +} + export function transformMathDelimiters(markdown: string): string { const out: string[] = []; let i = 0; @@ -206,6 +248,35 @@ export function transformMathDelimiters(markdown: string): string { } } + // Escaped dollars inside inline math (owner, 2026-10-12): normalize + // BEFORE tokenization — inside a balanced inline span, `\$` becomes the + // dollar-free `\text{\textdollar}` so the scanner's closer is the one + // the author meant (micromark would otherwise close at the `\$`, error + // out, and cascade). Display runs are copied whole and left untouched — + // a lone `$` can't close `$$`, so `\$` already survives there — and + // copying the run stops its second `$` posing as an inline opener. + // Spans without escaped dollars emit byte-identical text; prose `\$`, + // code, and unbalanced spans are untouched. + if (ch === "$" && !isEscaped(markdown, i)) { + const run = dollarRun(markdown, i); + if (run === 1) { + const closeAt = findInlineDollarCloseEscapeAware(markdown, i + 1); + if (closeAt !== -1) { + const content = markdown.slice(i + 1, closeAt); + const replaced = replaceEscapedDollars(content); + if (replaced !== content) { + out.push(`$${replaced}$`); + i = closeAt + 1; + continue; + } + } + } else { + out.push(markdown.slice(i, i + run)); + i += run; + continue; + } + } + out.push(ch); i += 1; } diff --git a/tests/render-markdown.test.tsx b/tests/render-markdown.test.tsx index fdbd1d9..4263d98 100644 --- a/tests/render-markdown.test.tsx +++ b/tests/render-markdown.test.tsx @@ -253,3 +253,95 @@ describe("render — bold is the real (the dead Bold-color fix)", () => assert.ok(html.includes(""), "italic renders a native em element"); }); }); + +// ─── escaped dollars inside math (owner, 2026-10-12) ──────────────────────── +// +// micromark's mathText closer is code-span logic — ANY size-matched `$` run +// closes, `\$` included — so `$x = \$5$` closed at the escape and errored. +// KaTeX itself renders `\$` fine (and its own auto-render scanner skips +// escaped dollars). The fix normalizes at the render seam: inside balanced +// inline spans, `\$` becomes the dollar-free `\text{\textdollar}` BEFORE +// tokenization, so the scanner's closer is the one the author meant. + +describe("escaped dollars inside math — transform units", () => { + it("replaces \\$ inside a balanced inline span", () => { + assert.equal( + transformMathDelimiters("It costs $x = \\$5$ at market."), + "It costs $x = \\text{\\textdollar}5$ at market.", + ); + }); + + it("replaces every escaped dollar in the span, including the \\text{\\$} form", () => { + assert.equal( + transformMathDelimiters("$\\$3 + \\text{\\$}4 = \\$7$ exactly."), + "$\\text{\\textdollar}3 + \\text{\\text{\\textdollar}}4 = \\text{\\textdollar}7$ exactly.", + ); + }); + + it("does NOT replace even-backslash dollars (row break + dollar keeps closing)", () => { + // `$a\\$` closes at that `$` in micromark today — the parity is load- + // bearing; replacing it would change span ends for legitimate TeX. + const md = "$a\\\\$b$ tail"; + assert.equal(transformMathDelimiters(md), md); + }); + + it("leaves prose escaped dollars and bare currency untouched", () => { + const md = "It costs \\$5 flat, then $5 and $10 more."; + assert.equal(transformMathDelimiters(md), md); + }); + + it("leaves display spans untouched — a lone $ can't close $$, \\$ already survives", () => { + const md = "$$x = \\$5,\\ q = \\$12$$"; + assert.equal(transformMathDelimiters(md), md); + }); + + it("leaves code spans, fences, and unbalanced spans untouched", () => { + assert.equal(transformMathDelimiters("Type `$x = \\$5$` literally."), "Type `$x = \\$5$` literally."); + assert.equal(transformMathDelimiters("```\n$x = \\$5$\n```"), "```\n$x = \\$5$\n```"); + assert.equal(transformMathDelimiters("Broken: $x = \\$5"), "Broken: $x = \\$5"); + }); + + it("clean spans stay byte-identical; only the escape-bearing span changes", () => { + const md = "$a$ costs $5\\$10$ and $b$"; + assert.equal( + transformMathDelimiters(md), + "$a$ costs $5\\text{\\textdollar}10$ and $b$", + ); + }); +}); + +describe("escaped dollars inside math — rendering", () => { + it("renders $x = \\$5$ as one clean math span with a visible dollar", () => { + const html = render("It costs $x = \\$5$ at market."); + assert.equal((html.match(/class="katex"/g) ?? []).length, 1, "one math span"); + assert.ok(!html.includes("katex-error"), "no KaTeX errors"); + assert.ok(html.includes("\\text{\\textdollar}"), "the dollar-free command reaches KaTeX"); + }); + + it("the session cascade case renders every later span cleanly", () => { + // The latex-testing session's Test 6: the early close used to error + // and could swallow the following text. + const html = render("Line A: $x = \\$5$.\n\nLine B: $y = 2$.\n\nWhat is $2 + 2$?"); + assert.equal((html.match(/class="katex"/g) ?? []).length, 3, "all three spans render"); + assert.ok(!html.includes("katex-error"), "no KaTeX errors"); + assert.ok(html.includes("Line B:"), "prose between spans survives"); + }); + + it("renders the \\text{\\$} form (Test 5's text-mode dollar)", () => { + const html = render("Price: $\\text{\\$}12$ today."); + assert.equal((html.match(/class="katex"/g) ?? []).length, 1); + assert.ok(!html.includes("katex-error")); + }); + + it("legacy \\(...\\$...\\) converts and renders with the dollar intact", () => { + const html = render("Old form \\(x = \\$5\\) renders."); + assert.equal((html.match(/class="katex"/g) ?? []).length, 1); + assert.ok(!html.includes("katex-error")); + }); + + it("code spans keep the escaped dollar literal", () => { + const html = render("Type `$x = \\$5$` literally."); + assert.equal((html.match(/class="katex"/g) ?? []).length, 0, "code is not math"); + assert.ok(html.includes("$x = \\$5$"), "the literal source stays visible"); + }); +});