expensify-common 2.0.212 → 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.
package/dist/CONST.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ declare const MAX_URL_HOSTNAME_LABEL_LENGTH = 63;
2
+ declare const MAX_URL_HOSTNAME_LENGTH = 253;
1
3
  /**
2
4
  * URL of our CloudFront Instance
3
5
  */
@@ -1735,4 +1737,4 @@ declare const UI: {
1735
1737
  * Set of most frequently used public domains
1736
1738
  */
1737
1739
  declare const PUBLIC_DOMAINS_SET: Set<string>;
1738
- export { g_cloudFront, g_cloudFrontImg, CONST, UI, PUBLIC_DOMAINS_SET };
1740
+ export { g_cloudFront, g_cloudFrontImg, CONST, UI, PUBLIC_DOMAINS_SET, MAX_URL_HOSTNAME_LABEL_LENGTH, MAX_URL_HOSTNAME_LENGTH };
package/dist/CONST.js CHANGED
@@ -1,8 +1,14 @@
1
1
  "use strict";
2
2
  /* eslint-disable no-useless-escape */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.PUBLIC_DOMAINS_SET = exports.UI = exports.CONST = exports.g_cloudFrontImg = exports.g_cloudFront = void 0;
4
+ exports.MAX_URL_HOSTNAME_LENGTH = exports.MAX_URL_HOSTNAME_LABEL_LENGTH = exports.PUBLIC_DOMAINS_SET = exports.UI = exports.CONST = exports.g_cloudFrontImg = exports.g_cloudFront = void 0;
5
5
  const EMAIL_BASE_REGEX = "(?=((?=[\\w'#%+-]+(?:\\.[\\w'#%+-]+)*@)[\\w\\.'#%+-]{1,64}@(?:(?=[a-z\\d]+(?:-+[a-z\\d]+)*\\.)(?:[a-z\\d-]{1,63}\\.)+[a-z]{2,63})(?= |_|\\b))(?<end>.*))\\S{3,254}(?=\\k<end>$)";
6
+ // DNS hostname labels are limited to 63 octets. URL hostnames are ASCII here, so characters and octets are equivalent.
7
+ const MAX_URL_HOSTNAME_LABEL_LENGTH = 63;
8
+ exports.MAX_URL_HOSTNAME_LABEL_LENGTH = MAX_URL_HOSTNAME_LABEL_LENGTH;
9
+ // DNS names can use 255 wire octets, which allows at most 253 visible ASCII characters without a trailing root dot.
10
+ const MAX_URL_HOSTNAME_LENGTH = 253;
11
+ exports.MAX_URL_HOSTNAME_LENGTH = MAX_URL_HOSTNAME_LENGTH;
6
12
  const MOMENT_FORMAT_STRING = 'YYYY-MM-DD';
7
13
  /**
8
14
  * URL of our CloudFront Instance
@@ -131,9 +131,16 @@ export default class ExpensiMark {
131
131
  */
132
132
  replace(text: string, { filterRules, shouldEscapeText, shouldKeepRawInput, disabledRules, extras }?: ReplaceOptions): string;
133
133
  /**
134
- * Checks matched URLs for validity and replace valid links with html elements
135
- */
136
- modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn, shouldScanForUrls?: boolean): string;
134
+ * Replaces valid URL matches and optionally narrows parsing to URL candidates.
135
+ *
136
+ * @param regex - The URL pattern used for the final match.
137
+ * @param textToCheck - The text containing possible URLs.
138
+ * @param replacement - The replacement applied to each accepted URL.
139
+ * @param shouldScanForUrls - Whether to scan URL candidates before running the regex.
140
+ * @param shouldRejectPartialHostnameMatches - Whether to mask invalid URL ranges before matching.
141
+ * @param followingText - Text after the candidate that the regex needs to distinguish URLs from emails.
142
+ */
143
+ modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn, shouldScanForUrls?: boolean, shouldRejectPartialHostnameMatches?: boolean, followingText?: string): string;
137
144
  /**
138
145
  * Checks matched Emails for validity and replace valid links with html elements
139
146
  */
