expensify-common 2.0.205 → 2.0.207

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.
@@ -24,7 +24,7 @@ type Extras = {
24
24
  export type { Extras };
25
25
  type ReplacementFn = (extras: Extras, ...matches: string[]) => string;
26
26
  type Replacement = ReplacementFn | string;
27
- type ProcessFn = (textToProcess: string, replacement: Replacement, shouldKeepRawInput: boolean) => string;
27
+ type ProcessFn = (textToProcess: string, replacement: Replacement, shouldKeepRawInput: boolean, shouldEscapeText: boolean) => string;
28
28
  type CommonRule = {
29
29
  name: string;
30
30
  replacement: Replacement;
@@ -41,6 +41,7 @@ const str_1 = __importDefault(require("./str"));
41
41
  const Constants = __importStar(require("./CONST"));
42
42
  const UrlPatterns = __importStar(require("./Url"));
43
43
  const Logger_1 = __importDefault(require("./Logger"));
44
+ const tlds_1 = __importDefault(require("./tlds"));
44
45
  const Utils = __importStar(require("./utils"));
45
46
  const EXTRAS_DEFAULT = {};
46
47
  // These constants represent the ASCII ranges for digits (0-9) and letters (A-Z, a-z).
@@ -54,6 +55,10 @@ const ASCII_WHITESPACE_END = ' '.charCodeAt(0);
54
55
  const NON_BREAKING_SPACE_CODE = 160;
55
56
  const URL_PROTOCOLS = ['https://', 'http://', 'ftps://', 'ftp://'];
56
57
  const URL_CANDIDATE_PREFIX_CHARACTERS = '@_*~';
58
+ const URL_TLD_LIST = tlds_1.default.toLowerCase().split('|');
59
+ const URL_TLDS = new Set(URL_TLD_LIST);
60
+ // Caps TLD scanning at the longest known TLD so long invalid URL-like text avoids expensive regex work.
61
+ const MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length));
57
62
  const PROTECTED_TAG_NAMES = new Set(['a', 'code', 'pre', 'video']);
58
63
  const MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
59
64
  const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
@@ -99,6 +104,16 @@ function replaceTextWithExtras(text, regexp, extras, replacement) {
99
104
  }
100
105
  return text.replace(regexp, replacement);
101
106
  }
107
+ /**
108
+ * Returns whether `text` can use optimized candidate scanning instead of full-text parsing.
109
+ *
110
+ * @param text - Text to check.
111
+ * @param shouldEscapeText - Whether HTML characters are escaped before parsing.
112
+ * @returns Whether candidate scanning can be used safely.
113
+ */
114
+ function canUseCandidateScanning(text, shouldEscapeText) {
115
+ return shouldEscapeText || (!text.includes('<') && !text.includes('>'));
116
+ }
102
117
  /** Returns whether the character is an ASCII letter or digit. */
103
118
  function isAsciiAlphaNumeric(character) {
104
119
  if (!character) {
@@ -182,16 +197,81 @@ function getProtocolAt(text, position) {
182
197
  }
183
198
  return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol);
184
199
  }
185
- /** Reads the text after the dot in example.com and returns where the hostname ends. */
186
- function findHostnameEnd(text, hostnameStart, dotPosition) {
187
- let hostnameEnd = dotPosition + 1;
188
- while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === '-')) {
189
- hostnameEnd++;
200
+ /**
201
+ * Returns whether one dot-separated hostname label is valid.
202
+ *
203
+ * @param text - Candidate URL text containing the label.
204
+ * @param start - Index of the label's first character.
205
+ * @param end - Index immediately after the label's last character.
206
+ */
207
+ function isValidHostnameLabel(text, start, end) {
208
+ if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) {
209
+ return false;
190
210
  }
191
- if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) {
192
- return undefined;
211
+ for (let index = start + 1; index < end - 1; index++) {
212
+ if (!isAsciiAlphaNumeric(text[index]) && text[index] !== '-') {
213
+ return false;
214
+ }
215
+ }
216
+ return true;
217
+ }
218
+ /**
219
+ * Finds the first character of a valid hostname before the dot that starts its top-level domain.
220
+ *
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.
224
+ */
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;
245
+ }
246
+ const separatorPosition = labelStart - 1;
247
+ if (separatorPosition < 0 || text[separatorPosition] !== '.') {
248
+ break;
249
+ }
250
+ labelEnd = separatorPosition;
251
+ }
252
+ return hostnameStart === dotPosition ? undefined : hostnameStart;
253
+ }
254
+ /**
255
+ * Finds the end of a known top-level domain after `dotPosition`.
256
+ *
257
+ * @param text - Candidate URL text.
258
+ * @param dotPosition - Index of the dot immediately before the top-level domain.
259
+ * @returns The index immediately after the top-level domain, or undefined when it is not known.
260
+ */
261
+ function findKnownTldEnd(text, dotPosition) {
262
+ const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH);
263
+ for (let end = dotPosition + 2; end <= maximumEnd; end++) {
264
+ const currentCharacter = text[end - 1];
265
+ if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== '-') {
266
+ break;
267
+ }
268
+ const nextCharacter = text[end];
269
+ const hasValidBoundary = !nextCharacter || nextCharacter === ':' || nextCharacter === '_' || !isWordCharacter(nextCharacter);
270
+ if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) {
271
+ return end;
272
+ }
193
273
  }
