Skip to content

Commit 131ed20

Browse files
AchoArnoldCopilot
andcommitted
fix(web): only strip URL trailing marker when it pairs with an opener
Trailing *_~` characters were stripped from every linkified URL, even when they're legitimate URL path characters (e.g. https://example.com/file_name_ linked to /file_name instead of /file_name_). Now a trailing marker is only dropped when the same character immediately precedes the URL, confirming it's an actual wrapping delimiter (e.g. *https://example.com*) rather than part of the URL itself. Sentence punctuation and balanced-parenthesis handling are unchanged. Also fixes a typo ("oe" -> "or") in a test case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a795297d-527c-4f30-a35a-ab379f2d50a8
1 parent ad45dfe commit 131ed20

2 files changed

Lines changed: 47 additions & 21 deletions

File tree

‎web/app/utils/whatsapp-format.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ test('formatWhatsappText - boundaries', async (t) => {
3737

3838
await t.test('does not bold a marker with no boundary at the end', () => {
3939
assert.equal(
40-
formatWhatsappText('*donotboldme*no space oe end of string'),
41-
'*donotboldme*no space oe end of string',
40+
formatWhatsappText('*donotboldme*no space or end of string'),
41+
'*donotboldme*no space or end of string',
4242
)
4343
})
4444

@@ -116,6 +116,16 @@ test('formatWhatsappText - links', async (t) => {
116116
},
117117
)
118118

119+
await t.test(
120+
'preserves a legitimate trailing marker character that is part of the URL',
121+
() => {
122+
assert.equal(
123+
formatWhatsappText('Download https://example.com/file_name_ now'),
124+
'Download <a href="https://example.com/file_name_" target="_blank" rel="noopener noreferrer">https://example.com/file_name_</a> now',
125+
)
126+
},
127+
)
128+
119129
await t.test('bolds a URL wrapped in asterisks', () => {
120130
assert.equal(
121131
formatWhatsappText('*https://example.com*'),

‎web/app/utils/whatsapp-format.ts‎

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,9 @@ const ITALIC_PATTERN = withBoundaries('_(\\S(?:[^_\\n]*\\S)?)_')
3939
const STRIKE_PATTERN = withBoundaries('~(\\S(?:[^~\\n]*\\S)?)~')
4040

4141
// Characters that are stripped off the end of a matched URL because they are
42-
// either trailing sentence punctuation or WhatsApp formatting markers (e.g.
43-
// the closing `*` in `*https://example.com*`), rather than part of the URL.
44-
const URL_TRAILING_CHARS = new Set([
45-
')',
42+
// Sentence punctuation that is always treated as trailing text, never part
43+
// of a URL (balanced closing parentheses are handled separately below).
44+
const URL_TRAILING_PUNCTUATION = new Set([
4645
',',
4746
'.',
4847
';',
@@ -53,19 +52,24 @@ const URL_TRAILING_CHARS = new Set([
5352
'"',
5453
']',
5554
'}',
56-
'*',
57-
'_',
58-
'~',
59-
'`',
6055
])
6156

57+
// WhatsApp formatting-marker characters. A trailing one of these is only
58+
// stripped from a URL when it pairs with a matching opening delimiter
59+
// immediately before the URL (e.g. the closing `*` in `*https://x.com*`),
60+
// so legitimate URL characters like the trailing `_` in `.../file_name_`
61+
// are preserved.
62+
const URL_TRAILING_MARKERS = new Set(['*', '_', '~', '`'])
63+
6264
/**
6365
* Splits a raw URL match into the actual URL and any trailing characters
64-
* that aren't part of it (sentence punctuation or formatting markers).
65-
* Closing parentheses are balance-checked so URLs such as
66-
* `https://example.com/page_(v2)` keep their closing parenthesis.
66+
* that aren't part of it. Closing parentheses are balance-checked so URLs
67+
* such as `https://example.com/page_(v2)` keep their closing parenthesis.
6768
*/
68-
function splitTrailingPunctuation(raw: string): {
69+
function splitTrailingPunctuation(
70+
raw: string,
71+
precedingChar: string | undefined,
72+
): {
6973
url: string
7074
trailing: string
7175
} {
@@ -79,9 +83,17 @@ function splitTrailingPunctuation(raw: string): {
7983
end--
8084
continue
8185
}
82-
if (!URL_TRAILING_CHARS.has(char)) break
86+
if (!URL_TRAILING_PUNCTUATION.has(char)) break
8387
end--
8488
}
89+
90+
if (end > 0) {
91+
const char = raw[end - 1] as string
92+
if (URL_TRAILING_MARKERS.has(char) && precedingChar === char) {
93+
end--
94+
}
95+
}
96+
8597
return { url: raw.slice(0, end), trailing: raw.slice(end) }
8698
}
8799

@@ -116,12 +128,16 @@ type PlaceholderStore = ReturnType<typeof createPlaceholderStore>
116128
* tokens so that markdown characters are not misinterpreted as formatting.
117129
*/
118130
function linkifyUrls(text: string, placeholders: PlaceholderStore): string {
119-
return text.replace(/(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi, (match) => {
120-
const { url, trailing } = splitTrailingPunctuation(match)
121-
const href = /^www\./i.test(url) ? `https://${url}` : url
122-
const anchor = `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`
123-
return placeholders.protect(anchor) + trailing
124-
})
131+
return text.replace(
132+
/(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi,
133+
(match, _group, offset: number, full: string) => {
134+
const precedingChar = offset > 0 ? full[offset - 1] : undefined
135+
const { url, trailing } = splitTrailingPunctuation(match, precedingChar)
136+
const href = /^www\./i.test(url) ? `https://${url}` : url
137+
const anchor = `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`
138+
return placeholders.protect(anchor) + trailing
139+
},
140+
)
125141
}
126142

127143
/**

0 commit comments

Comments
 (0)