fix(ui): harden TeX delimiter parsing
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: 00e9560f43c83e1edc61c185a4fc562c6c923e8b
|
||||
README.zh.md: 21226ab211106b7722139828762605cb71a4b498
|
||||
README.md: 3286e6da6020fa31ca13658e091a5b12dc57d28b
|
||||
README.zh.md: e3d8b6ac869d33850945db088472d9c1958ab0b2
|
||||
|
||||
@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`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 absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `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, responsive images, 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 `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `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, responsive images, 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 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ const previousBackslash: Previous = function (code) {
|
||||
}
|
||||
|
||||
const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
|
||||
const self = this
|
||||
|
||||
return start
|
||||
|
||||
function start(code: number | null): State | undefined {
|
||||
@@ -38,8 +36,8 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
|
||||
|
||||
function between(code: number | null): State | undefined {
|
||||
if (code === codes.eof) return nok(code)
|
||||
if (code === codes.backslash && self.previous !== codes.backslash) {
|
||||
return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, dataStart)(code)
|
||||
if (code === codes.backslash) {
|
||||
return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, afterCloseAttempt)(code)
|
||||
}
|
||||
if (markdownLineEnding(code)) {
|
||||
effects.enter(types.lineEnding)
|
||||
@@ -50,10 +48,22 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
|
||||
return dataStart(code)
|
||||
}
|
||||
|
||||
function afterCloseAttempt(code: number | null): State | undefined {
|
||||
return effects.check({ partial: true, tokenize: tokenizeOpen }, nok, dataStart)(code)
|
||||
}
|
||||
|
||||
function dataStart(code: number | null): State | undefined {
|
||||
effects.enter('mathTextData')
|
||||
effects.consume(code)
|
||||
return data
|
||||
return code === codes.backslash ? afterDataBackslash : data
|
||||
}
|
||||
|
||||
function afterDataBackslash(code: number | null): State | undefined {
|
||||
if (code === codes.backslash) {
|
||||
effects.consume(code)
|
||||
return data
|
||||
}
|
||||
return data(code)
|
||||
}
|
||||
|
||||
function data(code: number | null): State | undefined {
|
||||
@@ -88,11 +98,31 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
|
||||
return closeOk
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeOpen(openEffects: Parameters<Tokenizer>[0], openOk: State, openNok: State): State {
|
||||
return slash
|
||||
|
||||
function slash(code: number | null): State | undefined {
|
||||
/* v8 ignore next -- the opening check follows a failed close attempt at a backslash. */
|
||||
if (code !== codes.backslash) return openNok(code)
|
||||
openEffects.enter(types.chunkString)
|
||||
openEffects.consume(code)
|
||||
return parenthesis
|
||||
}
|
||||
|
||||
function parenthesis(code: number | null): State | undefined {
|
||||
if (code !== codes.leftParenthesis) return openNok(code)
|
||||
openEffects.consume(code)
|
||||
openEffects.exit(types.chunkString)
|
||||
return openOk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct {
|
||||
const tokenize: Tokenizer = function (effects, ok, nok) {
|
||||
const self = this
|
||||
let oddBackslashRun = false
|
||||
const tail = self.events.at(-1)
|
||||
const initialSize = tail?.[1].type === types.linePrefix
|
||||
? tail[2].sliceSerialize(tail[1], true).length
|
||||
@@ -124,11 +154,14 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number,
|
||||
|
||||
function content(code: number | null): State | undefined {
|
||||
if (code === codes.eof) return nok(code)
|
||||
if (code === marker && (marker !== codes.backslash || self.previous !== codes.backslash)) {
|
||||
return effects.attempt({ partial: true, tokenize: tokenizeClosingFence }, closed, markerValueStart)(code)
|
||||
if (code === marker && (marker !== codes.dollarSign || !oddBackslashRun)) {
|
||||
return effects.attempt(
|
||||
{ partial: true, tokenize: tokenizeClosingFence },
|
||||
closed,
|
||||
afterClosingFenceAttempt,
|
||||
)(code)
|
||||
}
|
||||
if (markdownLineEnding(code)) {
|
||||
/* v8 ignore next -- micromark gives same-line dollar flow to remark-math before this continuation branch. */
|
||||
return multiline
|
||||
? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
|
||||
: nok(code)
|
||||
@@ -136,6 +169,12 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number,
|
||||
return valueStart(code)
|
||||
}
|
||||
|
||||
function afterClosingFenceAttempt(code: number | null): State | undefined {
|
||||
return marker === codes.backslash
|
||||
? effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code)
|
||||
: markerValueStart(code)
|
||||
}
|
||||
|
||||
function afterContinuation(code: number | null): State | undefined {
|
||||
return effects.attempt(
|
||||
{ partial: true, tokenize: tokenizeClosingFence },
|
||||
@@ -148,12 +187,14 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number,
|
||||
|
||||
function valueStart(code: number | null): State | undefined {
|
||||
effects.enter('mathFlowValue')
|
||||
oddBackslashRun = code === codes.backslash
|
||||
effects.consume(code)
|
||||
return value
|
||||
}
|
||||
|
||||
function markerValueStart(code: number | null): State | undefined {
|
||||
effects.enter('mathFlowValue')
|
||||
oddBackslashRun = false
|
||||
effects.consume(code)
|
||||
return valueAfterMarker
|
||||
}
|
||||
@@ -171,6 +212,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number,
|
||||
effects.exit('mathFlowValue')
|
||||
return content(code)
|
||||
}
|
||||
oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false
|
||||
effects.consume(code)
|
||||
return value
|
||||
}
|
||||
@@ -208,6 +250,29 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number,
|
||||
return closeOk(code)
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeOpeningFence(
|
||||
openEffects: Parameters<Tokenizer>[0],
|
||||
openOk: State,
|
||||
openNok: State,
|
||||
): State {
|
||||
return sequenceStart
|
||||
|
||||
function sequenceStart(code: number | null): State | undefined {
|
||||
/* v8 ignore next -- the opening check follows a failed close attempt at the marker. */
|
||||
if (code !== marker) return openNok(code)
|
||||
openEffects.enter(types.chunkString)
|
||||
openEffects.consume(code)
|
||||
return sequenceEnd
|
||||
}
|
||||
|
||||
function sequenceEnd(code: number | null): State | undefined {
|
||||
if (code !== openMarker) return openNok(code)
|
||||
openEffects.consume(code)
|
||||
openEffects.exit(types.chunkString)
|
||||
return openOk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -272,7 +337,8 @@ const backslashMath: Extension = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add TeX backslash delimiters and same-line display-dollar blocks to remark.
|
||||
* Add TeX backslash delimiters and same-line display-dollar blocks for remark-math.
|
||||
* The same processor must register remark-math to compile the emitted math tokens.
|
||||
* @returns Nothing.
|
||||
*/
|
||||
export function remarkMathCompatibility(this: RemarkProcessor): undefined {
|
||||
|
||||
@@ -207,6 +207,13 @@ describe('MarkdownText', () => {
|
||||
source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)',
|
||||
math: 1,
|
||||
display: 0,
|
||||
value: '\\frac{1}{5}\n+\\frac{1}{7}',
|
||||
},
|
||||
{
|
||||
source: '\\[a\\\\\nb\\]',
|
||||
math: 1,
|
||||
display: 1,
|
||||
value: 'a\\\\\nb',
|
||||
},
|
||||
{
|
||||
source: '> \\[\n> \\frac{1}{5}\n> \\]',
|
||||
@@ -225,6 +232,9 @@ describe('MarkdownText', () => {
|
||||
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
|
||||
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
|
||||
expect(rendered.container.querySelector('.katex-error')).toBeNull()
|
||||
if ('value' in item) {
|
||||
expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value)
|
||||
}
|
||||
rendered.unmount()
|
||||
}
|
||||
|
||||
@@ -239,8 +249,10 @@ describe('MarkdownText', () => {
|
||||
const sources = [
|
||||
'$$\n\\theta\n$$',
|
||||
'$$$\\theta$$$',
|
||||
'$$a$b\nc',
|
||||
' \\[\n \\theta\n \\]',
|
||||
'\\(\\theta',
|
||||
'\\[\n\\[',
|
||||
'> \\[\nnot a quoted continuation\n\\]',
|
||||
]
|
||||
|
||||
@@ -251,6 +263,29 @@ describe('MarkdownText', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders escaped dollars and even backslash pairs before closing fences', () => {
|
||||
const source = [
|
||||
String.raw`$$100\$$$`,
|
||||
'',
|
||||
String.raw`\(a\\\)`,
|
||||
'',
|
||||
String.raw`\[b\\\]`,
|
||||
].join('\n')
|
||||
const { container } = render(<MarkdownText text={source} />)
|
||||
const values = [...container.querySelectorAll('annotation')].map(node => node.textContent)
|
||||
|
||||
expect(values).toEqual([String.raw`100\$`, String.raw`a\\`, String.raw`b\\`])
|
||||
expect(container.querySelector('.katex-error')).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds fallback work for repeated unclosed backslash delimiters', () => {
|
||||
const startedAt = performance.now()
|
||||
const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
|
||||
|
||||
expect(performance.now() - startedAt).toBeLessThan(1_000)
|
||||
expect(container.querySelector('.katex')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves TeX-looking fenced code literal', () => {
|
||||
const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```'
|
||||
const { container } = render(<MarkdownText text={source} />)
|
||||
|
||||
Reference in New Issue
Block a user