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; + } + }; +}