expensify-common 2.0.206 → 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.
- package/dist/ExpensiMark.d.ts +1 -1
- package/dist/ExpensiMark.js +183 -29
- package/dist/esm/ExpensiMark.d.ts +1 -1
- package/dist/esm/ExpensiMark.js +183 -29
- package/package.json +1 -1
package/dist/ExpensiMark.d.ts
CHANGED
|
@@ -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;
|
package/dist/ExpensiMark.js
CHANGED
|
@@ -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
|
-
/**
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
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,
|
|
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
|
-
|
|
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: (
|
|
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: (
|
|
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;
|
package/dist/esm/ExpensiMark.js
CHANGED
|
@@ -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
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
209
|
-
|
|
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,
|
|
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
|
-
|
|
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: (
|
|
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: (
|
|
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);
|