194
- return hostnameEnd;
274
+ return undefined;
195
275
  }
196
276
  /** Expands example.com to include nearby @, *, _, or ~ in any order, plus its path, until whitespace or HTML. */
197
277
  function extendUrlCandidateBoundaries(text, start, end) {
@@ -205,25 +285,87 @@ function extendUrlCandidateBoundaries(text, start, end) {
205
285
  }
206
286
  return { start: candidateStart, end: candidateEnd };
207
287
  }
208
- /** Finds possible URL ranges, skips URL-looking text inside protected tags, and leaves validity to the existing regex. */
288
+ /**
289
+ * Checks whether lowercase `expected` occurs in `text` at `position`, ignoring letter case in `text`.
290
+ *
291
+ * @param text - Text to check.
292
+ * @param expected - Lowercase string expected at `position`.
293
+ * @param position - Index where the comparison begins.
294
+ */
295
+ function startsWithIgnoreCase(text, expected, position) {
296
+ return text.slice(position, position + expected.length).toLowerCase() === expected;
297
+ }
298
+ /**
299
+ * Removes candidates that the original full-text URL regex would reject because of later HTML.
300
+ * It scans right to left so each candidate can use the nearest later HTML boundary without copying a long suffix.
301
+ *
302
+ * @param text - Text containing candidate URLs and later HTML boundaries.
303
+ * @param candidates - URL candidates ordered by their position in `text`.
304
+ * @returns Candidates that keep candidate-scanning output compatible with full-text parsing.
305
+ */
306
+ function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) {
307
+ var _a;
308
+ if (candidates.length === 0 || (!text.includes('<') && !text.includes('>'))) {
309
+ return candidates;
310
+ }
311
+ const validCandidates = [];
312
+ let candidateIndex = candidates.length - 1;
313
+ let nextLessThan = text.length;
314
+ let nextGreaterThan = text.length;
315
+ let nextOpeningAnchor = text.length;
316
+ let nextClosingAnchor = text.length;
317
+ for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) {
318
+ if (text[index] === '<') {
319
+ nextLessThan = index;
320
+ if (((_a = text[index + 1]) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'a') {
321
+ nextOpeningAnchor = index;
322
+ }
323
+ else if (startsWithIgnoreCase(text, '</a>', index)) {
324
+ nextClosingAnchor = index;
325
+ }
326
+ }
327
+ else if (text[index] === '>') {
328
+ nextGreaterThan = index;
329
+ }
330
+ while (candidateIndex >= 0 && candidates[candidateIndex].end === index) {
331
+ // Match the original URL regex when later HTML changes whether this candidate is valid.
332
+ const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith('</', nextLessThan) && !startsWithIgnoreCase(text, '</h1>', nextLessThan);
333
+ const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, '</pre>', nextLessThan) || startsWithIgnoreCase(text, '</code>', nextLessThan);
334
+ const isBlockedByFollowingHtml =
335
+ // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`.
336
+ nextGreaterThan < nextLessThan ||
337
+ // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than `</h1>`.
338
+ firstHtmlBoundaryIsClosingTag ||
339
+ // Mirrors `((?:(?!<a).)+)?<\/a>`: reject `</a>` unless another `<a>` appears first.
340
+ nextClosingAnchor < nextOpeningAnchor ||
341
+ // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag.
342
+ firstTagIsProtectedClosingTag;
343
+ if (!isBlockedByFollowingHtml) {
344
+ validCandidates.push(candidates[candidateIndex]);
345
+ }
346
+ candidateIndex--;
347
+ }
348
+ }
349
+ return validCandidates.reverse();
350
+ }
351
+ /** Finds possible URL ranges, skips URL-looking text inside protected tags, and leaves final validity to the existing regex. */
209
352
  function findUrlCandidates(text) {
210
353
  const candidates = [];
211
354
  const protectedTags = [];
212
355
  let index = 0;
213
- let hostnameRunStart = 0;
214
356
  while (index < text.length) {
215
357
  if (text[index] === '<') {
216
358
  const nextIndex = updateProtectedTagStack(text, index, protectedTags);
217
359
  if (nextIndex === undefined) {
218
- break;
360
+ // An incomplete tag is plain text, so skip only `<` and keep scanning for later URLs.
361
+ index++;
362
+ continue;
219
363
  }
220
364
  index = nextIndex;
221
- hostnameRunStart = index;
222
365
  continue;
223
366
  }
224
367
  if (protectedTags.length > 0) {
225
368
  index++;
226
- hostnameRunStart = index;
227
369
  continue;
228
370
  }
229
371
  const matchedProtocol = getProtocolAt(text, index);
@@ -231,29 +373,23 @@ function findUrlCandidates(text) {
231
373
  const candidate = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length);
232
374
  candidates.push(candidate);
233
375
  index = candidate.end;
234
- hostnameRunStart = candidate.end;
235
- continue;
236
- }
237
- if (!isHostnameCharacter(text[index])) {
238
- hostnameRunStart = index + 1;
239
- index++;
240
376
  continue;
241
377
  }
242
378
  if (text[index] !== '.') {
243
379
  index++;
244
380
  continue;
245
381
  }
246
- const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index);
247
- if (hostnameEnd === undefined) {
382
+ const tldEnd = findKnownTldEnd(text, index);
383
+ const hostnameStart = tldEnd === undefined ? undefined : findHostnameStart(text, index);
384
+ if (tldEnd === undefined || hostnameStart === undefined) {
248
385
  index++;
249
386
  continue;
250
387
  }
251
- const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd);
388
+ const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd);
252
389
  candidates.push(candidate);
