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