fix(markdown): currency-safe math delimiters

Single-dollar $...$ inline math collided with currency text ($50,
US$ 680, "$50M to $72M"), parsing money as math and corrupting it.
Drop single-dollar inline math; keep $$...$$ display math and add
\(...\) inline and \[...\] display via marked tokenizers (caught at
lex time so they survive backslash escaping and stay code-safe).

Also gate renderMathExpressions on a cheap $-presence check so the
split + regex passes are skipped for the non-math majority of blocks.
This commit is contained in:
Bohdan Triapitsyn
2026-06-17 00:33:39 +03:00
parent b6f7f3a478
commit a99eba1ca6
@@ -107,9 +107,64 @@ export const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
// marked parser (HTML string output) with safe external links
// ---------------------------------------------------------------------------
// Math delimiters that use backslashes — `\(...\)` (inline) and `\[...\]`
// (display) — must be caught during lexing: marked treats `\(`/`\[` as
// backslash escapes and strips the slash before any HTML post-process can see
// them. Registering them as tokenizers also makes them code-safe for free
// (marked tokenizes code spans/fences first, so these never fire inside code).
// Single-dollar `$...$` is intentionally NOT supported — it collides with
// currency text ($50, US$ 680); only `$$...$$` survives as display math (see
// renderMathExpressions). This mirrors KaTeX auto-render's default delimiters.
type MathToken = { type: string; raw: string; text: string };
const renderKatex = (math: string, raw: string, displayMode: boolean): string => {
try {
return katex.renderToString(math, { displayMode, throwOnError: false });
} catch {
return raw;
}
};
const inlineMathExtension = {
name: 'inlineMath',
level: 'inline' as const,
start(src: string) {
const index = src.indexOf('\\(');
return index < 0 ? undefined : index;
},
tokenizer(src: string): MathToken | undefined {
const match = /^\\\(([\s\S]+?)\\\)/.exec(src);
if (!match) return undefined;
return { type: 'inlineMath', raw: match[0], text: match[1] ?? '' };
},
renderer(token: Tokens.Generic) {
const math = token as MathToken;
return renderKatex(math.text, math.raw, false);
},
};
const blockMathExtension = {
name: 'blockMath',
level: 'block' as const,
start(src: string) {
const index = src.indexOf('\\[');
return index < 0 ? undefined : index;
},
tokenizer(src: string): MathToken | undefined {
const match = /^\\\[([\s\S]+?)\\\]/.exec(src);
if (!match) return undefined;
return { type: 'blockMath', raw: match[0], text: match[1] ?? '' };
},
renderer(token: Tokens.Generic) {
const math = token as MathToken;
return renderKatex(math.text, math.raw, true);
},
};
const parser = marked.use({
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
renderer: {
link({ href, title, text }) {
const target = href ?? '';
@@ -131,8 +186,14 @@ const parser = marked.use({
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
// ---------------------------------------------------------------------------
const renderMathInText = (text: string): string => {
let result = text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, math: string) => {
// Only `$$...$$` (display) is handled here. Single-dollar `$...$` inline math is
// deliberately omitted: it parses currency text ($50, US$ 680, "$50M to $72M")
// as math and corrupts it. Inline math is supported via `\(...\)` (see the
// marked extensions above). `$$` survives marked untouched (no backslash), so
// post-processing the parsed HTML — skipping code via renderMathExpressions —
// stays correct and code-safe.
const renderMathInText = (text: string): string =>
text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, math: string) => {
try {
return katex.renderToString(math, { displayMode: true, throwOnError: false });
} catch {
@@ -140,18 +201,11 @@ const renderMathInText = (text: string): string => {
}
});
result = result.replace(/(?<!\$)\$(?!\$)((?:[^$\\]|\\.)+?)\$(?!\$)/g, (_match, math: string) => {
try {
return katex.renderToString(math, { displayMode: false, throwOnError: false });
} catch {
return `$${math}$`;
}
});
return result;
};
const renderMathExpressions = (html: string): string => {
// No `$` anywhere means no math to render — skip the split + regex passes on
// the hot streaming path (the overwhelming majority of blocks have no math).
if (html.indexOf('$') === -1) return html;
const codeBlockPattern = /(<(?:pre|code|kbd)[^>]*>[\s\S]*?<\/(?:pre|code|kbd)>)/gi;
return html
.split(codeBlockPattern)