expensify-common 2.0.211 → 2.0.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,9 @@ const URL_TLD_LIST = TLD_REGEX.toLowerCase().split('|');
21
21
  const URL_TLDS = new Set(URL_TLD_LIST);
22
22
  // Caps TLD scanning at the longest known TLD so long invalid URL-like text avoids expensive regex work.
23
23
  const MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length));
24
+ const URL_HOSTNAME_LABEL_AT_POSITION_REGEX = new RegExp(UrlPatterns.URL_HOSTNAME_LABEL_REGEX, 'iy');
25
+ const URL_HOSTNAME_BOUNDARY_AT_POSITION_REGEX = new RegExp(UrlPatterns.URL_HOSTNAME_BOUNDARY_REGEX, 'iy');
26
+ const URL_SUFFIX_AT_POSITION_REGEX = new RegExp(`(?:\\:${UrlPatterns.URL_PORT_REGEX}(?![0-9]))?${UrlPatterns.URL_PATH_REGEX}(?:${UrlPatterns.URL_PARAM_REGEX}|${UrlPatterns.URL_FRAGMENT_REGEX})*`, 'iy');
24
27
  const PROTECTED_TAG_NAMES = new Set(['a', 'code', 'pre', 'video']);
25
28
  const MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
26
29
  const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
@@ -160,58 +163,50 @@ function getProtocolAt(text, position) {
160
163
  return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol);
161
164
  }
162
165
  /**
163
- * Returns whether one dot-separated hostname label is valid.
166
+ * Finds the valid prefix of a hostname label using the shared URL label pattern.
164
167
  *
165
168
  * @param text - Candidate URL text containing the label.
166
169
  * @param start - Index of the label's first character.
167
- * @param end - Index immediately after the label's last character.
170
+ * @returns The index after the valid label prefix, or undefined when the label starts with invalid syntax.
168
171
  */