253
390
  index = candidate.end;
254
- hostnameRunStart = candidate.end;
255
391
  }
256
- return candidates;
392
+ return filterUrlCandidatesBlockedByFollowingHtml(text, candidates);
257
393
  }
258
394
  /** Finds possible bold or strikethrough pairs, preserves marker order inside protected tags, and runs the existing regex only on each candidate. */
259
395
  function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) {
@@ -329,6 +465,22 @@ function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) {
329
465
  output.push(text.slice(outputStart));
330
466
  return output.join('');
331
467
  }
468
+ /**
469
+ * Creates the common processor for bold and strikethrough Markdown rules.
470
+ *
471
+ * @param regex - Rule regex used to validate a Markdown candidate.
472
+ * @param marker - Markdown marker used to find candidate ranges.
473
+ * @param canOpen - Checks whether a marker can start a Markdown range.
474
+ * @returns A processor that uses candidate scanning when safe and the original regex otherwise.
475
+ */
476
+ function processMarkdownRule(regex, marker, canOpen) {
477
+ return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => {
478
+ if (canUseCandidateScanning(textToProcess, shouldEscapeText)) {
479
+ return replaceMarkdownCandidates(textToProcess, regex, replacement, marker, canOpen);
480
+ }
481
+ return replaceTextWithExtras(textToProcess, regex, EXTRAS_DEFAULT, replacement);
482
+ };
483
+ }
332
484
  /**
333
485
  * replace block element with '\n' if :
334
486
  * 1. We have text within the element.
@@ -789,9 +941,10 @@ class ExpensiMark {
789
941
  */
790
942
  {
791
943
  name: 'autolink',
792
- process: (textToProcess, replacement) => {
944
+ process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => {
793
945
  const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>))`, 'gi');
794
- return this.modifyTextForUrlLinks(regex, textToProcess, replacement, true);
946
+ // 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));
795
948
  },
796
949
  replacement: (_extras, _match, g1, g2) => {
797
950
  const href = str_1.default.sanitizeURL(g2);
@@ -862,6 +1015,7 @@ class ExpensiMark {
862
1015
  regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!<a).)+)?<\\/a>|[^<>]*<\\/(?!em|h1|blockquote))`, 'gim'),
