diff --git a/src/util/__tests__/link-external-urls.spec.ts b/src/util/__tests__/link-external-urls.spec.ts
new file mode 100644
index 0000000000..d84c9a33ac
--- /dev/null
+++ b/src/util/__tests__/link-external-urls.spec.ts
@@ -0,0 +1,47 @@
+import linkExternalUrls from '../link-external-urls'
+
+const anchor = (url: string) => `${url}`
+
+describe('linkExternalUrls', () => {
+ it('wraps a plain URL in an anchor', () => {
+ expect(linkExternalUrls('https://x.com'))
+ .toBe(anchor('https://x.com'))
+ })
+
+ it('keeps wrapping quotes outside the link', () => {
+ expect(linkExternalUrls('"https://x.com"'))
+ .toBe(`"${anchor('https://x.com')}"`)
+ })
+
+ it('keeps a sentence-ending period as plain text', () => {
+ expect(linkExternalUrls('see https://x.com.'))
+ .toBe(`see ${anchor('https://x.com')}.`)
+ })
+
+ it('keeps a trailing comma in mid-sentence as plain text', () => {
+ expect(linkExternalUrls('go to https://x.com, then stop'))
+ .toBe(`go to ${anchor('https://x.com')}, then stop`)
+ })
+
+ it('keeps wrapping parentheses outside the link', () => {
+ expect(linkExternalUrls('(https://x.com)'))
+ .toBe(`(${anchor('https://x.com')})`)
+ })
+
+ it('preserves balanced parentheses inside the URL', () => {
+ const url = 'https://en.wikipedia.org/wiki/Foo_(bar)'
+
+ expect(linkExternalUrls(url))
+ .toBe(anchor(url))
+ })
+
+ it('links multiple URLs in one string', () => {
+ expect(linkExternalUrls('a https://x.com b https://y.com'))
+ .toBe(`a ${anchor('https://x.com')} b ${anchor('https://y.com')}`)
+ })
+
+ it('returns text without a URL unchanged', () => {
+ expect(linkExternalUrls('no link here'))
+ .toBe('no link here')
+ })
+})
diff --git a/src/util/link-external-urls.ts b/src/util/link-external-urls.ts
index 03b0a757df..fbdc6375e4 100644
--- a/src/util/link-external-urls.ts
+++ b/src/util/link-external-urls.ts
@@ -1,5 +1,27 @@
-const externalUrlRegExp = /(https?:\/\/\S+)/gi
+const externalUrlRegExp = /https?:\/\/[^\s<>"'`]+/gi
-const linkExternalUrls = (text: string) => text.replace(externalUrlRegExp, '$1')
+const trailingPunctuationRegExp = /[?!.,:*_~]+$/
+
+// Trim trailing punctuation and unmatched closing parens (GFM autolink rules).
+const trimUrl = (url: string): string => {
+ let result = url.replace(trailingPunctuationRegExp, '')
+
+ while (
+ result.endsWith(')') &&
+ (result.match(/\)/g)?.length ?? 0) > (result.match(/\(/g)?.length ?? 0)
+ ) {
+ result = result.slice(0, -1).replace(trailingPunctuationRegExp, '')
+ }
+
+ return result
+}
+
+const linkExternalUrls = (text: string) => text.replace(externalUrlRegExp, (match) => {
+ const url = trimUrl(match)
+
+ const trailing = match.slice(url.length)
+
+ return `${url}${trailing}`
+})
export default linkExternalUrls