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
|
// 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({
|
const parser = marked.use({
|
||||||
gfm: true,
|
gfm: true,
|
||||||
breaks: false,
|
breaks: false,
|
||||||
|
extensions: [inlineMathExtension, blockMathExtension],
|
||||||
renderer: {
|
renderer: {
|
||||||
link({ href, title, text }) {
|
link({ href, title, text }) {
|
||||||
const target = href ?? '';
|
const target = href ?? '';
|
||||||
@@ -131,8 +186,14 @@ const parser = marked.use({
|
|||||||
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
|
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const renderMathInText = (text: string): string => {
|
// Only `$$...$$` (display) is handled here. Single-dollar `$...$` inline math is
|
||||||
let result = text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, math: string) => {
|
// 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 {
|
try {
|
||||||
return katex.renderToString(math, { displayMode: true, throwOnError: false });
|
return katex.renderToString(math, { displayMode: true, throwOnError: false });
|
||||||
} catch {
|
} 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 => {
|
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;
|
const codeBlockPattern = /(<(?:pre|code|kbd)[^>]*>[\s\S]*?<\/(?:pre|code|kbd)>)/gi;
|
||||||
return html
|
return html
|
||||||
.split(codeBlockPattern)
|
.split(codeBlockPattern)
|
||||||
|
|||||||
Reference in New Issue
Block a user