Merge pull request #1753 from deepseek-harness/feat/md-incre-2

feat: markdown 增量解析
This commit is contained in:
imccyu
2026-08-06 15:42:54 +08:00
committed by GitHub
70 changed files with 3318 additions and 620 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md
2026-08-06-web-markdown-incremental-ast-renderer.md: 3599bfcc78dc4eefe5e82f461a469bdba15f3aae
2026-08-06-web-markdown-incremental-ast-renderer.zh.md: 2e00977da58ef29a77c45abcecf0f3bb62737929

View File

@@ -0,0 +1,33 @@
# Agent Note: Incremental streaming markdown through a direct mdast renderer
Status: implemented
English | [中文](2026-08-06-web-markdown-incremental-ast-renderer.zh.md)
## Problem
`MarkdownText` re-parsed the whole accumulated reply on every streaming publish: react-markdown's string-only API builds a fresh unified processor per render and runs micromark → mdast → hast → React over the full text, so per-chunk main-thread work grew linearly with the reply and the stream's cumulative cost grew quadratically. The existing mitigations (frame batching, the isolated streaming tail, the plain fence arm) bounded how often and how widely that work ran, never how much text each run re-parsed. Fixing it needs AST-level input — freezing settled blocks and re-parsing only the source tail — which the string-only wrapper structurally cannot express.
## Decision
`MarkdownText` renders mdast directly and parses incrementally while streaming:
- **Grammars** ([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)): `parseGfm` (streaming arm and `extractMarkdownPlainText`) and `parseGfmWithMath` (settled arm) call `mdast-util-from-markdown` with the same micromark extensions the replaced remark plugins wrapped, so block boundaries are identical everywhere. `mathCompatibility` (ex `remarkMathCompatibility`) now exports its micromark extension directly.
- **Incremental parsing** ([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)): CommonMark block parsing is line-based, so appended text reshapes only the parse frontier. `IncrementalMarkdownParser` keeps the trailing two blocks unstable (the last block is the frontier; the second-to-last is safety margin), freezes everything before them, and re-parses only the source tail from the last frozen block's `position.end.offset` — the parser's own offsets, no bespoke source scanning. Each source region parses O(1) times per stream instead of once per chunk; a single giant block (an unclosed fence) degrades to the old full-reparse cost and no worse. Non-append input resets the state under a bumped generation.
- **Rendering** ([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx), [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)): one switch over mdast node types replaces remark-rehype + react-markdown, reproducing the replaced pipeline's DOM byte-for-byte — table alignment as `text-align` styles, tight-list paragraph unwrapping, task-list classes and checkbox spacing, the footnote section (whose in-page anchors the protocol allowlist already reduced to plain text), literal raw HTML, the separator newlines that surface next to literal HTML text, and rehype-katex's three-arm error chain with KaTeX HTML mapped to React through the browser's own `DOMParser` (no wrapper element, so first/last-child margin rules still reach `.katex-display`; React 18 puts the `.katex-mathml` subtree in the HTML namespace exactly as the replaced pipeline did — a pre-existing limitation outside this parity contract, invisible to the visual `.katex-html` arm). Frozen blocks cache their React elements and keep source-offset keys, so crossing the freeze boundary reconciles instead of remounting; `MarkdownText` is memoized.
The DOM is pinned by `tests/fixtures/markdown-dom`: fixtures recorded from the react-markdown implementation before the swap, which the new renderer must reproduce under a whitespace-normalizing serializer. A fixture diff is a user-visible markdown style change to review, never to re-record for a refactor. `tests/markdown-incremental.spec.tsx` holds the equivalence property — at every appended prefix, chunked at 1/3/7/16 bytes, the live component's DOM equals a fresh mount's — plus freeze-boundary DOM-node identity and reset behavior.
This reverses the [assistant-markdown note](../feature/2026-07-23-web-assistant-markdown.md)'s rejected alternative ("maintain a custom React walker"): the incremental requirement is new evidence, the walker's security-sensitive branches (URL allowlist, image policy, inert HTML) were already product-owned functions, and the dependency no longer deleted owned code — it blocked the architecture. That note's untrusted-output policy and renderer selection are unchanged.
## Alternatives considered
**Keep react-markdown and split the source into per-segment `<ReactMarkdown>` instances.** Zero renderer ownership, but each frame parses the tail twice (boundary detection + render), settled math still re-parses everything, hast construction and the per-render processor remain, and blocks remount when crossing the freeze boundary because element trees cannot be cached across instances.
**Render cached mdast through `mdast-util-to-hast` + `hast-util-to-jsx-runtime`.** Keeps upstream's node mappings for free, but retains the hast intermediate per frame and two new direct dependencies for a pipeline whose mapping surface is small, closed, and now pinned by fixtures.
**Parse KaTeX output with `hast-util-from-html-isomorphic` (as rehype-katex does).** Pulls a parse5-based HTML parser into the bundle to parse trusted, vocabulary-constrained KaTeX output the browser's `DOMParser` (with the spec's SVG/MathML attribute adjustments) already parses identically.
## Consequences
Streaming per-chunk work now tracks the unstable tail instead of the whole reply, and react-markdown, remark-gfm, remark-math, rehype-katex, unified, and the hast chain left the browser bundle (`mdast-util-math` and `micromark-util-sanitize-uri` became direct dependencies; both were already transitive). The package owns ~25 node mappings, their tests, and the KaTeX DOM conversion — priced against the fixture contract that freezes their output. Two behavioral deviations, both healed by the settled full parse at finalize: a reference-style link or footnote whose definition lands on the other side of a freeze boundary renders literally while streaming, and a footnote reference can flash back to literal text when its definition freezes while the referencing block is still unstable. This module and KaTeX conversion assume a browser DOM (`DOMParser`), which the client-only package already did.

