Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -14,6 +15,7 @@ import { rehypeSourceRange, type MarkdownSourceRange } from './rehypeSourceRange

interface MarkdownMathRendererProps {
markdownContent: string;
isStreaming?: boolean;
components: Components;
sanitizeSchema: RehypeSanitizeOptions;
remarkAutolinkComputerFileLinks: Pluggable;
Expand All @@ -23,6 +25,7 @@ interface MarkdownMathRendererProps {

export const MarkdownMathRenderer: React.FC<MarkdownMathRendererProps> = ({
markdownContent,
isStreaming = false,
components,
sanitizeSchema,
remarkAutolinkComputerFileLinks,
Expand All @@ -31,7 +34,7 @@ export const MarkdownMathRenderer: React.FC<MarkdownMathRendererProps> = ({
}) => (
<div data-openbitfun-component="markdown" data-openbitfun-part="math">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkAutolinkBoundaries, remarkAutolinkComputerFileLinks]}
remarkPlugins={[remarkGfm, remarkMath, [remarkStreamingTableLinks, { isStreaming }], remarkAutolinkBoundaries, remarkAutolinkComputerFileLinks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], [rehypeSourceRange, sourceRange], rehypeKatex]}
urlTransform={urlTransform}
components={components}
Expand Down
36 changes: 36 additions & 0 deletions src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,42 @@ Second paragraph.
expect(mocks.getCurrentWorkspacePath).not.toHaveBeenCalled();
});

it('keeps streamed table link labels visible until the actual destination closes', async () => {
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(<MarkdownRenderer
content={content}
isStreaming={isStreaming}
sourceRange={{ start: 7, end: content.length, idPrefix: 'stream-table-' }}
fileActionsViaCallbackOnly
onFileViewRequest={onFileViewRequest}
/>));
};

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<HTMLButtonElement>('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',
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1763,7 +1764,7 @@ export const MarkdownRenderer = React.memo<MarkdownRendererProps>(({
const wrapperClassName = `markdown-renderer ${className}`.trim();
const basicMarkdownRenderer = (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkAutolinkBoundaries, remarkAutolinkInternalLinks]}
remarkPlugins={[remarkGfm, [remarkStreamingTableLinks, { isStreaming }], remarkAutolinkBoundaries, remarkAutolinkInternalLinks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], [rehypeSourceRange, sourceRange]]}
urlTransform={markdownUrlTransform}
components={components}
Expand All @@ -1789,6 +1790,7 @@ export const MarkdownRenderer = React.memo<MarkdownRendererProps>(({
<React.Suspense fallback={basicMarkdownRenderer}>
<MarkdownMathRenderer
markdownContent={markdownContent}
isStreaming={isStreaming}
components={components}
sanitizeSchema={sanitizeSchema}
remarkAutolinkComputerFileLinks={remarkAutolinkInternalLinks}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import type { Nodes } from 'mdast';
import { remarkStreamingTableLinks } from './remarkStreamingTableLinks';

const header = '| File | Description |\n| --- | --- |\n';
function render(tail: string, streaming = true, math = false) {
const source = header + tail;
const processor = unified().use(remarkParse).use(remarkGfm);
if (math) processor.use(remarkMath);
processor.use(remarkStreamingTableLinks, { isStreaming: streaming });
const original = processor.parse(source);
const tree = processor.runSync(structuredClone(original), { value: source });
const table = tree.children[0];
if (table.type !== 'table') throw new Error('Expected table');
const cell = table.children[table.children.length - 1].children[0];
const text = (node: Nodes): string => '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](<C:/path with spaces/Guide.md',
'[Guide](<C:/path with spaces/Guide.md>',
'[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] &amp; \\* \\&amp; `x](y`](/path').text).toBe('A [B] & * &amp; x](y');
expect(render('| [a\\|b](/path').text).toBe('a|b');
expect(render('| [a &copy &copy;](/path').text).toBe('a &copy ©');
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',
'| <span title="[Guide](/path">text</span>',
])('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);
}
});
});
141 changes: 141 additions & 0 deletions src/web-ui/src/infrastructure/markdown/remarkStreamingTableLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { Nodes, Parent, Root, TableCell } from 'mdast';
import { parseEntities } from 'parse-entities';

const ESCAPE = /[!-/:-@\[-`{-~]/;

Check warning on line 4 in src/web-ui/src/infrastructure/markdown/remarkStreamingTableLinks.ts

View workflow job for this annotation

GitHub Actions / Frontend Build

Unnecessary escape character: \[
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;
}
};
}
Loading