diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 6b7b80011e..b523204f12 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -401,6 +401,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 http(s) or buzz link ends at whitespace and at + 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. - **Drafts & sent** lists the messages you started but did not send, newest change first, with the number of drafts shown beside it in the sidebar, and your latest 100 sent messages in this community, grouped by day. A sent diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index 99024bdf85..91c679bde2 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -862,6 +862,39 @@ 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://example.com/a”。", "https://example.com/a"], + [ + "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", + ], + ["", "https://example.com/a%E3%80%82"], + [ + "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( + hrefs, + ); + 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 7645d8ec2a..f29694c085 100644 --- a/src/features/messages/MessageMarkdown.tsx +++ b/src/features/messages/MessageMarkdown.tsx @@ -337,6 +337,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 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; + }); + }); + }; +} + /** Offer only Markdown prose to profile controls and inline plugins. */ function remarkInlineContent(protectedContent: ProtectedContent) { const restore = (value: string) => @@ -858,6 +885,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..86508ca5db 100644 --- a/src/features/messages/message-link-parts.test.ts +++ b/src/features/messages/message-link-parts.test.ts @@ -150,6 +150,49 @@ 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://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://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"], + ["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/中文", + ], +])( + "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("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 c89c4ad0e6..fe717199d7 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; + // 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-]|:\/\/)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/gi, )) { if (match.index < offset) continue; if (match[1] !== undefined) {