Skip to content

Commit d5a27f4

Browse files
committed
feat: linkExternalUrls to use GFM-style detection
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
1 parent 804c020 commit d5a27f4

2 files changed

Lines changed: 71 additions & 2 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import linkExternalUrls from '@/util/link-external-urls'
2+
3+
const anchor = (url: string) => `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`
4+
5+
describe('linkExternalUrls', () => {
6+
it('wraps a plain URL in an anchor', () => {
7+
expect(linkExternalUrls('https://x.com'))
8+
.toBe(anchor('https://x.com'))
9+
})
10+
11+
it('keeps wrapping quotes outside the link', () => {
12+
expect(linkExternalUrls('"https://x.com"'))
13+
.toBe(`"${anchor('https://x.com')}"`)
14+
})
15+
16+
it('keeps a sentence-ending period as plain text', () => {
17+
expect(linkExternalUrls('see https://x.com.'))
18+
.toBe(`see ${anchor('https://x.com')}.`)
19+
})
20+
21+
it('keeps a trailing comma in mid-sentence as plain text', () => {
22+
expect(linkExternalUrls('go to https://x.com, then stop'))
23+
.toBe(`go to ${anchor('https://x.com')}, then stop`)
24+
})
25+
26+
it('keeps wrapping parentheses outside the link', () => {
27+
expect(linkExternalUrls('(https://x.com)'))
28+
.toBe(`(${anchor('https://x.com')})`)
29+
})
30+
31+
it('preserves balanced parentheses inside the URL', () => {
32+
const url = 'https://en.wikipedia.org/wiki/Foo_(bar)'
33+
34+
expect(linkExternalUrls(url))
35+
.toBe(anchor(url))
36+
})
37+
38+
it('links multiple URLs in one string', () => {
39+
expect(linkExternalUrls('a https://x.com b https://y.com'))
40+
.toBe(`a ${anchor('https://x.com')} b ${anchor('https://y.com')}`)
41+
})
42+
43+
it('returns text without a URL unchanged', () => {
44+
expect(linkExternalUrls('no link here'))
45+
.toBe('no link here')
46+
})
47+
})

src/util/link-external-urls.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
1-
const externalUrlRegExp = /(https?:\/\/\S+)/gi
1+
const externalUrlRegExp = /https?:\/\/[^\s<>"'`]+/gi
22

3-
const linkExternalUrls = (text: string) => text.replace(externalUrlRegExp, '<a target="_blank" href="$1">$1</a>')
3+
const trailingPunctuationRegExp = /[?!.,:*_~]+$/
4+
5+
// Trim trailing punctuation and unmatched closing parens (GFM autolink rules).
6+
const trimUrl = (url: string): string => {
7+
let result = url.replace(trailingPunctuationRegExp, '')
8+
9+
while (
10+
result.endsWith(')') &&
11+
(result.match(/\)/g)?.length ?? 0) > (result.match(/\(/g)?.length ?? 0)
12+
) {
13+
result = result.slice(0, -1).replace(trailingPunctuationRegExp, '')
14+
}
15+
16+
return result
17+
}
18+
19+
const linkExternalUrls = (text: string) => text.replace(externalUrlRegExp, (match) => {
20+
const url = trimUrl(match)
21+
22+
const trailing = match.slice(url.length)
23+
24+
return `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>${trailing}`
25+
})
426

527
export default linkExternalUrls

0 commit comments

Comments
 (0)