View File

@@ -0,0 +1,33 @@
# Agent Note: 经由直接 mdast 渲染器的增量流式 Markdown
Status: implemented
[English](2026-08-06-web-markdown-incremental-ast-renderer.md) | 中文
## Problem
`MarkdownText` 在每次流式发布时都重新解析整个已累积的回复:react-markdown 的纯字符串 API 每次渲染都新建 unified processor,并对全文跑完 micromark → mdast → hast → React,因此每个 chunk 的主线程工作量随回复长度线性增长,整个流的累计成本随之二次增长。既有缓解手段(帧级合并、隔离的流式尾部、围栏 plain 臂)约束的是这份工作跑多频繁、波及多广,从未约束每次重新解析多少文本。修复它需要 AST 级输入——冻结已定型的块、只重新解析源文本尾部——这是纯字符串封装在结构上无法表达的。
## Decision
`MarkdownText` 直接渲染 mdast,并在流式期间增量解析:
- **语法**([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)):`parseGfm`(流式臂与 `extractMarkdownPlainText`)和 `parseGfmWithMath`(定稿臂)以被替换的 remark 插件所包装的同一组 micromark 扩展调用 `mdast-util-from-markdown`,因此各处块边界完全一致。`mathCompatibility`(原 `remarkMathCompatibility`)现在直接导出其 micromark 扩展。
- **增量解析**([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)):CommonMark 块解析按行推进,追加文本只会重塑解析前沿。`IncrementalMarkdownParser` 保留末尾两个块不稳定(最后一块是前沿;倒数第二块是安全裕量),冻结其前的所有块,只从最后一个冻结块的 `position.end.offset` 起重新解析源尾部——用的是解析器自己的偏移量,没有任何自制源扫描。每个源区间在整个流中解析 O(1) 次而非每 chunk 一次;单个巨型块(未闭合围栏)退化为旧的全量重解析成本,不会更差。非追加输入在递增的 generation 下重置状态。
- **渲染**([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx)、[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)):一个对 mdast 节点类型的 switch 取代 remark-rehype + react-markdown,逐字节复刻被替换管线的 DOM——表格对齐渲染为 `text-align` 样式、紧凑列表段落解包、任务列表类名与复选框空格、脚注区(其页内锚点本就被协议白名单降为纯文本)、字面 raw HTML、会与字面 HTML 文本相邻显形的分隔换行,以及 rehype-katex 的三臂容错链,KaTeX HTML 经浏览器自带的 `DOMParser` 映射为 React(无包裹元素,首/末子元素的 margin 规则仍能作用于 `.katex-display`;React 18 会把 `.katex-mathml` 子树放进 HTML 命名空间,与被替换管线完全一致——既有限制,不在本对等性契约范围内,对承担视觉渲染的 `.katex-html` 臂不可见)。冻结块缓存其 React 元素并保持源偏移 key,跨过冻结边界时走 reconcile 而非重挂载;`MarkdownText` 已 memo 化。
DOM 由 `tests/fixtures/markdown-dom` 钉死:fixture 录制自替换前的 react-markdown 实现,新渲染器必须在空白规整序列化器下复现。fixture 差异即用户可见的 markdown 样式变更,必须按此评审,绝不能为重构而重录。`tests/markdown-incremental.spec.tsx` 承载等价性性质——以 1/3/7/16 字节分块,在每个追加前缀处,常驻组件的 DOM 都等于全新挂载——外加冻结边界的 DOM 节点同一性与重置行为。
这推翻了[助手 Markdown Note](../feature/2026-07-23-web-assistant-markdown.md) 中被否决的备选("维护一个自定义 React walker"):增量需求是当时不存在的新证据,walker 的安全敏感分支(URL 白名单、图片策略、惰性 HTML)本就是产品自有函数,而该依赖不再删减自有代码——它阻塞了架构。该 Note 的不可信输出策略与渲染器选型不变。
## Alternatives considered
**保留 react-markdown,把源文本切成逐段 `<ReactMarkdown>` 实例。** 渲染器零自有成本,但每帧对尾部解析两次(边界检测 + 渲染),定稿数学仍要全量重解析,hast 构建与逐渲染 processor 依旧存在,且块跨过冻结边界时会重挂载——元素树无法跨实例缓存。
**用 `mdast-util-to-hast` + `hast-util-to-jsx-runtime` 渲染缓存的 mdast。** 白拿上游节点映射,但每帧保留 hast 中间层,并为一个映射面小、封闭、且已被 fixture 钉死的管线引入两个新直接依赖。
**用 `hast-util-from-html-isomorphic` 解析 KaTeX 输出(rehype-katex 的做法)。** 为解析可信、词汇受限的 KaTeX 输出把基于 parse5 的 HTML 解析器拉进 bundle,而浏览器自带的 `DOMParser`(带规范的 SVG/MathML 属性调整)解析结果完全相同。
## Consequences
流式的每 chunk 工作量现在跟随不稳定尾部而非整个回复,react-markdown、remark-gfm、remark-math、rehype-katex、unified 及 hast 链退出浏览器 bundle(`mdast-util-math``micromark-util-sanitize-uri` 成为直接依赖;两者原本就是传递依赖)。包自有约 25 个节点映射、其测试以及 KaTeX DOM 转换——代价由冻结其输出的 fixture 契约对冲。两个行为偏差,均在定稿的全量解析处自愈:定义落在冻结边界另一侧的引用式链接或脚注在流式期间渲染为字面文本;当脚注定义先冻结而引用块仍不稳定时,脚注引用可能闪回字面文本。本模块与 KaTeX 转换假定浏览器 DOM(`DOMParser`),这个 client-only 包本就如此。

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 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
2026-07-23-web-assistant-markdown.md: c21f2dc3c0aff98dd6ea6a88ea7a4742198c92d6
2026-07-23-web-assistant-markdown.zh.md: e6677a6a22050b59343a67d6b282b92695c24009
2026-07-23-web-assistant-markdown.md: ad86559e3b5294b6bd67d69ff5c6ce37a5172008
2026-07-23-web-assistant-markdown.zh.md: b47375db843ea1c81b913e3f7ac8cd1bbc278830

