From 9df40b7b138587820288efabbef6e3b8409dac3e Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:09:06 +0800 Subject: [PATCH] fix(markdown): autolink read-only URLs in the parse tree, not raw text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only markdown surfaces (comments, descriptions, chat) pre-linkified bare URLs by rewriting the raw source to [url](url) before parsing. Because linkify-it treats `*` as a valid URL character, a bare URL followed by a bold close — `**PR:https://…/5081**` — had the trailing `**` swallowed into the match and rewritten as [url**](url**). That consumed the emphasis closer (the bold never closed; the leading `**` rendered as literal asterisks) and corrupted the href with a trailing `**` (MUL-4242). Let remark-gfm autolink URLs in the parse tree instead, where emphasis is already resolved so an adjacent delimiter can never be absorbed. The custom string pass now runs in a `urls: false` mode on read-only surfaces and only linkifies file paths (which gfm never does). A small remark plugin (remark-cjk-autolink) re-applies the existing CJK URL boundary to gfm's autolink literals so `https://x/a。后面` still stops at 。. The Tiptap editor path is unchanged (`urls: true`): @tiptap/markdown does not autolink bare URLs, so it still needs the string pass. Note: read-only URL autolinking now follows GFM semantics (scheme, www., or email required); bare fuzzy domains like `NBA.com` render as plain text on read-only surfaces, matching CommonMark/GFM. Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent --- packages/ui/markdown/Markdown.tsx | 10 ++- packages/ui/markdown/index.ts | 3 +- packages/ui/markdown/linkify.ts | 34 ++++++-- packages/ui/markdown/remark-cjk-autolink.ts | 79 +++++++++++++++++++ .../views/editor/readonly-content.test.tsx | 51 ++++++++++++ packages/views/editor/readonly-content.tsx | 8 +- .../editor/utils/preprocess-links.test.ts | 30 +++++++ packages/views/editor/utils/preprocess.ts | 8 +- 8 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 packages/ui/markdown/remark-cjk-autolink.ts diff --git a/packages/ui/markdown/Markdown.tsx b/packages/ui/markdown/Markdown.tsx index cff5c923f2..70cd40899a 100644 --- a/packages/ui/markdown/Markdown.tsx +++ b/packages/ui/markdown/Markdown.tsx @@ -12,6 +12,7 @@ import { CODE_LIGATURE_CLASS } from '@multica/ui/lib/code-style' import { CodeBlock, InlineCode } from './CodeBlock' import { isAllowedFileCardHref, preprocessFileCards } from './file-cards' import { preprocessLinks } from './linkify' +import { remarkCjkAutolink } from './remark-cjk-autolink' import { preprocessMentionShortcodes } from './mentions' import 'katex/dist/katex.min.css' import './markdown.css' @@ -449,11 +450,15 @@ export function Markdown({ [mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard] ) - // Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content + // Preprocess: convert mention shortcodes, file paths, and file cards to + // renderable content. URLs are intentionally left bare (urls: false) so + // remark-gfm autolinks them in the parse tree — a bare URL can then no longer + // swallow an adjacent markdown delimiter like a closing `**` (MUL-4242). + // remarkCjkAutolink re-applies the CJK boundary on gfm's autolink nodes. const processedContent = React.useMemo( () => { let result = preprocessMentionShortcodes(children) - result = preprocessLinks(result) + result = preprocessLinks(result, { urls: false }) result = preprocessFileCards(result, cdnDomain ?? '') return result }, @@ -467,6 +472,7 @@ export function Markdown({ [remarkMath, { singleDollarTextMath: false }], remarkBreaks, [remarkGfm, { singleTilde: false }], + remarkCjkAutolink, ]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]} urlTransform={urlTransform} diff --git a/packages/ui/markdown/index.ts b/packages/ui/markdown/index.ts index d11121fb27..98ed2afba9 100644 --- a/packages/ui/markdown/index.ts +++ b/packages/ui/markdown/index.ts @@ -1,7 +1,8 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown' export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock' export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown' -export { preprocessLinks, detectLinks, hasLinks } from './linkify' +export { preprocessLinks, detectLinks, hasLinks, CJK_URL_TERMINATOR_REGEX } from './linkify' +export { remarkCjkAutolink } from './remark-cjk-autolink' export { preprocessMentionShortcodes } from './mentions' export { preprocessFileCards, diff --git a/packages/ui/markdown/linkify.ts b/packages/ui/markdown/linkify.ts index 27fd2dd893..7fee366cbb 100644 --- a/packages/ui/markdown/linkify.ts +++ b/packages/ui/markdown/linkify.ts @@ -35,7 +35,10 @@ const BARE_FILENAME_REGEX = new RegExp(`^[\\w.-]+\\.(?:${FILE_EXTENSIONS})$`, 'i // character up to the next whitespace swallowed into the href. We truncate the // detected URL at the first occurrence of any of these characters. Character // set mirrors the fix applied in mattermost/marked#22. -const CJK_URL_TERMINATOR_REGEX = +// +// Exported so the read-only render pipeline can apply the same boundary to URLs +// that remark-gfm autolinks in the parse tree (see remark-cjk-autolink.ts). +export const CJK_URL_TERMINATOR_REGEX = /[!-/:-@[-`{-~、。「-】]/ interface DetectedLink { @@ -270,13 +273,18 @@ function collectLinkifyMatches(text: string, offset: number, out: DetectedLink[] } /** - * Detect all links (URLs, emails, file paths) in text + * Detect all links (URLs, emails, file paths) in text. + * + * `includeUrls` gates the URL/email pass. Read-only markdown renderers pass + * `false` and let remark-gfm autolink URLs in the parse tree instead, which + * cannot corrupt adjacent markdown (e.g. a trailing `**`). File paths, which + * remark-gfm does not linkify, are always detected. See preprocessLinks. */ -export function detectLinks(text: string): DetectedLink[] { +export function detectLinks(text: string, includeUrls = true): DetectedLink[] { const links: DetectedLink[] = [] // 1. Detect URLs and emails with linkify-it, applying CJK boundary handling. - collectLinkifyMatches(text, 0, links) + if (includeUrls) collectLinkifyMatches(text, 0, links) // 2. Detect file paths with custom regex // Reset regex state @@ -310,10 +318,20 @@ export function detectLinks(text: string): DetectedLink[] { } /** - * Preprocess text to convert raw URLs and file paths into markdown links - * Skips code blocks and already-linked content + * Preprocess text to convert raw URLs and file paths into markdown links. + * Skips code blocks and already-linked content. + * + * `opts.urls` (default `true`) controls the URL/email pass. The Tiptap editor + * keeps it on because @tiptap/markdown does not autolink bare URLs. Read-only + * react-markdown renderers pass `false`: they let remark-gfm autolink URLs in + * the parse tree, where a bare URL can no longer swallow an adjacent markdown + * delimiter — the string pass here can't tell `https://x**` (URL + bold close) + * from a URL that legitimately ends in `*`, so it corrupted both (MUL-4242). + * File paths (which remark-gfm never linkifies) are converted in both modes. */ -export function preprocessLinks(text: string): string { +export function preprocessLinks(text: string, opts?: { urls?: boolean }): string { + const includeUrls = opts?.urls ?? true + // Quick check - if no potential links, return early if (!linkify.pretest(text) && !/[~/.]\//.test(text)) { return text @@ -321,7 +339,7 @@ export function preprocessLinks(text: string): string { const codeRanges = findCodeRanges(text) const markdownLinkRanges = findMarkdownLinkRanges(text) - const links = detectLinks(text) + const links = detectLinks(text, includeUrls) if (links.length === 0) return text diff --git a/packages/ui/markdown/remark-cjk-autolink.ts b/packages/ui/markdown/remark-cjk-autolink.ts new file mode 100644 index 0000000000..03af9128ab --- /dev/null +++ b/packages/ui/markdown/remark-cjk-autolink.ts @@ -0,0 +1,79 @@ +import { CJK_URL_TERMINATOR_REGEX } from './linkify' + +/** + * remark-cjk-autolink — trim CJK punctuation that remark-gfm's autolink literal + * swallowed into a URL. + * + * Read-only renderers let remark-gfm autolink bare URLs in the parse tree, so an + * adjacent markdown delimiter (e.g. a closing `**`) is never absorbed into the + * href — that was MUL-4242. gfm's autolink literal, however, shares linkify-it's + * CJK weakness: `https://x/a。后面` extends the link across the ideographic full + * stop and the run after it. preprocessLinks used to trim this before parsing; + * since URLs are no longer preprocessed in read-only mode, we re-apply the same + * boundary on the parsed tree. + * + * Only autolink *literals* are touched — links whose href was derived from the + * visible text (`https://…`, `www.…` → `http://…`, `a@b` → `mailto:a@b`). + * Explicit `[label](url)` links keep whatever destination the author wrote, even + * when it contains CJK punctuation. + */ + +interface MdNode { + type: string + url?: string + value?: string + children?: MdNode[] +} + +// The scheme prefix remark-gfm prepends to an autolink literal's href. Returns +// null when `url` was not derived from `text`, i.e. an explicit link — leave it. +function autolinkSchemePrefix(url: string, text: string): string | null { + if (url === text) return '' + for (const prefix of ['http://', 'https://', 'mailto:']) { + if (url === prefix + text) return prefix + } + return null +} + +// If `node` is an autolink literal whose text runs past a CJK terminator, return +// [trimmed link, trailing text] to splice in its place; otherwise null. +function splitCjkAutolink(node: MdNode): MdNode[] | null { + if (node.type !== 'link' || !node.url || node.children?.length !== 1) return null + const child = node.children[0] + if (!child || child.type !== 'text' || typeof child.value !== 'string') return null + + const text = child.value + if (autolinkSchemePrefix(node.url, text) === null) return null + + const cut = text.search(CJK_URL_TERMINATOR_REGEX) + if (cut <= 0) return null // no CJK punctuation, or the text starts with it + + const rest = text.slice(cut) + const trimmed: MdNode = { + ...node, + url: node.url.slice(0, node.url.length - rest.length), + children: [{ type: 'text', value: text.slice(0, cut) }], + } + return [trimmed, { type: 'text', value: rest }] +} + +function transform(node: MdNode): void { + const children = node.children + if (!children) return + for (let i = 0; i < children.length; i++) { + const split = splitCjkAutolink(children[i]!) + if (split) { + children.splice(i, 1, ...split) + i += split.length - 1 // skip the appended trailing-text node + } else { + transform(children[i]!) + } + } +} + +/** unified/remark plugin. Attach after remark-gfm. */ +export function remarkCjkAutolink() { + return (tree: unknown): void => { + transform(tree as MdNode) + } +} diff --git a/packages/views/editor/readonly-content.test.tsx b/packages/views/editor/readonly-content.test.tsx index 7daa90cf2c..14f54beedc 100644 --- a/packages/views/editor/readonly-content.test.tsx +++ b/packages/views/editor/readonly-content.test.tsx @@ -616,3 +616,54 @@ describe("ReadonlyContent slash command rendering", () => { expect(container.querySelector("a")).not.toBeNull(); }); }); + +describe("ReadonlyContent bare URL autolinking (MUL-4242)", () => { + // A bare URL wrapped in bold used to be pre-linkified into [url**](url**), + // which swallowed the closing `**`: the bold never closed (leading `**` + // showed as literal asterisks) and the href was corrupted with a trailing + // `**`. URLs are now autolinked by remark-gfm in the parse tree — after + // emphasis is resolved — so an adjacent delimiter can no longer be absorbed. + it("renders a bold-wrapped bare URL as bold plus a clean link", () => { + const url = "https://github.com/multica-ai/multica/pull/5081"; + const { container } = render(); + + const strong = container.querySelector("strong"); + expect(strong).not.toBeNull(); + const anchor = strong!.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe(url); + // No literal asterisks leak into the text, no trailing `**` in the href. + expect(container.textContent).not.toContain("**"); + expect(anchor?.getAttribute("href")).not.toContain("*"); + }); + + it("still autolinks a plain bare URL", () => { + const { container } = render( + , + ); + expect(container.querySelector('a[href="https://example.com/foo"]')).not.toBeNull(); + }); + + it("stops an autolinked URL at CJK punctuation instead of swallowing it", () => { + const { container } = render( + , + ); + const anchor = container.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("https://example.com/foo"); + expect(anchor?.textContent).toBe("https://example.com/foo"); + // The CJK tail stays outside the link. + expect(container.textContent).toContain("。后面还有字"); + }); + + it("leaves an explicit link's destination untouched even when it ends in CJK", () => { + const { container } = render( + , + ); + const anchor = container.querySelector("a"); + // react-markdown percent-encodes the CJK char; the point is it is NOT + // trimmed off the way an autolink literal would be. + expect(decodeURIComponent(anchor?.getAttribute("href") ?? "")).toBe( + "https://example.com/x。", + ); + expect(anchor?.textContent).toBe("看"); + }); +}); diff --git a/packages/views/editor/readonly-content.tsx b/packages/views/editor/readonly-content.tsx index 8952e9959b..d3b5e85586 100644 --- a/packages/views/editor/readonly-content.tsx +++ b/packages/views/editor/readonly-content.tsx @@ -41,7 +41,7 @@ import { IssueMentionCard } from "../issues/components/issue-mention-card"; import { ProjectChip } from "../projects/components/project-chip"; import { useLinkHover, LinkHoverCard } from "./link-hover-card"; import { openLink, isMentionHref } from "./utils/link-handler"; -import { isAllowedFileCardHref } from "@multica/ui/markdown"; +import { isAllowedFileCardHref, remarkCjkAutolink } from "@multica/ui/markdown"; import { preprocessMarkdown } from "./utils/preprocess"; import { highlightToHtml } from "./utils/highlight-markdown"; import { MermaidDiagram } from "./mermaid-diagram"; @@ -432,8 +432,11 @@ export const ReadonlyContent = memo(function ReadonlyContent({ className, attachments, }: ReadonlyContentProps) { + // urls: false — let remark-gfm autolink bare URLs in the parse tree so an + // adjacent markdown delimiter (e.g. a closing `**`) is never swallowed into + // the href (MUL-4242). remarkCjkAutolink below re-applies the CJK boundary. const processed = useMemo( - () => highlightToHtml(preprocessMarkdown(content)), + () => highlightToHtml(preprocessMarkdown(content, { urls: false })), [content], ); const wrapperRef = useRef(null); @@ -458,6 +461,7 @@ export const ReadonlyContent = memo(function ReadonlyContent({ [remarkMath, { singleDollarTextMath: false }], remarkBreaks, [remarkGfm, { singleTilde: false }], + remarkCjkAutolink, ]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]} urlTransform={urlTransform} diff --git a/packages/views/editor/utils/preprocess-links.test.ts b/packages/views/editor/utils/preprocess-links.test.ts index cf2109c1b0..376d58678b 100644 --- a/packages/views/editor/utils/preprocess-links.test.ts +++ b/packages/views/editor/utils/preprocess-links.test.ts @@ -136,3 +136,33 @@ describe("preprocessLinks — bare filenames are not auto-linked as URLs", () => ); }); }); + +// Read-only react-markdown renderers pass { urls: false } and let remark-gfm +// autolink URLs in the parse tree instead, so a bare URL can no longer swallow +// an adjacent markdown delimiter like a closing ** (MUL-4242). File paths, which +// remark-gfm never linkifies, are still converted. +describe("preprocessLinks — urls:false (read-only mode)", () => { + it("leaves bare URLs untouched so remark-gfm can autolink them", () => { + expect(preprocessLinks("see https://example.com/x here", { urls: false })).toBe( + "see https://example.com/x here", + ); + }); + + it("does not rewrite a bold-wrapped URL into [url**](url**) (the root cause)", () => { + expect(preprocessLinks("**PR:https://example.com/x**", { urls: false })).toBe( + "**PR:https://example.com/x**", + ); + }); + + it("still linkifies explicit ./ file paths", () => { + expect(preprocessLinks("see ./src/main.go here", { urls: false })).toBe( + "see [./src/main.go](./src/main.go) here", + ); + }); + + it("default mode still linkifies URLs (editor path unchanged)", () => { + expect(preprocessLinks("see https://example.com/x here")).toBe( + "see [https://example.com/x](https://example.com/x) here", + ); + }); +}); diff --git a/packages/views/editor/utils/preprocess.ts b/packages/views/editor/utils/preprocess.ts index 2f4d546a7f..01fdcb2829 100644 --- a/packages/views/editor/utils/preprocess.ts +++ b/packages/views/editor/utils/preprocess.ts @@ -14,12 +14,16 @@ import { configStore } from "@multica/core/config"; * 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes) * 3. File card syntax (new !file[name](url) + legacy [name](cdnUrl)) → HTML div for * fileCard node parsing + * + * `opts.urls` (default `true`) forwards to preprocessLinks. The Tiptap editor + * needs it on; read-only react-markdown surfaces pass `false` and let remark-gfm + * autolink URLs in the parse tree instead (MUL-4242). See preprocessLinks. */ -export function preprocessMarkdown(markdown: string): string { +export function preprocessMarkdown(markdown: string, opts?: { urls?: boolean }): string { if (!markdown) return ""; const cdnDomain = configStore.getState().cdnDomain; const step1 = preprocessMentionShortcodes(markdown); - const step2 = preprocessLinks(step1); + const step2 = preprocessLinks(step1, opts); const step3 = preprocessFileCards(step2, cdnDomain); return step3; }