From eb6bb5913bdf6639f40675397683e04b45008bf3 Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 22 Sep 2026 19:45:40 +0800 Subject: [PATCH] fix(markdown): Stabilize streaming table links Show only the label of an unfinished inline link in the active table cell so long destinations do not expand columns before collapsing. Apply the same behavior to plain and math Markdown while preserving label formatting, source positions, and existing file-open routing. Restore normal Markdown rendering when streaming ends and leave ambiguous syntax unchanged. Keep processing limited to the current cell without adding a second Markdown parse or DOM measurements. Validation: 89 focused tests, check:web, and final TypeScript checks passed. The user confirmed the improved streaming experience. --- .../markdown/MarkdownMathRenderer.tsx | 5 +- .../markdown/MarkdownRenderer.test.tsx | 36 +++++ .../markdown/MarkdownRenderer.tsx | 4 +- .../remarkStreamingTableLinks.test.ts | 111 ++++++++++++++ .../markdown/remarkStreamingTableLinks.ts | 141 ++++++++++++++++++ 5 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 src/web-ui/src/infrastructure/markdown/remarkStreamingTableLinks.test.ts create mode 100644 src/web-ui/src/infrastructure/markdown/remarkStreamingTableLinks.ts diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx index 966688f089..fb09897777 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx @@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'; import type { Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { remarkAutolinkBoundaries } from './remarkAutolinkBoundaries'; +import { remarkStreamingTableLinks } from './remarkStreamingTableLinks'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; @@ -14,6 +15,7 @@ import { rehypeSourceRange, type MarkdownSourceRange } from './rehypeSourceRange interface MarkdownMathRendererProps { markdownContent: string; + isStreaming?: boolean; components: Components; sanitizeSchema: RehypeSanitizeOptions; remarkAutolinkComputerFileLinks: Pluggable; @@ -23,6 +25,7 @@ interface MarkdownMathRendererProps { export const MarkdownMathRenderer: React.FC = ({ markdownContent, + isStreaming = false, components, sanitizeSchema, remarkAutolinkComputerFileLinks, @@ -31,7 +34,7 @@ export const MarkdownMathRenderer: React.FC = ({ }) => (
{ + const prefix = 'Intro\n\n| File | Description |\n| --- | --- |\n| '; + const unfinished = `${prefix}[**Guide**](/srv/docs/long-directory/Guide.md`; + const render = async (content: string, isStreaming: boolean) => { + await act(async () => root.render()); + }; + + await render(unfinished, true); + const table = container.querySelector('table'); + const cell = container.querySelector('td'); + expect(cell?.textContent).toBe('Guide'); + expect(cell?.querySelector('strong')?.textContent).toBe('Guide'); + expect(cell?.querySelector('button, a')).toBeNull(); + expect(mocks.readFileContent).not.toHaveBeenCalled(); + expect(mocks.getCurrentWorkspacePath).not.toHaveBeenCalled(); + + await render(unfinished + ') | Explanation |', true); + expect(container.querySelector('table')).toBe(table); + expect(container.querySelector('td')).toBe(cell); + expect(cell?.textContent).toBe('Guide'); + const link = cell?.querySelector('button.file-link'); + expect(link).not.toBeNull(); + act(() => link?.click()); + expect(onFileViewRequest).toHaveBeenCalledWith('/srv/docs/long-directory/Guide.md', 'Guide.md', undefined); + + await render(unfinished, false); + expect(cell?.textContent).toContain('](/srv/docs/long-directory/Guide.md'); + expect(cell?.querySelector('button, a')).toBeNull(); + }); + it('preserves existing markdown nodes while streaming content is appended', async () => { const initialContent = [ 'Before image', diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx index 859fe74949..a426ad4b64 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx @@ -10,6 +10,7 @@ import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import { Tooltip } from '@openbitfun/ui'; import remarkGfm from 'remark-gfm'; import { remarkAutolinkBoundaries } from './remarkAutolinkBoundaries'; +import { remarkStreamingTableLinks } from './remarkStreamingTableLinks'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import { visit } from 'unist-util-visit'; @@ -1763,7 +1764,7 @@ export const MarkdownRenderer = React.memo(({ const wrapperClassName = `markdown-renderer ${className}`.trim(); const basicMarkdownRenderer = ( (({ 'value' in node ? node.value : 'children' in node ? node.children.map(text).join('') : ''; + return { tree, original, cell, text: text(cell) }; +} + +describe('streaming table link labels', () => { + it.each([false, true])('hides every destination prefix and preserves formatted labels (math=%s)', math => { + const destination = '/srv/workspace/docs/long-path/Guide_(advanced).md'; + for (let length = 0; length <= destination.length; length += 1) { + const result = render('| Before [**Guide** `v2`](' + destination.slice(0, length), true, math); + expect(result.text).toBe('Before Guide v2'); + expect(result.cell.children.some(node => node.type === 'strong')).toBe(true); + expect(result.cell.children.some(node => node.type === 'inlineCode')).toBe(true); + expect(result.cell.children.some(node => node.type === 'link')).toBe(false); + } + }); + + it.each([ + '[Guide](', + '[Guide](path\\(part\\).md', + '[Guide](path.md "Title with )', + "[Guide](path.md 'Title'", + '[Guide](path.md (Title)', + ])('handles unfinished destinations and titles: %s', tail => { + expect(render('| ' + tail).text).toBe('Guide'); + }); + + it('preserves nested brackets, escapes, entities and inline code', () => { + expect(render('| [A [B] & \\* \\& `x](y`](/path').text).toBe('A [B] & * & x](y'); + expect(render('| [a\\|b](/path').text).toBe('a|b'); + expect(render('| [a © ©](/path').text).toBe('a © ©'); + expect(render('| \\![Guide](/path').text).toBe('!Guide'); + }); + + it('preserves preceding bare links and leaves positionless GFM fallback cells untouched', () => { + const result = render('| https://example.com [Guide](/path'); + expect(result.text).toBe('https://example.com Guide'); + expect(result.cell.children[0].type).toBe('link'); + const fallback = render('| https\\://example.com [Guide](/path'); + expect(fallback.tree).toEqual(fallback.original); + }); + + it('handles a pending link in the second column and in a nested table', () => { + for (const source of [header + '| Existing | [Guide](/path', (header + '| [Guide](/path').split('\n').map(line => '> ' + line).join('\n')]) { + const processor = unified().use(remarkParse).use(remarkGfm).use(remarkStreamingTableLinks, { isStreaming: true }); + const tree = processor.runSync(processor.parse(source), { value: source }); + expect(JSON.stringify(tree)).not.toContain('](/path'); + } + }); + + it.each([ + '| [Guide](/path.md)', + '| [Guide](/path "title")', + '| [Guide](/path.md', + ])('restores standard parsing when streaming ends: %s', tail => { + const result = render(tail, false); + expect(result.tree).toEqual(result.original); + }); + + it.each([ + '| `example [Guide](/path`', + '| ![Guide](/path', + '| ![outer [Guide](/path', + '| \\[Guide](/path', + '| [Guide](/path) tail', + '| [Guide](/path | explanation', + '| [Guide](/path\n', + '| [Guide](/path\n| next | value |', + '| [Guide](/path invalid text', + '| text', + ])('leaves code, images, escapes, completed cells and invalid syntax unchanged: %s', tail => { + const result = render(tail); + expect(result.tree).toEqual(result.original); + }); + + it('preserves completed links and original source positions', () => { + const result = render('| [Earlier](/earlier.md) and [Guide](/path'); + expect(result.text).toBe('Earlier and Guide'); + const table = result.original.children[0]; + if (table.type !== 'table') throw new Error('Expected table'); + expect(result.cell.position).toEqual(table.children[1].children[0].position); + expect(result.cell.children[0]).toEqual(table.children[1].children[0].children[0]); + }); + + it('does not alter paragraphs or fenced code outside tables', () => { + for (const source of ['[Guide](/path', '```md\n[Guide](/path\n```']) { + const processor = unified().use(remarkParse).use(remarkGfm).use(remarkStreamingTableLinks, { isStreaming: true }); + const original = processor.parse(source); + expect(processor.runSync(structuredClone(original), { value: source })).toEqual(original); + } + }); +}); diff --git a/src/web-ui/src/infrastructure/markdown/remarkStreamingTableLinks.ts b/src/web-ui/src/infrastructure/markdown/remarkStreamingTableLinks.ts new file mode 100644 index 0000000000..ade0da6d84 --- /dev/null +++ b/src/web-ui/src/infrastructure/markdown/remarkStreamingTableLinks.ts @@ -0,0 +1,141 @@ +import type { Nodes, Parent, Root, TableCell } from 'mdast'; +import { parseEntities } from 'parse-entities'; + +const ESCAPE = /[!-/:-@\[-`{-~]/; +const OPAQUE = new Set(['inlineCode', 'inlineMath', 'html', 'link', 'linkReference', 'image', 'imageReference']); + +/** Decode a sliced text node without interpreting escaped ampersands as entities. */ +function decodeText(source: string): string { + let result = ''; + let start = 0; + for (let i = 0; i < source.length - 1; i += 1) { + if (source[i] === '\\' && ESCAPE.test(source[i + 1])) { + result += parseEntities(source.slice(start, i), { nonTerminated: false }) + source[i + 1]; + i += 1; + start = i + 1; + } + } + return result + parseEntities(source.slice(start), { nonTerminated: false }); +} + +// Accept only a destination/title that could still become a valid inline link. +// A real closing parenthesis is left entirely to the Markdown parser. +function isPendingDestination(source: string): boolean { + let i = 0; + while (source[i] === ' ' || source[i] === '\t') i += 1; + const angle = source[i] === '<'; + if (angle) i += 1; + let depth = 0; + for (; i < source.length; i += 1) { + const char = source[i]; + if (char === '\\' && ESCAPE.test(source[i + 1] ?? '')) { i += 1; continue; } + if (angle) { + if (char === '<') return false; + if (char === '>') { i += 1; break; } + } else { + if (char === '(') depth += 1; + if (char === ')') { + if (depth === 0) return false; + depth -= 1; + } + if (char === ' ' || char === '\t') { + if (depth > 0) return false; + break; + } + if (char === '<' || char.charCodeAt(0) < 32) return false; + } + } + if (i === source.length) return true; + const whitespaceStart = i; + while (source[i] === ' ' || source[i] === '\t') i += 1; + if (i === source.length) return true; + if (i === whitespaceStart) return false; + const quote = source[i]; + if (quote !== '"' && quote !== "'" && quote !== '(') return false; + const close = quote === '(' ? ')' : quote; + for (i += 1; i < source.length; i += 1) { + if (source[i] === '\\' && ESCAPE.test(source[i + 1] ?? '')) { i += 1; continue; } + if (quote === '(' && source[i] === '(') return false; + if (source[i] === close) return /^[ \t]*$/.test(source.slice(i + 1)); + } + return true; +} + +function pendingLabel(cell: TableCell, source: string, start: number, end: number) { + const opaque: Array<{ start: number; end: number }> = []; + const collect = (node: Nodes) => { + if (OPAQUE.has(node.type)) { + const from = node.position?.start.offset; + const to = node.position?.end.offset; + if (from !== undefined && to !== undefined) opaque.push({ start: from, end: to }); + } else if ('children' in node) node.children.forEach(collect); + }; + collect(cell); + let range = 0; + let escapedAt = -1; + const brackets: Array<{ offset: number; image: boolean }> = []; + for (let i = start; i < end; i += 1) { + while (opaque[range] && opaque[range].end <= i) range += 1; + if (opaque[range] && opaque[range].start <= i) { i = opaque[range].end - 1; continue; } + if (source[i] === '\\' && ESCAPE.test(source[i + 1] ?? '')) { i += 1; escapedAt = i; continue; } + if (source[i] === '[') brackets.push({ offset: i, image: source[i - 1] === '!' && escapedAt !== i - 1 }); + if (source[i] !== ']') continue; + const open = brackets.pop(); + if (!open || brackets.length || source[i + 1] !== '(') continue; + // Do not reinterpret a malformed earlier candidate or incomplete image. + if (open.image || !isPendingDestination(source.slice(i + 2, end))) return; + return { start: open.offset + 1, end: i }; + } +} + +function keepLabel(nodes: TableCell['children'], source: string, open: number, label: { start: number; end: number }): TableCell['children'] | undefined { + const result: TableCell['children'] = []; + for (const node of nodes) { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + // GFM can replace escaped bare URLs with positionless siblings. Leave that + // cell untouched rather than guessing offsets or dropping existing content. + if (start === undefined || end === undefined) return; + if (end <= open || (start >= label.start && end <= label.end)) { + result.push(node); + continue; + } + if (start >= label.end) break; + if (node.type === 'text') { + const prefix = start < open ? source.slice(start, Math.min(end, open)) : ''; + const text = source.slice(Math.max(start, label.start), Math.min(end, label.end)); + result.push({ ...node, value: decodeText(prefix) + decodeText(text) }); + } else if ('children' in node) { + const children = keepLabel(node.children, source, open, label); + if (!children) return; + result.push({ ...node, children }); + } + } + return result; +} + +/** Hide only an unfinished inline-link destination in the actively streamed table cell. */ +export function remarkStreamingTableLinks(options?: { isStreaming: boolean }) { + return (tree: Root, file: { value: unknown }) => { + if (!options?.isStreaming) return; + const source = String(file.value); + // A newline or a cell separator commits the cell; never conceal old malformed text. + if (!source || /[\r\n]/.test(source[source.length - 1])) return; + let node: Nodes = tree; + while ('children' in node && node.type !== 'tableCell') { + const children: Parent['children'] = node.children; + if (!children.length) return; + node = children[children.length - 1]; + } + if (node.type !== 'tableCell') return; + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start === undefined || end === undefined || !/^[ \t]*$/.test(source.slice(end))) return; + if (source.slice(start, end).includes('\n')) return; + const label = pendingLabel(node, source, start, end); + if (label) { + const children = keepLabel(node.children, source, label.start - 1, label); + if (children) node.children = children; + } + }; +}