View File

@@ -12,9 +12,9 @@ The Web conversation preserves assistant Markdown source through session events,
`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk.
`MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk.
Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). When one inline-code token consists entirely of an absolute HTTP(S) URL, its code chrome contains the same keyboard-focusable safe external anchor as an ordinary link; port, path, and query text remain unchanged, while commands, partial URLs, other schemes, and fenced code stay inert. `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes.
Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). When one inline-code token consists entirely of an absolute HTTP(S) URL, its code chrome contains the same keyboard-focusable safe external anchor as an ordinary link; port, path, and query text remain unchanged, while commands, partial URLs, other schemes, and fenced code stay inert. `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through the settled grammar's math extensions; `mathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes.
The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle.
@@ -26,7 +26,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
## Alternatives considered
**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path.
**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.*
**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored surfaces remain literal until the product chooses that behavior explicitly.
@@ -42,4 +42,4 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
## Consequences
Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. URL-shaped inline code becomes navigable without changing its visible literal, while unsafe schemes and mixed code remain non-interactive. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred.
Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses only the unstable tail after each accumulated update; incomplete Markdown can temporarily change the tail's structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. URL-shaped inline code becomes navigable without changing its visible literal, while unsafe schemes and mixed code remain non-interactive. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred.

View File

@@ -12,9 +12,9 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
`MarkdownText` 使用 `react-markdown``remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token高亮已注册语法否则回退为纯等宽文本。轮次流式输出期间围栏停留在纯文本分支以免每收到一个分片就对增长中的围栏重新分词。
`MarkdownText` `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有该机制及其 DOM 一致性契约)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token高亮已注册语法否则回退为纯等宽文本。轮次流式输出期间围栏停留在纯文本分支以免每收到一个分片就对增长中的围栏重新分词。
视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md``markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*``--dsw-font-markdown-*``--dsw-alias-border-l*``--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。当单个行内代码 token 完全由绝对 HTTP(S) URL 构成时,其代码外框会包含一个与普通链接相同、可通过键盘聚焦的安全外链锚点;端口、路径与查询文本保持不变,而命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math``rehype-katex` 渲染 KaTeX`remarkMathCompatibility``\(...\)``\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层不是正则重写也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内GFM 任务列表继续使用原生复选框。
视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md``markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*``--dsw-font-markdown-*``--dsw-alias-border-l*``--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。当单个行内代码 token 完全由绝对 HTTP(S) URL 构成时,其代码外框会包含一个与普通链接相同、可通过键盘聚焦的安全外链锚点;端口、路径与查询文本保持不变,而命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过定稿语法的数学扩展渲染 KaTeX`mathCompatibility``\(...\)``\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层不是正则重写也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内GFM 任务列表继续使用原生复选框。
该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。
@@ -26,7 +26,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
## 考虑过的替代方案
**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。
**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有。*
**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。
@@ -42,4 +42,4 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
## 后果
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围最终事件也不会切换渲染器。URL 形态的行内代码会在不改变其可见字面文本的情况下变得可导航,而采用不安全 scheme 或混有其他内容的代码仍不可交互。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表citation、anchor 和 thinking-small 表层仍暂缓。
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后流式输出重新解析不稳定的尾部;未完成的 Markdown 可能暂时改变尾部结构,但独立的尾部会限定 React 失效范围最终事件也不会切换渲染器。URL 形态的行内代码会在不改变其可见字面文本的情况下变得可导航,而采用不安全 scheme 或混有其他内容的代码仍不可交互。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表citation、anchor 和 thinking-small 表层仍暂缓。

View File

@@ -47,6 +47,7 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
@@ -63,12 +64,14 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`koffi`](https://github.com/Koromix/koffi) | MIT |
| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
| [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT |
| [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT |
| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT |
| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT |
| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
@@ -77,10 +80,6 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`pnpm`](https://github.com/pnpm/pnpm) | MIT |
| [`react`](https://github.com/facebook/react) | MIT |
| [`react-dom`](https://github.com/facebook/react) | MIT |
| [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT |
| [`rehype-katex`](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex) | MIT |
| [`remark-gfm`](https://github.com/remarkjs/remark-gfm) | MIT |
| [`remark-math`](https://github.com/remarkjs/remark-math/tree/main/packages/remark-math) | MIT |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
| [`tsx`](https://github.com/privatenumber/tsx) | MIT |
@@ -111,7 +110,6 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |

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', () => {

406
pnpm-lock.yaml generated
View File

@@ -1688,6 +1688,9 @@ importers:
'@shikijs/langs':
specifier: ^4.3.1
version: 4.3.1
'@types/mdast':
specifier: ^4.0.4
version: 4.0.4
anser:
specifier: ^2.3.5
version: 2.3.5
@@ -1703,6 +1706,9 @@ importers:
mdast-util-gfm:
specifier: ^3.1.0
version: 3.1.0
mdast-util-math:
specifier: ^3.0.0
version: 3.0.0
micromark-core-commonmark:
specifier: ^2.0.3
version: 2.0.3
@@ -1721,6 +1727,9 @@ importers:
micromark-util-classify-character:
specifier: ^2.0.1
version: 2.0.1
micromark-util-sanitize-uri:
specifier: ^2.0.1
version: 2.0.1
micromark-util-symbol:
specifier: ^2.0.1
version: 2.0.1
@@ -1733,18 +1742,6 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@18.3.31)(react@18.3.1)
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
remark-math:
specifier: ^6.0.0
version: 6.0.0
shiki:
specifier: ^4.3.1
version: 4.3.1
@@ -9080,9 +9077,6 @@ packages:
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -9157,9 +9151,6 @@ packages:
'@types/turndown@5.0.6':
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -9512,9 +9503,6 @@ packages:
ast-v8-to-istanbul@1.0.4:
resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==}
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -9603,9 +9591,6 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -9957,10 +9942,6 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
@@ -10068,9 +10049,6 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -10304,39 +10282,12 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hast-util-from-dom@5.0.1:
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
hast-util-from-html-isomorphic@2.0.0:
resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==}
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
hono@4.12.29:
resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==}
engines: {node: '>=16.9.0'}
@@ -10354,9 +10305,6 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -10408,9 +10356,6 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
internmap@1.0.1:
resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
@@ -10426,15 +10371,6 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -10447,9 +10383,6 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
@@ -10850,15 +10783,6 @@ packages:
mdast-util-math@3.0.0:
resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
mdast-util-mdx-jsx@3.2.0:
resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -11206,16 +11130,10 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
@@ -11363,12 +11281,6 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -11405,24 +11317,6 @@ packages:
resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
rehype-katex@7.0.1:
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-math@6.0.0:
resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@@ -11650,12 +11544,6 @@ packages:
strnum@2.4.0:
resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
stylis@4.4.0:
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
@@ -11718,9 +11606,6 @@ packages:
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
@@ -11838,12 +11723,6 @@ packages:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -11891,9 +11770,6 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -12108,9 +11984,6 @@ packages:
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
engines: {node: 20 || >=22}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@@ -14154,10 +14027,6 @@ snapshots:
'@types/esrecurse@4.3.1': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.9
'@types/estree@1.0.9': {}
'@types/geojson@7946.0.16': {}
@@ -14228,8 +14097,6 @@ snapshots:
'@types/turndown@5.0.6': {}
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@types/web-bluetooth@0.0.21': {}
@@ -14629,8 +14496,6 @@ snapshots:
estree-walker: 3.0.3
js-tokens: 10.0.0
bail@2.0.2: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
@@ -14711,8 +14576,6 @@ snapshots:
character-entities@2.0.2: {}
character-reference-invalid@2.0.1: {}
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -15046,8 +14909,6 @@ snapshots:
encodeurl@2.0.0: {}
entities@6.0.1: {}
entities@7.0.1: {}
entities@8.0.0: {}
@@ -15245,8 +15106,6 @@ snapshots:
estraverse@5.3.0: {}
estree-util-is-identifier-name@3.0.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
@@ -15530,47 +15389,6 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-from-dom@5.0.1:
dependencies:
'@types/hast': 3.0.5
hastscript: 9.0.1
web-namespaces: 2.0.1
hast-util-from-html-isomorphic@2.0.0:
dependencies:
'@types/hast': 3.0.5
hast-util-from-dom: 5.0.1
hast-util-from-html: 2.0.3
unist-util-remove-position: 5.0.0
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.5
devlop: 1.1.0
hast-util-from-parse5: 8.0.3
parse5: 7.3.0
vfile: 6.0.3
vfile-message: 4.0.3
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.5
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
property-information: 7.2.0
vfile: 6.0.3
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.5
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.5
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.5
@@ -15585,45 +15403,10 @@ snapshots:
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.9
'@types/hast': 3.0.5
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
mdast-util-mdx-expression: 2.0.1
mdast-util-mdx-jsx: 3.2.0
mdast-util-mdxjs-esm: 2.0.1
property-information: 7.2.0
space-separated-tokens: 2.0.2
style-to-js: 1.1.21
unist-util-position: 5.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.5
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.5
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.5
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.2.0
space-separated-tokens: 2.0.2
hono@4.12.29: {}
hookable@5.5.3: {}
@@ -15638,8 +15421,6 @@ snapshots:
html-escaper@2.0.2: {}
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
http-errors@2.0.1:
@@ -15688,8 +15469,6 @@ snapshots:
inherits@2.0.4: {}
inline-style-parser@0.2.7: {}
internmap@1.0.1: {}
internmap@2.0.3: {}
@@ -15698,15 +15477,6 @@ snapshots:
ipaddr.js@1.9.1: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-decimal@2.0.1: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -15715,8 +15485,6 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hexadecimal@2.0.1: {}
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
@@ -16152,45 +15920,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-jsx@3.2.0:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
unist-util-stringify-position: 4.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
mdast-util-mdxjs-esm@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -16713,22 +16442,8 @@ snapshots:
pako@1.0.11: {}
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
character-entities-legacy: 3.0.0
character-reference-invalid: 2.0.1
decode-named-character-reference: 1.3.0
is-alphanumerical: 2.0.1
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse-ms@4.0.0: {}
parse5@7.3.0:
dependencies:
entities: 6.0.1
parse5@8.0.1:
dependencies:
entities: 8.0.0
@@ -16859,24 +16574,6 @@ snapshots:
react-is@17.0.2: {}
react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1):
dependencies:
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@types/react': 18.3.31
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 18.3.1
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-refresh@0.17.0: {}
react@18.3.1:
@@ -16916,59 +16613,6 @@ snapshots:
'@eslint-community/regexpp': 4.12.2
refa: 0.12.1
rehype-katex@7.0.1:
dependencies:
'@types/hast': 3.0.5
'@types/katex': 0.16.8
hast-util-from-html-isomorphic: 2.0.0
hast-util-to-text: 4.0.2
katex: 0.16.47
unist-util-visit-parents: 6.0.2
vfile: 6.0.3
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-math@6.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-math: 3.0.0
micromark-extension-math: 3.1.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.3
micromark-util-types: 2.0.2
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-rehype@11.1.2:
dependencies:
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
mdast-util-to-hast: 13.2.1
unified: 11.0.5
vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {}
@@ -17272,14 +16916,6 @@ snapshots:
dependencies:
anynum: 1.0.0
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
stylis@4.4.0: {}
superjson@2.2.6:
@@ -17327,8 +16963,6 @@ snapshots:
trim-lines@3.0.1: {}
trough@2.2.0: {}
ts-algebra@2.0.0: {}
ts-api-utils@2.5.0(typescript@6.0.3):
@@ -17417,21 +17051,6 @@ snapshots:
unicorn-magic@0.3.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
bail: 2.0.2
devlop: 1.1.0
extend: 3.0.2
is-plain-obj: 4.1.0
trough: 2.2.0
vfile: 6.0.3
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
@@ -17482,11 +17101,6 @@ snapshots:
vary@1.1.2: {}
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
vfile: 6.0.3
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -17767,8 +17381,6 @@ snapshots:
walk-up-path@4.0.0: {}
web-namespaces@2.0.1: {}
web-streams-polyfill@3.3.3: {}
webidl-conversions@8.0.1: {}