Merge branch 'master' into feat/session-completed-dot

This commit is contained in:
GeeeekExplorer
2026-08-06 15:53:29 +08:00
committed by GitHub
70 changed files with 3318 additions and 620 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: c64e86152737fd55c49ac39cc2e7b523e0323774
README.zh.md: 29123c3570122bc0fe6a1808a75c5bc659315eaa
README.md: 385730c94831d2fd4af83f9eca0f55941551c796
README.zh.md: b8a75dbffc6549f6294dfda5988c67d6569386c9

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`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{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. 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. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. `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{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. 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. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `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
@@ -42,6 +42,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. Inline links and references resolved within one parse are unaffected.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error.

View File

@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出
@@ -42,6 +42,7 @@
## 已知限制与暂缓事项
- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。内联链接以及在同一次解析内完成解析的引用不受影响。
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。

View File

@@ -21,25 +21,24 @@
"license": "BSD-3-Clause",
"dependencies": {
"@shikijs/langs": "^4.3.1",
"@types/mdast": "^4.0.4",
"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",
"mdast-util-math": "^3.0.0",
"micromark-core-commonmark": "^2.0.3",
"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-classify-character": "^2.0.1",
"micromark-util-sanitize-uri": "^2.0.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",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^4.3.1"
},
"devDependencies": {

View File

@@ -1,175 +1,164 @@
import { isValidElement, useMemo, type ReactNode } 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 { remarkCjkFriendlyStrong } from './remarkCjkFriendlyStrong.ts'
import { remarkMathCompatibility } from './remarkMathCompatibility.ts'
/**
* Untrusted assistant-Markdown renderer over the direct mdast pipeline:
* `parse.ts` grammars, the incremental streaming parser, and `render.tsx`.
* While a message streams, all but the trailing two blocks freeze as cached
* React elements and only the source tail behind them re-parses per chunk,
* so per-chunk work tracks the tail size instead of the whole reply. Frozen
* blocks keep their source-offset keys when they cross the freeze boundary,
* so React reconciles instead of remounting. Known deviation while
* streaming: a reference-style link or footnote whose definition sits on the
* other side of the freeze boundary renders literally until the settled
* full parse self-heals it.
*/
import { memo, useMemo, useRef } from 'react'
import type { ReactNode } from 'react'
import { IncrementalMarkdownParser } from './incremental.ts'
import { parseGfm, parseGfmWithMath } from './parse.ts'
import {
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
wrapBlockChildren,
} from './render.tsx'
import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
const streamingRemarkPlugins = [remarkGfm, remarkCjkFriendlyStrong]
const settledRemarkPlugins = [
remarkGfm,
remarkCjkFriendlyStrong,
remarkMathCompatibility,
remarkMath,
]
const settledRehypePlugins = [rehypeKatex]
export type { MarkdownCodeLabels } from './render.tsx'
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
}
} catch {
return ''
/** One settled full render: parse with math, resolve references, append the footnote section. */
function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] {
const root = parseGfmWithMath(text)
const targets = createReferenceTargets()
collectReferenceTargets(root.children, targets)
const context: MarkdownRenderContext = {
streaming: false,
codeLabels,
targets,
footnoteOrder: [],
footnoteCounts: new Map(),
}
}
const safeUrl: UrlTransform = url => sanitizeUrl(url)
function renderSafeLink(href: string, children: ReactNode): ReactNode {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
const blocks = wrapBlockChildren(
renderBlocks(root.children.map((node, index) => ({ node, key: index })), context),
false,
)
const section = renderFootnoteSection(context)
return section === null ? blocks : [...blocks, '\n', section]
}
function inlineCodeHttpUrl(value: string): string | undefined {
if (value.trim() !== value) return undefined
try {
const protocol = new URL(value).protocol
return protocol === 'http:' || protocol === 'https:' ? value : undefined
} catch {
return undefined
/**
* Streaming render state for one growing message: the incremental parser,
* the frozen blocks' cached elements, and the reference/footnote state their
* rendering consumed (footnote numbering assigned to frozen references is
* final, so the tail continues from a copy of it each frame).
*/
class StreamingRenderer {
private readonly parser = new IncrementalMarkdownParser(parseGfm)
private generation = -1
private frozenCount = 0
private frozenElements: ReactNode[] = []
private frozenTargets: ReferenceTargets = createReferenceTargets()
private frozenFootnoteOrder: string[] = []
private frozenFootnoteCounts = new Map<string, number>()
private lastText: string | null = null
private lastRendered: ReactNode[] = []
/** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */
constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {}
/**
* Render the current accumulated text. Idempotent per text value, so React
* may re-execute the calling render freely.
* @param text - The full accumulated markdown source.
* @returns Frozen elements, re-rendered tail, and the footnote section.
*/
render(text: string): ReactNode[] {
if (text === this.lastText) return this.lastRendered
const { frozen, tail, generation } = this.parser.update(text)
if (generation !== this.generation) {
this.generation = generation
this.frozenCount = 0
this.frozenElements = []
this.frozenTargets = createReferenceTargets()
this.frozenFootnoteOrder = []
this.frozenFootnoteCounts = new Map()
}
const newlyFrozen = frozen.slice(this.frozenCount)
collectReferenceTargets(newlyFrozen.map(block => block.node), this.frozenTargets)
// Targets visible this frame: everything frozen so far plus the current
// tail parse — a newly frozen block's references resolved against the
// same parse tree its definitions came from.
const frameTargets: ReferenceTargets = {
definitions: new Map(this.frozenTargets.definitions),
footnotes: new Map(this.frozenTargets.footnotes),
}
collectReferenceTargets(tail.map(block => block.node), frameTargets)
if (newlyFrozen.length > 0) {
const frozenContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
targets: frameTargets,
footnoteOrder: this.frozenFootnoteOrder,
footnoteCounts: this.frozenFootnoteCounts,
}
// Separator newlines are cached alongside the elements so the
// assembled children match the settled pipeline's block wrapping.
const batch = [...this.frozenElements]
for (const element of renderBlocks(newlyFrozen, frozenContext)) {
if (batch.length > 0) batch.push('\n')
batch.push(element)
}
this.frozenElements = batch
this.frozenCount = frozen.length
}
const tailContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
targets: frameTargets,
footnoteOrder: [...this.frozenFootnoteOrder],
footnoteCounts: new Map(this.frozenFootnoteCounts),
}
const children = [...this.frozenElements]
for (const element of renderBlocks(tail, tailContext)) {
if (children.length > 0) children.push('\n')
children.push(element)
}
const section = renderFootnoteSection(tailContext)
if (section !== null) children.push('\n', section)
this.lastText = text
this.lastRendered = children
return this.lastRendered
}
}
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
function remoteImageUrl(url: string): string | undefined {
try {
const protocol = new URL(url).protocol
return protocol === 'http:' || protocol === 'https:' ? url : undefined
} catch {
return undefined
}
}
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
return {
a: ({ href = '', children }) => renderSafeLink(href, children),
code: ({ className, children }) => {
const href = typeof children === 'string' ? inlineCodeHttpUrl(children) : undefined
return <code className={className}>{href === undefined ? children : renderSafeLink(href, children)}</code>
},
img: ({ alt = '', src = '' }) => {
const imageSrc = remoteImageUrl(src)
if (imageSrc === undefined) return <span className={css.imageAlt}>{alt}</span>
return (
<img
className={css.image}
src={imageSrc}
alt={alt}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
)
},
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
// Fenced blocks route through the shared CodeBlock (shiki for registered
// grammars, identical-geometry plain fallback for unknown/absent
// languages); inline code keeps the <code> path (the :not(pre) rule
// styles it), with a safe anchor only for complete HTTP(S) values. While
// the message streams, the fence renders the plain arm — retokenizing a
// growing fence on every chunk is quadratic main-thread work; the
// finalize swap highlights it once.
pre: ({ children }) => {
// The markdown pipeline always hands `pre` its single `code` element;
// the undefined arm guards a react-markdown representation change.
/* v8 ignore next 2 */
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
const raw = child?.props.children
// A fence whose content isn't one plain string (e.g. an empty fence)
// keeps the stock <pre> rather than guessing.
if (typeof raw !== 'string') return <pre>{children}</pre>
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
return (
<CodeBlock
code={raw}
lang={streaming ? undefined : lang}
copyLabel={codeLabels?.copyLabel}
copiedLabel={codeLabels?.copiedLabel}
/>
)
},
}
}
const staticComponents = buildComponents(false)
const streamingComponents = buildComponents(true)
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
* 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.
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on
* the finalize swap) and parses incrementally across chunks; `codeLabels`
* forwards localized copy-button labels to fence CodeBlocks — pass a
* reference-stable object (memoized per locale revision), because a new
* identity discards the streaming render cache mid-message.
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
* relative links, and unsafe protocols are disabled; complete HTTP(S)
* inline-code values become safe external links, while absolute HTTP(S)
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
* images render directly.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
streaming?: boolean
codeLabels?: MarkdownCodeLabels | undefined
}) {
// The label-free tables stay module-level singletons so the common case
// keeps referential stability across renders without a hook.
const components = useMemo(() => {
if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
return buildComponents(streaming, codeLabels)
}, [streaming, codeLabels])
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={streaming ? streamingRemarkPlugins : settledRemarkPlugins}
rehypePlugins={streaming ? undefined : settledRehypePlugins}
components={components}
urlTransform={safeUrl}
>
{text}
</ReactMarkdown>
</div>
)
}
const streamRef = useRef<StreamingRenderer | null>(null)
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
const children = useMemo(() => {
if (!streaming) {
streamRef.current = null
return renderSettled(text, codeLabels)
}
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
streamRef.current = new StreamingRenderer(codeLabels)
streamLabelsRef.current = codeLabels
}
return streamRef.current.render(text)
}, [text, streaming, codeLabels])
return <div className={css.markdown}>{children}</div>
})

View File

@@ -6,10 +6,6 @@ import { classifyCharacter } from 'micromark-util-classify-character'
import { codes, constants } from 'micromark-util-symbol'
import type { Construct, Extension, State, Tokenizer } from 'micromark-util-types'
interface RemarkProcessor {
data(): { micromarkExtensions?: Extension[] }
}
const cjkCharacter = new RegExp([
'\\p{Script_Extensions=Han}',
'\\p{Script_Extensions=Hiragana}',
@@ -73,16 +69,15 @@ const cjkFriendlyAttention: Construct = {
tokenize: tokenizeCjkFriendlyAttention,
}
const cjkFriendlyStrong: Extension = {
const cjkFriendlyStrongExtension: Extension = {
text: { [codes.asterisk]: cjkFriendlyAttention },
}
/**
* Extend CommonMark asterisk strong emphasis for punctuation-delimited CJK prose.
* @returns Nothing.
* Extend CommonMark asterisk strong emphasis for punctuation-delimited CJK
* prose, as a micromark syntax extension for `fromMarkdown`.
* @returns The micromark syntax extension.
*/
export function remarkCjkFriendlyStrong(this: RemarkProcessor): undefined {
const data = this.data()
const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
extensions.push(cjkFriendlyStrong)
export function cjkFriendlyStrong(): Extension {
return cjkFriendlyStrongExtension
}

View File

@@ -0,0 +1,130 @@
/**
* Incremental block-level markdown parsing for an append-only text stream.
*
* Re-parsing the whole accumulated document on every streaming chunk is
* quadratic in the final reply length. CommonMark block parsing is line-based
* and appended text can only reshape the parse frontier — the last top-level
* block (a paragraph becoming a setext heading or a table, a list continuing
* after a blank line, an unclosed fence swallowing lines) — so earlier blocks
* are final. This parser therefore freezes all but the trailing
* {@link UNSTABLE_TAIL_BLOCKS} blocks and re-parses only the source tail
* behind them: each source region is parsed O(1) times over the stream
* instead of once per chunk.
*
* The freeze boundary comes from the parser's own `position` offsets, never
* from custom source scanning. The cut sits at the *end offset* of the last
* frozen block (not the next block's start): a following block's start offset
* excludes up to three spaces of insignificant leading indentation, which is
* harmless to drop, but cutting at the previous end also keeps the
* inter-block blank lines in the tail so the sliced source stays verbatim.
*
* Known deviation, shared with any prefix-freeze scheme: micromark resolves
* reference-style links and footnotes document-wide at parse time, so a
* reference whose definition lands on the other side of the freeze boundary
* renders literally until the settled full parse self-heals it.
*/
import type { Root, RootContent } from 'mdast'
/**
* Trailing blocks kept unstable. Appended text reshapes at most the last
* block; the second-to-last is retained as safety margin so a freeze decision
* never has to reason about the parse frontier.
*/
const UNSTABLE_TAIL_BLOCKS = 2
/** A top-level mdast block plus a render key that is stable across chunks. */
export interface PositionedBlock {
/** The parsed block. Positions inside it are relative to its parse slice. */
readonly node: RootContent
/**
* The block's start offset in the full source text. Stable from the frame
* a block first appears through freezing, so React reconciles rather than
* remounts when a block crosses the freeze boundary.
*/
readonly key: number
}
/** One {@link IncrementalMarkdownParser.update} result. */
export interface IncrementalBlocks {
/** Blocks that can no longer change; grows monotonically per generation. */
readonly frozen: readonly PositionedBlock[]
/** The re-parsed unstable tail (at most {@link UNSTABLE_TAIL_BLOCKS} blocks plus growth). */
readonly tail: readonly PositionedBlock[]
/** Bumped whenever non-append input discards the frozen prefix; callers drop caches keyed on it. */
readonly generation: number
}
/**
* A block's render key: its absolute source start offset. A position-less
* node (a grammar is free to omit positions) falls back to a negative
* list-index key — unique within one update's tail, which is the only place
* the fallback can occur: freezing requires the cut block's position, so a
* position-less parse keeps every block in the tail (real grammars always
* stamp positions and never take this path).
*/
function blockKey(node: RootContent, base: number, index: number): number {
const offset = node.position?.start.offset
return offset === undefined ? -(index + 1) : base + offset
}
/**
* Append-only incremental parser over a caller-supplied grammar. One instance
* accumulates one streaming document; non-append input resets it.
*/
export class IncrementalMarkdownParser {
private prevText = ''
private tailStart = 0
private frozen: PositionedBlock[] = []
private generation = 0
private cached: IncrementalBlocks | null = null
/** @param parse - Grammar shared with whatever renders the blocks, so boundaries agree. */
constructor(private readonly parse: (text: string) => Root) {}
/**
* Fold the current accumulated text and return the frozen/tail split.
* Idempotent for identical input (the previous result is returned as-is),
* so callers may invoke it from render paths that re-execute.
* @param text - The full accumulated markdown source.
* @returns Frozen and tail blocks with stream-stable render keys.
*/
update(text: string): IncrementalBlocks {
if (this.cached !== null && text === this.prevText) return this.cached
// Deliberate O(prefix) memcmp per update: sound divergence detection has
// to verify the whole retained prefix, and startsWith compares bytes two
// orders of magnitude faster than parsing them — the cost this class
// exists to remove. Passing append/reset deltas instead would push
// append bookkeeping across the session-projection seam for a check
// that stays sub-millisecond at realistic reply sizes.
if (!text.startsWith(this.prevText)) {
this.prevText = ''
this.tailStart = 0
this.frozen = []
this.generation += 1
}
this.prevText = text
const base = this.tailStart
const blocks = this.parse(text.slice(base)).children
let firstUnstable = Math.max(0, blocks.length - UNSTABLE_TAIL_BLOCKS)
if (firstUnstable > 0) {
const cutEnd = blocks[firstUnstable - 1]?.position?.end.offset
if (cutEnd === undefined) {
// A grammar that omits positions leaves nothing to cut at; keep the
// whole parse in the tail rather than guessing a boundary.
firstUnstable = 0
} else {
for (const node of blocks.slice(0, firstUnstable)) {
this.frozen.push({ node, key: blockKey(node, base, this.frozen.length) })
}
this.tailStart = base + cutEnd
}
}
const tail = blocks.slice(firstUnstable).map((node, index) => ({
node,
key: blockKey(node, base, index),
}))
this.cached = { frozen: [...this.frozen], tail, generation: this.generation }
return this.cached
}
}

View File

@@ -0,0 +1,90 @@
/**
* TeX-to-React via KaTeX, replicating the rehype-katex pipeline this renderer
* replaced: the same three-arm error chain (strict render, `strict: 'ignore'`
* retry, error span) and a DOM-identical element tree, so settled math keeps
* its exact markup. KaTeX emits an HTML string; the browser's own HTML parser
* (`DOMParser`, applying the spec's SVG/MathML foreign-content attribute
* adjustments KaTeX output relies on) turns it into a tree this module maps
* onto React elements — KaTeX output is a static span/MathML/SVG vocabulary
* with no raw user HTML, the same trust shiki's tree gets in CodeBlock.
*
* React 18 has no MathML support, so the `.katex-mathml` subtree's elements
* land in the HTML namespace — exactly as they did under the replaced
* hast-util-to-jsx-runtime pipeline. The visual arm is the `.katex-html`
* span tree; the MathML arm serves assistive technology, which reads it by
* tag name regardless of namespace.
*/
import { createElement } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import katex from 'katex'
/**
* Convert one inline `style` attribute string into React's style object.
* KaTeX emits only plain kebab-case declarations (no custom properties and no
* nameless declarations), so camel-casing the property is the whole mapping.
*/
function styleObject(css: string): CSSProperties {
const style: Record<string, string> = {}
for (const declaration of css.split(';')) {
const colon = declaration.indexOf(':')
if (colon === -1) continue
const name = declaration.slice(0, colon).trim()
const key = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
style[key] = declaration.slice(colon + 1).trim()
}
return style
}
/** Map one parsed DOM node onto a React element (text nodes pass through). */
function domToReact(node: ChildNode, key: number): ReactNode {
if (node.nodeType === Node.TEXT_NODE) return node.textContent
/* v8 ignore next 2 -- KaTeX output holds only elements and text; other
node kinds cannot appear in its serialized vocabulary. */
if (node.nodeType !== Node.ELEMENT_NODE) return null
const element = node as Element
const props: Record<string, unknown> = { key }
for (const attribute of element.attributes) {
if (attribute.name === 'class') props['className'] = attribute.value
else if (attribute.name === 'style') props['style'] = styleObject(attribute.value)
else props[attribute.name] = attribute.value
}
const children = [...element.childNodes].map(domToReact)
return children.length === 0
? createElement(element.localName, props)
: createElement(element.localName, props, ...children)
}
/**
* Render TeX source to React elements through KaTeX.
* @param value - The TeX source (math node value; fenced `math` blocks append
* their trailing newline to match the replaced pipeline's text extraction).
* @param displayMode - Display (block) versus inline rendering.
* @returns KaTeX's element tree, or the error span when the source does not
* parse (colored with KaTeX's stock `errorColor`, matching rehype-katex).
*/
export function renderTexToReact(value: string, displayMode: boolean): ReactNode {
let html: string
try {
html = katex.renderToString(value, { displayMode, throwOnError: true })
} catch (error) {
try {
html = katex.renderToString(value, { displayMode, strict: 'ignore', throwOnError: false })
} catch {
// KaTeX renders ParseErrors itself under throwOnError: false; only its
// internal errors reach here, so mirror rehype-katex's manual span.
/* v8 ignore next 8 */
return (
<span
className="katex-error"
style={{ color: '#cc0000' }}
title={String(error)}
>
{value}
</span>
)
}
}
const parsed = new DOMParser().parseFromString(html, 'text/html')
return [...parsed.body.childNodes].map(domToReact)
}

View File

@@ -8,10 +8,6 @@ import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark
// 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)
@@ -342,12 +338,12 @@ const backslashMath: Extension = {
}
/**
* 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.
* TeX backslash delimiters and same-line display-dollar blocks as a micromark
* syntax extension reusing `micromark-extension-math`'s token vocabulary; the
* caller must also register `math()` on the same parse so the emitted tokens
* compile to standard math nodes.
* @returns The micromark syntax extension.
*/
export function remarkMathCompatibility(this: RemarkProcessor): undefined {
const data = this.data()
const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
extensions.push(backslashMath)
export function mathCompatibility(): Extension {
return backslashMath
}

View File

@@ -0,0 +1,44 @@
/**
* The markdown renderer's two mdast grammars, one per rendering arm. Each
* arm is internally consistent — the incremental tail parses, the one-shot
* parses, and the plain-text projection of a given grammar always agree on
* where blocks start and end — and the settled grammar is the streaming one
* plus the math extensions, so the arms differ only where TeX delimiters
* begin a math construct (a `$$` block is a paragraph while streaming and a
* math block once settled, by design).
*/
import type { Root } from 'mdast'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { mathFromMarkdown } from 'mdast-util-math'
import { gfm } from 'micromark-extension-gfm'
import { math } from 'micromark-extension-math'
import { cjkFriendlyStrong } from './cjkFriendlyStrong.ts'
import { mathCompatibility } from './mathCompatibility.ts'
/**
* Parse GFM markdown (the streaming arm's grammar: no math, so incomplete
* TeX never flashes KaTeX errors mid-stream).
* @param text - Markdown source.
* @returns The mdast root.
*/
export function parseGfm(text: string): Root {
return fromMarkdown(text, {
extensions: [gfm(), cjkFriendlyStrong()],
mdastExtensions: [gfmFromMarkdown()],
})
}
/**
* Parse GFM markdown plus TeX math with the compatibility delimiters
* (the settled arm's grammar).
* @param text - Markdown source.
* @returns The mdast root.
*/
export function parseGfmWithMath(text: string): Root {
return fromMarkdown(text, {
extensions: [gfm(), cjkFriendlyStrong(), mathCompatibility(), math()],
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()],
})
}

View File

@@ -1,12 +1,12 @@
/**
* Markdown-to-plain-text projection for compact summaries and labels.
* Parsing shares the renderer's GFM grammar; raw HTML stays literal, links
* keep their labels, images keep alt text, and code keeps its source text.
* Parsing shares the renderer's streaming GFM grammar ({@link parseGfm}), so
* the projection strips exactly the markup the renderer would draw; raw HTML
* stays literal, links keep their labels, images keep alt text, and code
* keeps its source text.
*/
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
import { parseGfm } from './parse.ts'
/** Amount of parsed Markdown content returned by the extractor. */
export type MarkdownPlainTextMode = 'all' | 'first-line' | 'first-paragraph'
@@ -108,10 +108,7 @@ export function extractMarkdownPlainText(
options: MarkdownPlainTextOptions = {},
): string {
const { mode = 'all' } = options
const root = fromMarkdown(markdown, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
}) as MarkdownNode
const root = parseGfm(markdown) as MarkdownNode
const all = fullText(root)
switch (mode) {
case 'all':

View File

@@ -0,0 +1,544 @@
/**
* Direct mdast→React markdown renderer. Replaces the react-markdown /
* remark-rehype pipeline with one switch over parsed nodes so streaming can
* cache frozen blocks as React elements; the rendered DOM is pinned
* byte-for-byte by `tests/fixtures/markdown-dom` and must not drift.
*
* Untrusted-output policy (unchanged from the replaced pipeline): link and
* image destinations pass a protocol allowlist, images additionally require
* absolute HTTP(S), raw HTML renders as literal text (no HTML enters the
* DOM), and KaTeX runs without trusted commands. Fragment-anchor URLs fail
* the allowlist, so footnote references and back-references render as plain
* text rather than in-page links.
*
* Merge-extensible node unions fall through the documented default (render
* nothing) rather than ending in assertNever: grammars registered elsewhere
* may add node types this renderer has no mapping for.
*/
import { Fragment, createElement } from 'react'
import type { Key, ReactNode } from 'react'
import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
import { CodeBlock } from './CodeBlock.tsx'
import { renderTexToReact } from './katex.tsx'
import type { PositionedBlock } from './incremental.ts'
import css from './MarkdownText.module.css'
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
}
} catch {
// Relative and otherwise unparsable destinations are disallowed alongside
// disallowed protocols; new URL() has no other failure mode for strings.
return ''
}
}
function remoteImageUrl(url: string): string | undefined {
try {
const protocol = new URL(url).protocol
return protocol === 'http:' || protocol === 'https:' ? url : undefined
} catch {
// Same single failure mode as above: not an absolute URL.
return undefined
}
}
/** Link/image reference targets collected from a document (first definition per identifier wins, as in CommonMark). */
export interface ReferenceTargets {
/** Link/image definitions keyed by upper-cased identifier. */
definitions: Map<string, Md.Definition>
/** Footnote definitions keyed by upper-cased identifier. */
footnotes: Map<string, Md.FootnoteDefinition>
}
/**
* Create an empty {@link ReferenceTargets}.
* @returns Fresh empty maps.
*/
export function createReferenceTargets(): ReferenceTargets {
return { definitions: new Map(), footnotes: new Map() }
}
/**
* Record every definition and footnote definition under `nodes` into
* `targets`, depth-first, keeping the first definition per identifier.
* @param nodes - Subtrees to walk (top-level blocks or any nested children).
* @param targets - Accumulator, typically shared across incremental segments.
*/
export function collectReferenceTargets(
nodes: readonly Md.RootContent[],
targets: ReferenceTargets,
): void {
for (const node of nodes) {
if (node.type === 'definition') {
const id = node.identifier.toUpperCase()
if (!targets.definitions.has(id)) targets.definitions.set(id, node)
} else if (node.type === 'footnoteDefinition') {
const id = node.identifier.toUpperCase()
if (!targets.footnotes.has(id)) targets.footnotes.set(id, node)
}
if ('children' in node) collectReferenceTargets(node.children, targets)
}
}
/**
* One render pass's state: immutable options and targets plus the footnote
* numbering accumulated in document order while references render.
*/
export interface MarkdownRenderContext {
/** Streaming arm: fences render plain and TeX stays literal. */
readonly streaming: boolean
/** Localized fence copy-button labels. */
readonly codeLabels: MarkdownCodeLabels | undefined
/** Reference targets visible to this pass. */
readonly targets: ReferenceTargets
/** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */
readonly footnoteOrder: string[]
/** References rendered per identifier; drives the section's back-reference count. */
readonly footnoteCounts: Map<string, number>
}
/**
* Render top-level blocks. Nodes that render nothing (definitions, unmapped
* types) are dropped rather than kept as null placeholders, matching the
* replaced pipeline's child lists so separator newlines land identically.
* @param blocks - Blocks with their stream-stable render keys.
* @param context - The pass state; footnote numbering mutates in document order.
* @returns One React node per rendered block.
*/
export function renderBlocks(
blocks: readonly PositionedBlock[],
context: MarkdownRenderContext,
): ReactNode[] {
return blocks
.map(block => renderNode(block.node, block.key, context))
.filter(element => element !== null)
}
/**
* Interleave the newline text nodes the replaced pipeline emitted between
* block-level children. They are invisible between elements but coalesce
* into adjacent literal raw-HTML text, where the DOM parity fixtures pin
* them.
* @param elements - Rendered block children with empty renders already dropped.
* @param edges - Also emit the leading and trailing newline (hast's loose wrap).
* @returns The interleaved children.
*/
export function wrapBlockChildren(elements: readonly ReactNode[], edges: boolean): ReactNode[] {
const wrapped: ReactNode[] = []
for (const element of elements) {
if (edges || wrapped.length > 0) wrapped.push('\n')
wrapped.push(element)
}
if (edges && elements.length > 0) wrapped.push('\n')
return wrapped
}
/**
* A block child rendered for a parent that must tell paragraphs apart from
* other blocks (list items unwrap them when tight; footnote bodies receive
* their back-references inside the trailing paragraph).
*/
type BlockEntry = { paragraph: ReactNode[] } | { element: ReactNode }
/** Render container children into {@link BlockEntry} values, dropping empty renders. */
function renderBlockEntries(
blocks: readonly Md.RootContent[],
context: MarkdownRenderContext,
): BlockEntry[] {
const entries: BlockEntry[] = []
for (const [index, block] of blocks.entries()) {
if (block.type === 'paragraph') {
entries.push({ paragraph: renderChildren(block.children, context) })
} else {
const element = renderNode(block, index, context)
if (element !== null) entries.push({ element })
}
}
return entries
}
function renderChildren(
nodes: readonly Md.RootContent[],
context: MarkdownRenderContext,
): ReactNode[] {
return nodes.map((node, index) => renderNode(node, index, context))
}
function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderContext): ReactNode {
switch (node.type) {
case 'text':
return node.value
case 'paragraph':
return <p key={key}>{renderChildren(node.children, context)}</p>
case 'heading':
return createElement(`h${node.depth}`, { key }, ...renderChildren(node.children, context))
case 'blockquote':
return (
<blockquote key={key}>
{wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)}
</blockquote>
)
case 'thematicBreak':
return <hr key={key} />
case 'break':
// The replaced pipeline emitted a newline text node after each <br>.
return <Fragment key={key}><br />{'\n'}</Fragment>
case 'strong':
return <strong key={key}>{renderChildren(node.children, context)}</strong>
case 'emphasis':
return <em key={key}>{renderChildren(node.children, context)}</em>
case 'delete':
return <del key={key}>{renderChildren(node.children, context)}</del>
case 'inlineCode': {
// Parity with mdast-util-to-hast: inline code renders line endings as spaces.
const value = node.value.replace(/\r?\n|\r/g, ' ')
// An inline-code token that is entirely an absolute HTTP(S) URL keeps
// its code chrome and gains the same safe external anchor as a link;
// commands, partial URLs, and other schemes stay inert. The value is
// authored text, not a parsed destination, so no normalizeUri: port,
// path, and query render unchanged.
const href = inlineCodeHttpUrl(value)
return <code key={key}>{href === undefined ? value : renderSafeLink(href, [value], 'link')}</code>
}
case 'html':
// No HTML parser enters the pipeline: raw HTML stays literal text.
return node.value
case 'code':
return renderCode(node, key, context)
case 'math':
return <Fragment key={key}>{renderTexToReact(node.value, true)}</Fragment>
case 'inlineMath':
return <Fragment key={key}>{renderTexToReact(node.value, false)}</Fragment>
case 'list':
return renderList(node, key, context)
case 'listItem':
// Reachable only in hand-built trees: the grammar emits items inside lists.
return renderListItem(node, listItemLoose(node), key, context)
case 'table':
return renderTable(node, key, context)
case 'link':
return renderAnchor(node.url, renderChildren(node.children, context), key)
case 'linkReference':
return renderLinkReference(node, key, context)
case 'image':
return renderImage(node.url, node.alt ?? '', key)
case 'imageReference':
return renderImageReference(node, key, context)
case 'footnoteReference':
return renderFootnoteReference(node, key, context)
case 'definition':
case 'footnoteDefinition':
// Targets render elsewhere: definitions resolve references in place;
// footnote bodies render in the trailing section.
return null
default:
// Documented default for the merge-extensible union: node types without
// a mapping (tableRow/tableCell outside a table, frontmatter, future
// grammar contributions) render nothing.
return null
}
}
function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): ReactNode {
const language = node.lang ?? undefined
if (node.value === '') {
// Parity: the replaced pipeline kept the stock <pre> for an empty fence.
return (
<pre key={key}>
<code className={language === undefined ? undefined : `language-${language}`} />
</pre>
)
}
// The replaced pipeline recovered the grammar id from the hast class with
// /language-([\w-]+)/, which truncates at the first non-word character.
const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
if (!context.streaming && lang === 'math') {
// ```math fences render as display TeX once settled (rehype-katex parity);
// its text extraction saw the code block's trailing newline.
return <Fragment key={key}>{renderTexToReact(`${node.value}\n`, true)}</Fragment>
}
return (
<CodeBlock
key={key}
// The replaced hast pipeline appended one synthetic newline that
// CodeBlock's display trim removes; feeding the bare value would make
// that trim eat a REAL trailing blank line inside the fence instead.
code={`${node.value}\n`}
lang={context.streaming ? undefined : lang}
copyLabel={context.codeLabels?.copyLabel}
copiedLabel={context.codeLabels?.copiedLabel}
/>
)
}
/** A list is loose when it or any of its items is spread; every item then keeps its paragraphs. */
function listLoose(list: Md.List): boolean {
return (list.spread ?? false) || list.children.some(listItemLoose)
}
function listItemLoose(item: Md.ListItem): boolean {
return item.spread ?? item.children.length > 1
}
function renderList(node: Md.List, key: Key, context: MarkdownRenderContext): ReactNode {
const loose = listLoose(node)
const properties: { start?: number; className?: string } = {}
if (typeof node.start === 'number' && node.start !== 1) properties.start = node.start
if (node.children.some(item => typeof item.checked === 'boolean')) {
properties.className = 'contains-task-list'
}
return createElement(
node.ordered === true ? 'ol' : 'ul',
{ key, ...properties },
...node.children.map((item, index) => renderListItem(item, loose, index, context)),
)
}
function renderListItem(
item: Md.ListItem,
loose: boolean,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const entries = renderBlockEntries(item.children, context)
const task = typeof item.checked === 'boolean'
if (task) {
const checkbox = <input key="task-checkbox" type="checkbox" checked={item.checked === true} disabled />
const head = entries[0]
if (head !== undefined && 'paragraph' in head) {
head.paragraph = head.paragraph.length > 0 ? [checkbox, ' ', ...head.paragraph] : [checkbox]
} else {
entries.unshift({ paragraph: [checkbox] })
}
}
// Newline placement and tight-paragraph unwrapping mirror
// mdast-util-to-hast's list-item handler: a newline before every child
// except a tight leading paragraph, and after a trailing non-paragraph
// (or any trailing child when loose).
const parts: ReactNode[] = []
for (const [index, entry] of entries.entries()) {
const isParagraph = 'paragraph' in entry
if (loose || index !== 0 || !isParagraph) parts.push('\n')
if (!isParagraph) parts.push(entry.element)
else if (loose) parts.push(<p key={`p-${index}`}>{entry.paragraph}</p>)
else parts.push(<Fragment key={`p-${index}`}>{entry.paragraph}</Fragment>)
}
const tail = entries[entries.length - 1]
if (tail !== undefined && (loose || !('paragraph' in tail))) parts.push('\n')
return (
<li key={key} className={task ? 'task-list-item' : undefined}>
{parts}
</li>
)
}
function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode {
const align = node.align ?? null
const [headRow, ...bodyRows] = node.children
return (
<div key={key} className={css.tableScroll}>
<table>
{headRow !== undefined && <thead>{renderTableRow(headRow, 'th', align, 0, context)}</thead>}
{bodyRows.length > 0 && (
<tbody>
{bodyRows.map((row, index) => renderTableRow(row, 'td', align, index + 1, context))}
</tbody>
)}
</table>
</div>
)
}
function renderTableRow(
row: Md.TableRow,
cellTag: 'th' | 'td',
align: readonly Md.AlignType[] | null,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
// With column alignment present, every row renders exactly one cell per
// column, padding or truncating the row (mdast-util-to-hast parity).
const length = align === null ? row.children.length : align.length
const cells: ReactNode[] = []
for (let index = 0; index < length; index++) {
const cell = row.children[index]
const alignValue = align?.[index]
cells.push(createElement(
cellTag,
// hast-util-to-jsx-runtime's default tableCellAlignToStyle turned the
// deprecated align attribute into an inline style; keep that DOM.
{ key: index, style: alignValue == null ? undefined : { textAlign: alignValue } },
...(cell === undefined ? [] : renderChildren(cell.children, context)),
))
}
return <tr key={key}>{cells}</tr>
}
/** Anchor over an already-authored href: allowlisted or unwrapped, external links get the safe attributes. */
function renderSafeLink(href: string, children: ReactNode[], key: Key): ReactNode {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <Fragment key={key}>{children}</Fragment>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
key={key}
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
}
/** Anchor over a parsed markdown destination, which hast normalized before the allowlist saw it. */
function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode {
return renderSafeLink(normalizeUri(url), children, key)
}
/**
* The complete inline-code value when it is exactly an absolute HTTP(S) URL
* (no surrounding whitespace); anything else stays inert code.
*/
function inlineCodeHttpUrl(value: string): string | undefined {
if (value.trim() !== value) return undefined
try {
const protocol = new URL(value).protocol
return protocol === 'http:' || protocol === 'https:' ? value : undefined
} catch {
// Not an absolute URL at all — the only way new URL() rejects a string.
return undefined
}
}
function renderImage(url: string, alt: string, key: Key): ReactNode {
const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
if (imageSrc === undefined) {
return <span key={key} className={css.imageAlt}>{alt}</span>
}
return (
<img
key={key}
className={css.image}
src={imageSrc}
alt={alt}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
)
}
/** The bracketed source text a reference reverts to when its definition is missing. */
function referenceSuffix(node: Md.LinkReference | Md.ImageReference): string {
if (node.referenceType === 'collapsed') return '][]'
if (node.referenceType === 'full') return `][${node.label ?? node.identifier}]`
return ']'
}
function renderLinkReference(
node: Md.LinkReference,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
const children = renderChildren(node.children, context)
if (definition === undefined) {
// The grammar only emits references whose definitions exist somewhere in
// the same parse, but incremental segments and hand-built trees may still
// present unresolved ones: revert to the bracketed source text.
return <Fragment key={key}>{'['}{children}{referenceSuffix(node)}</Fragment>
}
return renderAnchor(definition.url, children, key)
}
function renderImageReference(
node: Md.ImageReference,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
if (definition === undefined) return `![${node.alt ?? ''}${referenceSuffix(node)}`
return renderImage(definition.url, node.alt ?? '', key)
}
function renderFootnoteReference(
node: Md.FootnoteReference,
key: Key,
context: MarkdownRenderContext,
): ReactNode {
const id = node.identifier.toUpperCase()
const seen = context.footnoteCounts.get(id)
if (seen === undefined) context.footnoteOrder.push(id)
context.footnoteCounts.set(id, (seen ?? 0) + 1)
// The in-page anchor fails the protocol allowlist, so only the numbered
// superscript renders (matching the replaced pipeline's unwrapped link).
return <sup key={key}>{String(context.footnoteOrder.indexOf(id) + 1)}</sup>
}
/**
* Render the trailing footnote section for every footnote referenced during
* the pass, in first-reference order, with one plain-text back-reference
* marker per rendered reference.
* @param context - The pass state after all blocks rendered.
* @returns The section, or null when no referenced footnote has a definition.
*/
export function renderFootnoteSection(context: MarkdownRenderContext): ReactNode | null {
const items: ReactNode[] = []
for (const id of context.footnoteOrder) {
const definition = context.targets.footnotes.get(id)
if (definition === undefined) continue
const count = context.footnoteCounts.get(id) ?? 0
const backrefs: ReactNode[] = []
for (let reference = 1; reference <= count; reference++) {
if (backrefs.length > 0) backrefs.push(' ')
backrefs.push('↩')
if (reference > 1) backrefs.push(<sup key={`re-${reference}`}>{String(reference)}</sup>)
}
const entries = renderBlockEntries(definition.children, context)
const tail = entries[entries.length - 1]
const body: ReactNode[] = entries.map((entry, index) => (
'paragraph' in entry
? (
<p key={`p-${index}`}>
{entry.paragraph}
{entry === tail && <>{' '}{backrefs}</>}
</p>
)
: entry.element
))
// Without a trailing paragraph the back-references join the block list
// itself (and pick up the wrap newlines), as in the replaced pipeline.
if (tail === undefined || !('paragraph' in tail)) body.push(...backrefs)
items.push(
<li key={id} id={`user-content-fn-${normalizeUri(id.toLowerCase())}`}>
{wrapBlockChildren(body, true)}
</li>,
)
}
if (items.length === 0) return null
return (
<section key="footnotes" data-footnotes className="footnotes">
<h2 id="footnote-label" className="sr-only">Footnotes</h2>
<ol>{items}</ol>
</section>
)
}

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<blockquote>
<p>
#text "level one\nstill one"
<blockquote>
<p>
#text "nested"
<ul>
<li>
#text "quoted list"
<p>
#text "after"

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<blockquote>
<p>
#text "level one\nstill one"
<blockquote>
<p>
#text "nested"
<ul>
<li>
#text "quoted list"
<p>
#text "after"