169
- function isValidHostnameLabel(text, start, end) {
170
- if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) {
171
- return false;
172
- }
173
- for (let index = start + 1; index < end - 1; index++) {
174
- if (!isAsciiAlphaNumeric(text[index]) && text[index] !== '-') {
175
- return false;
176
- }
172
+ function findValidHostnameLabelEnd(text, start) {
173
+ URL_HOSTNAME_LABEL_AT_POSITION_REGEX.lastIndex = start;
174
+ const match = URL_HOSTNAME_LABEL_AT_POSITION_REGEX.exec(text);
175
+ return match === null ? undefined : URL_HOSTNAME_LABEL_AT_POSITION_REGEX.lastIndex;
176
+ }
177
+ /**
178
+ * Finds the first character after a URL's hostname.
179
+ *
180
+ * @param text - Text containing the URL candidate.
181
+ * @param hostnameStart - Index of the hostname's first character.
182
+ * @returns The index immediately after the hostname.
183
+ */
184
+ function findHostnameEnd(text, hostnameStart) {
185
+ let hostnameEnd = hostnameStart;
186
+ while (hostnameEnd < text.length && isHostnameCharacter(text[hostnameEnd])) {
187
+ hostnameEnd++;
177
188
  }
178
- return true;
189
+ return hostnameEnd;
179
190
  }
180
191
  /**
181
- * Finds the first character of a valid hostname before the dot that starts its top-level domain.
192
+ * Finds where a separate hostname can start after punctuation inside the current hostname run.
182
193
  *
183
- * @param text - Candidate URL text containing the hostname.
184
- * @param dotPosition - Index of the dot immediately before the top-level domain.
185
- * @returns The hostname's first-character index, or undefined when no valid hostname precedes the dot.
194
+ * @param text - Text containing the hostname run.
195
+ * @param start - First index to check.
196
+ * @param end - Index immediately after the hostname run.
197
+ * @returns The next hostname start allowed by the URL pattern, or `end` when none exists.
186
198
  */
187
- function findHostnameStart(text, dotPosition) {
188
- let hostnameStart = dotPosition;
189
- let labelEnd = dotPosition;
190
- while (labelEnd > 0) {
191
- let rawLabelStart = labelEnd - 1;
192
- while (rawLabelStart >= 0 && text[rawLabelStart] !== '.' && isHostnameCharacter(text[rawLabelStart])) {
193
- rawLabelStart--;
194
- }
195
- rawLabelStart++;
196
- let labelStart = rawLabelStart;
197
- while (labelStart < labelEnd && text[labelStart] === '-') {
198
- labelStart++;
199
- }
200
- if (!isValidHostnameLabel(text, labelStart, labelEnd)) {
201
- break;
202
- }
203
- hostnameStart = labelStart;
204
- // A leading hyphen ends the hostname, but the existing URL regex can still match the valid suffix after it.
205
- if (labelStart !== rawLabelStart) {
206
- break;
199
+ function findNextHostnameStart(text, start, end) {
200
+ for (let index = start; index < end; index++) {
201
+ if (text[index - 1] !== '.' && text[index - 1] !== '-') {
202
+ continue;
207
203
  }
208
- const separatorPosition = labelStart - 1;
209
- if (separatorPosition < 0 || text[separatorPosition] !== '.') {
210
- break;
204
+ URL_HOSTNAME_BOUNDARY_AT_POSITION_REGEX.lastIndex = index;
205
+ if (URL_HOSTNAME_BOUNDARY_AT_POSITION_REGEX.test(text)) {
206
+ return index;
211
207
  }
212
- labelEnd = separatorPosition;
213
208
  }
214
- return hostnameStart === dotPosition ? undefined : hostnameStart;
209
+ return end;
215
210
  }
216
211
  /**
217
212
  * Finds the end of a known top-level domain after `dotPosition`.
@@ -235,17 +230,78 @@ function findKnownTldEnd(text, dotPosition) {
235
230
  }
236
231
  return undefined;
237
232
  }
238
- /** Expands example.com to include nearby @, *, _, or ~ in any order, plus its path, until whitespace or HTML. */
239
- function extendUrlCandidateBoundaries(text, start, end) {
233
+ /**
234
+ * Validates a hostname and records a linkable prefix before a later malformed label.
235
+ * Preserves known-TLD prefixes before oversized continuations without recovering text inside an invalid leading label.
236
+ *
237
+ * @param text - Text containing the URL candidate.
238
+ * @param hostnameStart - Index of the hostname's first character.
239
+ * @param hostnameEnd - Index immediately after the hostname.
240
+ * @param hasProtocol - Whether the URL allows hostnames without a known TLD, including single-label hosts.
241
+ * @returns The hostname validity and any prefix that can be linked before a malformed or oversized continuation.
242
+ */
243
+ function analyzeHostname(text, hostnameStart, hostnameEnd, hasProtocol) {
244
+ let hostnameContentEnd = hostnameEnd;
245
+ while (hostnameContentEnd > hostnameStart && (text[hostnameContentEnd - 1] === '.' || text[hostnameContentEnd - 1] === '-')) {
246
+ hostnameContentEnd--;
247
+ }
248
+ if (hostnameContentEnd === hostnameStart) {
249
+ return { isValid: false };
250
+ }
251
+ let labelStart = hostnameStart;
252
+ let validHostnamePrefixEnd;
253
+ let validKnownTldPrefixEnd;
254
+ for (let index = hostnameStart; index <= hostnameContentEnd; index++) {
255
+ if (index !== hostnameContentEnd && text[index] !== '.') {
256
+ continue;
257
+ }
258
+ const validLabelEnd = findValidHostnameLabelEnd(text, labelStart);
259
+ if (validLabelEnd === undefined) {
260
+ return { isValid: false, validHostnamePrefixEnd };
261
+ }
262
+ // Measure the valid label prefix before any trailing hyphens that end it.
263
+ let labelContentEnd = index;
264
+ while (labelContentEnd > labelStart && text[labelContentEnd - 1] === '-') {
265
+ labelContentEnd--;
266
+ }
267
+ if (labelContentEnd - labelStart > Constants.MAX_URL_HOSTNAME_LABEL_LENGTH || labelContentEnd - hostnameStart > Constants.MAX_URL_HOSTNAME_LENGTH) {
268
+ return { isValid: false, validHostnamePrefixEnd: validKnownTldPrefixEnd };
269
+ }
270
+ // Protocol URLs can end at any valid label; bare URLs still need a known TLD.
271
+ if (hasProtocol) {
272
+ validHostnamePrefixEnd = validLabelEnd;
273
+ }
274
+ if (validLabelEnd !== index) {
275
+ return { isValid: false, validHostnamePrefixEnd };
276
+ }
277
+ if (index !== hostnameContentEnd) {
278
+ const tldEnd = findKnownTldEnd(text, index);
279
+ if (tldEnd !== undefined && tldEnd <= hostnameContentEnd && tldEnd - hostnameStart <= Constants.MAX_URL_HOSTNAME_LENGTH) {
280
+ validKnownTldPrefixEnd = tldEnd;
281
+ if (!hasProtocol) {
282
+ validHostnamePrefixEnd = tldEnd;
283
+ }
284
+ }
285
+ }
286
+ labelStart = index + 1;
287
+ }
288
+ return { isValid: true, validHostnamePrefixEnd };
289
+ }
290
+ /** Finds the candidate start, including any adjacent @, *, _, or ~ before the URL. */
291
+ function findUrlCandidateStart(text, start) {
240
292
  let candidateStart = start;
241
293
  while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) {
242
294
  candidateStart--;
243
295
  }
296
+ return candidateStart;
297
+ }
298
+ /** Expands example.com to include nearby @, *, _, or ~ in any order, plus its path, until whitespace or HTML. */
299
+ function extendUrlCandidateBoundaries(text, start, end) {
244
300
  let candidateEnd = end;
245
301
  while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== '<') {
246
302
  candidateEnd++;
247
303
  }
248
- return { start: candidateStart, end: candidateEnd };
304
+ return { start: findUrlCandidateStart(text, start), end: candidateEnd };
249
305
  }
250
306
  /**
251
307
  * Checks whether lowercase `expected` occurs in `text` at `position`, ignoring letter case in `text`.
@@ -257,6 +313,167 @@ function extendUrlCandidateBoundaries(text, start, end) {
257
313
  function startsWithIgnoreCase(text, expected, position) {
258
314
  return text.slice(position, position + expected.length).toLowerCase() === expected;
259
315
  }
316
+ /**
317
+ * Finds the first unmatched closing parenthesis, which ends a URL rather than belonging to its path.
318
+ *
319
+ * @param text - Text containing the URL or its suffix.
320
+ * @param start - First index to inspect, inclusive.
321
+ * @param end - Last index to inspect, exclusive.
322
+ * @returns The unmatched parenthesis index, or end when all closing parentheses are paired.
323
+ */
324
+ function findUrlEndBeforeUnmatchedParenthesis(text, start, end) {
325
+ let unmatchedOpenParentheses = 0;
326
+ for (let index = start; index < end; index++) {
327
+ if (text[index] === '(') {
328
+ unmatchedOpenParentheses++;
329
+ }
330
+ else if (text[index] === ')') {
331
+ if (unmatchedOpenParentheses === 0) {
332
+ return index;
333
+ }
334
+ unmatchedOpenParentheses--;
335
+ }
336
+ }
337
+ return end;
338
+ }
339
+ /**
340
+ * Finds where a URL ends by matching its optional port, path, query, and fragment after the hostname.
341
+ *
342
+ * @param text - Text containing the URL candidate.
343
+ * @param hostnameEnd - Index immediately after the hostname.
344
+ * @returns The first index after the URL recognized by ExpensiMark.
345
+ */
346
+ function findUrlEnd(text, hostnameEnd) {
347
+ var _a;
348
+ URL_SUFFIX_AT_POSITION_REGEX.lastIndex = hostnameEnd;
349
+ const match = URL_SUFFIX_AT_POSITION_REGEX.exec(text);
350
+ return findUrlEndBeforeUnmatchedParenthesis(text, hostnameEnd, hostnameEnd + ((_a = match === null || match === void 0 ? void 0 : match[0].length) !== null && _a !== void 0 ? _a : 0));
351
+ }
352
+ /**
353
+ * Analyzes a URL candidate and protects invalid ranges while allowing separate domains after punctuation.
354
+ *
355
+ * @param text - Text containing the URL candidate.
356
+ * @param urlStart - Index where the URL starts, excluding Markdown punctuation.
357
+ * @param hostnameStart - Index where the hostname starts.
358
+ * @returns The hostname end and any invalid ranges, plus the end of the connected URL text.
359
+ */
360
+ function analyzeUrlRange(text, urlStart, hostnameStart) {
361
+ var _a;
362
+ const hostnameEnd = findHostnameEnd(text, hostnameStart);
363
+ const hostnameAnalysis = analyzeHostname(text, hostnameStart, hostnameEnd, hostnameStart !== urlStart);
364
+ if (hostnameAnalysis.isValid) {
365
+ return { hostnameEnd };
366
+ }
367
+ let invalidRangeEnd = findUrlEnd(text, hostnameEnd);
368
+ if (text[hostnameEnd] === ':') {
369
+ // The broad hostname scan can include the letters of the next protocol, as in example.com..http://valid.org.
370
+ for (const protocol of URL_PROTOCOLS) {
371
+ const protocolStart = hostnameEnd - protocol.indexOf(':');
372
+ if (protocolStart >= hostnameStart && startsWithIgnoreCase(text, protocol, protocolStart)) {
373
+ invalidRangeEnd = protocolStart;
374
+ break;
375
+ }
376
+ }
377
+ }
378
+ const invalidRanges = [];
379
+ let invalidRangeStart = (_a = hostnameAnalysis.validHostnamePrefixEnd) !== null && _a !== void 0 ? _a : urlStart;
380
+ // Use the URL pattern's boundary rule to check each separate hostname after punctuation once.
381
+ let index = findNextHostnameStart(text, hostnameStart + 1, hostnameEnd);
382
+ while (index < hostnameEnd) {
383
+ const followingHostnameStart = index;
384
+ index = findNextHostnameStart(text, index + 1, hostnameEnd);
385
+ const followingHostnameAnalysis = analyzeHostname(text, followingHostnameStart, index, false);
386
+ if (followingHostnameAnalysis.validHostnamePrefixEnd === undefined) {
387
+ continue;
388
+ }
389
+ invalidRanges.push({ start: invalidRangeStart, end: followingHostnameStart });
390
+ // Only a valid final hostname owns the path/query; malformed continuations keep their nested domains plain.
391
+ invalidRangeStart = followingHostnameAnalysis.validHostnamePrefixEnd;
392
+ if (followingHostnameAnalysis.isValid && index === hostnameEnd) {
393
+ invalidRangeStart = invalidRangeEnd;
394
+ }
395
+ }
396
+ if (invalidRangeStart < invalidRangeEnd) {
397
+ invalidRanges.push({ start: invalidRangeStart, end: invalidRangeEnd });
398
+ }
399
+ return { hostnameEnd, invalidRanges, urlEnd: invalidRangeEnd };
400
+ }
401
+ /**
402
+ * Finds the first hostname character before a known top-level domain, including invalid labels.
403
+ *
404
+ * @param text - Text containing the bare hostname.
405
+ * @param dotPosition - Index of the dot immediately before the known top-level domain.
406
+ * @returns The start of the hostname run, including leading dots and hyphens.
407
+ */
408
+ function findRawHostnameStart(text, dotPosition) {
409
+ let hostnameStart = dotPosition;
410
+ while (hostnameStart > 0 && isHostnameCharacter(text[hostnameStart - 1])) {
411
+ hostnameStart--;
412
+ }
413
+ return hostnameStart;
414
+ }
415
+ /**
416
+ * Finds URL ranges whose hostname is malformed or exceeds the supported DNS length limits.
417
+ * These ranges keep both URL parsing paths from matching domains inside invalid hostname labels or connected URL suffixes.
418
+ *
419
+ * @param text - Text containing possible protocol and bare URLs.
420
+ * @returns Connected invalid URL ranges that must remain plain text.
421
+ */
422
+ function findInvalidUrlRanges(text) {
423
+ const ranges = [];
424
+ let index = 0;
425
+ while (index < text.length) {
426
+ const matchedProtocol = getProtocolAt(text, index);
427
+ let analysis;
428
+ if (matchedProtocol) {
429
+ analysis = analyzeUrlRange(text, index, index + matchedProtocol.length);
430
+ }
431
+ else {
432
+ if (text[index] !== '.') {
433
+ index++;
434
+ continue;
435
+ }
436
+ const tldEnd = findKnownTldEnd(text, index);
437
+ if (tldEnd === undefined) {
438
+ index++;
439
+ continue;
440
+ }
441
+ const hostnameStart = findRawHostnameStart(text, index);
442
+ analysis = analyzeUrlRange(text, hostnameStart, hostnameStart);
443
+ }
444
+ if (analysis.invalidRanges) {
445
+ for (const range of analysis.invalidRanges) {
446
+ ranges.push(range);
447
+ }
448
+ index = analysis.urlEnd;
449
+ }
450
+ else {
451
+ // Skip the valid URL's suffix so domains in its path, query, or fragment are not scanned again.
452
+ index = findUrlEnd(text, analysis.hostnameEnd);
453
+ }
454
+ }
455
+ return ranges;
456
+ }
457
+ /**
458
+ * Creates the text searched by the URL regex. It replaces rejected URL ranges with spaces
459
+ * so the regex can find other URLs. The result keeps the original length because the parser
460
+ * uses the match indexes to build output from the original text.
461
+ *
462
+ * @param text - Original text being parsed for URLs.
463
+ * @param invalidRanges - Ordered, non-overlapping ranges rejected by URL validation.
464
+ * @returns A copy of `text` with those ranges replaced by spaces.
465
+ */
466
+ function maskInvalidUrlRanges(text, invalidRanges) {
467
+ const parts = [];
468
+ let start = 0;
469
+ for (const range of invalidRanges) {
470
+ // Output still comes from the original text; spaces only stop regexes from crossing invalid ranges.
471
+ parts.push(text.slice(start, range.start), ' '.repeat(range.end - range.start));
472
+ start = range.end;
473
+ }
474
+ parts.push(text.slice(start));
475
+ return parts.join('');
476
+ }
260
477
  /**
261
478
  * Removes candidates that the original full-text URL regex would reject because of later HTML.
262
479
  * It scans right to left so each candidate can use the nearest later HTML boundary without copying a long suffix.
@@ -310,7 +527,7 @@ function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) {
310
527
  }
311
528
  return validCandidates.reverse();
312
529
  }
313
- /** Finds possible URL ranges, skips URL-looking text inside protected tags, and leaves final validity to the existing regex. */
530
+ /** Finds possible URL ranges, skips URL-looking text inside protected tags, rejects malformed or oversized hostnames, and leaves remaining validation to the existing regex. */
314
531
  function findUrlCandidates(text) {
315
532
  const candidates = [];
316
533
  const protectedTags = [];
@@ -331,25 +548,43 @@ function findUrlCandidates(text) {
331
548
  continue;
332
549
  }
333
550
  const matchedProtocol = getProtocolAt(text, index);
551
+ let urlStart = index;
552
+ let candidateEnd;
334
553
  if (matchedProtocol) {
335
- const candidate = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length);
336
- candidates.push(candidate);
337
- index = candidate.end;
338
- continue;
554
+ candidateEnd = index + matchedProtocol.length;
339
555
  }
340
- if (text[index] !== '.') {
341
- index++;
342
- continue;
556
+ else {
557
+ if (text[index] !== '.') {
558
+ index++;
559
+ continue;
560
+ }
561
+ const tldEnd = findKnownTldEnd(text, index);
562
+ if (tldEnd === undefined) {
563
+ index++;
564
+ continue;
565
+ }
566
+ urlStart = findRawHostnameStart(text, index);
567
+ candidateEnd = tldEnd;
343
568
  }
344
- const tldEnd = findKnownTldEnd(text, index);
345
- const hostnameStart = tldEnd === undefined ? undefined : findHostnameStart(text, index);
346
- if (tldEnd === undefined || hostnameStart === undefined) {
347
- index++;
569
+ const analysis = analyzeUrlRange(text, urlStart, matchedProtocol ? candidateEnd : urlStart);
570
+ if (analysis.invalidRanges) {
571
+ // Match valid portions separately so long rejected gaps never reach the full URL regex.
572
+ let candidateStart = urlStart;
573
+ for (const range of analysis.invalidRanges) {
574
+ if (candidateStart < range.start) {
575
+ candidates.push({ start: findUrlCandidateStart(text, candidateStart), end: range.start });
576
+ }
577
+ candidateStart = range.end;
578
+ }
579
+ if (candidateStart < analysis.urlEnd) {
580
+ candidates.push({ start: findUrlCandidateStart(text, candidateStart), end: analysis.urlEnd });
581
+ }
582
+ index = analysis.urlEnd;
348
583
  continue;
349
584
  }
350
- const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd);
351
- candidates.push(candidate);
352
- index = candidate.end;
585
+ const broadCandidate = extendUrlCandidateBoundaries(text, urlStart, candidateEnd);
586
+ candidates.push(broadCandidate);
587
+ index = broadCandidate.end;
353
588
  }
354
589
  return filterUrlCandidatesBlockedByFollowingHtml(text, candidates);
355
590
  }
@@ -906,7 +1141,7 @@ class ExpensiMark {
906
1141
  process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => {
907
1142
  const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>))`, 'gi');
908
1143
  // Raw HTML depends on complete-text lookaheads. Text without user-provided HTML can safely use the faster candidate scanner.
909
- return this.modifyTextForUrlLinks(regex, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText));
1144
+ return this.modifyTextForUrlLinks(regex, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText), true);
910
1145
  },
911
1146
  replacement: (_extras, _match, g1, g2) => {
912
1147
  const href = Str.sanitizeURL(g2);
@@ -1474,9 +1709,16 @@ class ExpensiMark {
1474
1709
  return restoreVictoryChartTags(replacedText, victoryChartTags);
1475
1710
  }
1476
1711
  /**
1477
- * Checks matched URLs for validity and replace valid links with html elements
1712
+ * Replaces valid URL matches and optionally narrows parsing to URL candidates.
1713
+ *
1714
+ * @param regex - The URL pattern used for the final match.
1715
+ * @param textToCheck - The text containing possible URLs.
1716
+ * @param replacement - The replacement applied to each accepted URL.
1717
+ * @param shouldScanForUrls - Whether to scan URL candidates before running the regex.
1718
+ * @param shouldRejectPartialHostnameMatches - Whether to mask invalid URL ranges before matching.
1719
+ * @param followingText - Text after the candidate that the regex needs to distinguish URLs from emails.
1478
1720
  */
1479
- modifyTextForUrlLinks(regex, textToCheck, replacement, shouldScanForUrls = false) {
1721
+ modifyTextForUrlLinks(regex, textToCheck, replacement, shouldScanForUrls = false, shouldRejectPartialHostnameMatches = false, followingText = '') {
1480
1722
  if (shouldScanForUrls) {
1481
1723
  const candidates = findUrlCandidates(textToCheck);
1482
1724
  if (candidates.length === 0) {
@@ -1487,36 +1729,42 @@ class ExpensiMark {
1487
1729
  let outputStart = 0;
1488
1730
  for (const { start, end } of candidates) {
1489
1731
  const candidate = textToCheck.slice(start, end);
1732
+ // A shortened candidate can end at an email's local part, so keep its domain visible to the regex.
1733
+ const contextEnd = textToCheck[end] === '@' ? findHostnameEnd(textToCheck, end + 1) : end;
1490
1734
  candidateRegex.lastIndex = 0;
1491
1735
  output.push(textToCheck.slice(outputStart, start));
1492
- output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement));
1736
+ output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement, false, shouldRejectPartialHostnameMatches, textToCheck.slice(end, contextEnd)));
1493
1737
  outputStart = end;
1494
1738
  }
1495
1739
  output.push(textToCheck.slice(outputStart));
1496
1740
  return output.join('');
1497
1741
  }
1498
- let match = regex.exec(textToCheck);
1742
+ const matchingRegex = regex;
1743
+ const textWithContext = textToCheck + followingText;
1744
+ const invalidUrlRanges = shouldRejectPartialHostnameMatches ? findInvalidUrlRanges(textWithContext) : [];
1745
+ const maskedText = invalidUrlRanges.length > 0 ? maskInvalidUrlRanges(textWithContext, invalidUrlRanges) : textWithContext;
1746
+ const textToMatch = maskedText.slice(0, textToCheck.length);
1747
+ const contextRegex = followingText ? new RegExp(regex.source, regex.flags.replace('g', 'y')) : undefined;
1748
+ let match = matchingRegex.exec(textToMatch);
1499
1749
  let replacedText = '';
1500
1750
  let startIndex = 0;
1501
1751
  while (match !== null) {
1502
- // We end the link at the last closing parenthesis that matches an opening parenthesis because unmatched closing parentheses are unlikely to be in the url
1503
- // and can be part of markdown for example
1504
- let unmatchedOpenParentheses = 0;
1505
- let url = match[2];
1506
- for (let i = 0; i < url.length; i++) {
1507
- if (url[i] === '(') {
1508
- unmatchedOpenParentheses++;
1509
- }
1510
- else if (url[i] === ')') {
1511
- // Unmatched closing parenthesis
1512
- if (unmatchedOpenParentheses <= 0) {
1513
- const numberOfCharsToRemove = url.length - i;
1514
- match[0] = match[0].substr(0, match[0].length - numberOfCharsToRemove);
1515
- url = url.substr(0, url.length - numberOfCharsToRemove);
1516
- break;
1517
- }
1518
- unmatchedOpenParentheses--;
1752
+ if (contextRegex && match.index + match[0].length === textToCheck.length) {
1753
+ // Recheck only this match with its following text, without searching outside the candidate.
1754
+ contextRegex.lastIndex = match.index;
1755
+ const contextMatch = contextRegex.exec(maskedText);
1756
+ if (contextMatch === null) {
1757
+ match = matchingRegex.exec(textToMatch);
1758
+ continue;
1519
1759
  }
1760
+ match = contextMatch;
1761
+ matchingRegex.lastIndex = contextRegex.lastIndex;
1762
+ }
1763
+ let url = match[2];
1764
+ const urlEnd = findUrlEndBeforeUnmatchedParenthesis(url, 0, url.length);
1765
+ if (urlEnd < url.length) {
1766
+ match[0] = match[0].slice(0, match[0].length - (url.length - urlEnd));
1767
+ url = url.slice(0, urlEnd);
1520
1768
  }
1521
1769
  // Because we are removing ) parenthesis, some special characters that shouldn't be in the href are in the href
1522
1770
  // For example google.com/toto.) is accepted by the regular expression above and we remove the ) parenthesis, so the link becomes google.com/toto. which is not a valid link
@@ -1579,7 +1827,7 @@ class ExpensiMark {
1579
1827
  }
1580
1828
  startIndex = match.index + match[0].length;
1581
1829
  // Now we move to the next match that the js regex found in the text
1582
- match = regex.exec(textToCheck);
1830
+ match = matchingRegex.exec(textToMatch);
1583
1831
  }
1584
1832
  if (startIndex < textToCheck.length) {
1585
1833
  replacedText = replacedText.concat(textToCheck.substr(startIndex));
package/dist/esm/Url.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export const URL_HOSTNAME_BOUNDARY_REGEX: "(?=[a-z0-9])(?<![a-z0-9]-*)(?<![a-z0-9]\\.)";
2
+ export const URL_HOSTNAME_LABEL_REGEX: "[a-z0-9](?=(?<label>[-a-z0-9]{0,61}[a-z0-9])?)\\k<label>";
1
3
  export const URL_WEBSITE_REGEX: string;
2
4
  export const URL_PATH_REGEX: string;
3
5
  export const URL_PARAM_REGEX: string;
@@ -5,6 +7,7 @@ export const URL_FRAGMENT_REGEX: string;
5
7
  export const URL_REGEX: string;
6
8
  export const URL_REGEX_WITH_REQUIRED_PROTOCOL: string;
7
9
  export const URL_PROTOCOL_REGEX: "((ht|f)tps?:\\/\\/)";
10
+ export const URL_PORT_REGEX: "([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])";
8
11
  export const LOOSE_URL_REGEX: string;
9
- export const LOOSE_URL_WEBSITE_REGEX: "((ht|f)tps?:\\/\\/)((?!-)[-a-z0-9]+(?<!-)(?:\\.(?!-)[-a-z0-9]+(?<!-))*)(?:\\:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])|\\b|(?=_))";
12
+ export const LOOSE_URL_WEBSITE_REGEX: "((ht|f)tps?:\\/\\/)(?!(?:(?!\\.[.-]|-\\.)[-a-z0-9.]){253}(?:(?!\\.[.-]|-\\.)[-a-z0-9.])*[a-z0-9](?:\\b|(?=_)))((?!-)[-a-z0-9]{1,63}(?<!-)(?:\\.(?!-)[-a-z0-9]{1,63}(?<!-))*)(?:\\:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])|\\b|(?=_))";
10
13
  export const MARKDOWN_URL_REGEX: string;
package/dist/esm/Url.js CHANGED
@@ -1,15 +1,26 @@
1
1
  import TLD_REGEX from './tlds';
2
- const ALLOWED_PORTS = '([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])';
2
+ import { MAX_URL_HOSTNAME_LABEL_LENGTH, MAX_URL_HOSTNAME_LENGTH } from './CONST';
3
+ const URL_PORT_REGEX = '([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])';
3
4
  const URL_PROTOCOL_REGEX = '((ht|f)tps?:\\/\\/)';
4
- const URL_WEBSITE_REGEX = `${URL_PROTOCOL_REGEX}?((?:www\\.)?[a-z0-9](?=(?<label>[-a-z0-9]*[a-z0-9])?)\\k<label>\\.)+(?:${TLD_REGEX})\
5
- (?:\\:${ALLOWED_PORTS}|\\b|(?=_))(?!@(?:[a-z\\d-]+\\.)+[a-z]{2,})`;
5
+ const MAX_URL_HOSTNAME_LABEL_BODY_LENGTH = MAX_URL_HOSTNAME_LABEL_LENGTH - 2;
6
+ const URL_HOSTNAME_LABEL_REGEX = `[a-z0-9](?=(?<label>[-a-z0-9]{0,${MAX_URL_HOSTNAME_LABEL_BODY_LENGTH}}[a-z0-9])?)\\k<label>`;
7
+ const LOOSE_URL_HOSTNAME_LABEL_REGEX = `(?!-)[-a-z0-9]{1,${MAX_URL_HOSTNAME_LABEL_LENGTH}}(?<!-)`;
8
+ // Dots and leading hyphens can separate text from a domain; keep letters and attached label hyphens together.
9
+ // Check the first character before looking back so long punctuation runs do not repeatedly scan backward.
10
+ const URL_HOSTNAME_BOUNDARY_REGEX = '(?=[a-z0-9])(?<![a-z0-9]-*)(?<![a-z0-9]\\.)';
11
+ // Repeated dots and leading or trailing label hyphens end the hostname before malformed continuation text.
12
+ const URL_HOSTNAME_LENGTH_CHARACTER_REGEX = '(?:(?!\\.[.-]|-\\.)[-a-z0-9.])';
13
+ // Reject oversized hostnames without counting malformed continuations, ports, paths, queries, fragments, or trailing punctuation.
14
+ const URL_HOSTNAME_MAX_LENGTH_ASSERTION = `(?!${URL_HOSTNAME_LENGTH_CHARACTER_REGEX}{${MAX_URL_HOSTNAME_LENGTH}}${URL_HOSTNAME_LENGTH_CHARACTER_REGEX}*[a-z0-9](?:\\b|(?=_)))`;
15
+ const URL_WEBSITE_REGEX = `${URL_PROTOCOL_REGEX}?${URL_HOSTNAME_BOUNDARY_REGEX}${URL_HOSTNAME_MAX_LENGTH_ASSERTION}((?:www\\.)?${URL_HOSTNAME_LABEL_REGEX}\\.)+(?:${TLD_REGEX})\
16
+ (?:\\:${URL_PORT_REGEX}|\\b|(?=_))(?!@(?:[a-z\\d-]+\\.)+[a-z]{2,})`;
6
17
  const addEscapedChar = (reg) => `(?:${reg}|&(?:amp|#x27);)`;
7
18
  const URL_PATH_REGEX = `(?:${addEscapedChar('[.,=(+$!*]')}?\\/${addEscapedChar('[-\\w$@.+!*:(),=%~]')}*${addEscapedChar('[-\\w~@:%)]')}|\\/)*`;
8
19
  const URL_PARAM_REGEX = `(?:\\?${addEscapedChar('[-\\w$@.+!*()\\/,=%{}:;\\[\\]\\|_|~]')}+)?`;
9
20
  const URL_FRAGMENT_REGEX = `(?:#${addEscapedChar('[-\\w$@.+!*()[\\],=%;\\/:~]')}*)?`;
10
21
  const URL_REGEX = `((${URL_WEBSITE_REGEX})${URL_PATH_REGEX}(?:${URL_PARAM_REGEX}|${URL_FRAGMENT_REGEX})*)`;
11
22
  const URL_REGEX_WITH_REQUIRED_PROTOCOL = URL_REGEX.replace(`${URL_PROTOCOL_REGEX}?`, URL_PROTOCOL_REGEX);
12
- const LOOSE_URL_WEBSITE_REGEX = `${URL_PROTOCOL_REGEX}((?!-)[-a-z0-9]+(?<!-)(?:\\.(?!-)[-a-z0-9]+(?<!-))*)(?:\\:${ALLOWED_PORTS}|\\b|(?=_))`;
23
+ const LOOSE_URL_WEBSITE_REGEX = `${URL_PROTOCOL_REGEX}${URL_HOSTNAME_MAX_LENGTH_ASSERTION}(${LOOSE_URL_HOSTNAME_LABEL_REGEX}(?:\\.${LOOSE_URL_HOSTNAME_LABEL_REGEX})*)(?:\\:${URL_PORT_REGEX}|\\b|(?=_))`;
13
24
  const LOOSE_URL_REGEX = `((${LOOSE_URL_WEBSITE_REGEX})${URL_PATH_REGEX}(?:${URL_PARAM_REGEX}|${URL_FRAGMENT_REGEX})*)`;
14
25
  const MARKDOWN_URL_REGEX = `(${LOOSE_URL_REGEX}|${URL_REGEX})`;
15
- export { URL_WEBSITE_REGEX, URL_PATH_REGEX, URL_PARAM_REGEX, URL_FRAGMENT_REGEX, URL_REGEX, URL_REGEX_WITH_REQUIRED_PROTOCOL, URL_PROTOCOL_REGEX, LOOSE_URL_REGEX, LOOSE_URL_WEBSITE_REGEX, MARKDOWN_URL_REGEX, };
26
+ export { URL_HOSTNAME_BOUNDARY_REGEX, URL_HOSTNAME_LABEL_REGEX, URL_WEBSITE_REGEX, URL_PATH_REGEX, URL_PARAM_REGEX, URL_FRAGMENT_REGEX, URL_REGEX, URL_REGEX_WITH_REQUIRED_PROTOCOL, URL_PROTOCOL_REGEX, URL_PORT_REGEX, LOOSE_URL_REGEX, LOOSE_URL_WEBSITE_REGEX, MARKDOWN_URL_REGEX, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expensify-common",
3
- "version": "2.0.211",
3
+ "version": "2.0.213",
4
4
  "author": "Expensify, Inc.",
5
5
  "description": "Expensify libraries and components shared across different repos",
6
6
  "homepage": "https://expensify.com",
@@ -260,7 +260,7 @@
260
260
  "devEngines": {
261
261
  "runtime": {
262
262
  "name": "node",
263
- "version": ">=24.19.0",
263
+ "version": ">=24.22.1",
264
264
  "onFail": "error"
265
265
  },
266
266
  "packageManager": {