diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx index c329e1a244..966688f089 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx @@ -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'; @@ -30,7 +31,7 @@ export const MarkdownMathRenderer: React.FC = ({ }) => (
{ 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()); + 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( diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx index c4fa7eb5a4..1f0033c8d6 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx @@ -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'; @@ -1640,7 +1641,7 @@ export const MarkdownRenderer = React.memo(({ const wrapperClassName = `markdown-renderer ${className}`.trim(); const basicMarkdownRenderer = ( { + 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]); + }); +}); diff --git a/src/web-ui/src/infrastructure/markdown/remarkAutolinkBoundaries.ts b/src/web-ui/src/infrastructure/markdown/remarkAutolinkBoundaries.ts new file mode 100644 index 0000000000..f8cf2a5620 --- /dev/null +++ b/src/web-ui/src/infrastructure/markdown/remarkAutolinkBoundaries.ts @@ -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; + } + }; +}