View File

@@ -0,0 +1,20 @@
<div class="_markdown_404681">
<p>
<strong>
#text "注意:"
#text "内容在标点后直接闭合。"
<p>
#text "**Notice:**text keeps upstream parsing."
<p>
#text "*提醒!*单星号也保持上游行为。"
<p>
<code>
<a href="https://example.com/preview?q=one%20two#result" rel="noopener noreferrer" target="_blank">
#text "https://example.com/preview?q=one%20two#result"
#text " 与 "
<code>
#text "curl http://127.0.0.1:3199/"
#text " 以及 "
<code>
#text "javascript:alert(1)"
#text "。"

View File

@@ -0,0 +1,20 @@
<div class="_markdown_404681">
<p>
<strong>
#text "注意:"
#text "内容在标点后直接闭合。"
<p>
#text "**Notice:**text keeps upstream parsing."
<p>
#text "*提醒!*单星号也保持上游行为。"
<p>
<code>
<a href="https://example.com/preview?q=one%20two#result" rel="noopener noreferrer" target="_blank">
#text "https://example.com/preview?q=one%20two#result"
#text " 与 "
<code>
#text "curl http://127.0.0.1:3199/"
#text " 以及 "
<code>
#text "javascript:alert(1)"
#text "。"

View File

@@ -0,0 +1,78 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div>
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " answer"
<span style="color:var(--shiki-token-keyword)">
#text ":"
<span style="color:var(--shiki-token-constant)">
#text " number"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " 42"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "no language"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
#text "unknown-lang"
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "plain fallback"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div>
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " withMeta"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " true"
<pre>
<code>
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "indented code block\nsecond line"

