feat(web): render TeX math in Markdown

This commit is contained in:
ZiyaZhang
2026-08-01 00:05:23 -07:00
parent 67b166b928
commit 1e334fa955
9 changed files with 236 additions and 7 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: f06e8c014c19d17197980a232e8b42080aac2901
README.zh.md: feb97ee9fa834fbff10052909630ffcbbce9c78a
README.md: 8bdc3f043488631424c85a4319020c3d8ba5437b
README.zh.md: 52b507bb924ca4c05e92ed09d819339621c3bc20

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output

View File

@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM`$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出

View File

@@ -23,13 +23,16 @@
"@shikijs/langs": "^4.3.1",
"anser": "^2.3.5",
"clsx": "^2.0.0",
"katex": "^0.16.47",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^4.3.1"
},
"devDependencies": {

View File

@@ -1,5 +1,5 @@
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
Modules. Cite pills, header anchors, and thinking-small variants are
intentionally absent (no matching DOM). Token names match that sheet. */
.markdown {
@@ -160,6 +160,12 @@
font-family: var(--ds-font-family-code);
}
.markdown :global(.katex-display) {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.markdown input[type='checkbox'] {
margin: 0 8px 0 0;
accent-color: var(--dsw-alias-label-secondary);

View File

@@ -1,11 +1,15 @@
import { isValidElement, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { CodeBlock } from './CodeBlock.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
const remarkPlugins = [remarkGfm, remarkMath]
const rehypePlugins = [rehypeKatex]
function sanitizeUrl(url: string): string {
try {
@@ -93,7 +97,7 @@ const streamingComponents = buildComponents(true)
* pass a reference-stable object (memoized per locale revision), because the
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
* @returns A GFM document with TeX math rendered through KaTeX and raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
@@ -110,6 +114,7 @@ export function MarkdownText({ text, streaming = false, codeLabels }: {
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
urlTransform={safeUrl}
>

View File

@@ -94,6 +94,11 @@ describe('MarkdownText', () => {
expect(done.container.querySelector('pre.shiki')).not.toBeNull()
})
it('forwards localized labels to fenced code blocks', () => {
render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
})
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
const markdown = [
'<script>globalThis.compromised = true</script>',
@@ -121,6 +126,24 @@ describe('MarkdownText', () => {
expect(container.querySelectorAll('li')).toHaveLength(2)
expect(screen.getByText('**unfinished')).toBeTruthy()
})
it('renders inline and display TeX through KaTeX without enabling trusted commands', () => {
const source = [
'Einstein wrote $E = mc^2$.',
'',
'$$',
'\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
'$$',
'',
'$\\href{javascript:alert(1)}{unsafe}$',
].join('\n')
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex')).toHaveLength(3)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}')
expect(container.querySelector('a')).toBeNull()
})
})
describe('JsonBlock', () => {