From 31b99930d98479bddc261c1c2514fa980d7b41b3 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 6 Aug 2026 14:58:33 +0800 Subject: [PATCH] fix: address markdown renderer review findings Restore the replaced pipeline's synthetic fence newline so a real trailing blank line inside a fence survives CodeBlock's display trim, and pin it plus header-only tables in the DOM-parity corpus. Route extractMarkdownPlainText through parseGfm so the grammar has one source. Document the fixture provenance check (all 46 fixtures reproduce byte-identically from react-markdown at 9e8101b800), the deliberate O(prefix) divergence memcmp, the per-arm scope of grammar consistency, the React 18 MathML namespace limitation, and the prefix-equivalence corpus constraint. --- ...arkdown-incremental-ast-renderer.i18n.yaml | 4 +- ...6-web-markdown-incremental-ast-renderer.md | 2 +- ...eb-markdown-incremental-ast-renderer.zh.md | 2 +- .../ui-primitives/src/markdown/incremental.ts | 11 +++++- .../ui-primitives/src/markdown/katex.tsx | 6 +++ .../ui-primitives/src/markdown/parse.ts | 12 +++--- .../ui-primitives/src/markdown/plain-text.ts | 15 +++----- .../ui-primitives/src/markdown/render.tsx | 5 ++- .../fence-trailing-blank-lines.settled.txt | 37 +++++++++++++++++++ .../fence-trailing-blank-lines.streaming.txt | 23 ++++++++++++ .../table-header-only.settled.txt | 11 ++++++ .../table-header-only.streaming.txt | 11 ++++++ .../tests/markdown-dom-parity.spec.tsx | 24 ++++++++++++ .../tests/markdown-incremental.spec.tsx | 10 ++++- 14 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml index c5634f42ee..20d21565b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml @@ -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/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md -2026-08-06-web-markdown-incremental-ast-renderer.md: 98d28fa27f8e6e4b5f2ac21831c8f80f8c0c3631 -2026-08-06-web-markdown-incremental-ast-renderer.zh.md: c2c19fe36b8290c1802f6e2b321336e1a68d686f +2026-08-06-web-markdown-incremental-ast-renderer.md: 3599bfcc78dc4eefe5e82f461a469bdba15f3aae +2026-08-06-web-markdown-incremental-ast-renderer.zh.md: 2e00977da58ef29a77c45abcecf0f3bb62737929 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md index 98d28fa27f..3599bfcc78 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md @@ -14,7 +14,7 @@ English | [中文](2026-08-06-web-markdown-incremental-ast-renderer.zh.md) - **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`). Frozen blocks cache their React elements and keep source-offset keys, so crossing the freeze boundary reconciles instead of remounting; `MarkdownText` is memoized. +- **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. diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md index c2c19fe36b..2e00977da5 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md @@ -14,7 +14,7 @@ Status: implemented - **语法**([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 元素并保持源偏移 key,跨过冻结边界时走 reconcile 而非重挂载;`MarkdownText` 已 memo 化。 +- **渲染**([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 节点同一性与重置行为。 diff --git a/packages/client/ui-primitives/src/markdown/incremental.ts b/packages/client/ui-primitives/src/markdown/incremental.ts index af56232e3a..3947d4801f 100644 --- a/packages/client/ui-primitives/src/markdown/incremental.ts +++ b/packages/client/ui-primitives/src/markdown/incremental.ts @@ -58,7 +58,10 @@ export interface IncrementalBlocks { /** * 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, which keeps sibling keys unique without inventing offsets. + * 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 @@ -88,6 +91,12 @@ export class IncrementalMarkdownParser { */ 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 diff --git a/packages/client/ui-primitives/src/markdown/katex.tsx b/packages/client/ui-primitives/src/markdown/katex.tsx index bae1aa5104..45c1226b83 100644 --- a/packages/client/ui-primitives/src/markdown/katex.tsx +++ b/packages/client/ui-primitives/src/markdown/katex.tsx @@ -7,6 +7,12 @@ * 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' diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts index 5b604afeec..50f8f179b9 100644 --- a/packages/client/ui-primitives/src/markdown/parse.ts +++ b/packages/client/ui-primitives/src/markdown/parse.ts @@ -1,9 +1,11 @@ /** - * The markdown renderer's two mdast grammars, one per rendering arm. Both are - * built from the same micromark extensions, so block boundaries and inline - * semantics are identical wherever a document (or a document tail) is parsed: - * the incremental streaming path, the settled path, and the plain-text - * projection all agree on where blocks start and end. + * 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' diff --git a/packages/client/ui-primitives/src/markdown/plain-text.ts b/packages/client/ui-primitives/src/markdown/plain-text.ts index af449dc774..6797fdec6e 100644 --- a/packages/client/ui-primitives/src/markdown/plain-text.ts +++ b/packages/client/ui-primitives/src/markdown/plain-text.ts @@ -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': diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 073284e810..4ac8b4dbc1 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -279,7 +279,10 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re return ( +
+
+
+
+
+