View File

@@ -0,0 +1,53 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "const answer: number = 42"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "no language"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "plain fallback"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "const withMeta = true"
<pre>
<code>
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "indented code block\nsecond line"

View File

@@ -0,0 +1 @@
<div class="_markdown_404681">

View File

@@ -0,0 +1 @@
<div class="_markdown_404681">

View File

@@ -0,0 +1,3 @@
<div class="_markdown_404681">
<p>
#text "AT&T, 3 < 4, *not em*, backslash \\ literal, © entity."

View File

@@ -0,0 +1,3 @@
<div class="_markdown_404681">
<p>
#text "AT&T, 3 < 4, *not em*, backslash \\ literal, © entity."

View File

@@ -0,0 +1,37 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "kept blank line follows\n"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div>
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " doubled"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " true"
#text "\n"
<span class="line">
#text "\n"
<span class="line">
<p>
#text "after"

View File

@@ -0,0 +1,23 @@
<div class="_markdown_404681">
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "kept blank line follows\n"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "const doubled = true\n\n"
<p>
#text "after"

View File

@@ -0,0 +1,29 @@
<div class="_markdown_404681">
<p>
#text "First use"
<sup>
#text "1"
#text " and reuse"
<sup>
#text "1"
#text " and another"
<sup>
#text "2"
#text "."
<section class="footnotes" data-footnotes="true">
<h2 class="sr-only" id="footnote-label">
#text "Footnotes"
<ol>
<li id="user-content-fn-a">
<p>
#text "Footnote a body with "
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "link"
#text ". ↩ ↩"
<sup>
#text "2"
<li id="user-content-fn-b">
<p>
#text "Footnote b first paragraph."
<p>
#text "Second paragraph of b. ↩"

