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