@@ -59,6 +59,9 @@ const URL_TLD_LIST = tlds_1.default.toLowerCase().split('|');
59
59
  const URL_TLDS = new Set(URL_TLD_LIST);
60
60
  // Caps TLD scanning at the longest known TLD so long invalid URL-like text avoids expensive regex work.
61
61
  const MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length));
62
+ const URL_HOSTNAME_LABEL_AT_POSITION_REGEX = new RegExp(UrlPatterns.URL_HOSTNAME_LABEL_REGEX, 'iy');
63
+ const URL_HOSTNAME_BOUNDARY_AT_POSITION_REGEX = new RegExp(UrlPatterns.URL_HOSTNAME_BOUNDARY_REGEX, 'iy');
64
+ 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');
62
65
  const PROTECTED_TAG_NAMES = new Set(['a', 'code', 'pre', 'video']);
63
66
  const MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
64
67
  const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
@@ -198,58 +201,50 @@ function getProtocolAt(text, position) {
198
201
  return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol);
199
202
  }
200
203
  /**
201
- * Returns whether one dot-separated hostname label is valid.
204
+ * Finds the valid prefix of a hostname label using the shared URL label pattern.
202
205
  *
203
206
  * @param text - Candidate URL text containing the label.
204
207
  * @param start - Index of the label's first character.
205
- * @param end - Index immediately after the label's last character.
208
+ * @returns The index after the valid label prefix, or undefined when the label starts with invalid syntax.
206
209
  */