View File

@@ -0,0 +1,29 @@
<div class="_markdown_404681">
<p>
#text "First use"
<sup>
#text "1"
#text " and reuse"
<sup>
#text "1"
#text " and another"
<sup>
#text "2"
#text "."
<section class="footnotes" data-footnotes="true">
<h2 class="sr-only" id="footnote-label">
#text "Footnotes"
<ol>
<li id="user-content-fn-a">
<p>
#text "Footnote a body with "
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "link"
#text ". ↩ ↩"
<sup>
#text "2"
<li id="user-content-fn-b">
<p>
#text "Footnote b first paragraph."
<p>
#text "Second paragraph of b. ↩"

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<p>
#text "Mixed "
<del>
#text "gone"
#text " text with "
<a href="http://www.example.com" rel="noopener noreferrer" target="_blank">
#text "www.example.com"
#text " literal and "
<a href="mailto:user@example.com">
#text "user@example.com"
#text " email."

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<p>
#text "Mixed "
<del>
#text "gone"
#text " text with "
<a href="http://www.example.com" rel="noopener noreferrer" target="_blank">
#text "www.example.com"
#text " literal and "
<a href="mailto:user@example.com">
#text "user@example.com"
#text " email."

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<p>
#text "two-space break"
<br>
#text "\nafter break"
<p>
#text "backslash break"
<br>
#text "\nafter backslash"
<hr>
<p>
#text "tail"

View File

@@ -0,0 +1,12 @@
<div class="_markdown_404681">
<p>
#text "two-space break"
<br>
#text "\nafter break"
<p>
#text "backslash break"
<br>
#text "\nafter backslash"
<hr>
<p>
#text "tail"

View File

@@ -0,0 +1,15 @@
<div class="_markdown_404681">
<h4>
#text "Small heading"
<ul>
<li>
#text "one"
<li>
#text "two"
<h5>
#text "Next"
<ol>
<li>
#text "a"
<li>
#text "b"

View File

@@ -0,0 +1,15 @@
<div class="_markdown_404681">
<h4>
#text "Small heading"
<ul>
<li>
#text "one"
<li>
#text "two"
<h5>
#text "Next"
<ol>
<li>
#text "a"
<li>
#text "b"

View File

@@ -0,0 +1,33 @@
<div class="_markdown_404681">
<h1>
#text "H1 with "
<code>
#text "code"
<h2>
#text "H2"
<h3>
#text "H3"
<h4>
#text "H4"
<h5>
#text "H5"
<h6>
#text "H6"
<p>
#text "Paragraph one with "
<strong>
#text "strong"
#text ", "
<em>
#text "emphasis"
#text ", "
<del>
#text "strike"
#text ", and "
<code>
#text "inline"
#text "."
<h1>
#text "Setext title"
<h2>
#text "Second setext"

View File

@@ -0,0 +1,33 @@
<div class="_markdown_404681">
<h1>
#text "H1 with "
<code>
#text "code"
<h2>
#text "H2"
<h3>
#text "H3"
<h4>
#text "H4"
<h5>
#text "H5"
<h6>
#text "H6"
<p>
#text "Paragraph one with "
<strong>
#text "strong"
#text ", "
<em>
#text "emphasis"
#text ", "
<del>
#text "strike"
#text ", and "
<code>
#text "inline"
#text "."
<h1>
#text "Setext title"
<h2>
#text "Second setext"

View File

@@ -0,0 +1,14 @@
<div class="_markdown_404681">
<p>
<img alt="https image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/secure.png">
<p>
<img alt="http image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="http://example.com/plain.png">
<p>
<span class="_imageAlt_404681">
#text "relative dropped"
#text " and inline "
<span class="_imageAlt_404681">
#text "bad scheme"
#text " end."
<p>
<img alt="" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/empty-alt.png">

View File

@@ -0,0 +1,14 @@
<div class="_markdown_404681">
<p>
<img alt="https image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/secure.png">
<p>
<img alt="http image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="http://example.com/plain.png">
<p>
<span class="_imageAlt_404681">
#text "relative dropped"
#text " and inline "
<span class="_imageAlt_404681">
#text "bad scheme"
#text " end."
<p>
<img alt="" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/empty-alt.png">

View File

@@ -0,0 +1,6 @@
<div class="_markdown_404681">
<p>
#text "Spans "
<code>
#text "a b"
#text " across a line."

View File

@@ -0,0 +1,6 @@
<div class="_markdown_404681">
<p>
#text "Spans "
<code>
#text "a b"
#text " across a line."

View File

@@ -0,0 +1,25 @@
<div class="_markdown_404681">
<p>
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "https ok"
#text " and "
<a href="mailto:dev@example.com">
#text "mailto ok"
#text "."
<p>
#text "relative dropped and js dropped and "
<a href="HTTPS://example.com" rel="noopener noreferrer" target="_blank">
#text "upper kept"
#text "."
<p>
<a href="https://deepseek.com" rel="noopener noreferrer" target="_blank">
#text "https://deepseek.com"
#text " and bare autolink "
<a href="https://autolink.example.com" rel="noopener noreferrer" target="_blank">
#text "https://autolink.example.com"
#text " literal."
<p>
#text "[spaces encoded]("
<a href="https://example.com/a" rel="noopener noreferrer" target="_blank">
#text "https://example.com/a"
#text " b)"