863
1016
  replacement: '$1<a href="mailto:$2">$2</a>',
864
1017
  rawInputReplacement: '$1<a href="mailto:$2" data-raw-href="$2" data-link-variant="auto">$2</a>',
1018
+ shouldSkipProcessing: (textToCheck) => !textToCheck.includes('@'),
865
1019
  },
866
1020
  /**
867
1021
  * This regex matches a short user mention in a string.
@@ -897,7 +1051,7 @@ class ExpensiMark {
897
1051
  // \B will match everything that \b doesn't, so it works
898
1052
  // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb
899
1053
  name: 'bold',
900
- process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, '*', canOpenBoldMarkdown),
1054
+ process: processMarkdownRule(BOLD_MARKDOWN_REGEX, '*', canOpenBoldMarkdown),
901
1055
  replacement: (_extras, match, g1, g2) => {
902
1056
  if (g1.includes('_')) {
903
1057
  return `${g1}<strong>${g2}</strong>`;
@@ -907,7 +1061,7 @@ class ExpensiMark {
907
1061
  },
908
1062
  {
909
1063
  name: 'strikethrough',
910
- process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, '~', canOpenStrikethroughMarkdown),
1064
+ process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, '~', canOpenStrikethroughMarkdown),
911
1065
  replacement: (_extras, match, g1) => (g1.includes('</pre>') || containsNonPairTag(g1) ? match : `<del>${g1}</del>`),
912
1066
  },
913
1067
  {
@@ -1330,7 +1484,7 @@ class ExpensiMark {
1330
1484
  }
1331
1485
  const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement;
1332
1486
  if ('process' in rule) {
1333
- replacedText = rule.process(replacedText, replacement, shouldKeepRawInput);
1487
+ replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText);
1334
1488
  }
1335
1489
  else {
1336
1490
  replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement);
@@ -24,7 +24,7 @@ type Extras = {
24
24
  export type { Extras };
25
25
  type ReplacementFn = (extras: Extras, ...matches: string[]) => string;
26
26
  type Replacement = ReplacementFn | string;
27
- type ProcessFn = (textToProcess: string, replacement: Replacement, shouldKeepRawInput: boolean) => string;
27
+ type ProcessFn = (textToProcess: string, replacement: Replacement, shouldKeepRawInput: boolean, shouldEscapeText: boolean) => string;
28
28
  type CommonRule = {
29
29
  name: string;
30
30
  replacement: Replacement;
@@ -3,6 +3,7 @@ import Str from './str';
3
3
  import * as Constants from './CONST';
4
4
  import * as UrlPatterns from './Url';
5
5
  import Logger from './Logger';
6
+ import TLD_REGEX from './tlds';
6
7
  import * as Utils from './utils';
7
8
  const EXTRAS_DEFAULT = {};
8
9
  // These constants represent the ASCII ranges for digits (0-9) and letters (A-Z, a-z).
@@ -16,6 +17,10 @@ const ASCII_WHITESPACE_END = ' '.charCodeAt(0);
16
17
  const NON_BREAKING_SPACE_CODE = 160;
17
18
  const URL_PROTOCOLS = ['https://', 'http://', 'ftps://', 'ftp://'];
18
19
  const URL_CANDIDATE_PREFIX_CHARACTERS = '@_*~';
20
+ const URL_TLD_LIST = TLD_REGEX.toLowerCase().split('|');
21
+ const URL_TLDS = new Set(URL_TLD_LIST);
22
+ // Caps TLD scanning at the longest known TLD so long invalid URL-like text avoids expensive regex work.
23
+ const MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length));
19
24
  const PROTECTED_TAG_NAMES = new Set(['a', 'code', 'pre', 'video']);
20
25
  const MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
21
26
  const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
@@ -61,6 +66,16 @@ function replaceTextWithExtras(text, regexp, extras, replacement) {
61
66
  }
62
67
  return text.replace(regexp, replacement);
63
68
  }
69
+ /**
70
+ * Returns whether `text` can use optimized candidate scanning instead of full-text parsing.
71
+ *
72
+ * @param text - Text to check.
73
+ * @param shouldEscapeText - Whether HTML characters are escaped before parsing.
74
+ * @returns Whether candidate scanning can be used safely.
75
+ */
76
+ function canUseCandidateScanning(text, shouldEscapeText) {
77
+ return shouldEscapeText || (!text.includes('<') && !text.includes('>'));
78
+ }
64
79
  /** Returns whether the character is an ASCII letter or digit. */
