Skip to content

Commit 579a93b

Browse files
authored
feat(markdown): add KaTeX math rendering via Streamdown plugin API (#156)
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
1 parent d450a61 commit 579a93b

5 files changed

Lines changed: 110 additions & 1 deletion

File tree

bun.lock

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@
4242
"@fontsource/source-serif-4": "^5.2.9",
4343
"@tauri-apps/api": "^2.11.0",
4444
"framer-motion": "^12.38.0",
45+
"katex": "^0.16.0",
4546
"react": "^19.2.4",
4647
"react-dom": "^19.2.4",
48+
"rehype-katex": "^7.0.1",
49+
"remark-math": "^6.0.0",
4750
"streamdown": "^2.5.0"
4851
},
4952
"overrides": {

src/App.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@import 'tailwindcss';
22
@source "../node_modules/streamdown/dist";
33
@import 'streamdown/styles.css';
4+
@import 'katex/dist/katex.min.css';
45

56
/* Source Serif 4 reading face for AI prose. Chrome stays Inter. */
67
@import '@fontsource/source-serif-4/400.css';

src/components/MarkdownRenderer.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React, { memo } from 'react';
2-
import { Streamdown } from 'streamdown';
2+
import { Streamdown, type MathPlugin } from 'streamdown';
3+
import remarkMath from 'remark-math';
4+
import rehypeKatex from 'rehype-katex';
35

46
interface MarkdownRendererProps {
57
content: string;
@@ -8,6 +10,13 @@ interface MarkdownRendererProps {
810
isStreaming?: boolean;
911
}
1012

13+
const mathPlugin: MathPlugin = {
14+
name: 'katex',
15+
type: 'math',
16+
remarkPlugin: remarkMath,
17+
rehypePlugin: rehypeKatex,
18+
};
19+
1120
/**
1221
* Renders markdown content using Streamdown, a streaming-aware markdown
1322
* renderer that handles incomplete syntax and memoizes completed blocks.
@@ -22,6 +31,10 @@ interface MarkdownRendererProps {
2231
* then sanitized, stripping script tags, event handlers, iframes, and
2332
* javascript: URLs. Link safety is disabled so links render as native
2433
* anchor elements with target="_blank" and rel="noopener noreferrer".
34+
* KaTeX is appended after rehype-sanitize in Streamdown's pipeline and
35+
* is therefore not covered by the allowlist. XSS safety for math content
36+
* relies on KaTeX's own output escaping and the default trust:false setting,
37+
* which blocks arbitrary-HTML LaTeX macros such as \href and \htmlClass.
2538
*
2639
* Memoized to skip re-renders when props are unchanged, which matters
2740
* during LLM token streaming where sibling bubbles would otherwise
@@ -47,6 +60,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
4760
In a Tauri app the webview opens external links in the system
4861
browser, making the modal unnecessary friction. */
4962
linkSafety={{ enabled: false }}
63+
plugins={{ math: mathPlugin }}
5064
>
5165
{content}
5266
</Streamdown>

src/components/__tests__/MarkdownRenderer.test.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,68 @@ describe('MarkdownRenderer', () => {
190190
});
191191
});
192192

193+
describe('Math rendering', () => {
194+
it('renders display math blocks via KaTeX', () => {
195+
const { container } = render(
196+
<MarkdownRenderer content={'$$x = \\frac{-b}{2a}$$'} />,
197+
);
198+
expect(container.querySelector('.katex')).not.toBeNull();
199+
expect(container.textContent).not.toContain('$$');
200+
});
201+
202+
it('renders inline math via KaTeX', () => {
203+
const { container } = render(
204+
<MarkdownRenderer content={'Energy is $E = mc^2$ at rest'} />,
205+
);
206+
expect(container.querySelector('.katex')).not.toBeNull();
207+
expect(container.textContent).not.toContain('$E = mc^2$');
208+
});
209+
210+
it('does not render raw LaTeX source as plain text', () => {
211+
const { container } = render(
212+
<MarkdownRenderer content={'$$\\sum_{i=0}^{n} i$$'} />,
213+
);
214+
expect(container.querySelector('.katex')).not.toBeNull();
215+
// MathML <annotation> always contains the raw LaTeX source for
216+
// accessibility; check only the visible katex-html portion.
217+
const katexHtml = container.querySelector('.katex-html');
218+
expect(katexHtml).not.toBeNull();
219+
expect(katexHtml!.textContent).not.toContain('\\sum');
220+
});
221+
222+
it('preserves XSS protection alongside math rendering', () => {
223+
const { container } = render(
224+
<MarkdownRenderer
225+
content={'$E = mc^2$\n\n<script>alert("xss")</script>'}
226+
/>,
227+
);
228+
expect(container.querySelector('.katex')).not.toBeNull();
229+
expect(container.querySelector('script')).toBeNull();
230+
expect(container.innerHTML).not.toContain('<script');
231+
});
232+
233+
it('blocks javascript: URLs in LaTeX \\href via trust:false', () => {
234+
const { container } = render(
235+
<MarkdownRenderer content={'$\\href{javascript:alert(1)}{click}$'} />,
236+
);
237+
// trust:false renders \href as a red error span, not a clickable link.
238+
// MathML <annotation> contains raw source as non-executable text;
239+
// check only the visible katex-html portion for any live href.
240+
const katexHtml = container.querySelector('.katex-html');
241+
expect(katexHtml).not.toBeNull();
242+
expect(katexHtml!.innerHTML).not.toMatch(/javascript:/i);
243+
expect(container.querySelector('a[href]')).toBeNull();
244+
});
245+
246+
it('renders math in streaming mode without leaking raw LaTeX', () => {
247+
const { container } = render(
248+
<MarkdownRenderer content={'$$E = mc^2$$'} isStreaming={true} />,
249+
);
250+
expect(container.querySelector('.katex')).not.toBeNull();
251+
expect(container.textContent).not.toContain('$$');
252+
});
253+
});
254+
193255
describe('Edge cases', () => {
194256
it('handles empty string', () => {
195257
const { container } = render(<MarkdownRenderer content="" />);

0 commit comments

Comments
 (0)