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