Allow apostrophes within linkified URL paths.

This commit is contained in:
Greyson Parrelli
2026-09-10 17:10:14 -04:00
committed by Cody Henthorne
parent c7c3ecb838
commit 86b2819ba6
2 changed files with 28 additions and 3 deletions
@@ -27,13 +27,26 @@ object Linkifier {
/** Closing brackets that are trimmed only if the URL has no matching opener inside */
private val CLOSING_BRACKETS = mapOf(')' to '(', ']' to '[', '}' to '{')
/**
* Characters that always terminate a URL: whitespace (including the unicode variants), bidi/format
* controls, and punctuation that is only ever used to quote or wrap a URL.
*/
private const val URL_TERMINATOR = "\\s\\u0085\\u00A0\\u1680\\u2000-\\u200D\\u2028\\u2029\\u202A-\\u202F\\u205F\\u2066-\\u2069\\u3000\\uFEFF<>\"`|\\\\"
/**
* Characters we treat as definitely-not-part-of-a-URL when extending past the host. Commas and
* semicolons are allowed inside a URL, but not when they are acting as a separator before another
* obvious URL.
*/
private const val URL_CHAR =
"(?:[^\\s\\u0085\\u00A0\\u1680\\u2000-\\u200D\\u2028\\u2029\\u202A-\\u202F\\u205F\\u2066-\\u2069\\u3000\\uFEFF<>\"'`,;|\\\\]|[,;](?!https?://|www\\.))"
private const val URL_CHAR = "(?:[^$URL_TERMINATOR,;]|[,;](?!https?://|www\\.))"
/**
* Like [URL_CHAR], but for the authority (host/port/userinfo) portion of a URL, so it stops at the
* first path/query/fragment delimiter. Apostrophes are excluded here so that a possessive like
* `https://signal.org's blog` doesn't drag the `'s` into the host, while still allowing them
* within a path (see [URL_CHAR]).
*/
private const val AUTHORITY_CHAR = "(?:[^$URL_TERMINATOR,;'/?#]|[,;](?!https?://|www\\.))"
/**
* A single domain label: letter/digit, optional letter/digit/hyphen body. Used for intermediate
@@ -56,7 +69,8 @@ object Linkifier {
"(?i)" +
"(?:" +
// Variant 1: explicit scheme (http or https). User intent is unambiguous; no TLD check.
"https?://" + URL_CHAR + "+" +
"https?://" + AUTHORITY_CHAR + "+" +
"(?:[/?#]" + URL_CHAR + "*)?" +
"|" +
// Variant 2: starts with www. — also unambiguous; no TLD check.
"www\\." + DOMAIN_LABEL + "(?:\\." + DOMAIN_LABEL + ")+" +
@@ -108,6 +108,17 @@ class LinkifierTest(private val case: Case) {
Case("semicolon in url path is preserved", "https://example.com/pricewatch/compare/2290176;2126182/", listOf(web("https://example.com/pricewatch/compare/2290176;2126182/"))),
Case("semicolon in url query is preserved", "Go to https://example.com/search?a=1;b=2", listOf(web("https://example.com/search?a=1;b=2"))),
// ----- apostrophes -----
Case("apostrophe in url path is preserved", "https://en.wikipedia.org/wiki/Occam's_razor", listOf(web("https://en.wikipedia.org/wiki/Occam's_razor"))),
Case("apostrophe in url path is preserved mid-sentence", "see https://en.wikipedia.org/wiki/Occam's_razor for more", listOf(web("https://en.wikipedia.org/wiki/Occam's_razor"))),
Case("apostrophe in bare domain path is preserved", "en.wikipedia.org/wiki/Occam's_razor", listOf(web("en.wikipedia.org/wiki/Occam's_razor", url = "http://en.wikipedia.org/wiki/Occam's_razor"))),
Case("apostrophe in url query is preserved", "https://example.com/search?q=Occam's", listOf(web("https://example.com/search?q=Occam's"))),
Case("trailing apostrophe is trimmed", "https://example.com/foo' end", listOf(web("https://example.com/foo"))),
Case("single-quoted url does not include the quotes", "'https://signal.org'", listOf(web("https://signal.org"))),
Case("possessive after schemed host is not part of the url", "https://signal.org's blog is good", listOf(web("https://signal.org"))),
Case("possessive after bare domain is not part of the url", "signal.org's blog is good", listOf(web("signal.org", url = "http://signal.org"))),
Case("possessive after www domain is not part of the url", "www.signal.org's blog is good", listOf(web("www.signal.org", url = "http://www.signal.org"))),
// ----- bracket / paren handling -----
Case("trailing closing paren without opener is trimmed", "(see https://signal.org)", listOf(web("https://signal.org"))),
Case("trailing closing paren with matching opener inside is preserved", "https://en.wikipedia.org/wiki/Foo_(bar) and more", listOf(web("https://en.wikipedia.org/wiki/Foo_(bar)"))),