expensify-common 2.0.211 → 2.0.213
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CONST.d.ts +3 -1
- package/dist/CONST.js +7 -1
- package/dist/ExpensiMark.d.ts +10 -3
- package/dist/ExpensiMark.js +329 -81
- package/dist/Url.d.ts +14 -8
- package/dist/Url.js +19 -5
- package/dist/esm/CONST.d.ts +3 -1
- package/dist/esm/CONST.js +5 -1
- package/dist/esm/ExpensiMark.d.ts +10 -3
- package/dist/esm/ExpensiMark.js +329 -81
- package/dist/esm/Url.d.ts +4 -1
- package/dist/esm/Url.js +16 -5
- package/package.json +2 -2
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
|
package/dist/ExpensiMark.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
135
|
-
|
|
136
|
-
|
|
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
|
*/
|
package/dist/ExpensiMark.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
* @
|
|
208
|
+
* @returns The index after the valid label prefix, or undefined when the label starts with invalid syntax.
|
|
206
209
|
*/
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
227
|
+
return hostnameEnd;
|
|
217
228
|
}
|
|
218
229
|
/**
|
|
219
|
-
* Finds
|
|
230
|
+
* Finds where a separate hostname can start after punctuation inside the current hostname run.
|
|
220
231
|
*
|
|
221
|
-
* @param text -
|
|
222
|
-
* @param
|
|
223
|
-
* @
|
|
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
|
|
226
|
-
let
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
247
|
-
if (
|
|
248
|
-
|
|
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
|
|
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
|
-
/**
|
|
277
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
374
|
-
candidates.push(candidate);
|
|
375
|
-
index = candidate.end;
|
|
376
|
-
continue;
|
|
592
|
+
candidateEnd = index + matchedProtocol.length;
|
|
377
593
|
}
|
|
378
|
-
|
|
379
|
-
index
|
|
380
|
-
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
389
|
-
candidates.push(
|
|
390
|
-
index =
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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 =
|
|
1868
|
+
match = matchingRegex.exec(textToMatch);
|
|
1621
1869
|
}
|
|
1622
1870
|
if (startIndex < textToCheck.length) {
|
|
1623
1871
|
replacedText = replacedText.concat(textToCheck.substr(startIndex));
|