65
80
  function isAsciiAlphaNumeric(character) {
66
81
  if (!character) {
@@ -144,16 +159,81 @@ function getProtocolAt(text, position) {
144
159
  }
145
160
  return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol);
146
161
  }
147
- /** Reads the text after the dot in example.com and returns where the hostname ends. */
148
- function findHostnameEnd(text, hostnameStart, dotPosition) {
149
- let hostnameEnd = dotPosition + 1;
150
- while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === '-')) {
151
- hostnameEnd++;
162
+ /**
163
+ * Returns whether one dot-separated hostname label is valid.
164
+ *
165
+ * @param text - Candidate URL text containing the label.
166
+ * @param start - Index of the label's first character.
167
+ * @param end - Index immediately after the label's last character.
168
+ */
169
+ function isValidHostnameLabel(text, start, end) {
170
+ if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) {
171
+ return false;
152
172
  }
153
- if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) {
154
- return undefined;
173
+ for (let index = start + 1; index < end - 1; index++) {
174
+ if (!isAsciiAlphaNumeric(text[index]) && text[index] !== '-') {
175
+ return false;
176
+ }
177
+ }
178
+ return true;
179
+ }
180
+ /**
181
+ * Finds the first character of a valid hostname before the dot that starts its top-level domain.
182
+ *
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.
186
+ */
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;
207
+ }
208
+ const separatorPosition = labelStart - 1;
209
+ if (separatorPosition < 0 || text[separatorPosition] !== '.') {
210
+ break;
211
+ }
212
+ labelEnd = separatorPosition;
213
+ }
214
+ return hostnameStart === dotPosition ? undefined : hostnameStart;
215
+ }
216
+ /**
217
+ * Finds the end of a known top-level domain after `dotPosition`.
218
+ *
219
+ * @param text - Candidate URL text.
220
+ * @param dotPosition - Index of the dot immediately before the top-level domain.
221
+ * @returns The index immediately after the top-level domain, or undefined when it is not known.
222
+ */
223
+ function findKnownTldEnd(text, dotPosition) {
224
+ const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH);
225
+ for (let end = dotPosition + 2; end <= maximumEnd; end++) {
226
+ const currentCharacter = text[end - 1];
227
+ if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== '-') {
228
+ break;
229
+ }
230
+ const nextCharacter = text[end];
231
+ const hasValidBoundary = !nextCharacter || nextCharacter === ':' || nextCharacter === '_' || !isWordCharacter(nextCharacter);
232
+ if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) {
233
+ return end;
234
+ }
155
235
  }
156
- return hostnameEnd;
236
+ return undefined;
157
237
  }
158
238
  /** Expands example.com to include nearby @, *, _, or ~ in any order, plus its path, until whitespace or HTML. */
159
239
  function extendUrlCandidateBoundaries(text, start, end) {
@@ -167,25 +247,87 @@ function extendUrlCandidateBoundaries(text, start, end) {
167
247
  }
168
248
  return { start: candidateStart, end: candidateEnd };
169
249
  }