View File

@@ -0,0 +1,25 @@
<div class="_markdown_404681">
<p>
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "https ok"
#text " and "
<a href="mailto:dev@example.com">
#text "mailto ok"
#text "."
<p>
#text "relative dropped and js dropped and "
<a href="HTTPS://example.com" rel="noopener noreferrer" target="_blank">
#text "upper kept"
#text "."
<p>
<a href="https://deepseek.com" rel="noopener noreferrer" target="_blank">
#text "https://deepseek.com"
#text " and bare autolink "
<a href="https://autolink.example.com" rel="noopener noreferrer" target="_blank">
#text "https://autolink.example.com"
#text " literal."
<p>
#text "[spaces encoded]("
<a href="https://example.com/a" rel="noopener noreferrer" target="_blank">
#text "https://example.com/a"
#text " b)"

View File

@@ -0,0 +1,44 @@
<div class="_markdown_404681">
<ul>
<li>
#text "tight one"
<li>
#text "tight two\n"
<ul>
<li>
#text "child"
<ol>
<li>
<p>
#text "first"
<li>
<p>
#text "second"
<li>
<p>
#text "ordered with start"
<li>
<p>
#text "next"
<ul>
<li>
<p>
#text "loose item one"
<li>
<p>
#text "loose item two"
<p>
#text "second paragraph of loose item"
<li>
<p>
#text "item with nested blocks"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "fenced inside list"

View File

@@ -0,0 +1,44 @@
<div class="_markdown_404681">
<ul>
<li>
#text "tight one"
<li>
#text "tight two\n"
<ul>
<li>
#text "child"
<ol>
<li>
<p>
#text "first"
<li>
<p>
#text "second"
<li>
<p>
#text "ordered with start"
<li>
<p>
#text "next"
<ul>
<li>
<p>
#text "loose item one"
<li>
<p>
#text "loose item two"
<p>
#text "second paragraph of loose item"
<li>
<p>
#text "item with nested blocks"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "fenced inside list"

View File

@@ -0,0 +1,125 @@
<div class="_markdown_404681">
<p>
#text "Trusted commands stay off: "
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mstyle mathcolor="#cc0000">
<mtext>
#text "\\href"
<annotation encoding="application/x-tex">
#text "\\href{javascript:alert(1)}{unsafe}"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1em; vertical-align: -0.25em;">
<span class="mord text" style="color: rgb(204, 0, 0);">
<span class="mord" style="color: rgb(204, 0, 0);">
#text "\\href"
#text "."
<p>
#text "Unbalanced errors render the error arm: "
<span class="katex-error" style="color: rgb(204, 0, 0);" title="ParseError: KaTeX parse error: Unexpected end of input in a macro argument, expected '}' at end of input: \\frac{">
#text "\\frac{"
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th>
#text "Symbol"
<th>
#text "Value"
<tbody>
<tr>
<td>
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mi>
#text "θ"
<annotation encoding="application/x-tex">
#text "\\theta"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 0.6944em;">
<span class="mord mathnormal" style="margin-right: 0.0278em;">
#text "θ"
<td>
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mfrac>
<mn>
#text "1"
<mn>
#text "5"
<annotation encoding="application/x-tex">
#text "\\frac{1}{5}"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1.1901em; vertical-align: -0.345em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 0.8451em;">
<span style="top: -2.655em;">
<span class="pstrut" style="height: 3em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
<span class="mord mtight">
#text "5"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.394em;">
<span class="pstrut" style="height: 3em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
<span class="mord mtight">
#text "1"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.345em;">
<span>
<span class="mclose nulldelimiter">
<span class="katex-display">
<span class="katex">
<span class="katex-mathml">
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<msqrt>
<mn>
#text "2"
<annotation encoding="application/x-tex">
#text "\\sqrt{2}\n"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1.04em; vertical-align: -0.0839em;">
<span class="mord sqrt">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 0.9561em;">
<span class="svg-align" style="top: -3em;">
<span class="pstrut" style="height: 3em;">
<span class="mord" style="padding-left: 0.833em;">
<span class="mord">
#text "2"
<span style="top: -2.9161em;">
<span class="pstrut" style="height: 3em;">
<span class="hide-tail" style="min-width: 0.853em; height: 1.08em;">
<svg height="1.08em" preserveAspectRatio="xMinYMin slice" viewBox="0 0 400000 1080" width="400em" xmlns="http://www.w3.org/2000/svg">
<path d="M95,702\nc-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14\nc0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54\nc44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10\ns173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429\nc69,-144,104.5,-217.7,106.5,-221\nl0 -0\nc5.3,-9.3,12,-14,20,-14\nH400000v40H845.2724\ns-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7\nc-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z\nM834 80h400000v40h-400000z">
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.0839em;">
<span>

View File

@@ -0,0 +1,29 @@
<div class="_markdown_404681">
<p>
#text "Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$."
<p>
#text "Unbalanced errors render the error arm: $\\frac{$"
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th>
#text "Symbol"
<th>
#text "Value"
<tbody>
<tr>
<td>
#text "$\\theta$"
<td>
#text "(\\frac{1}{5})"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<pre class="_plain_9aea57">
<code>
#text "\\sqrt{2}"

View File

@@ -0,0 +1,320 @@
<div class="_markdown_404681">
<p>
#text "Einstein wrote "
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mi>
#text "E"
<mo>
#text "="
<mi>
#text "m"
<msup>
<mi>
#text "c"
<mn>
#text "2"
<annotation encoding="application/x-tex">
#text "E = mc^2"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 0.6833em;">
<span class="mord mathnormal" style="margin-right: 0.0576em;">
#text "E"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="mrel">
#text "="
<span class="mspace" style="margin-right: 0.2778em;">
<span class="base">
<span class="strut" style="height: 0.8141em;">
<span class="mord mathnormal">
#text "m"
<span class="mord">
<span class="mord mathnormal">
#text "c"
<span class="msupsub">
<span class="vlist-t">
<span class="vlist-r">
<span class="vlist" style="height: 0.8141em;">
<span style="top: -3.063em; margin-right: 0.05em;">
<span class="pstrut" style="height: 2.7em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
#text "2"
#text " inline."
<span class="katex-display">
<span class="katex">
<span class="katex-mathml">
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mfrac>
<mrow>
<mi mathvariant="normal">
#text "∂"
<mi mathvariant="bold">
#text "u"
<mrow>
<mi mathvariant="normal">
#text "∂"
<mi>
#text "t"
<mo>
#text "+"
<mo stretchy="false">
#text "("
<mi mathvariant="bold">
#text "u"
<mo>
#text "⋅"
<mi mathvariant="normal">
#text "∇"
<mo stretchy="false">
#text ")"
<mi mathvariant="bold">
#text "u"
<mo>
#text "="
<mo>
#text ""
<mfrac>
<mn>
#text "1"
<mi>
#text "ρ"
<mi mathvariant="normal">
#text "∇"
<mi>
#text "p"
<annotation encoding="application/x-tex">
#text "\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 2.0574em; vertical-align: -0.686em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 1.3714em;">
<span style="top: -2.314em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord" style="margin-right: 0.0556em;">
#text "∂"
<span class="mord mathnormal">
#text "t"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.677em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord" style="margin-right: 0.0556em;">
#text "∂"
<span class="mord mathbf">
#text "u"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.686em;">
<span>
<span class="mclose nulldelimiter">
<span class="mspace" style="margin-right: 0.2222em;">
<span class="mbin">
#text "+"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="base">
<span class="strut" style="height: 1em; vertical-align: -0.25em;">
<span class="mopen">
#text "("
<span class="mord mathbf">
#text "u"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="mbin">
#text "⋅"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="base">
<span class="strut" style="height: 1em; vertical-align: -0.25em;">
<span class="mord">
#text "∇"
<span class="mclose">
#text ")"
<span class="mord mathbf">
#text "u"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="mrel">
#text "="
<span class="mspace" style="margin-right: 0.2778em;">
<span class="base">
<span class="strut" style="height: 2.2019em; vertical-align: -0.8804em;">
<span class="mord">
#text ""
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 1.3214em;">
<span style="top: -2.314em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord mathnormal">
#text "ρ"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.677em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord">
#text "1"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.8804em;">
<span>
<span class="mclose nulldelimiter">
<span class="mord">
#text "∇"
<span class="mord mathnormal">
#text "p"
<p>
#text "Backslash inline "
<span class="katex">
<span class="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mfrac>
<mn>
#text "1"
<mn>
#text "5"
<annotation encoding="application/x-tex">
#text "\\frac{1}{5}"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1.1901em; vertical-align: -0.345em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 0.8451em;">
<span style="top: -2.655em;">
<span class="pstrut" style="height: 3em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
<span class="mord mtight">
#text "5"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.394em;">
<span class="pstrut" style="height: 3em;">
<span class="sizing reset-size6 size3 mtight">
<span class="mord mtight">
<span class="mord mtight">
#text "1"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.345em;">
<span>
<span class="mclose nulldelimiter">
#text " and display:"
<span class="katex-display">
<span class="katex">
<span class="katex-mathml">
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mfrac>
<mi>
#text "π"
<mn>
#text "4"
<mo>
#text "<"
<mi>
#text "θ"
<mo>
#text "<"
<mfrac>
<mi>
#text "π"
<mn>
#text "2"
<annotation encoding="application/x-tex">
#text "\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}"
<span aria-hidden="true" class="katex-html">
<span class="base">
<span class="strut" style="height: 1.7936em; vertical-align: -0.686em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 1.1076em;">
<span style="top: -2.314em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord">
#text "4"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.677em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord mathnormal" style="margin-right: 0.0359em;">
#text "π"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.686em;">
<span>
<span class="mclose nulldelimiter">
<span class="mspace" style="margin-right: 0.2778em;">
<span class="mrel">
#text "<"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="base">
<span class="strut" style="height: 0.7335em; vertical-align: -0.0391em;">
<span class="mord mathnormal" style="margin-right: 0.0278em;">
#text "θ"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="mrel">
#text "<"
<span class="mspace" style="margin-right: 0.2778em;">
<span class="base">
<span class="strut" style="height: 1.7936em; vertical-align: -0.686em;">
<span class="mord">
<span class="mopen nulldelimiter">
<span class="mfrac">
<span class="vlist-t vlist-t2">
<span class="vlist-r">
<span class="vlist" style="height: 1.1076em;">
<span style="top: -2.314em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord">
#text "2"
<span style="top: -3.23em;">
<span class="pstrut" style="height: 3em;">
<span class="frac-line" style="border-bottom-width: 0.04em;">
<span style="top: -3.677em;">
<span class="pstrut" style="height: 3em;">
<span class="mord">
<span class="mord mathnormal" style="margin-right: 0.0359em;">
#text "π"
<span class="vlist-s">
#text ""
<span class="vlist-r">
<span class="vlist" style="height: 0.686em;">
<span>
<span class="mclose nulldelimiter">

View File

@@ -0,0 +1,9 @@
<div class="_markdown_404681">
<p>
#text "Einstein wrote $E = mc^2$ inline."
<p>
#text "$$\n\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p\n$$"
<p>
#text "Backslash inline (\\frac{1}{5}) and display:"
<p>
#text "[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}]"

View File

@@ -0,0 +1,7 @@
<div class="_markdown_404681">
#text "<script>globalThis.compromised = true</script>\n"
<p>
#text "Paragraph with inline <img src=\"x\" onerror=\"boom\"> html and <b>bold tag</b> kept literal?"
#text "\n<div class=\"x\">\nhtml block content\n</div>\n"
<p>
#text "after"

View File

@@ -0,0 +1,7 @@
<div class="_markdown_404681">
#text "<script>globalThis.compromised = true</script>\n"
<p>
#text "Paragraph with inline <img src=\"x\" onerror=\"boom\"> html and <b>bold tag</b> kept literal?"
#text "\n<div class=\"x\">\nhtml block content\n</div>\n"
<p>
#text "after"

View File

@@ -0,0 +1,16 @@
<div class="_markdown_404681">
<p>
#text "A "
<a href="https://example.com/ref" rel="noopener noreferrer" target="_blank">
#text "full"
#text " reference, a "
<a href="https://example.com/collapsed" rel="noopener noreferrer" target="_blank">
#text "collapsed"
#text " one, and a "
<a href="https://example.com/shortcut" rel="noopener noreferrer" target="_blank">
#text "shortcut"
#text " one."
<p>
#text "[missing full][nope], [missing collapsed][], ![missing image][gone]."
<p>
<img alt="ref image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/ref.png">