207
- function isValidHostnameLabel(text, start, end) {
208
- if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) {
209
- return false;
210
- }
211
- for (let index = start + 1; index < end - 1; index++) {
212
- if (!isAsciiAlphaNumeric(text[index]) && text[index] !== '-') {
213
- return false;
214
- }
210
+ function findValidHostnameLabelEnd(text, start) {
211
+ URL_HOSTNAME_LABEL_AT_POSITION_REGEX.lastIndex = start;
212
+ const match = URL_HOSTNAME_LABEL_AT_POSITION_REGEX.exec(text);
213
+ return match === null ? undefined : URL_HOSTNAME_LABEL_AT_POSITION_REGEX.lastIndex;
214
+ }
215
+ /**
216
+ * Finds the first character after a URL's hostname.
217
+ *
218
+ * @param text - Text containing the URL candidate.
219
+ * @param hostnameStart - Index of the hostname's first character.
220
+ * @returns The index immediately after the hostname.
221
+ */
222
+ function findHostnameEnd(text, hostnameStart) {
223
+ let hostnameEnd = hostnameStart;
224
+ while (hostnameEnd < text.length && isHostnameCharacter(text[hostnameEnd])) {
225
+ hostnameEnd++;
215
226
  }
216
- return true;
227
+ return hostnameEnd;
217
228
  }
218
229
  /**
219
- * Finds the first character of a valid hostname before the dot that starts its top-level domain.
230
+ * Finds where a separate hostname can start after punctuation inside the current hostname run.
220
231
  *
221
- * @param text - Candidate URL text containing the hostname.
222
- * @param dotPosition - Index of the dot immediately before the top-level domain.
223
- * @returns The hostname's first-character index, or undefined when no valid hostname precedes the dot.
232
+ * @param text - Text containing the hostname run.
233
+ * @param start - First index to check.
234
+ * @param end - Index immediately after the hostname run.
235
+ * @returns The next hostname start allowed by the URL pattern, or `end` when none exists.
224
236
  */
225
- function findHostnameStart(text, dotPosition) {
226
- let hostnameStart = dotPosition;
227
- let labelEnd = dotPosition;
228
- while (labelEnd > 0) {
229
- let rawLabelStart = labelEnd - 1;
230
- while (rawLabelStart >= 0 && text[rawLabelStart] !== '.' && isHostnameCharacter(text[rawLabelStart])) {
231
- rawLabelStart--;
232
- }
233
- rawLabelStart++;
234
- let labelStart = rawLabelStart;
235
- while (labelStart < labelEnd && text[labelStart] === '-') {
236
- labelStart++;
237
- }
238
- if (!isValidHostnameLabel(text, labelStart, labelEnd)) {
239
- break;
240
- }
241
- hostnameStart = labelStart;
242
- // A leading hyphen ends the hostname, but the existing URL regex can still match the valid suffix after it.
243
- if (labelStart !== rawLabelStart) {
244
- break;
237
+ function findNextHostnameStart(text, start, end) {
238
+ for (let index = start; index < end; index++) {
239
+ if (text[index - 1] !== '.' && text[index - 1] !== '-') {
240
+ continue;
245
241
  }
246
- const separatorPosition = labelStart - 1;
247
- if (separatorPosition < 0 || text[separatorPosition] !== '.') {
248
- break;
242
+ URL_HOSTNAME_BOUNDARY_AT_POSITION_REGEX.lastIndex = index;
243
+ if (URL_HOSTNAME_BOUNDARY_AT_POSITION_REGEX.test(text)) {
244
+ return index;
249
245
  }
250
- labelEnd = separatorPosition;
251
246
  }
252
- return hostnameStart === dotPosition ? undefined : hostnameStart;
247
+ return end;
253
248
  }
254
249
  /**
255
250
  * Finds the end of a known top-level domain after `dotPosition`.
@@ -273,17 +268,78 @@ function findKnownTldEnd(text, dotPosition) {
273
268
  }
274
269
  return undefined;
275
270
  }
276
- /** Expands example.com to include nearby @, *, _, or ~ in any order, plus its path, until whitespace or HTML. */
277
- function extendUrlCandidateBoundaries(text, start, end) {
271
+ /**
272
+ * Validates a hostname and records a linkable prefix before a later malformed label.
273
+ * Preserves known-TLD prefixes before oversized continuations without recovering text inside an invalid leading label.
274
+ *
275
+ * @param text - Text containing the URL candidate.
276
+ * @param hostnameStart - Index of the hostname's first character.
277
+ * @param hostnameEnd - Index immediately after the hostname.
278
+ * @param hasProtocol - Whether the URL allows hostnames without a known TLD, including single-label hosts.
279
+ * @returns The hostname validity and any prefix that can be linked before a malformed or oversized continuation.
280
+ */
281
+ function analyzeHostname(text, hostnameStart, hostnameEnd, hasProtocol) {
282
+ let hostnameContentEnd = hostnameEnd;
283
+ while (hostnameContentEnd > hostnameStart && (text[hostnameContentEnd - 1] === '.' || text[hostnameContentEnd - 1] === '-')) {
284
+ hostnameContentEnd--;
285
+ }
286
+ if (hostnameContentEnd === hostnameStart) {
287
+ return { isValid: false };
288
+ }
289
+ let labelStart = hostnameStart;
290
+ let validHostnamePrefixEnd;
291
+ let validKnownTldPrefixEnd;
292
+ for (let index = hostnameStart; index <= hostnameContentEnd; index++) {
293
+ if (index !== hostnameContentEnd && text[index] !== '.') {
294
+ continue;
295
+ }
296
+ const validLabelEnd = findValidHostnameLabelEnd(text, labelStart);
297
+ if (validLabelEnd === undefined) {
298
+ return { isValid: false, validHostnamePrefixEnd };
299
+ }
300
+ // Measure the valid label prefix before any trailing hyphens that end it.
301
+ let labelContentEnd = index;
302
+ while (labelContentEnd > labelStart && text[labelContentEnd - 1] === '-') {
303
+ labelContentEnd--;
304
+ }
305
+ if (labelContentEnd - labelStart > Constants.MAX_URL_HOSTNAME_LABEL_LENGTH || labelContentEnd - hostnameStart > Constants.MAX_URL_HOSTNAME_LENGTH) {
306
+ return { isValid: false, validHostnamePrefixEnd: validKnownTldPrefixEnd };
307
+ }
308
+ // Protocol URLs can end at any valid label; bare URLs still need a known TLD.
309
+ if (hasProtocol) {
310
+ validHostnamePrefixEnd = validLabelEnd;
311
+ }
312
+ if (validLabelEnd !== index) {
313
+ return { isValid: false, validHostnamePrefixEnd };
314
+ }
315
+ if (index !== hostnameContentEnd) {
316
+ const tldEnd = findKnownTldEnd(text, index);
317
+ if (tldEnd !== undefined && tldEnd <= hostnameContentEnd && tldEnd - hostnameStart <= Constants.MAX_URL_HOSTNAME_LENGTH) {
318
+ validKnownTldPrefixEnd = tldEnd;
319
+ if (!hasProtocol) {
320
+ validHostnamePrefixEnd = tldEnd;
321
+ }
322
+ }
323
+ }
324
+ labelStart = index + 1;
325
+ }
326
+ return { isValid: true, validHostnamePrefixEnd };
327
+ }
328
+ /** Finds the candidate start, including any adjacent @, *, _, or ~ before the URL. */
329
+ function findUrlCandidateStart(text, start) {
278
330
  let candidateStart = start;
279
331
  while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) {
280
332
  candidateStart--;
281
333
  }
334
+ return candidateStart;
335
+ }
336
+ /** Expands example.com to include nearby @, *, _, or ~ in any order, plus its path, until whitespace or HTML. */
337
+ function extendUrlCandidateBoundaries(text, start, end) {
282
338
  let candidateEnd = end;
283
339
  while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== '<') {
284
340
  candidateEnd++;
285
341
  }
286
- return { start: candidateStart, end: candidateEnd };
342
+ return { start: findUrlCandidateStart(text, start), end: candidateEnd };
287
343
  }
288
344
  /**
289
345
  * Checks whether lowercase `expected` occurs in `text` at `position`, ignoring letter case in `text`.
@@ -295,6 +351,167 @@ function extendUrlCandidateBoundaries(text, start, end) {
295
351
  function startsWithIgnoreCase(text, expected, position) {
296
352
  return text.slice(position, position + expected.length).toLowerCase() === expected;
297
353
  }
354
+ /**
355
+ * Finds the first unmatched closing parenthesis, which ends a URL rather than belonging to its path.
356
+ *
357
+ * @param text - Text containing the URL or its suffix.
358
+ * @param start - First index to inspect, inclusive.
359
+ * @param end - Last index to inspect, exclusive.
360
+ * @returns The unmatched parenthesis index, or end when all closing parentheses are paired.
361
+ */
362
+ function findUrlEndBeforeUnmatchedParenthesis(text, start, end) {
363
+ let unmatchedOpenParentheses = 0;
364
+ for (let index = start; index < end; index++) {
365
+ if (text[index] === '(') {
366
+ unmatchedOpenParentheses++;
367
+ }
368
+ else if (text[index] === ')') {
369
+ if (unmatchedOpenParentheses === 0) {
370
+ return index;
371
+ }
372
+ unmatchedOpenParentheses--;
373
+ }
374
+ }
375
+ return end;
376
+ }
377
+ /**
378
+ * Finds where a URL ends by matching its optional port, path, query, and fragment after the hostname.
379
+ *
380
+ * @param text - Text containing the URL candidate.
381
+ * @param hostnameEnd - Index immediately after the hostname.
382
+ * @returns The first index after the URL recognized by ExpensiMark.
383
+ */
384
+ function findUrlEnd(text, hostnameEnd) {
385
+ var _a;
386
+ URL_SUFFIX_AT_POSITION_REGEX.lastIndex = hostnameEnd;
387
+ const match = URL_SUFFIX_AT_POSITION_REGEX.exec(text);
388
+ return findUrlEndBeforeUnmatchedParenthesis(text, hostnameEnd, hostnameEnd + ((_a = match === null || match === void 0 ? void 0 : match[0].length) !== null && _a !== void 0 ? _a : 0));
389
+ }
390
+ /**
391
+ * Analyzes a URL candidate and protects invalid ranges while allowing separate domains after punctuation.
392
+ *
393
+ * @param text - Text containing the URL candidate.
394
+ * @param urlStart - Index where the URL starts, excluding Markdown punctuation.
395
+ * @param hostnameStart - Index where the hostname starts.
396
+ * @returns The hostname end and any invalid ranges, plus the end of the connected URL text.
397
+ */
398
+ function analyzeUrlRange(text, urlStart, hostnameStart) {
399
+ var _a;
400
+ const hostnameEnd = findHostnameEnd(text, hostnameStart);
401
+ const hostnameAnalysis = analyzeHostname(text, hostnameStart, hostnameEnd, hostnameStart !== urlStart);
402
+ if (hostnameAnalysis.isValid) {
403
+ return { hostnameEnd };
404
+ }
405
+ let invalidRangeEnd = findUrlEnd(text, hostnameEnd);
406
+ if (text[hostnameEnd] === ':') {
407
+ // The broad hostname scan can include the letters of the next protocol, as in example.com..http://valid.org.
408
+ for (const protocol of URL_PROTOCOLS) {
409
+ const protocolStart = hostnameEnd - protocol.indexOf(':');
410
+ if (protocolStart >= hostnameStart && startsWithIgnoreCase(text, protocol, protocolStart)) {
411
+ invalidRangeEnd = protocolStart;
412
+ break;
413
+ }
414
+ }
415
+ }
416
+ const invalidRanges = [];
417
+ let invalidRangeStart = (_a = hostnameAnalysis.validHostnamePrefixEnd) !== null && _a !== void 0 ? _a : urlStart;
418
+ // Use the URL pattern's boundary rule to check each separate hostname after punctuation once.
419
+ let index = findNextHostnameStart(text, hostnameStart + 1, hostnameEnd);
420
+ while (index < hostnameEnd) {
421
+ const followingHostnameStart = index;
422
+ index = findNextHostnameStart(text, index + 1, hostnameEnd);
423
+ const followingHostnameAnalysis = analyzeHostname(text, followingHostnameStart, index, false);
424
+ if (followingHostnameAnalysis.validHostnamePrefixEnd === undefined) {
425
+ continue;
426
+ }
427
+ invalidRanges.push({ start: invalidRangeStart, end: followingHostnameStart });
428
+ // Only a valid final hostname owns the path/query; malformed continuations keep their nested domains plain.
429
+ invalidRangeStart = followingHostnameAnalysis.validHostnamePrefixEnd;
430
+ if (followingHostnameAnalysis.isValid && index === hostnameEnd) {
431
+ invalidRangeStart = invalidRangeEnd;
432
+ }
433
+ }
434
+ if (invalidRangeStart < invalidRangeEnd) {
435
+ invalidRanges.push({ start: invalidRangeStart, end: invalidRangeEnd });
436
+ }
437
+ return { hostnameEnd, invalidRanges, urlEnd: invalidRangeEnd };
438
+ }
439
+ /**
440
+ * Finds the first hostname character before a known top-level domain, including invalid labels.
441
+ *
442
+ * @param text - Text containing the bare hostname.
443
+ * @param dotPosition - Index of the dot immediately before the known top-level domain.
444
+ * @returns The start of the hostname run, including leading dots and hyphens.
445
+ */
446
+ function findRawHostnameStart(text, dotPosition) {
447
+ let hostnameStart = dotPosition;
448
+ while (hostnameStart > 0 && isHostnameCharacter(text[hostnameStart - 1])) {
449
+ hostnameStart--;
450
+ }
451
+ return hostnameStart;
452
+ }
453
+ /**
454
+ * Finds URL ranges whose hostname is malformed or exceeds the supported DNS length limits.
455
+ * These ranges keep both URL parsing paths from matching domains inside invalid hostname labels or connected URL suffixes.
456
+ *
457
+ * @param text - Text containing possible protocol and bare URLs.
458
+ * @returns Connected invalid URL ranges that must remain plain text.
459
+ */
460
+ function findInvalidUrlRanges(text) {
461
+ const ranges = [];
462
+ let index = 0;
463
+ while (index < text.length) {
464
+ const matchedProtocol = getProtocolAt(text, index);
465
+ let analysis;
466
+ if (matchedProtocol) {
467
+ analysis = analyzeUrlRange(text, index, index + matchedProtocol.length);
468
+ }
469
+ else {
470
+ if (text[index] !== '.') {
471
+ index++;
472
+ continue;
473
+ }
474
+ const tldEnd = findKnownTldEnd(text, index);
475
+ if (tldEnd === undefined) {
476
+ index++;
477
+ continue;
478
+ }
479
+ const hostnameStart = findRawHostnameStart(text, index);
480
+ analysis = analyzeUrlRange(text, hostnameStart, hostnameStart);
481
+ }
482
+ if (analysis.invalidRanges) {
483
+ for (const range of analysis.invalidRanges) {
484
+ ranges.push(range);
485
+ }
486
+ index = analysis.urlEnd;
487
+ }
488
+ else {
489
+ // Skip the valid URL's suffix so domains in its path, query, or fragment are not scanned again.
490
+ index = findUrlEnd(text, analysis.hostnameEnd);
491
+ }
492
+ }
493
+ return ranges;
494
+ }
495
+ /**
496
+ * Creates the text searched by the URL regex. It replaces rejected URL ranges with spaces
497
+ * so the regex can find other URLs. The result keeps the original length because the parser
498
+ * uses the match indexes to build output from the original text.
499
+ *
500
+ * @param text - Original text being parsed for URLs.
501
+ * @param invalidRanges - Ordered, non-overlapping ranges rejected by URL validation.
502
+ * @returns A copy of `text` with those ranges replaced by spaces.
503
+ */
504
+ function maskInvalidUrlRanges(text, invalidRanges) {
505
+ const parts = [];
506
+ let start = 0;
507
+ for (const range of invalidRanges) {
508
+ // Output still comes from the original text; spaces only stop regexes from crossing invalid ranges.
509
+ parts.push(text.slice(start, range.start), ' '.repeat(range.end - range.start));
510
+ start = range.end;
511
+ }
512
+ parts.push(text.slice(start));
513
+ return parts.join('');
514
+ }
298
515
  /**
299
516
  * Removes candidates that the original full-text URL regex would reject because of later HTML.
300
517
  * It scans right to left so each candidate can use the nearest later HTML boundary without copying a long suffix.
@@ -348,7 +565,7 @@ function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) {
348
565
  }
349
566
  return validCandidates.reverse();
350
567
  }
351
- /** Finds possible URL ranges, skips URL-looking text inside protected tags, and leaves final validity to the existing regex. */
568
+ /** Finds possible URL ranges, skips URL-looking text inside protected tags, rejects malformed or oversized hostnames, and leaves remaining validation to the existing regex. */
352
569
  function findUrlCandidates(text) {
353
570
  const candidates = [];
354
571
  const protectedTags = [];
@@ -369,25 +586,43 @@ function findUrlCandidates(text) {
369
586
  continue;
370
587
  }
371
588
  const matchedProtocol = getProtocolAt(text, index);
589
+ let urlStart = index;
590
+ let candidateEnd;
372
591
  if (matchedProtocol) {
373
- const candidate = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length);
374
- candidates.push(candidate);
375
- index = candidate.end;
376
- continue;
592
+ candidateEnd = index + matchedProtocol.length;
377
593
  }
378
- if (text[index] !== '.') {
379
- index++;
380
- continue;
594
+ else {
595
+ if (text[index] !== '.') {
596
+ index++;
597
+ continue;
598
+ }
599
+ const tldEnd = findKnownTldEnd(text, index);
600
+ if (tldEnd === undefined) {
601
+ index++;
602
+ continue;
603
+ }
604
+ urlStart = findRawHostnameStart(text, index);
605
+ candidateEnd = tldEnd;
381
606
  }
382
- const tldEnd = findKnownTldEnd(text, index);
383
- const hostnameStart = tldEnd === undefined ? undefined : findHostnameStart(text, index);
384
- if (tldEnd === undefined || hostnameStart === undefined) {
385
- index++;
607
+ const analysis = analyzeUrlRange(text, urlStart, matchedProtocol ? candidateEnd : urlStart);
608
+ if (analysis.invalidRanges) {
609
+ // Match valid portions separately so long rejected gaps never reach the full URL regex.
610
+ let candidateStart = urlStart;
611
+ for (const range of analysis.invalidRanges) {
612
+ if (candidateStart < range.start) {
613
+ candidates.push({ start: findUrlCandidateStart(text, candidateStart), end: range.start });
614
+ }
615
+ candidateStart = range.end;
616
+ }
617
+ if (candidateStart < analysis.urlEnd) {
618
+ candidates.push({ start: findUrlCandidateStart(text, candidateStart), end: analysis.urlEnd });
619
+ }
620
+ index = analysis.urlEnd;
386
621
  continue;
387
622
  }
388
- const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd);
389
- candidates.push(candidate);
390
- index = candidate.end;
623
+ const broadCandidate = extendUrlCandidateBoundaries(text, urlStart, candidateEnd);
624
+ candidates.push(broadCandidate);
625
+ index = broadCandidate.end;
391
626
  }
392
627
  return filterUrlCandidatesBlockedByFollowingHtml(text, candidates);
393
628
  }
@@ -944,7 +1179,7 @@ class ExpensiMark {
944
1179
  process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => {
945
1180
  const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>))`, 'gi');
946
1181
  // Raw HTML depends on complete-text lookaheads. Text without user-provided HTML can safely use the faster candidate scanner.
947
- return this.modifyTextForUrlLinks(regex, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText));
1182
+ return this.modifyTextForUrlLinks(regex, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText), true);
948
1183
  },
949
1184
  replacement: (_extras, _match, g1, g2) => {
950
1185
  const href = str_1.default.sanitizeURL(g2);
@@ -1512,9 +1747,16 @@ class ExpensiMark {
1512
1747
  return restoreVictoryChartTags(replacedText, victoryChartTags);
1513
1748
  }
1514
1749
  /**
1515
- * Checks matched URLs for validity and replace valid links with html elements
1750
+ * Replaces valid URL matches and optionally narrows parsing to URL candidates.
1751
+ *
1752
+ * @param regex - The URL pattern used for the final match.
1753
+ * @param textToCheck - The text containing possible URLs.
1754
+ * @param replacement - The replacement applied to each accepted URL.
1755
+ * @param shouldScanForUrls - Whether to scan URL candidates before running the regex.
1756
+ * @param shouldRejectPartialHostnameMatches - Whether to mask invalid URL ranges before matching.
1757
+ * @param followingText - Text after the candidate that the regex needs to distinguish URLs from emails.
1516
1758
  */
1517
- modifyTextForUrlLinks(regex, textToCheck, replacement, shouldScanForUrls = false) {
1759
+ modifyTextForUrlLinks(regex, textToCheck, replacement, shouldScanForUrls = false, shouldRejectPartialHostnameMatches = false, followingText = '') {
1518
1760
  if (shouldScanForUrls) {
1519
1761
  const candidates = findUrlCandidates(textToCheck);
1520
1762
  if (candidates.length === 0) {
@@ -1525,36 +1767,42 @@ class ExpensiMark {
1525
1767
  let outputStart = 0;
1526
1768
  for (const { start, end } of candidates) {
1527
1769
  const candidate = textToCheck.slice(start, end);
1770
+ // A shortened candidate can end at an email's local part, so keep its domain visible to the regex.
1771
+ const contextEnd = textToCheck[end] === '@' ? findHostnameEnd(textToCheck, end + 1) : end;
1528
1772
  candidateRegex.lastIndex = 0;
1529
1773
  output.push(textToCheck.slice(outputStart, start));
1530
- output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement));
1774
+ output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement, false, shouldRejectPartialHostnameMatches, textToCheck.slice(end, contextEnd)));
1531
1775
  outputStart = end;
1532
1776
  }
1533
1777
  output.push(textToCheck.slice(outputStart));
1534
1778
  return output.join('');
1535
1779
  }
1536
- let match = regex.exec(textToCheck);
1780
+ const matchingRegex = regex;
1781
+ const textWithContext = textToCheck + followingText;
1782
+ const invalidUrlRanges = shouldRejectPartialHostnameMatches ? findInvalidUrlRanges(textWithContext) : [];
1783
+ const maskedText = invalidUrlRanges.length > 0 ? maskInvalidUrlRanges(textWithContext, invalidUrlRanges) : textWithContext;
1784
+ const textToMatch = maskedText.slice(0, textToCheck.length);
1785
+ const contextRegex = followingText ? new RegExp(regex.source, regex.flags.replace('g', 'y')) : undefined;
1786
+ let match = matchingRegex.exec(textToMatch);
1537
1787
  let replacedText = '';
1538
1788
  let startIndex = 0;
1539
1789
  while (match !== null) {
1540
- // 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
1541
- // and can be part of markdown for example
1542
- let unmatchedOpenParentheses = 0;
1543
- let url = match[2];
1544
- for (let i = 0; i < url.length; i++) {
1545
- if (url[i] === '(') {
1546
- unmatchedOpenParentheses++;
1547
- }
1548
- else if (url[i] === ')') {
1549
- // Unmatched closing parenthesis
1550
- if (unmatchedOpenParentheses <= 0) {
1551
- const numberOfCharsToRemove = url.length - i;
1552
- match[0] = match[0].substr(0, match[0].length - numberOfCharsToRemove);
1553
- url = url.substr(0, url.length - numberOfCharsToRemove);
1554
- break;
1555
- }
1556
- unmatchedOpenParentheses--;
1790
+ if (contextRegex && match.index + match[0].length === textToCheck.length) {
1791
+ // Recheck only this match with its following text, without searching outside the candidate.
1792
+ contextRegex.lastIndex = match.index;
1793
+ const contextMatch = contextRegex.exec(maskedText);
1794
+ if (contextMatch === null) {
1795
+ match = matchingRegex.exec(textToMatch);
1796
+ continue;
1557
1797
  }
1798
+ match = contextMatch;
1799
+ matchingRegex.lastIndex = contextRegex.lastIndex;
1800
+ }
1801
+ let url = match[2];
1802
+ const urlEnd = findUrlEndBeforeUnmatchedParenthesis(url, 0, url.length);
1803
+ if (urlEnd < url.length) {
1804
+ match[0] = match[0].slice(0, match[0].length - (url.length - urlEnd));
1805
+ url = url.slice(0, urlEnd);
1558
1806
  }
1559
1807
  // Because we are removing ) parenthesis, some special characters that shouldn't be in the href are in the href
1560
1808
  // 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
@@ -1617,7 +1865,7 @@ class ExpensiMark {
1617
1865
  }
1618
1866
  startIndex = match.index + match[0].length;
1619
1867
  // Now we move to the next match that the js regex found in the text
1620
- match = regex.exec(textToCheck);
1868
+ match = matchingRegex.exec(textToMatch);
1621
1869
  }
1622
1870
  if (startIndex < textToCheck.length) {
1623
1871
  replacedText = replacedText.concat(textToCheck.substr(startIndex));