170
- /** Finds possible URL ranges, skips URL-looking text inside protected tags, and leaves validity to the existing regex. */
250
+ /**
251
+ * Checks whether lowercase `expected` occurs in `text` at `position`, ignoring letter case in `text`.
252
+ *
253
+ * @param text - Text to check.
254
+ * @param expected - Lowercase string expected at `position`.
255
+ * @param position - Index where the comparison begins.
256
+ */
257
+ function startsWithIgnoreCase(text, expected, position) {
258
+ return text.slice(position, position + expected.length).toLowerCase() === expected;
259
+ }
260
+ /**
261
+ * Removes candidates that the original full-text URL regex would reject because of later HTML.
262
+ * It scans right to left so each candidate can use the nearest later HTML boundary without copying a long suffix.
263
+ *
264
+ * @param text - Text containing candidate URLs and later HTML boundaries.
265
+ * @param candidates - URL candidates ordered by their position in `text`.
266
+ * @returns Candidates that keep candidate-scanning output compatible with full-text parsing.
267
+ */
268
+ function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) {
269
+ var _a;
270
+ if (candidates.length === 0 || (!text.includes('<') && !text.includes('>'))) {
271
+ return candidates;
272
+ }
273
+ const validCandidates = [];
274
+ let candidateIndex = candidates.length - 1;
275
+ let nextLessThan = text.length;
276
+ let nextGreaterThan = text.length;
277
+ let nextOpeningAnchor = text.length;
278
+ let nextClosingAnchor = text.length;
279
+ for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) {
280
+ if (text[index] === '<') {
281
+ nextLessThan = index;
282
+ if (((_a = text[index + 1]) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'a') {
283
+ nextOpeningAnchor = index;
284
+ }
285
+ else if (startsWithIgnoreCase(text, '</a>', index)) {
286
+ nextClosingAnchor = index;
287
+ }
288
+ }
289
+ else if (text[index] === '>') {
290
+ nextGreaterThan = index;
291
+ }
292
+ while (candidateIndex >= 0 && candidates[candidateIndex].end === index) {
293
+ // Match the original URL regex when later HTML changes whether this candidate is valid.
294
+ const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith('</', nextLessThan) && !startsWithIgnoreCase(text, '</h1>', nextLessThan);
295
+ const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, '</pre>', nextLessThan) || startsWithIgnoreCase(text, '</code>', nextLessThan);
296
+ const isBlockedByFollowingHtml =
297
+ // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`.
298
+ nextGreaterThan < nextLessThan ||
299
+ // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than `</h1>`.
300
+ firstHtmlBoundaryIsClosingTag ||
301
+ // Mirrors `((?:(?!<a).)+)?<\/a>`: reject `</a>` unless another `<a>` appears first.
302
+ nextClosingAnchor < nextOpeningAnchor ||
303
+ // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag.
304
+ firstTagIsProtectedClosingTag;
305
+ if (!isBlockedByFollowingHtml) {
306
+ validCandidates.push(candidates[candidateIndex]);
307
+ }
308
+ candidateIndex--;
309
+ }
310
+ }
311
+ return validCandidates.reverse();
312
+ }
313
+ /** Finds possible URL ranges, skips URL-looking text inside protected tags, and leaves final validity to the existing regex. */
171
314
  function findUrlCandidates(text) {
172
315
  const candidates = [];
173
316
  const protectedTags = [];
174
317
  let index = 0;
175
- let hostnameRunStart = 0;
176
318
  while (index < text.length) {
177
319
  if (text[index] === '<') {
178
320
  const nextIndex = updateProtectedTagStack(text, index, protectedTags);
179
321
  if (nextIndex === undefined) {
180
- break;
322
+ // An incomplete tag is plain text, so skip only `<` and keep scanning for later URLs.
323
+ index++;
324
+ continue;
181
325
  }
182
326
  index = nextIndex;
183
- hostnameRunStart = index;
184
327
  continue;
185
328
  }
186
329
  if (protectedTags.length > 0) {
187
330
  index++;
188
- hostnameRunStart = index;
189
331
  continue;
190
332
  }
191
333
  const matchedProtocol = getProtocolAt(text, index);
@@ -193,29 +335,23 @@ function findUrlCandidates(text) {
193
335
  const candidate = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length);
194
336
  candidates.push(candidate);
195
337
  index = candidate.end;
196
- hostnameRunStart = candidate.end;
197
- continue;
198
- }
199
- if (!isHostnameCharacter(text[index])) {
200
- hostnameRunStart = index + 1;
201
- index++;
202
338
  continue;
203
339
  }
204
340
  if (text[index] !== '.') {
205
341
  index++;
206
342
  continue;
207
343
  }
208
- const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index);
209
- if (hostnameEnd === undefined) {
344
+ const tldEnd = findKnownTldEnd(text, index);
345
+ const hostnameStart = tldEnd === undefined ? undefined : findHostnameStart(text, index);
346
+ if (tldEnd === undefined || hostnameStart === undefined) {
210
347
  index++;
211
348
  continue;
212
349
  }
213
- const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd);
350
+ const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd);
214
351
  candidates.push(candidate);
