Merge remote-tracking branch 'origin/master' into feat/py-types-code-mode
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: d4e6c2508f07f4832f2e12a836c2d447b656421e
|
||||
README.zh.md: 76dfbebd5e9db494b49d65a2528977b7ac9fed15
|
||||
README.md: 03e7e3649fd0913fb48579aa87634153f67f5baf
|
||||
README.zh.md: 090ecc34e8d514e38853de8ed52e82d3bf019b43
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
@@ -44,6 +44,6 @@
|
||||
|
||||
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
|
||||
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
|
||||
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。
|
||||
- **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。
|
||||
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
|
||||
- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"micromark-extension-math": "^3.1.0",
|
||||
"micromark-factory-space": "^2.0.1",
|
||||
"micromark-util-character": "^2.1.1",
|
||||
"micromark-util-symbol": "^2.0.1",
|
||||
"micromark-util-types": "^2.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -5,11 +5,16 @@ import rehypeKatex from 'rehype-katex'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { CodeBlock } from './CodeBlock.tsx'
|
||||
import { remarkMathCompatibility } from './remarkMathCompatibility.ts'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
const streamingRemarkPlugins = [remarkGfm]
|
||||
const settledRemarkPlugins = [remarkGfm, remarkMath]
|
||||
const settledRemarkPlugins = [
|
||||
remarkGfm,
|
||||
remarkMathCompatibility,
|
||||
remarkMath,
|
||||
]
|
||||
const settledRehypePlugins = [rehypeKatex]
|
||||
|
||||
function sanitizeUrl(url: string): string {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/** Extend upstream dollar-only math syntax with TeX delimiters while reusing its token vocabulary. */
|
||||
|
||||
import { factorySpace } from 'micromark-factory-space'
|
||||
import type {} from 'micromark-extension-math'
|
||||
import { markdownLineEnding } from 'micromark-util-character'
|
||||
import { codes, constants, types } from 'micromark-util-symbol'
|
||||
import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark-util-types'
|
||||
|
||||
// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback.
|
||||
|
||||
interface RemarkProcessor {
|
||||
data(): { micromarkExtensions?: Extension[] }
|
||||
}
|
||||
|
||||
const previousBackslash: Previous = function (code) {
|
||||
if (code !== codes.backslash) return true
|
||||
const tail = this.events.at(-1)
|
||||
/* v8 ignore next -- a previous code necessarily has a preceding event. */
|
||||
if (tail === undefined) return false
|
||||
return tail[1].type === types.characterEscape
|
||||
}
|
||||
|
||||
const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
|
||||
return start
|
||||
|
||||
function start(code: number | null): State | undefined {
|
||||
/* v8 ignore next -- the text construct is dispatched only for a backslash. */
|
||||
if (code !== codes.backslash) return nok(code)
|
||||
effects.enter('mathText')
|
||||
effects.enter('mathTextSequence')
|
||||
effects.consume(code)
|
||||
return open
|
||||
}
|
||||
|
||||
function open(code: number | null): State | undefined {
|
||||
if (code !== codes.leftParenthesis) return nok(code)
|
||||
effects.consume(code)
|
||||
effects.exit('mathTextSequence')
|
||||
return between
|
||||
}
|
||||
|
||||
function between(code: number | null): State | undefined {
|
||||
if (code === codes.eof) return nok(code)
|
||||
if (code === codes.backslash) {
|
||||
return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, afterCloseAttempt)(code)
|
||||
}
|
||||
if (markdownLineEnding(code)) {
|
||||
effects.enter(types.lineEnding)
|
||||
effects.consume(code)
|
||||
effects.exit(types.lineEnding)
|
||||
return between
|
||||
}
|
||||
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 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 {
|
||||
if (code === codes.eof || code === codes.backslash || markdownLineEnding(code)) {
|
||||
effects.exit('mathTextData')
|
||||
return between(code)
|
||||
}
|
||||
effects.consume(code)
|
||||
return data
|
||||
}
|
||||
|
||||
function close(code: number | null): State | undefined {
|
||||
effects.exit('mathText')
|
||||
return ok(code)
|
||||
}
|
||||
|
||||
function tokenizeClose(closeEffects: Parameters<Tokenizer>[0], closeOk: State, closeNok: State): State {
|
||||
return slash
|
||||
|
||||
function slash(code: number | null): State | undefined {
|
||||
/* v8 ignore next -- this partial construct is attempted only at a backslash. */
|
||||
if (code !== codes.backslash) return closeNok(code)
|
||||
closeEffects.enter('mathTextSequence')
|
||||
closeEffects.consume(code)
|
||||
return parenthesis
|
||||
}
|
||||
|
||||
function parenthesis(code: number | null): State | undefined {
|
||||
if (code !== codes.rightParenthesis) return closeNok(code)
|
||||
closeEffects.consume(code)
|
||||
closeEffects.exit('mathTextSequence')
|
||||
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
|
||||
: 0
|
||||
|
||||
return start
|
||||
|
||||
function start(code: number | null): State | undefined {
|
||||
/* v8 ignore next -- the flow construct is dispatched only for its marker. */
|
||||
if (code !== marker) return nok(code)
|
||||
effects.enter('mathFlow')
|
||||
effects.enter('mathFlowFence')
|
||||
effects.enter('mathFlowFenceSequence')
|
||||
effects.consume(code)
|
||||
return open
|
||||
}
|
||||
|
||||
function open(code: number | null): State | undefined {
|
||||
if (code !== openMarker) return nok(code)
|
||||
effects.consume(code)
|
||||
effects.exit('mathFlowFenceSequence')
|
||||
effects.exit('mathFlowFence')
|
||||
return marker === codes.dollarSign ? afterDollarOpen : content
|
||||
}
|
||||
|
||||
function afterDollarOpen(code: number | null): State | undefined {
|
||||
return code === codes.dollarSign ? nok(code) : content(code)
|
||||
}
|
||||
|
||||
function content(code: number | null): State | undefined {
|
||||
if (code === codes.eof) return nok(code)
|
||||
if (code === marker && (marker !== codes.dollarSign || !oddBackslashRun)) {
|
||||
return effects.attempt(
|
||||
{ partial: true, tokenize: tokenizeClosingFence },
|
||||
closed,
|
||||
afterClosingFenceAttempt,
|
||||
)(code)
|
||||
}
|
||||
if (markdownLineEnding(code)) {
|
||||
return multiline
|
||||
? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
|
||||
: nok(code)
|
||||
}
|
||||
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 },
|
||||
closed,
|
||||
initialSize
|
||||
? factorySpace(effects, content, types.linePrefix, initialSize + 1)
|
||||
: content,
|
||||
)(code)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function valueAfterMarker(code: number | null): State | undefined {
|
||||
if (code === marker) {
|
||||
effects.consume(code)
|
||||
return value
|
||||
}
|
||||
return value(code)
|
||||
}
|
||||
|
||||
function value(code: number | null): State | undefined {
|
||||
if (code === codes.eof || code === marker || markdownLineEnding(code)) {
|
||||
effects.exit('mathFlowValue')
|
||||
return content(code)
|
||||
}
|
||||
oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false
|
||||
effects.consume(code)
|
||||
return value
|
||||
}
|
||||
|
||||
function closed(code: number | null): State | undefined {
|
||||
effects.exit('mathFlow')
|
||||
return ok(code)
|
||||
}
|
||||
|
||||
function tokenizeClosingFence(
|
||||
closeEffects: Parameters<Tokenizer>[0],
|
||||
closeOk: State,
|
||||
closeNok: State,
|
||||
): State {
|
||||
return factorySpace(closeEffects, sequenceStart, types.linePrefix, constants.tabSize)
|
||||
|
||||
function sequenceStart(code: number | null): State | undefined {
|
||||
if (code !== marker) return closeNok(code)
|
||||
closeEffects.enter('mathFlowFence')
|
||||
closeEffects.enter('mathFlowFenceSequence')
|
||||
closeEffects.consume(code)
|
||||
return sequenceEnd
|
||||
}
|
||||
|
||||
function sequenceEnd(code: number | null): State | undefined {
|
||||
if (code !== closeMarker) return closeNok(code)
|
||||
closeEffects.consume(code)
|
||||
closeEffects.exit('mathFlowFenceSequence')
|
||||
return factorySpace(closeEffects, after, types.whitespace)
|
||||
}
|
||||
|
||||
function after(code: number | null): State | undefined {
|
||||
if (code !== codes.eof && !markdownLineEnding(code)) return closeNok(code)
|
||||
closeEffects.exit('mathFlowFence')
|
||||
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 {
|
||||
concrete: true,
|
||||
name: marker === codes.dollarSign ? 'sameLineDollarMathFlow' : 'backslashMathFlow',
|
||||
tokenize,
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizeNonLazyContinuation: Tokenizer = function (effects, ok, nok) {
|
||||
const self = this
|
||||
|
||||
return start
|
||||
|
||||
function start(code: number | null): State | undefined {
|
||||
/* v8 ignore next -- continuation constructs are attempted only after a line ending. */
|
||||
if (code === codes.eof) return ok(code)
|
||||
/* v8 ignore next -- continuation constructs are attempted only after a line ending. */
|
||||
if (!markdownLineEnding(code)) return nok(code)
|
||||
effects.enter(types.lineEnding)
|
||||
effects.consume(code)
|
||||
effects.exit(types.lineEnding)
|
||||
return lineStart
|
||||
}
|
||||
|
||||
function lineStart(code: number | null): State | undefined {
|
||||
return self.parser.lazy[self.now().line] ? nok(code) : ok(code)
|
||||
}
|
||||
}
|
||||
|
||||
const nonLazyContinuation: Construct = {
|
||||
partial: true,
|
||||
tokenize: tokenizeNonLazyContinuation,
|
||||
}
|
||||
|
||||
const backslashMathText: Construct = {
|
||||
name: 'backslashMathText',
|
||||
previous: previousBackslash,
|
||||
tokenize: tokenizeBackslashMathText,
|
||||
}
|
||||
|
||||
const backslashMathFlow = createMathFlow(
|
||||
codes.backslash,
|
||||
codes.leftSquareBracket,
|
||||
codes.rightSquareBracket,
|
||||
true,
|
||||
)
|
||||
|
||||
const sameLineDollarMathFlow = createMathFlow(
|
||||
codes.dollarSign,
|
||||
codes.dollarSign,
|
||||
codes.dollarSign,
|
||||
false,
|
||||
)
|
||||
|
||||
const backslashMath: Extension = {
|
||||
flow: {
|
||||
[codes.backslash]: backslashMathFlow,
|
||||
[codes.dollarSign]: sameLineDollarMathFlow,
|
||||
},
|
||||
text: { [codes.backslash]: backslashMathText },
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const data = this.data()
|
||||
const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
|
||||
extensions.push(backslashMath)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Extension } from 'micromark-util-types'
|
||||
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -171,6 +173,161 @@ describe('MarkdownText', () => {
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => {
|
||||
const source = [
|
||||
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
|
||||
'',
|
||||
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
|
||||
'',
|
||||
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
|
||||
'',
|
||||
'| Symbol | Value |',
|
||||
'| --- | --- |',
|
||||
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
|
||||
].join('\n')
|
||||
const { container } = render(<MarkdownText text={source} />)
|
||||
|
||||
expect(container.querySelectorAll('.katex')).toHaveLength(6)
|
||||
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
|
||||
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}')
|
||||
expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent)
|
||||
.toContain('\\tag{1}')
|
||||
expect(container.querySelector('.katex-error')).toBeNull()
|
||||
expect(container.querySelector('table .katex')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => {
|
||||
const cases = [
|
||||
{
|
||||
source: '\\(\\alpha \\, \\beta\\)',
|
||||
math: 1,
|
||||
display: 0,
|
||||
},
|
||||
{
|
||||
source: String.raw`\\\(x\)`,
|
||||
math: 1,
|
||||
display: 0,
|
||||
value: 'x',
|
||||
},
|
||||
{
|
||||
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> \\]',
|
||||
math: 1,
|
||||
display: 1,
|
||||
},
|
||||
{
|
||||
source: '- \\[\n \\frac{1}{5}\n \\]',
|
||||
math: 1,
|
||||
display: 1,
|
||||
},
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const rendered = render(<MarkdownText text={item.source} />)
|
||||
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()
|
||||
}
|
||||
|
||||
const literal = render(<MarkdownText text={'\\\\(x\\)\n\n\\[x'} />)
|
||||
expect(literal.container.querySelectorAll('.katex')).toHaveLength(0)
|
||||
expect(literal.container.querySelector('.katex-display')).toBeNull()
|
||||
expect(literal.container.textContent).toContain('[x')
|
||||
})
|
||||
|
||||
it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => {
|
||||
const cases = [
|
||||
{ source: '$$\n\\theta\n$$', math: 1, display: 1 },
|
||||
{ source: '$$$\\theta$$$', math: 1, display: 0 },
|
||||
{ source: '$$a$b\nc', math: 0, display: 0 },
|
||||
{ source: ' \\[\n \\theta\n \\]', math: 1, display: 1 },
|
||||
{ source: '\\(\\theta', math: 0, display: 0 },
|
||||
{ source: String.raw`\(a\\)`, math: 0, display: 0 },
|
||||
{ source: '\\[\n\\[', math: 0, display: 0 },
|
||||
{ source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const rendered = render(<MarkdownText text={item.source} />)
|
||||
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
|
||||
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
|
||||
expect(rendered.container.querySelector('.katex-error')).toBeNull()
|
||||
rendered.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets display math interrupt an open paragraph', () => {
|
||||
for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) {
|
||||
const rendered = render(<MarkdownText text={source} />)
|
||||
expect(rendered.container.querySelectorAll('p')).toHaveLength(1)
|
||||
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
rendered.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a dollar block with trailing text to upstream inline math', () => {
|
||||
const { container } = render(<MarkdownText text="$$x$$ trailing" />)
|
||||
|
||||
expect(container.querySelectorAll('.katex')).toHaveLength(1)
|
||||
expect(container.querySelector('.katex-display')).toBeNull()
|
||||
expect(container.querySelector('annotation')?.textContent).toBe('x')
|
||||
expect(container.textContent).toContain('trailing')
|
||||
})
|
||||
|
||||
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} />)
|
||||
|
||||
expect(container.querySelector('.katex')).toBeNull()
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('\\[\\frac{1}{5}\\]')
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$')
|
||||
})
|
||||
|
||||
it('registers the compatibility extension on a bare remark processor', () => {
|
||||
const data: { micromarkExtensions?: Extension[] } = {}
|
||||
remarkMathCompatibility.call({ data: () => data })
|
||||
|
||||
expect(data.micromarkExtensions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
|
||||
const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
|
||||
const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'
|
||||
|
||||
Reference in New Issue
Block a user