feat(web): render TeX math in Markdown
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user