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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -279,7 +279,10 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
|
||||
return (
|
||||
<CodeBlock
|
||||
key={key}
|
||||
code={node.value}
|
||||
// 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}
|
||||
|
||||
37
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt
vendored
Normal file
37
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt
vendored
Normal 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"
|
||||
@@ -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"
|
||||
11
packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt
vendored
Normal file
11
packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt
vendored
Normal 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"
|
||||
11
packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt
vendored
Normal file
11
packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt
vendored
Normal 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"
|
||||
@@ -6,6 +6,15 @@
|
||||
// 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'
|
||||
@@ -139,6 +148,21 @@ const CORPUS: Record<string, string> = {
|
||||
' 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).',
|
||||
|
||||
@@ -12,7 +12,15 @@ import { parseGfm } from '../src/markdown/parse.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** A many-block document exercising every freeze-sensitive construct. */
|
||||
/**
|
||||
* 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',
|
||||
'',
|
||||
|
||||
Reference in New Issue
Block a user