feat(messages): KaTeX math rendering (P4-4)
Renders LaTeX via spec data-mx-maths spans/divs (KaTeX render of the attr, children as fallback) and conservative $…$ / $$…$$ text detection (escape-aware, currency-guarded, never inside code/pre). KaTeX + CSS load lazily on first math (ReactPrism pattern) — verified absent from the eager bundle. Sanitizer unchanged by design (we render post-sanitize from attr/text; no incoming MathML accepted). +14 unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,9 +43,14 @@ import { onEnterOrSpace } from '../utils/keyboard';
|
||||
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
||||
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
||||
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
|
||||
import { splitMathSegments } from '../utils/mathParse';
|
||||
|
||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
||||
|
||||
// KaTeX (and its CSS) is heavy, so it is code-split behind this dynamic import
|
||||
// and is NOT part of the eager import graph — see src/app/components/math/KaTeX.tsx.
|
||||
const KaTeXMath = lazy(() => import('../components/math/KaTeX'));
|
||||
|
||||
/** Languages handled by the custom TDS tokenizer. */
|
||||
const TDS_TOKENIZER_LANGS = new Set([
|
||||
'js',
|
||||
@@ -78,6 +83,27 @@ function renderTokenizedCode(code: string, lang: string): React.ReactNode {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders LaTeX via the lazily-loaded KaTeX component.
|
||||
*
|
||||
* `suspenseFallback` is shown while the KaTeX chunk loads (the raw LaTeX text).
|
||||
* `errorFallback` is shown if rendering fails outright — for the spec
|
||||
* `data-mx-maths` path this is the element's original children (the spec
|
||||
* fallback content); for the plain-text `$…$` path it is the raw source.
|
||||
*/
|
||||
const renderMath = (
|
||||
latex: string,
|
||||
displayMode: boolean,
|
||||
suspenseFallback: React.ReactNode,
|
||||
errorFallback: React.ReactNode,
|
||||
): JSX.Element => (
|
||||
<ErrorBoundary fallback={<>{errorFallback}</>}>
|
||||
<Suspense fallback={<>{suspenseFallback}</>}>
|
||||
<KaTeXMath latex={latex} displayMode={displayMode} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
|
||||
|
||||
export const LINKIFY_OPTS: LinkifyOpts = {
|
||||
@@ -503,6 +529,21 @@ export const getReactCustomHtmlParser = (
|
||||
if (mention) return mention;
|
||||
}
|
||||
|
||||
if ((name === 'span' || name === 'div') && 'data-mx-maths' in props) {
|
||||
// Spec (CS-API §11.5): render the `data-mx-maths` LaTeX with KaTeX
|
||||
// (block for <div>, inline for <span>). On failure fall back to the
|
||||
// element's existing children, which the spec defines as the fallback
|
||||
// representation.
|
||||
const latex = String(props['data-mx-maths']);
|
||||
const displayMode = name === 'div';
|
||||
const fallback = displayMode ? (
|
||||
<div {...props}>{domToReact(children as unknown as DOMNode[], opts)}</div>
|
||||
) : (
|
||||
<span {...props}>{domToReact(children as unknown as DOMNode[], opts)}</span>
|
||||
);
|
||||
return renderMath(latex, displayMode, latex, fallback);
|
||||
}
|
||||
|
||||
if (name === 'span' && 'data-mx-spoiler' in props) {
|
||||
return (
|
||||
<span
|
||||
@@ -546,20 +587,50 @@ export const getReactCustomHtmlParser = (
|
||||
}
|
||||
|
||||
if (domNode instanceof DOMText) {
|
||||
const linkify =
|
||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
|
||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a');
|
||||
const parentName =
|
||||
domNode.parent && 'name' in domNode.parent ? domNode.parent.name : undefined;
|
||||
const linkify = parentName !== 'code' && parentName !== 'a';
|
||||
// Never parse `$…$`/`$$…$$` math inside <pre>/<code> (verbatim regions).
|
||||
const mathAllowed = parentName !== 'code' && parentName !== 'pre';
|
||||
|
||||
let jsx = scaleSystemEmoji(domNode.data);
|
||||
const renderTextChunk = (text: string): (string | JSX.Element)[] | JSX.Element => {
|
||||
let jsx = scaleSystemEmoji(text);
|
||||
if (params.highlightRegex) {
|
||||
jsx = highlightText(params.highlightRegex, jsx);
|
||||
}
|
||||
if (linkify) {
|
||||
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
|
||||
}
|
||||
return jsx;
|
||||
};
|
||||
|
||||
if (params.highlightRegex) {
|
||||
jsx = highlightText(params.highlightRegex, jsx);
|
||||
if (mathAllowed) {
|
||||
const segments = splitMathSegments(domNode.data);
|
||||
if (segments.some((segment) => segment.type !== 'text')) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === 'text') {
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return (
|
||||
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
||||
);
|
||||
}
|
||||
const raw =
|
||||
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<React.Fragment key={index}>
|
||||
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (linkify) {
|
||||
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
|
||||
}
|
||||
return jsx;
|
||||
return renderTextChunk(domNode.data);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user