View File

@@ -0,0 +1,16 @@
<div class="_markdown_404681">
<p>
#text "A "
<a href="https://example.com/ref" rel="noopener noreferrer" target="_blank">
#text "full"
#text " reference, a "
<a href="https://example.com/collapsed" rel="noopener noreferrer" target="_blank">
#text "collapsed"
#text " one, and a "
<a href="https://example.com/shortcut" rel="noopener noreferrer" target="_blank">
#text "shortcut"
#text " one."
<p>
#text "[missing full][nope], [missing collapsed][], ![missing image][gone]."
<p>
<img alt="ref image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/ref.png">

View File

@@ -0,0 +1,8 @@
<div class="_markdown_404681">
<h2>
#text "Streaming"
<ul>
<li>
#text "first"
<li>
#text "**unfinished"

View File

@@ -0,0 +1,8 @@
<div class="_markdown_404681">
<h2>
#text "Streaming"
<ul>
<li>
#text "first"
<li>
#text "**unfinished"

View File

@@ -0,0 +1,11 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th>
#text "a"
<th>
#text "b"
<p>
#text "after"

View File

@@ -0,0 +1,11 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th>
#text "a"
<th>
#text "b"
<p>
#text "after"

View File

@@ -0,0 +1,35 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th style="text-align: left;">
#text "Left"
<th style="text-align: center;">
#text "Center"
<th style="text-align: right;">
#text "Right"
<th>
#text "None"
<tbody>
<tr>
<td style="text-align: left;">
#text "a"
<td style="text-align: center;">
#text "b"
<td style="text-align: right;">
#text "c"
<td>
<code>
#text "code"
<tr>
<td style="text-align: left;">
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "link"
<td style="text-align: center;">
<em>
#text "em"
<td style="text-align: right;">
#text "1"
<td>
#text "2"

View File

@@ -0,0 +1,35 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<table>
<thead>
<tr>
<th style="text-align: left;">
#text "Left"
<th style="text-align: center;">
#text "Center"
<th style="text-align: right;">
#text "Right"
<th>
#text "None"
<tbody>
<tr>
<td style="text-align: left;">
#text "a"
<td style="text-align: center;">
#text "b"
<td style="text-align: right;">
#text "c"
<td>
<code>
#text "code"
<tr>
<td style="text-align: left;">
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
#text "link"
<td style="text-align: center;">
<em>
#text "em"
<td style="text-align: right;">
#text "1"
<td>
#text "2"

View File

@@ -0,0 +1,19 @@
<div class="_markdown_404681">
<ul class="contains-task-list">
<li class="task-list-item">
<input checked="" disabled="" type="checkbox">
#text " done with "
<strong>
#text "strong"
<li class="task-list-item">
<input disabled="" type="checkbox">
#text " pending"
<li>
#text "plain sibling"
<ol class="contains-task-list">
<li class="task-list-item">
<input checked="" disabled="" type="checkbox">
#text " ordered done"
<li class="task-list-item">
<input disabled="" type="checkbox">
#text " ordered pending"

View File

@@ -0,0 +1,19 @@
<div class="_markdown_404681">
<ul class="contains-task-list">
<li class="task-list-item">
<input checked="" disabled="" type="checkbox">
#text " done with "
<strong>
#text "strong"
<li class="task-list-item">
<input disabled="" type="checkbox">
#text " pending"
<li>
#text "plain sibling"
<ol class="contains-task-list">
<li class="task-list-item">
<input checked="" disabled="" type="checkbox">
#text " ordered done"
<li class="task-list-item">
<input disabled="" type="checkbox">
#text " ordered pending"

View File

@@ -0,0 +1,265 @@
// @vitest-environment jsdom
// DOM-parity contract for MarkdownText: every corpus document's rendered DOM
// is pinned as a file snapshot. The fixtures were recorded from the
// react-markdown implementation this renderer replaced; the custom mdast
// renderer must reproduce them byte-for-byte (after whitespace
// normalization), so a fixture diff means a user-visible markdown style
// change and must be reviewed as such — never re-record to silence a
// refactor.
//
// Provenance is reproducible: the replaced pipeline last lived at commit
// 9e8101b800 (origin/master before the renderer swap merged). Checking out
// that ref in a worktree, copying this spec, and running it records all
// fixtures from react-markdown byte-identical to the ones committed here:
// git worktree add /tmp/parity origin/master --detach && cd /tmp/parity
// pnpm install && cp <this spec> packages/client/ui-primitives/tests/
// npx vitest run packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx
// diff -r <recorded fixtures> <this branch's fixtures> # byte-identical
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
/**
* Serialize rendered DOM deterministically: adjacent text nodes coalesced
* (React renders adjacent string children as separate DOM text nodes while
* hast merges them — invisible either way), whitespace-only runs dropped
* outside `pre` (the markdown pipeline injects cosmetic newlines between
* blocks that HTML rendering collapses), attributes sorted by name, children
* indented for reviewable diffs.
*/
function serialize(node: Node, indent: string, inPre: boolean): string {
if (node.nodeType !== Node.ELEMENT_NODE) return ''
const element = node as Element
const attrs = [...element.attributes]
.map(attr => `${attr.name}=${JSON.stringify(attr.value)}`)
.sort()
.join(' ')
const open = attrs === '' ? element.tagName.toLowerCase() : `${element.tagName.toLowerCase()} ${attrs}`
const nowInPre = inPre || element.tagName === 'PRE'
return `${indent}<${open}>\n${serializeChildren(element, `${indent} `, nowInPre)}`
}
function serializeChildren(element: Element, indent: string, inPre: boolean): string {
let out = ''
let textRun = ''
const flush = (): void => {
if (textRun !== '' && (inPre || textRun.trim() !== '')) {
out += `${indent}#text ${JSON.stringify(textRun)}\n`
}
textRun = ''
}
for (const child of element.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
textRun += child.textContent ?? ''
continue
}
flush()
out += serialize(child, indent, inPre)
}
flush()
return out
}
/** Render one markdown source through MarkdownText and serialize the DOM. */
function renderCase(text: string, streaming: boolean): string {
const { container, unmount } = render(<MarkdownText text={text} streaming={streaming} />)
const out = [...container.childNodes].map(child => serialize(child, '', false)).join('')
unmount()
return out
}
const CORPUS: Record<string, string> = {
'headings-and-paragraphs': [
'# H1 with `code`',
'',
'## H2',
'',
'### H3',
'',
'#### H4',
'',
'##### H5',
'',
'###### H6',
'',
'Paragraph one with **strong**, *emphasis*, ~~strike~~, and `inline`.',
'',
'Setext title',
'=========',
'',
'Second setext',
'---------',
].join('\n'),
'heading-tight-against-list': '#### Small heading\n\n- one\n- two\n\n##### Next\n\n1. a\n2. b',
'hard-breaks-and-hr': 'two-space break \nafter break\n\nbackslash break\\\nafter backslash\n\n---\n\ntail',
'blockquote-nested': '> level one\n> still one\n>\n> > nested\n>\n> - quoted list\n\nafter',
'lists-tight-loose-nested': [
'- tight one',
'- tight two',
' - child',
'',
'1. first',
'2. second',
'',
'3. ordered with start',
'4. next',
'',
'- loose item one',
'',
'- loose item two',
'',
' second paragraph of loose item',
'',
'- item with nested blocks',
'',
' ```',
' fenced inside list',
' ```',
].join('\n'),
'task-lists': '- [x] done with **strong**\n- [ ] pending\n- plain sibling\n\n1. [x] ordered done\n2. [ ] ordered pending',
'table-with-alignment': [
'| Left | Center | Right | None |',
'| :--- | :---: | ---: | --- |',
'| a | b | c | `code` |',
'| [link](https://example.com) | *em* | 1 | 2 |',
].join('\n'),
'code-fences': [
'```ts',
'const answer: number = 42',
'```',
'',
'```',
'no language',
'```',
'',
'```unknown-lang',
'plain fallback',
'```',
'',
'```ts some=meta',
'const withMeta = true',
'```',
'',
'```',
'```',
'',
' indented code block',
' second line',
].join('\n'),
'fence-trailing-blank-lines': [
'```',
'kept blank line follows',
'',
'```',
'',
'```ts',
'const doubled = true',
'',
'',
'```',
'',
'after',
].join('\n'),
'table-header-only': '| a | b |\n| --- | --- |\n\nafter',
'inline-code-with-newline': 'Spans `a\nb` across a line.',
'links-and-autolinks': [
'[https ok](https://example.com "with title") and [mailto ok](mailto:dev@example.com).',
'',
'[relative dropped](/settings) and [js dropped](javascript:alert(1)) and [upper kept](HTTPS://example.com).',
'',
'<https://deepseek.com> and bare autolink https://autolink.example.com literal.',
'',
'[spaces encoded](https://example.com/a b)',
].join('\n'),
'images': [
'![https image](https://example.com/secure.png "img title")',
'',
'![http image](http://example.com/plain.png)',
'',
'![relative dropped](private.png) and inline ![bad scheme](javascript:alert(1)) end.',
'',
'![](https://example.com/empty-alt.png)',
].join('\n'),
'reference-links-and-images': [
'A [full][ref] reference, a [collapsed][] one, and a [shortcut] one.',
'',
'[missing full][nope], [missing collapsed][], ![missing image][gone].',
'',
'![ref image][imgref]',
'',
'[ref]: https://example.com/ref "ref title"',
'[collapsed]: https://example.com/collapsed',
'[shortcut]: https://example.com/shortcut',
'[imgref]: https://example.com/ref.png',
].join('\n'),
'footnotes': [
'First use[^a] and reuse[^a] and another[^b].',
'',
'[^a]: Footnote a body with [link](https://example.com).',
'',
'[^b]: Footnote b first paragraph.',
'',
' Second paragraph of b.',
].join('\n'),
'raw-html-dropped': [
'<script>globalThis.compromised = true</script>',
'',
'Paragraph with inline <img src="x" onerror="boom"> html and <b>bold tag</b> kept literal?',
'',
'<div class="x">',
'html block content',
'</div>',
'',
'after',
].join('\n'),
'entities-and-escapes': 'AT&amp;T, 3 &lt; 4, \\*not em\\*, backslash \\\\ literal, &copy; entity.',
'math-inline-and-display': [
'Einstein wrote $E = mc^2$ inline.',
'',
'$$',
'\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
'$$',
'',
'Backslash inline \\(\\frac{1}{5}\\) and display:',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
].join('\n'),
'math-edge-cases': [
'Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$.',
'',
'Unbalanced errors render the error arm: $\\frac{$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
'',
'```math',
'\\sqrt{2}',
'```',
].join('\n'),
'gfm-strikethrough-and-literals': 'Mixed ~~gone~~ text with www.example.com literal and user@example.com email.',
'cjk-strong-and-inline-code-url': [
'**注意:**内容在标点后直接闭合。',
'',
'**Notice:**text keeps upstream parsing.',
'',
'*提醒!*单星号也保持上游行为。',
'',
'`https://example.com/preview?q=one%20two#result` 与 `curl http://127.0.0.1:3199/` 以及 `javascript:alert(1)`。',
].join('\n'),
'definition-only': '[unused]: https://example.com/unused',
'streaming-typical-partial': '## Streaming\n\n- first\n- **unfinished',
}
describe('MarkdownText DOM parity fixtures', () => {
for (const [name, text] of Object.entries(CORPUS)) {
it(`settled: ${name}`, async () => {
await expect(renderCase(text, false)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.settled.txt`)
})
it(`streaming: ${name}`, async () => {
await expect(renderCase(text, true)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.streaming.txt`)
})
}
})

View File

