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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user