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 @@ -2,6 +2,7 @@ import React from 'react';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { remarkAutolinkBoundaries } from './remarkAutolinkBoundaries';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
Expand Down Expand Up @@ -30,7 +31,7 @@ export const MarkdownMathRenderer: React.FC<MarkdownMathRendererProps> = ({
}) => (
<div data-openbitfun-component="markdown" data-openbitfun-part="math">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkAutolinkComputerFileLinks]}
remarkPlugins={[remarkGfm, remarkMath, remarkAutolinkBoundaries, remarkAutolinkComputerFileLinks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], [rehypeSourceRange, sourceRange], rehypeKatex]}
urlTransform={urlTransform}
components={components}
Expand Down
11 changes: 11 additions & 0 deletions src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ describe('Markdown file links', () => {
vi.clearAllMocks();
});

it.each([false, true])('keeps fullwidth parentheses outside bare web links (escaped=%s)', async escaped => {
const url = 'http://127.0.0.1:8000';
const bare = escaped ? url.replace(':', '\\:') : url;
const content = `\uff08Link1 ${bare}\uff09\uff08Link2 [${url}](${url}) \uff09`;
await act(async () => root.render(<MarkdownRenderer content={content} />));
const links = [...container.querySelectorAll('a')];
expect(links.map(link => link.getAttribute('href'))).toEqual([url, url]);
expect(links.map(link => link.textContent)).toEqual([url, url]);
expect(container.textContent).toContain(`\uff08Link1 ${url}\uff09\uff08Link2 ${url} \uff09`);
});