@@ -0,0 +1,427 @@
// @vitest-environment jsdom
// Incremental streaming behavior: a MarkdownText kept mounted across
// append-only rerenders must show, at every step, exactly the DOM a fresh
// mount of the same prefix shows, while reusing the frozen blocks' DOM nodes
// instead of remounting them.
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { Root, RootContent } from 'mdast'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts'
import { parseGfm } from '../src/markdown/parse.ts'
afterEach(cleanup)
/**
* A many-block document exercising every freeze-sensitive construct. The
* prefix-equivalence property below holds only while no reference or
* footnote definition lands on the far side of a freeze boundary from its
* use: a fresh mount parses everything in one tree while the live stream's
* frozen blocks are already baked (the fingerprint test demonstrates the
* documented deviation). Keep definitions adjacent to their references when
* extending this corpus.
*/
const STREAM_DOC = [
'# Title',
'',
'First paragraph with **strong** and `code`.',
'',
'- list item one',
'- list item two',
'',
' continuation of item two',
'',
'Setext heading',
'===',
'',
'| a | b |',
'| --- | --- |',
'| 1 | 2 |',
'',
'```ts',
'const x = 1',
'',
'still inside the fence',
'```',
'',
'> quote with lazy',
'continuation line',
'',
'Uses a footnote[^n] twice[^n].',
'',
'[^n]: The footnote body.',
'',
'Closing paragraph after enough blocks to freeze everything above.',
'',
'One more tail block.',
].join('\n')
describe('incremental streaming rendering', () => {
for (const chunkSize of [1, 3, 7, 16]) {
it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => {
const live = render(<MarkdownText text="" streaming />)
for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) {
const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length))
live.rerender(<MarkdownText text={prefix} streaming />)
const fresh = render(<MarkdownText text={prefix} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
}
live.unmount()
})
}
it('keeps frozen block DOM nodes across freezes instead of remounting', () => {
const paragraphs = Array.from({ length: 8 }, (_, i) => `Paragraph number ${i}.`)
const first = `${paragraphs[0]}\n\n`
const live = render(<MarkdownText text={first} streaming />)
const firstBlock = live.container.querySelector('p')
expect(firstBlock?.textContent).toBe(paragraphs[0])
live.rerender(<MarkdownText text={paragraphs.join('\n\n')} streaming />)
// Same DOM node instance: the block kept its key across the freeze boundary.
expect(live.container.querySelector('p')).toBe(firstBlock)
expect(live.container.querySelectorAll('p')).toHaveLength(paragraphs.length)
live.unmount()
})
it('recovers when the text diverges instead of appending', () => {
const live = render(<MarkdownText text={'alpha\n\nbeta\n\ngamma\n\ndelta'} streaming />)
live.rerender(<MarkdownText text={'totally\n\ndifferent\n\ndocument'} streaming />)
const fresh = render(<MarkdownText text={'totally\n\ndifferent\n\ndocument'} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
live.unmount()
fresh.unmount()
})
it('drops the streaming cache when the copy labels change identity', () => {
const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n')
const live = render(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy' }} />)
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy'])
live.rerender(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren' }} />)
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren'])
live.unmount()
})
it('settles into the full math-enabled render after streaming', () => {
const doc = 'Value $E = mc^2$ inline.\n\nSecond.\n\nThird.\n\nFourth.'
const live = render(<MarkdownText text={doc} streaming />)
expect(live.container.querySelector('.katex')).toBeNull()
live.rerender(<MarkdownText text={doc} />)
const settled = render(<MarkdownText text={doc} />)
expect(live.container.innerHTML).toBe(settled.container.innerHTML)
expect(live.container.querySelector('.katex')).not.toBeNull()
live.unmount()
settled.unmount()
})
})
describe('incremental parsing is actually in effect', () => {
it('hands the grammar only the source tail once blocks freeze', () => {
const calls: string[] = []
const recording = (text: string): Root => {
calls.push(text)
return parseGfm(text)
}
const parser = new IncrementalMarkdownParser(recording)
const paragraphs = Array.from({ length: 40 }, (_, i) => `Paragraph number ${i} with some words.`)
let text = ''
for (const paragraph of paragraphs) {
text += `${paragraph}\n\n`
parser.update(text)
}
expect(text.length).toBeGreaterThan(1500)
// Warm-up aside, every parse sees only the unstable tail: bounded by a
// few paragraphs, not the growing document.
const steady = calls.slice(5)
expect(Math.max(...steady.map(call => call.length))).toBeLessThan(200)
expect(steady.every(call => !call.includes('Paragraph number 0 '))).toBe(true)
// Cumulative parsed bytes stay linear in the document; full re-parsing
// would have accumulated ~40/2 times the document length here.
const totalParsed = calls.reduce((sum, call) => sum + call.length, 0)
expect(totalParsed).toBeLessThan(text.length * 5)
})
it('shows the documented streaming fingerprint: a definition frozen earlier no longer resolves a new reference, and settling heals it', () => {
const doc = [
'[ref]: https://example.com/target',
'',
'Paragraph one keeps the definition company.',
'',
'Paragraph two pushes the freeze boundary.',
'',
'Paragraph three freezes the definition out.',
'',
'See [the link][ref] for details.',
].join('\n')
const head = doc.slice(0, doc.indexOf('See'))
const live = render(<MarkdownText text={head} streaming />)
live.rerender(<MarkdownText text={doc} streaming />)
// The tail re-parse cannot see the frozen definition, so the reference
// stays literal — the direct observable that the whole text was NOT
// re-parsed (a one-shot mount of the same text resolves it).
expect(live.container.querySelector('a')).toBeNull()
expect(live.container.textContent).toContain('[the link][ref]')
const fresh = render(<MarkdownText text={doc} streaming />)
expect(fresh.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
fresh.unmount()
// The settled swap re-parses everything and heals the deviation.
live.rerender(<MarkdownText text={doc} />)
expect(live.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
live.unmount()
})
})
describe('freeze dynamics around frontier-sensitive constructs', () => {
it('an unclosed fence pins the tail: nothing freezes until it closes', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
let text = 'p1.\n\np2.\n\np3.\n\n```ts\n'
const opened = parser.update(text)
const frozenAtOpen = opened.frozen.length
expect(opened.tail[opened.tail.length - 1]?.node.type).toBe('code')
for (const line of ['const a = 1\n', '\n', 'looks like a paragraph\n', '- looks like a list\n']) {
text += line
const grown = parser.update(text)
// The fence swallows everything appended, so the block census cannot
// grow and the freeze boundary must hold still.
expect(grown.frozen.length).toBe(frozenAtOpen)
expect(grown.tail[grown.tail.length - 1]?.node.type).toBe('code')
}
text += '```\n\nafter one.\n\nafter two.\n'
const closed = parser.update(text)
expect(closed.frozen.length).toBeGreaterThan(frozenAtOpen)
const frozenCode = closed.frozen.find(block => block.node.type === 'code')?.node
expect(frozenCode?.type === 'code' && frozenCode.value).toContain('looks like a list')
})
it('a list can keep extending across blank lines until it freezes whole', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
let text = 'intro.\n\nsecond.\n\nthird.\n\n- item a\n- item b\n'
const before = parser.update(text)
const frozenBefore = before.frozen.length
text += '\n- item c\n'
const extended = parser.update(text)
expect(extended.frozen.length).toBe(frozenBefore)
const tailList = extended.tail[extended.tail.length - 1]?.node
expect(tailList?.type === 'list' && tailList.children).toHaveLength(3)
text += '\nafter.\n\nmore.\n\nend.\n'
const after = parser.update(text)
const frozenList = after.frozen.find(block => block.node.type === 'list')?.node
expect(frozenList?.type === 'list' && frozenList.children).toHaveLength(3)
})
it('keeps every previously frozen key as a stable prefix across the stream', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
let previous: readonly number[] = []
for (let end = 7; end < STREAM_DOC.length + 7; end += 7) {
const { frozen } = parser.update(STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)))
const keys = frozen.map(block => block.key)
expect(keys.slice(0, previous.length)).toEqual(previous)
previous = keys
}
expect(previous.length).toBeGreaterThan(4)
})
})
describe('multibyte content', () => {
const CJK_DOC = [
'# 标题 🎉',
'',
'这是一段包含 **加粗**、`行内代码` 与表情 😀🚀 的中文段落。',
'',
'- 列表项一 ✅',
'- 列表项二',
'',
'> 引用一行,带表情 🐟',
'',
'```',
'中文代码 🎯',
'```',
'',
'| 键 | 值 |',
'| --- | --- |',
'| 甲 | 乙 |',
'',
'结尾段落,足够多的块让前面全部冻结。🌊',
].join('\n')
it('code-unit chunking (splitting surrogate pairs mid-stream) matches fresh renders', () => {
const live = render(<MarkdownText text="" streaming />)
for (let end = 1; end < CJK_DOC.length + 1; end += 1) {
const prefix = CJK_DOC.slice(0, Math.min(end, CJK_DOC.length))
live.rerender(<MarkdownText text={prefix} streaming />)
const fresh = render(<MarkdownText text={prefix} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
}
live.unmount()
})
it('freeze-cut offsets agree with one-shot parse offsets on astral content', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
let result = parser.update(CJK_DOC.slice(0, 3))
for (let end = 6; end < CJK_DOC.length + 3; end += 3) {
result = parser.update(CJK_DOC.slice(0, Math.min(end, CJK_DOC.length)))
}
const oneShot = parseGfm(CJK_DOC).children.map(node => node.position?.start.offset)
expect([...result.frozen, ...result.tail].map(block => block.key)).toEqual(oneShot)
expect(result.frozen.length).toBeGreaterThan(3)
})
})
describe('streaming composition across freezes', () => {
it('continues footnote numbering from frozen references and lists all definitions', () => {
const doc = [
'Alpha uses a footnote[^a].',
'',
'[^a]: First note body.',
'',
'Filler one.',
'',
'Filler two.',
'',
'Filler three.',
'',
'Beta uses another[^b].',
'',
'[^b]: Second note body.',
].join('\n')
const head = doc.slice(0, doc.indexOf('Beta'))
const live = render(<MarkdownText text={head} streaming />)
live.rerender(<MarkdownText text={doc} streaming />)
expect([...live.container.querySelectorAll('p sup')].map(sup => sup.textContent)).toEqual(['1', '2'])
expect([...live.container.querySelectorAll('section.footnotes li')].map(li => li.id))
.toEqual(['user-content-fn-a', 'user-content-fn-b'])
expect(live.container.querySelector('section.footnotes')?.textContent).toContain('First note body. ↩')
const fresh = render(<MarkdownText text={doc} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
live.unmount()
})
it('keeps every frozen block DOM node through the rest of the stream', () => {
const paragraphs = Array.from({ length: 12 }, (_, i) => `Stable paragraph ${i}.`)
const half = `${paragraphs.slice(0, 6).join('\n\n')}\n\n`
const live = render(<MarkdownText text={half} streaming />)
const captured = [...live.container.querySelectorAll('p')]
expect(captured.length).toBe(6)
let text = half
for (const paragraph of paragraphs.slice(6)) {
text += `${paragraph}\n\n`
live.rerender(<MarkdownText text={text} streaming />)
}
const finalNodes = [...live.container.querySelectorAll('p')]
expect(finalNodes.slice(0, 6)).toEqual(captured)
expect(finalNodes).toHaveLength(12)
live.unmount()
})
it('renders an empty document for definition-only streams, including trailing blank lines', () => {
const doc = '[a]: https://example.com/1\n\n[b]: https://example.com/2\n\n[c]: https://example.com/3\n\n[d]: https://example.com/4'
const live = render(<MarkdownText text={doc.slice(0, 30)} streaming />)
live.rerender(<MarkdownText text={doc} streaming />)
live.rerender(<MarkdownText text={`${doc}\n\n\n`} streaming />)
const fresh = render(<MarkdownText text={`${doc}\n\n\n`} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
expect(live.container.querySelector('div')?.childNodes).toHaveLength(0)
fresh.unmount()
live.unmount()
})
it('survives streaming → settled → streaming prop flips with a fresh incremental state', () => {
const live = render(<MarkdownText text={'a.\n\nb.'} streaming />)
live.rerender(<MarkdownText text={'a.\n\nb.'} />)
const settled = render(<MarkdownText text={'a.\n\nb.'} />)
expect(live.container.innerHTML).toBe(settled.container.innerHTML)
settled.unmount()
live.rerender(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
const fresh = render(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
live.unmount()
})
it('matches fresh renders under irregular deterministic chunk sizes', () => {
let seed = 42
const nextSize = (): number => {
seed = (seed * 1103515245 + 12345) % 2147483648
return 1 + (seed % 13)
}
const live = render(<MarkdownText text="" streaming />)
let end = 0
while (end < STREAM_DOC.length) {
end = Math.min(end + nextSize(), STREAM_DOC.length)
const prefix = STREAM_DOC.slice(0, end)
live.rerender(<MarkdownText text={prefix} streaming />)
const fresh = render(<MarkdownText text={prefix} streaming />)
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
fresh.unmount()
}
live.unmount()
})
})
describe('IncrementalMarkdownParser', () => {
it('freezes all but the trailing two blocks and keeps freezing as blocks appear', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
const first = parser.update('a\n\nb\n\nc\n\nd\n\ne')
expect(first.frozen.map(b => b.node.type)).toEqual(['paragraph', 'paragraph', 'paragraph'])
expect(first.tail).toHaveLength(2)
const second = parser.update('a\n\nb\n\nc\n\nd\n\ne\n\nf\n\ng')
expect(second.frozen).toHaveLength(5)
expect(second.tail).toHaveLength(2)
// Previously returned frozen entries keep their identity and keys.
expect(second.frozen.slice(0, 3)).toEqual(first.frozen)
expect(second.generation).toBe(first.generation)
})
it('holds every block in the tail until more than two exist', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
const result = parser.update('only\n\ntwo blocks')
expect(result.frozen).toHaveLength(0)
expect(result.tail).toHaveLength(2)
})
it('returns the cached result for identical input', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
const first = parser.update('a\n\nb\n\nc')
expect(parser.update('a\n\nb\n\nc')).toBe(first)
})
it('bumps the generation and discards frozen blocks on non-append input', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
const before = parser.update('a\n\nb\n\nc\n\nd')
expect(before.frozen.length).toBeGreaterThan(0)
const after = parser.update('different')
expect(after.generation).toBe(before.generation + 1)
expect(after.frozen).toHaveLength(0)
expect(after.tail.map(b => b.node.type)).toEqual(['paragraph'])
})
it('keys blocks by absolute source offset across freezes', () => {
const doc = 'aaa\n\nbbb\n\nccc\n\nddd\n\neee'
const parser = new IncrementalMarkdownParser(parseGfm)
const grown = parser.update(doc)
const oneShotKeys = parseGfm(doc).children.map(node => node.position?.start.offset)
expect([...grown.frozen, ...grown.tail].map(b => b.key)).toEqual(oneShotKeys)
})
it('never freezes under a grammar that omits positions', () => {
const bare = (text: string): Root => {
const root = parseGfm(text)
const strip = (nodes: RootContent[]): void => {
for (const node of nodes) {
delete node.position
if ('children' in node) strip(node.children)
}
}
strip(root.children)
return root
}
const parser = new IncrementalMarkdownParser(bare)
const result = parser.update('a\n\nb\n\nc\n\nd\n\ne')
expect(result.frozen).toHaveLength(0)
expect(result.tail).toHaveLength(5)
// Fallback keys stay unique per sibling.
expect(new Set(result.tail.map(b => b.key)).size).toBe(5)
})
})

