From 9ea49bcfff3b0bda92a712e53cf6e4204291c5ee Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Tue, 3 Nov 2026 23:04:53 -0800 Subject: [PATCH 1/6] fix(messages): stop bare links at fullwidth and CJK punctuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare URL followed by CJK text or fullwidth punctuation, such as "https://github.com/block/buzz-app。", linked to a destination that included the punctuation and the text after it. Rendered messages get bare links from remark-gfm, which ends a URL only at whitespace and trims only ASCII punctuation. The composer and other callers use messageLinkParts, which had the same gap. messageLinkParts now ends a bare URL at fullwidth and CJK punctuation. CJK letters stay allowed, so IRIs keep linking whole. MessageMarkdown ends GFM's literal autolinks at the same point. Explicit and [url](url) destinations are unchanged. Co-authored-by: Cursor --- .../messages/MessageMarkdown.test.tsx | 22 +++++++++++++++ src/features/messages/MessageMarkdown.tsx | 28 +++++++++++++++++++ .../messages/message-link-parts.test.ts | 18 ++++++++++++ src/features/messages/message-link-parts.ts | 3 +- 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index 2841b36d86..a821218d77 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -813,6 +813,28 @@ describe("Markdown inline extensions", () => { }, ); + it.each([ + [ + "https://github.com/block/buzz-app。", + "https://github.com/block/buzz-app", + ], + ["看这个 https://example.com/spec,大家看一下", "https://example.com/spec"], + ["(https://example.com)", "https://example.com/"], + [ + "https://zh.wikipedia.org/wiki/中文 ok", + "https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%96%87", + ], + ["", "https://example.com/a%E3%80%82"], + ])("ends a bare link at fullwidth punctuation: %s", (content, href) => { + const html = render(content); + expect([...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1])).toEqual([ + href, + ]); + expect(html.replace(/<[^>]*>/g, "")).toBe( + content.replace(/^<(.*)>$/, "$1"), + ); + }); + it("keeps emoji-only sizing and readable fallback when media or extensions are unavailable", () => { const options = { emoji: [party], extensions, largeEmoji: true }; const html = render(":party_parrot:", { diff --git a/src/features/messages/MessageMarkdown.tsx b/src/features/messages/MessageMarkdown.tsx index b24a86e502..0b372522d0 100644 --- a/src/features/messages/MessageMarkdown.tsx +++ b/src/features/messages/MessageMarkdown.tsx @@ -341,6 +341,33 @@ function rehypeEditedSlot() { }; } +/** GFM ends a bare URL only at whitespace. End it where bare message links end; + * `` and `[url](url)` keep their destination. */ +function remarkBareLinkEnd(content: string) { + return function visit(parent: MarkdownNode) { + if (!parent.children) return; + parent.children = parent.children.flatMap((child) => { + const text = child.url; + const { start, end } = child.position ?? {}; + if ( + child.type !== "link" || + !text || + child.children?.[0]?.value !== text || + (start && content.slice(start.offset, end?.offset) !== text) + ) { + visit(child); + return [child]; + } + const url = messageLinkParts(text).find((part) => part.url)?.url; + if (!url || url === text || !text.startsWith(url)) return [child]; + return [ + { ...child, url, children: [{ type: "text", value: url }] }, + { type: "text", value: text.slice(url.length) }, + ]; + }); + }; +} + /** Offer only Markdown prose to profile controls and inline plugins. */ function remarkInlineContent(protectedContent: ProtectedContent) { const restore = (value: string) => @@ -855,6 +882,7 @@ const MarkdownBody = memo(function MarkdownBody({ Markdown({ remarkPlugins: [ remarkGfm, + [remarkBareLinkEnd, protectedContent.content], remarkBreaks, [remarkSpoilers, `<${protectedContent.prefix}spoiler\uE002>`], [remarkInlineContent, protectedContent], diff --git a/src/features/messages/message-link-parts.test.ts b/src/features/messages/message-link-parts.test.ts index 1c95fe2abd..7700aee192 100644 --- a/src/features/messages/message-link-parts.test.ts +++ b/src/features/messages/message-link-parts.test.ts @@ -150,6 +150,24 @@ it("keeps balanced bare URL delimiters in the decorated range and trims only unm expect(parts.map((part) => part.text).join("")).toBe(content); }); +it.each([ + ["https://github.com/block/buzz-app。", "https://github.com/block/buzz-app"], + ["看这个 https://example.com/spec,大家看一下", "https://example.com/spec"], + ["(https://example.com)", "https://example.com"], + ["https://example.com/a?b=c。", "https://example.com/a?b=c"], + [ + "见 https://zh.wikipedia.org/wiki/中文", + "https://zh.wikipedia.org/wiki/中文", + ], +])( + "ends a bare URL at fullwidth punctuation but keeps CJK letters: %s", + (content, url) => { + const parts = messageLinkParts(content); + expect(parts.filter((part) => part.url)).toEqual([{ text: url, url }]); + expect(parts.map((part) => part.text).join("")).toBe(content); + }, +); + it.each([ "[this shadcdn/ui clone](https://github.com/duobaseio/forui)", "[this shadcdn/ui clone]()", diff --git a/src/features/messages/message-link-parts.ts b/src/features/messages/message-link-parts.ts index c89c4ad0e6..37bd6cbc93 100644 --- a/src/features/messages/message-link-parts.ts +++ b/src/features/messages/message-link-parts.ts @@ -95,8 +95,9 @@ export function messageLinkParts( ): LinkPart[] { const parts: LinkPart[] = []; let offset = 0; + // Fullwidth punctuation ends a bare URL; CJK letters do not (IRIs stay whole). for (const match of content.matchAll( - /(?"`]+>|(?:https?|buzz):\/\/[^\s<>"`]+/gi, + /(?"`]+>|(?:https?|buzz):\/\/[^\s<>"`,。、!?;:()「」『』《》【】〈〉…~]+/gi, )) { if (match.index < offset) continue; if (match[1] !== undefined) { From 8f6ff7b108d4166c15852d4fe88d882f8259db5c Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Tue, 3 Nov 2026 23:49:56 -0800 Subject: [PATCH 2/6] fix(messages): end bare links at the rest of the CJK punctuation set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare links still took fullwidth period and brackets, tortoise shell and lenticular brackets, halfwidth corner brackets and curly quotes into the destination, e.g. "https://example.com/path[注]" or "他说“https://example.com”。". Add them to the bare URL class and say in the help guide where a bare link ends and how to keep such characters in one. Co-authored-by: Cursor Signed-off-by: Cheng Peng --- docs/buzz-help.md | 2 ++ src/features/messages/MessageMarkdown.test.tsx | 1 + src/features/messages/message-link-parts.test.ts | 4 ++++ src/features/messages/message-link-parts.ts | 4 ++-- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index d1dd661ab9..e951262653 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -212,6 +212,8 @@ that agent in Buzz; they do not notify the external app through this connection. and Linux) an HTTP(S) link in a message to open it in your default browser instead of a side panel. You can also right-click the link and choose **Open in browser**. +- **Link boundaries**: a bare link ends at whitespace and at fullwidth or CJK + punctuation such as `,`, `。` or `」`. Wrap it as `` to keep them in it. - **Sidebar width**: drag the sidebar's right edge, or focus its resize handle and use the Left/Right arrow keys. Double-click the handle to reset the width. Messages and plugin pages share the saved width within the same community diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index a821218d77..238cf9e5ea 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -820,6 +820,7 @@ describe("Markdown inline extensions", () => { ], ["看这个 https://example.com/spec,大家看一下", "https://example.com/spec"], ["(https://example.com)", "https://example.com/"], + ["他说“https://example.com/a”。", "https://example.com/a"], [ "https://zh.wikipedia.org/wiki/中文 ok", "https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%96%87", diff --git a/src/features/messages/message-link-parts.test.ts b/src/features/messages/message-link-parts.test.ts index 7700aee192..766ccf0470 100644 --- a/src/features/messages/message-link-parts.test.ts +++ b/src/features/messages/message-link-parts.test.ts @@ -155,6 +155,10 @@ it.each([ ["看这个 https://example.com/spec,大家看一下", "https://example.com/spec"], ["(https://example.com)", "https://example.com"], ["https://example.com/a?b=c。", "https://example.com/a?b=c"], + ["https://example.com/path.", "https://example.com/path"], + ["https://example.com/path[注]", "https://example.com/path"], + ["他说“https://example.com”。", "https://example.com"], + ["「https://example.com」", "https://example.com"], [ "见 https://zh.wikipedia.org/wiki/中文", "https://zh.wikipedia.org/wiki/中文", diff --git a/src/features/messages/message-link-parts.ts b/src/features/messages/message-link-parts.ts index 37bd6cbc93..628d4fdb50 100644 --- a/src/features/messages/message-link-parts.ts +++ b/src/features/messages/message-link-parts.ts @@ -95,9 +95,9 @@ export function messageLinkParts( ): LinkPart[] { const parts: LinkPart[] = []; let offset = 0; - // Fullwidth punctuation ends a bare URL; CJK letters do not (IRIs stay whole). + // Fullwidth, CJK and curly-quote punctuation ends a bare URL; CJK letters do not. for (const match of content.matchAll( - /(?"`]+>|(?:https?|buzz):\/\/[^\s<>"`,。、!?;:()「」『』《》【】〈〉…~]+/gi, + /(?"`]+>|(?:https?|buzz):\/\/[^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]+/gi, )) { if (match.index < offset) continue; if (match[1] !== undefined) { From 3ecc0d88122446511a5e5e266077bf86ad8b6e1d Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Tue, 3 Nov 2026 23:55:28 -0800 Subject: [PATCH 3/6] fix(messages): link every URL in a run split at CJK punctuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "https://a.com,https://b.com" is one GFM autolink. Only the first URL became a Markdown link; the rest went through the plain text path, which leaves the href unencoded and drops the link's label. Turn every part messageLinkParts finds in the run into its own node. Co-authored-by: Cursor Signed-off-by: Cheng Peng --- src/features/messages/MessageMarkdown.test.tsx | 13 +++++++++---- src/features/messages/MessageMarkdown.tsx | 12 ++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index 238cf9e5ea..fd0d308665 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -826,11 +826,16 @@ describe("Markdown inline extensions", () => { "https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%96%87", ], ["", "https://example.com/a%E3%80%82"], - ])("ends a bare link at fullwidth punctuation: %s", (content, href) => { + [ + "https://a.com,https://zh.wikipedia.org/wiki/中文", + "https://a.com/", + "https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%96%87", + ], + ])("ends a bare link at fullwidth punctuation: %s", (content, ...hrefs) => { const html = render(content); - expect([...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1])).toEqual([ - href, - ]); + expect([...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1])).toEqual( + hrefs, + ); expect(html.replace(/<[^>]*>/g, "")).toBe( content.replace(/^<(.*)>$/, "$1"), ); diff --git a/src/features/messages/MessageMarkdown.tsx b/src/features/messages/MessageMarkdown.tsx index 0b372522d0..e67969e61e 100644 --- a/src/features/messages/MessageMarkdown.tsx +++ b/src/features/messages/MessageMarkdown.tsx @@ -358,12 +358,12 @@ function remarkBareLinkEnd(content: string) { visit(child); return [child]; } - const url = messageLinkParts(text).find((part) => part.url)?.url; - if (!url || url === text || !text.startsWith(url)) return [child]; - return [ - { ...child, url, children: [{ type: "text", value: url }] }, - { type: "text", value: text.slice(url.length) }, - ]; + const parts = messageLinkParts(text).filter((part) => part.text); + if (parts.length < 2) return [child]; + return parts.map(({ text: value, url }) => { + const node = { type: "text", value }; + return url ? { type: "link", url, children: [node] } : node; + }); }); }; } From 2d72b7b2a6f3638bc306a2c9b23609fa97e63b81 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Wed, 4 Nov 2026 08:44:26 -0800 Subject: [PATCH 4/6] fix(messages): keep ideographic dots inside a bare link's host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The URL parser reads 。, . and 。 in a host as ".", so on main "https://github。com/block/buzz-app" opens github.com. Ending bare links at those characters cut it to "https://github". Let them continue the host when an ASCII host label follows; in the path they still end the link. The help guide now says the boundary applies to bare http(s) and buzz links and names the curly apostrophe. Co-authored-by: Cursor Signed-off-by: Cheng Peng --- docs/buzz-help.md | 5 +++-- src/features/messages/MessageMarkdown.test.tsx | 4 ++++ src/features/messages/message-link-parts.test.ts | 4 ++++ src/features/messages/message-link-parts.ts | 4 ++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index e951262653..ed749b3253 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -212,8 +212,9 @@ that agent in Buzz; they do not notify the external app through this connection. and Linux) an HTTP(S) link in a message to open it in your default browser instead of a side panel. You can also right-click the link and choose **Open in browser**. -- **Link boundaries**: a bare link ends at whitespace and at fullwidth or CJK - punctuation such as `,`, `。` or `」`. Wrap it as `` to keep them in it. +- **Link boundaries**: a bare http(s) or buzz link ends at whitespace and at + fullwidth, CJK or curly-quote punctuation such as `,`, `。`, `」` or `’`. + Wrap it as `` to keep them in it. - **Sidebar width**: drag the sidebar's right edge, or focus its resize handle and use the Left/Right arrow keys. Double-click the handle to reset the width. Messages and plugin pages share the saved width within the same community diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index fd0d308665..7fccb84273 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -821,6 +821,10 @@ describe("Markdown inline extensions", () => { ["看这个 https://example.com/spec,大家看一下", "https://example.com/spec"], ["(https://example.com)", "https://example.com/"], ["他说“https://example.com/a”。", "https://example.com/a"], + [ + "https://github。com/block/buzz-app。", + "https://github.com/block/buzz-app", + ], [ "https://zh.wikipedia.org/wiki/中文 ok", "https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%96%87", diff --git a/src/features/messages/message-link-parts.test.ts b/src/features/messages/message-link-parts.test.ts index 766ccf0470..239ea42367 100644 --- a/src/features/messages/message-link-parts.test.ts +++ b/src/features/messages/message-link-parts.test.ts @@ -159,6 +159,10 @@ it.each([ ["https://example.com/path[注]", "https://example.com/path"], ["他说“https://example.com”。", "https://example.com"], ["「https://example.com」", "https://example.com"], + ["https://example.com。大家", "https://example.com"], + ["https://example.com/a。b", "https://example.com/a"], + ["https://github。com/block/buzz-app", "https://github。com/block/buzz-app"], + ["https://github.com:8080/x。", "https://github.com:8080/x"], [ "见 https://zh.wikipedia.org/wiki/中文", "https://zh.wikipedia.org/wiki/中文", diff --git a/src/features/messages/message-link-parts.ts b/src/features/messages/message-link-parts.ts index 628d4fdb50..5bb99c8799 100644 --- a/src/features/messages/message-link-parts.ts +++ b/src/features/messages/message-link-parts.ts @@ -95,9 +95,9 @@ export function messageLinkParts( ): LinkPart[] { const parts: LinkPart[] = []; let offset = 0; - // Fullwidth, CJK and curly-quote punctuation ends a bare URL; CJK letters do not. + // CJK punctuation ends a bare URL, except 。.。 before a host label (URLs read them as dots). for (const match of content.matchAll( - /(?"`]+>|(?:https?|buzz):\/\/[^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]+/gi, + /(?"`]+>|(?:https?|buzz):\/\/(?:[^\s<>"`/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-z\d-]+(?![a-z\d-]|:\D)))+(?:[/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/gi, )) { if (match.index < offset) continue; if (match[1] !== undefined) { From ce5a9501ea960bcbc4cb7e58d9986c43e2372798 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Wed, 4 Nov 2026 09:01:49 -0800 Subject: [PATCH 5/6] fix(messages): stop the host dot rule at a port or IPv6 address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Letting 。 continue a host also let it continue after a port or an IPv6 literal, so "http://localhost:3000。OK" became an invalid host and lost its link. The host part now ends at the first ":", and a URL with an empty authority ("https:///example.com/x") links again, as on main. The help guide says 。 works as a dot in a host name. Co-authored-by: Cursor Signed-off-by: Cheng Peng --- docs/buzz-help.md | 4 ++-- src/features/messages/MessageMarkdown.test.tsx | 1 + src/features/messages/message-link-parts.test.ts | 3 +++ src/features/messages/message-link-parts.ts | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index ed749b3253..13cfb5a1eb 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -213,8 +213,8 @@ that agent in Buzz; they do not notify the external app through this connection. instead of a side panel. You can also right-click the link and choose **Open in browser**. - **Link boundaries**: a bare http(s) or buzz link ends at whitespace and at - fullwidth, CJK or curly-quote punctuation such as `,`, `。`, `」` or `’`. - Wrap it as `` to keep them in it. + fullwidth, CJK or curly-quote punctuation such as `,`, `。`, `」` or `’`. In a + host name, `。` works as a dot. Wrap it as `` to keep them in it. - **Sidebar width**: drag the sidebar's right edge, or focus its resize handle and use the Left/Right arrow keys. Double-click the handle to reset the width. Messages and plugin pages share the saved width within the same community diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index 7fccb84273..1e32a2423b 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -825,6 +825,7 @@ describe("Markdown inline extensions", () => { "https://github。com/block/buzz-app。", "https://github.com/block/buzz-app", ], + ["http://localhost:3000。OK", "http://localhost:3000/"], [ "https://zh.wikipedia.org/wiki/中文 ok", "https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%96%87", diff --git a/src/features/messages/message-link-parts.test.ts b/src/features/messages/message-link-parts.test.ts index 239ea42367..00de426835 100644 --- a/src/features/messages/message-link-parts.test.ts +++ b/src/features/messages/message-link-parts.test.ts @@ -163,6 +163,9 @@ it.each([ ["https://example.com/a。b", "https://example.com/a"], ["https://github。com/block/buzz-app", "https://github。com/block/buzz-app"], ["https://github.com:8080/x。", "https://github.com:8080/x"], + ["http://localhost:3000。OK", "http://localhost:3000"], + ["https://[::1]。OK", "https://[::1]"], + ["https:///example.com/x。", "https:///example.com/x"], [ "见 https://zh.wikipedia.org/wiki/中文", "https://zh.wikipedia.org/wiki/中文", diff --git a/src/features/messages/message-link-parts.ts b/src/features/messages/message-link-parts.ts index 5bb99c8799..7787aa7158 100644 --- a/src/features/messages/message-link-parts.ts +++ b/src/features/messages/message-link-parts.ts @@ -97,7 +97,7 @@ export function messageLinkParts( let offset = 0; // CJK punctuation ends a bare URL, except 。.。 before a host label (URLs read them as dots). for (const match of content.matchAll( - /(?"`]+>|(?:https?|buzz):\/\/(?:[^\s<>"`/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-z\d-]+(?![a-z\d-]|:\D)))+(?:[/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/gi, + /(?"`]+>|(?:https?|buzz):\/\/(?:[^\s<>"`:/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-z\d-]+(?![a-z\d-]|:\D)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/gi, )) { if (match.index < offset) continue; if (match[1] !== undefined) { From 2dd13819dabc5aa192596322540d23c7c1eebb06 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Wed, 4 Nov 2026 09:46:22 -0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(messages):=20end=20a=20host=20at=20?= =?UTF-8?q?=E3=80=82=20only=20before=20another=20scheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host dot rule refused a dot before ":" plus a non-digit, so "https://github。com:/block/buzz-app" linked "https://github", while main opens github.com/block/buzz-app. Only a following "://" ends the host now; "https://a.com。https://b.com" still splits into two links. Co-authored-by: Cursor Signed-off-by: Cheng Peng --- src/features/messages/message-link-parts.test.ts | 14 ++++++++++++++ src/features/messages/message-link-parts.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/features/messages/message-link-parts.test.ts b/src/features/messages/message-link-parts.test.ts index 00de426835..86508ca5db 100644 --- a/src/features/messages/message-link-parts.test.ts +++ b/src/features/messages/message-link-parts.test.ts @@ -166,6 +166,11 @@ it.each([ ["http://localhost:3000。OK", "http://localhost:3000"], ["https://[::1]。OK", "https://[::1]"], ["https:///example.com/x。", "https:///example.com/x"], + [ + "https://github。com:/block/buzz-app", + "https://github。com:/block/buzz-app", + ], + ["https://github。com/x。", "https://github。com/x"], [ "见 https://zh.wikipedia.org/wiki/中文", "https://zh.wikipedia.org/wiki/中文", @@ -179,6 +184,15 @@ it.each([ }, ); +it("ends a bare URL at fullwidth punctuation before another scheme", () => { + expect( + messageLinkParts("https://a.com。https://b.com").filter((part) => part.url), + ).toEqual([ + { text: "https://a.com", url: "https://a.com" }, + { text: "https://b.com", url: "https://b.com" }, + ]); +}); + it.each([ "[this shadcdn/ui clone](https://github.com/duobaseio/forui)", "[this shadcdn/ui clone]()", diff --git a/src/features/messages/message-link-parts.ts b/src/features/messages/message-link-parts.ts index 7787aa7158..fe717199d7 100644 --- a/src/features/messages/message-link-parts.ts +++ b/src/features/messages/message-link-parts.ts @@ -97,7 +97,7 @@ export function messageLinkParts( let offset = 0; // CJK punctuation ends a bare URL, except 。.。 before a host label (URLs read them as dots). for (const match of content.matchAll( - /(?"`]+>|(?:https?|buzz):\/\/(?:[^\s<>"`:/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-z\d-]+(?![a-z\d-]|:\D)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/gi, + /(?"`]+>|(?:https?|buzz):\/\/(?:[^\s<>"`:/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-z\d-]+(?![a-z\d-]|:\/\/)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/gi, )) { if (match.index < offset) continue; if (match[1] !== undefined) {