it('does not resolve workspace path for markdown without local file links', async () => {
await act(async () => {
root.render(
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useR
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import { Tooltip } from '@openbitfun/ui';
import remarkGfm from 'remark-gfm';
import { remarkAutolinkBoundaries } from './remarkAutolinkBoundaries';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { visit } from 'unist-util-visit';
Expand Down Expand Up @@ -1640,7 +1641,7 @@ export const MarkdownRenderer = React.memo<MarkdownRendererProps>(({
const wrapperClassName = `markdown-renderer ${className}`.trim();
const basicMarkdownRenderer = (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkAutolinkInternalLinks]}
remarkPlugins={[remarkGfm, remarkAutolinkBoundaries, remarkAutolinkInternalLinks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], [rehypeSourceRange, sourceRange]]}
urlTransform={markdownUrlTransform}
components={components}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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 { remarkAutolinkBoundaries } from './remarkAutolinkBoundaries';

const url = 'http://127.0.0.1:8000';
const open = '\uff08';
const close = '\uff09';
const label = '\u94fe\u63a5';

function parse(content: string, math = false) {
const processor = unified().use(remarkParse).use(remarkGfm);
if (math) processor.use(remarkMath);
processor.use(remarkAutolinkBoundaries);
const tree = processor.runSync(processor.parse(content), { value: content });
const links: string[] = [];
const read = (node: { type: string; url?: string; value?: string; children?: typeof tree.children }): string => {
if (node.type === 'link') links.push(node.url!);
return node.value ?? (node.children ?? []).map(read).join('');
};
return { text: read(tree), links };
}

describe('bare URL prose boundaries', () => {
it.each([false, true])('separates the reported adjacent labels (math=%s)', math => {
for (const bare of [url, url.replace(':', '\\:')]) {
const content = `${open}${label}1 ${bare}${close}${open}${label}2 [${url}](${url}) ${close}`;
expect(parse(content, math)).toEqual({
links: [url, url],
text: `${open}${label}1 ${url}${close}${open}${label}2 ${url} ${close}`,
});
}
});

it.each(['\uff0c', '\u3002', '\u3001', '\uff1b', '\uff01', '\uff1f', '\u201d', '\u300b', '\u3011'])('returns punctuation and following prose to text: %s', boundary => {
const content = `${url}${boundary}${label}`;
expect(parse(content)).toEqual({ links: [url], text: content });
});

it('preserves adjacent bare links and www links', () => {
const content = `${open}${url}${close}${open}https://example.com/path${close}\uff0cwww.example.com/path\u3002`;
expect(parse(content)).toEqual({ links: [url, 'https://example.com/path', 'http://www.example.com/path'], text: content });
});

it('preserves sibling order in a dense paragraph mixing bare and authored links', () => {
const entries = Array.from({ length: 1000 }, (_, index) => {
const destination = `${url}/${index}`;
return index % 2 === 0
? { markdown: `${destination}${close}${label}`, text: `${destination}${close}${label}`, destination }
: { markdown: `[${label}](${destination}${close})`, text: label, destination: `${destination}${close}` };
});
expect(parse(entries.map(entry => entry.markdown).join(' '))).toEqual({
links: entries.map(entry => entry.destination),
text: entries.map(entry => entry.text).join(' '),
});
});

it('repairs links in nested blocks and inline formatting without changing their order', () => {
const content = `> - **${url}/first${close}** and *${url}/second${close}*\n> - ${url}/third${close}\n\n| Link |\n| --- |\n| ${url}/fourth${close} |`;
const result = parse(content);
expect(result.links).toEqual(['first', 'second', 'third', 'fourth'].map(path => `${url}/${path}`));
expect(result.text.match(/\uff09/g)).toHaveLength(4);
});

it.each([
'https://example.com/\u4e2d\u6587?q=\u641c\u7d22#\u7ae0\u8282',
'https://example.com/wiki/Function_(mathematics)',
'https://example.com/%EF%BC%89',
])('preserves valid URL content: %s', value => {
expect(parse(`${open}${value}${close}`).links).toEqual([value]);
});

it('preserves authored inline, angle and reference links with punctuation in the URL', () => {
const destination = `https://example.com/a${close}b`;
const content = `[${destination}](${destination}) <${destination}> [${destination}][ref]\n\n[ref]: ${destination}`;
expect(parse(content).links).toEqual([destination, destination]);
const baseline = unified().use(remarkParse).use(remarkGfm);
const original = baseline.parse(content);
expect(baseline().use(remarkAutolinkBoundaries).runSync(baseline.parse(content), { value: content })).toEqual(original);
});

it('preserves explicitly linked IPv6 URLs', () => {
const destination = 'http://[::1]:8000/path?q=1&next=2#section';
expect(parse(`[IPv6](${destination})`).links).toEqual([destination]);
});

it('does not link code or image labels', () => {
const content = `\`${url}${close}\`\n\n\`\`\`\n${url}${close}\n\`\`\`\n\n![${url}${close}](image.png)`;
expect(parse(content).links).toEqual([]);
});

it('retains math and prose links in the same document', () => {
expect(parse(`$x^2$\n\n${url}${close}`, true).links).toEqual([url]);
});
});
69 changes: 69 additions & 0 deletions src/web-ui/src/infrastructure/markdown/remarkAutolinkBoundaries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
type MarkdownNode = {
type: string;
value?: string;
url?: string;
title?: string | null;
position?: {
start: { line: number; column: number; offset?: number };
end: { line: number; column: number; offset?: number };
};
children?: MarkdownNode[];
};

// CJK prose punctuation delimits bare URLs. Keep Chinese letters, ASCII URL
// punctuation (including balanced parentheses), and percent escapes intact.
const PROSE_BOUNDARY = /([\u3001\u3002\u3008-\u3011\u3014-\u301f\uff01\uff08\uff09\uff0c\uff1a\uff1b\uff1f\u2018-\u201d]+)/;
const URL_PREFIX = /^(https?:\/\/|www\.)/i;

function splitBareLink(node: MarkdownNode, source: string): MarkdownNode[] | undefined {
if (node.type !== 'link' || !node.url || node.children?.length !== 1) return;
const label = node.children[0];
if (label.type !== 'text' || !label.value || !URL_PREFIX.test(label.value)) return;
if (!PROSE_BOUNDARY.test(label.value)) return;

// GFM's fallback transform produces positionless links after Markdown
// escapes are decoded. Authored inline/angle links retain source positions.
// Eight characters cover the longest supported prefix, https://.
const offset = node.position?.start.offset;
if (offset !== undefined && !URL_PREFIX.test(source.slice(offset, offset + 8))) return;

return label.value.split(PROSE_BOUNDARY).filter(Boolean).map(part => {
if (!URL_PREFIX.test(part)) return { type: 'text', value: part };
return {
type: 'link',
title: null,
url: /^www\./i.test(part) ? `http://${part}` : part,
children: [{ type: 'text', value: part }],
};
});
}

/** Apply prose boundaries only to GFM-generated links, never authored links. */
export function remarkAutolinkBoundaries() {
return (tree: MarkdownNode, file: { value: unknown }) => {
const source = String(file.value);
const pending = [tree];
while (pending.length > 0) {
const parent = pending.pop()!;
const children = parent.children;
if (!children) continue;
let nextChildren: MarkdownNode[] | undefined;
for (let index = 0; index < children.length; index += 1) {
const child = children[index];
const replacements = splitBareLink(child, source);
if (replacements) {
// Copy the prefix only on the first change. Append each remaining
// sibling once instead of shifting the array for every split link.
nextChildren ??= children.slice(0, index);
for (const replacement of replacements) nextChildren.push(replacement);
} else {
nextChildren?.push(child);
// Traverse only original children. Split links are already final;
// visiting their newly generated nodes would repeat the work.
if (child.children?.length) pending.push(child);
}
}
if (nextChildren) parent.children = nextChildren;
}
};
}
Loading