math: render escaped dollars inside inline math
micromark-extension-math's inline closer is code-span logic — any
size-matched $ run closes, backslash-escaped dollars included — so
"$x = \$5$" closed at the escape, errored in KaTeX, and could cascade
into later text. KaTeX itself renders the escaped dollar fine, and its
own auto-render scanner skips escaped dollars, so the author's intent
is unambiguous.
Fix at the render seam (transformMathDelimiters): inside a balanced
inline span, an escaped dollar becomes the dollar-free
\text{\textdollar} before tokenization, with an escape-aware closer
(odd-backslash dollars never close; even-backslash row-break closers
keep closing). Display spans are untouched (a lone $ can't close $$ —
the escape already survives there), prose escaped dollars, code, and
unbalanced spans pass through, and spans without escaped dollars emit
byte-identical text — corpus-verified over 489 dollar-bearing fields
in 40 real sessions (35 changed, all escaped-dollar spans; the
economics session's dollar-amount math now renders).
The latex-testing session's Test-6 question renders 14 KaTeX spans
with zero errors (was 12 + 2 errors). 1578/1578 tests both configs;
typecheck, lint, build, and Electron-runtime smoke green.
This commit is contained in:
@@ -129,6 +129,48 @@ function closeEndsLine(text: string, afterClose: number): boolean {
|
|||||||
* Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`.
|
* Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`.
|
||||||
* Everything else passes through byte-for-byte.
|
* 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 {
|
export function transformMathDelimiters(markdown: string): string {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
let i = 0;
|
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);
|
out.push(ch);
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,3 +253,95 @@ describe("render — bold is the real <strong> (the dead Bold-color fix)", () =>
|
|||||||
assert.ok(html.includes("<em>"), "italic renders a native em element");
|
assert.ok(html.includes("<em>"), "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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user