215
352
  index = candidate.end;
216
- hostnameRunStart = candidate.end;
217
353
  }
218
- return candidates;
354
+ return filterUrlCandidatesBlockedByFollowingHtml(text, candidates);
219
355
  }
220
356
  /** Finds possible bold or strikethrough pairs, preserves marker order inside protected tags, and runs the existing regex only on each candidate. */
221
357
  function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) {
@@ -291,6 +427,22 @@ function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) {
291
427
  output.push(text.slice(outputStart));
292
428
  return output.join('');
293
429
  }
430
+ /**
431
+ * Creates the common processor for bold and strikethrough Markdown rules.
432
+ *
433
+ * @param regex - Rule regex used to validate a Markdown candidate.
434
+ * @param marker - Markdown marker used to find candidate ranges.
435
+ * @param canOpen - Checks whether a marker can start a Markdown range.
436
+ * @returns A processor that uses candidate scanning when safe and the original regex otherwise.
437
+ */
438
+ function processMarkdownRule(regex, marker, canOpen) {
439
+ return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => {
440
+ if (canUseCandidateScanning(textToProcess, shouldEscapeText)) {
441
+ return replaceMarkdownCandidates(textToProcess, regex, replacement, marker, canOpen);
442
+ }
443
+ return replaceTextWithExtras(textToProcess, regex, EXTRAS_DEFAULT, replacement);
444
+ };
445
+ }
294
446
  /**
295
447
  * replace block element with '\n' if :
296
448
  * 1. We have text within the element.
@@ -751,9 +903,10 @@ class ExpensiMark {
751
903
  */
752
904
  {
753
905
  name: 'autolink',
754
- process: (textToProcess, replacement) => {
906
+ process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => {
755
907
  const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>))`, 'gi');
756
- return this.modifyTextForUrlLinks(regex, textToProcess, replacement, true);
908
+ // 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));
757
910
  },
758
911
  replacement: (_extras, _match, g1, g2) => {
759
912
  const href = Str.sanitizeURL(g2);
@@ -824,6 +977,7 @@ class ExpensiMark {
824
977
  regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!<a).)+)?<\\/a>|[^<>]*<\\/(?!em|h1|blockquote))`, 'gim'),
825
978
  replacement: '$1<a href="mailto:$2">$2</a>',
826
979
  rawInputReplacement: '$1<a href="mailto:$2" data-raw-href="$2" data-link-variant="auto">$2</a>',
980
+ shouldSkipProcessing: (textToCheck) => !textToCheck.includes('@'),
827
981
  },
828
982
  /**
829
983
  * This regex matches a short user mention in a string.
@@ -859,7 +1013,7 @@ class ExpensiMark {
859
1013
  // \B will match everything that \b doesn't, so it works
860
1014
  // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb
861
1015
  name: 'bold',
862
- process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, '*', canOpenBoldMarkdown),
1016
+ process: processMarkdownRule(BOLD_MARKDOWN_REGEX, '*', canOpenBoldMarkdown),
863
1017
  replacement: (_extras, match, g1, g2) => {
864
1018
  if (g1.includes('_')) {
865
1019
  return `${g1}<strong>${g2}</strong>`;
@@ -869,7 +1023,7 @@ class ExpensiMark {
869
1023
  },
870
1024
  {
871
1025
  name: 'strikethrough',
872
- process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, '~', canOpenStrikethroughMarkdown),
1026
+ process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, '~', canOpenStrikethroughMarkdown),
873
1027
  replacement: (_extras, match, g1) => (g1.includes('</pre>') || containsNonPairTag(g1) ? match : `<del>${g1}</del>`),
874
1028
  },
875
1029
  {
@@ -1292,7 +1446,7 @@ class ExpensiMark {
1292
1446
  }
1293
1447
  const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement;
1294
1448
  if ('process' in rule) {
1295
- replacedText = rule.process(replacedText, replacement, shouldKeepRawInput);
1449
+ replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText);
1296
1450
  }
1297
1451
  else {
1298
1452
  replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expensify-common",
3
- "version": "2.0.205",
3
+ "version": "2.0.207",
4
4
  "author": "Expensify, Inc.",
5
5
  "description": "Expensify libraries and components shared across different repos",
6
6
  "homepage": "https://expensify.com",