View File

@@ -0,0 +1,225 @@
// @vitest-environment jsdom
// Branch coverage for the mdast renderer that real parses cannot reach: the
// grammar only emits references whose definitions exist, always stamps
// positions and align arrays, and never emits bare list items — but the
// renderer is a pure function over mdast, so hand-built trees exercise its
// defensive arms directly.
import { StrictMode } from 'react'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type * as Md from 'mdast'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import {
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
} from '../src/markdown/render.tsx'
import type { MarkdownRenderContext } from '../src/markdown/render.tsx'
afterEach(cleanup)
function makeContext(): MarkdownRenderContext {
return {
streaming: false,
codeLabels: undefined,
targets: createReferenceTargets(),
footnoteOrder: [],
footnoteCounts: new Map(),
}
}
function renderNodes(nodes: Md.RootContent[], context = makeContext()): HTMLElement {
const { container } = render(
<div>{renderBlocks(nodes.map((node, key) => ({ node, key })), context)}</div>,
)
return container
}
const text = (value: string): Md.Text => ({ type: 'text', value })
describe('renderBlocks over hand-built trees', () => {
it('reverts unresolved references to their bracketed source', () => {
const container = renderNodes([
{
type: 'paragraph',
children: [
{ type: 'linkReference', identifier: 'a', referenceType: 'shortcut', children: [text('one')] },
{ type: 'linkReference', identifier: 'b', referenceType: 'collapsed', children: [text('two')] },
{ type: 'linkReference', identifier: 'c', label: 'C', referenceType: 'full', children: [text('three')] },
{ type: 'imageReference', identifier: 'd', referenceType: 'full', alt: 'pic' },
{ type: 'imageReference', identifier: 'e', referenceType: 'shortcut', alt: null },
],
},
])
expect(container.textContent).toBe('[one][two][][three][C]![pic][d]![]')
expect(container.querySelector('a')).toBeNull()
})
it('keeps the first definition when identifiers repeat', () => {
const targets = createReferenceTargets()
collectReferenceTargets([
{ type: 'definition', identifier: 'dup', url: 'https://example.com/first' },
{ type: 'definition', identifier: 'dup', url: 'https://example.com/second' },
{ type: 'footnoteDefinition', identifier: 'fn', children: [] },
{ type: 'footnoteDefinition', identifier: 'fn', children: [{ type: 'paragraph', children: [text('late')] }] },
], targets)
expect(targets.definitions.get('DUP')?.url).toBe('https://example.com/first')
expect(targets.footnotes.get('FN')?.children).toEqual([])
})
it('renders a bare list item, computing looseness from the item itself', () => {
const item: Md.ListItem = {
type: 'listItem',
spread: null,
children: [
{ type: 'paragraph', children: [text('alpha')] },
{ type: 'paragraph', children: [text('beta')] },
],
}
const container = renderNodes([item])
// Two block children make the parentless item loose: paragraphs stay wrapped.
expect([...container.querySelectorAll('li > p')].map(p => p.textContent)).toEqual(['alpha', 'beta'])
})
it('renders spread-null lists and align-less tables', () => {
const container = renderNodes([
{
type: 'list',
ordered: false,
spread: null,
children: [{ type: 'listItem', spread: null, children: [{ type: 'paragraph', children: [text('solo')] }] }],
},
{
type: 'table',
children: [
{ type: 'tableRow', children: [{ type: 'tableCell', children: [text('h')] }] },
{ type: 'tableRow', children: [{ type: 'tableCell', children: [text('short')] }] },
],
},
])
expect(container.querySelector('li')?.textContent).toBe('solo')
expect(container.querySelector('th')?.getAttribute('style')).toBeNull()
expect(container.querySelector('td')?.textContent).toBe('short')
})
it('pads rows against the alignment width with empty cells', () => {
const container = renderNodes([
{
type: 'table',
align: ['left', 'right'],
children: [
{ type: 'tableRow', children: [{ type: 'tableCell', children: [text('only')] }] },
],
},
])
const cells = [...container.querySelectorAll('th')]
expect(cells).toHaveLength(2)
expect(cells[1]?.textContent).toBe('')
})
it('renders a checked item without any content as a bare checkbox', () => {
const container = renderNodes([
{
type: 'list',
ordered: false,
children: [
{ type: 'listItem', checked: true, children: [] },
{ type: 'listItem', checked: false, children: [{ type: 'paragraph', children: [] }] },
],
},
])
const items = [...container.querySelectorAll('li.task-list-item')]
expect(items).toHaveLength(2)
for (const item of items) {
expect(item.querySelector('input[type="checkbox"]')).not.toBeNull()
expect(item.textContent?.trim()).toBe('')
}
})
it('renders images with a null alt as an empty alt attribute', () => {
const targets = createReferenceTargets()
targets.definitions.set('R', { type: 'definition', identifier: 'r', url: 'https://example.com/r.png' })
const container = renderNodes([
{ type: 'paragraph', children: [{ type: 'image', url: 'https://example.com/x.png', alt: null }] },
{ type: 'paragraph', children: [{ type: 'imageReference', identifier: 'r', referenceType: 'full', alt: null }] },
], { ...makeContext(), targets })
const images = [...container.querySelectorAll('img')]
expect(images.map(image => image.getAttribute('alt'))).toEqual(['', ''])
})
it('drops a definition nested in a list item without leaving a separator behind', () => {
const container = renderNodes([
{
type: 'list',
ordered: true,
start: 3,
children: [{
type: 'listItem',
children: [
{ type: 'paragraph', children: [text('body')] },
{ type: 'definition', identifier: 'x', url: 'https://example.com' },
],
}],
},
])
expect(container.querySelector('ol')?.getAttribute('start')).toBe('3')
// The two mdast children make the item loose (wrap newlines around the
// paragraph); the dropped definition contributes nothing else.
expect(container.querySelector('li')?.textContent).toBe('\nbody\n')
})
it('renders nothing for node types without a mapping', () => {
const container = renderNodes([
{ type: 'yaml', value: 'front: matter' },
{ type: 'tableRow', children: [] },
{ type: 'paragraph', children: [text('after')] },
])
expect(container.textContent).toBe('after')
})
})
describe('renderFootnoteSection edge shapes', () => {
it('skips referenced footnotes without definitions and returns null when none remain', () => {
const context = makeContext()
context.footnoteOrder.push('GHOST')
context.footnoteCounts.set('GHOST', 1)
expect(renderFootnoteSection(context)).toBeNull()
})
it('renders no back-reference markers for an uncounted footnote', () => {
const context = makeContext()
context.targets.footnotes.set('Q', {
type: 'footnoteDefinition',
identifier: 'q',
children: [{ type: 'paragraph', children: [text('quiet')] }],
})
context.footnoteOrder.push('Q')
const { container } = render(<div>{renderFootnoteSection(context)}</div>)
expect(container.querySelector('li')?.textContent).toBe('\nquiet \n')
})
it('appends back-references after a non-paragraph body', () => {
const context = makeContext()
context.targets.footnotes.set('N', {
type: 'footnoteDefinition',
identifier: 'n',
children: [{ type: 'code', value: 'code body', lang: null }],
})
context.footnoteOrder.push('N')
context.footnoteCounts.set('N', 1)
const { container } = render(<div>{renderFootnoteSection(context)}</div>)
const item = container.querySelector('li')
expect(item?.querySelector('.md-code-block')).not.toBeNull()
expect(item?.textContent).toContain('↩')
})
})
describe('MarkdownText under StrictMode', () => {
it('streams identically when React double-invokes render work', () => {
const doc = 'one\n\ntwo\n\nthree\n\nfour\n\nfive'
const strict = render(<StrictMode><MarkdownText text={doc.slice(0, 8)} streaming /></StrictMode>)
strict.rerender(<StrictMode><MarkdownText text={doc} streaming /></StrictMode>)
const plain = render(<MarkdownText text={doc} streaming />)
expect(strict.container.innerHTML).toBe(plain.container.innerHTML)
strict.unmount()
plain.unmount()
})
})

View File

@@ -1,10 +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 { remarkCjkFriendlyStrong } from '../src/markdown/remarkCjkFriendlyStrong.ts'
import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
afterEach(cleanup)
@@ -149,14 +148,10 @@ describe('MarkdownText', () => {
expect(container.querySelector('pre code a')).toBeNull()
})
it('registers the CJK strong extension and rejects a parser without CommonMark attention markers', () => {
const data: { micromarkExtensions?: Extension[] } = {}
const processor = { data: () => data }
remarkCjkFriendlyStrong.call(processor)
remarkCjkFriendlyStrong.call(processor)
expect(data.micromarkExtensions).toHaveLength(2)
const construct = data.micromarkExtensions?.[0]?.text?.[42]
it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => {
const extension = cjkFriendlyStrong()
expect(cjkFriendlyStrong()).toBe(extension)
const construct = extension.text?.[42]
const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize
expect(tokenizer).toBeTypeOf('function')
expect(() => tokenizer?.call({
@@ -420,11 +415,11 @@ describe('MarkdownText', () => {
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 })
it('exposes the compatibility syntax as a micromark extension', () => {
const extension = mathCompatibility()
expect(data.micromarkExtensions).toHaveLength(1)
expect(Object.keys(extension)).toEqual(['flow', 'text'])
expect(mathCompatibility()).toBe(extension)
})
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {