diff --git a/__tests__/ExpensiMark-HTML-test.js b/__tests__/ExpensiMark-HTML-test.js index 548c6f36..0f937a4d 100644 --- a/__tests__/ExpensiMark-HTML-test.js +++ b/__tests__/ExpensiMark-HTML-test.js @@ -615,32 +615,516 @@ test('Test wrapped URLs', () => { }); describe('Test long input candidate parsing', () => { - const anchor = (url, label = url) => `${label}`; + const anchor = (url, label = url) => `${label}`; + const invalidUrlSuffixesWithNestedDomain = [ + '/path/example.org', + '?next=example.org', + '#example.org', + ':1/path/example.org', + ':8080?next=example.org', + ':65535#example.org', + '/path(a)example.org', + '/path/(example.org)', + '/path/http://example.org', + ]; - test('autolinks a long domain without parsing the full input as a URL', () => { - const domain = `${'a'.repeat(70)}.com`; + test('autolinks a domain with the maximum hostname label length without parsing the full input as a URL', () => { + // Given a valid 63-character hostname label after a long plain-text prefix. + const domain = `${'a'.repeat(63)}.com`; const prefix = 'a'.repeat(14500); - expect(parser.replace(`${prefix} ${domain}`)).toBe(`${prefix} ${anchor(domain)}`); + + // When ExpensiMark parses the complete message. + const result = parser.replace(`${prefix} ${domain}`); + + // Then it autolinks the valid domain without treating the long prefix as part of the URL. + expect(result).toBe(`${prefix} ${anchor(domain)}`); }); - test('handles long valid and invalid bare-domain candidates without changing their output', () => { - // Given long valid and invalid domain-shaped values, because the optimization must not change what users see while typing. - const hostname = 'a'.repeat(8496); - const validUrl = `${hostname}.com`; - const invalidUrl = `${validUrl}x`; + test('does not autolink bare domains whose hostname label exceeds 63 characters', () => { + // Given domains at and above the DNS hostname-label limit, including the long value from the reported composer issue. + const validUrl = `${'a'.repeat(63)}.com`; + const invalidUrl = `${'a'.repeat(64)}.com`; + const reportedInvalidUrl = `${'a'.repeat(8496)}.com`; const repeatedDots = `${'a.'.repeat(4499)}a`; // When ExpensiMark parses each value. const validResult = parser.replace(validUrl); const invalidResult = parser.replace(invalidUrl); + const reportedInvalidResult = parser.replace(reportedInvalidUrl); const repeatedDotsResult = parser.replace(repeatedDots); - // Then it links only the valid domain, which protects both the expected output and responsive typing for invalid long input. + // Then it links only the domain whose labels are valid and leaves oversized or unknown domains as plain text. expect(validResult).toBe(anchor(validUrl)); expect(invalidResult).toBe(invalidUrl); + expect(reportedInvalidResult).toBe(reportedInvalidUrl); expect(repeatedDotsResult).toBe(repeatedDots); }); + test('does not autolink a long protocol hostname whose label exceeds the limit', () => { + // Given a protocol URL whose single hostname label is close to the App parser limit. + const input = `https://${'a'.repeat(9960)}.com`; + + // When ExpensiMark parses the invalid URL candidate. + const result = parser.replace(input); + + // Then it leaves the complete candidate plain without sending the oversized hostname to URL matching. + expect(result).toBe(input); + }); + + test('skips URL matching for a long protocol hostname whose label exceeds the limit', () => { + // Given an oversized protocol hostname and a URL matcher that records when it runs. + const input = `https://${'a'.repeat(9960)}.com`; + const urlRegex = /example/g; + const urlRegexExecSpy = jest.spyOn(urlRegex, 'exec'); + + // When the candidate scanner processes the invalid URL. + const result = parser.modifyTextForUrlLinks(urlRegex, input, jest.fn(), true); + + // Then it leaves the input unchanged and rejects it before running the URL matcher. + expect(result).toBe(input); + expect(urlRegexExecSpy).not.toHaveBeenCalled(); + }); + + test.each([ + ['a valid hyphenated label', 'valid-label', true], + ['a leading hyphen', '-invalid', false], + ['an oversized label', 'a'.repeat(64), false], + ])('keeps URL parsing paths aligned for %s', (_name, label, shouldLink) => { + // Given the same hostname label in escaped text and alongside raw HTML. + const url = `https://${label}.local`; + const rawHtmlPrefix = 'x '; + const expectedUrl = shouldLink ? anchor(url) : url; + + // When ExpensiMark parses it through candidate scanning and full-text URL matching. + const candidateScanningResult = parser.replace(url); + const fullTextResult = parser.replace(`${rawHtmlPrefix}${url}`, {shouldEscapeText: false}); + + // Then both parsing paths apply the same hostname-label rules. + expect(candidateScanningResult).toBe(expectedUrl); + expect(fullTextResult).toBe(`${rawHtmlPrefix}${expectedUrl}`); + }); + + test.each([ + ['bare domain', (hostname) => `${hostname}.example.com`], + ['protocol URL', (hostname) => `https://${hostname}-valid.com`], + ['protocol URL with a valid prefix', (hostname) => `https://valid-${hostname}.com`], + ['bare URL with a known-TLD prefix', (hostname) => `example.com.${hostname}`], + ['protocol URL with a known-TLD prefix', (hostname) => `https://example.com.${hostname}`], + ['labeled link', (hostname) => `[label](https://${hostname}-valid.com)`], + ['image', (hostname) => `![alt](https://${hostname}-valid.com/image.png)`], + ['video', (hostname) => `![video](https://${hostname}-valid.com/video.mp4)`], + ])('does not autolink a valid suffix inside an invalid %s', (_name, createInput) => { + // Given URL-based text containing an oversized hostname label before a valid-looking suffix. + const input = createInput('a'.repeat(64)); + + // When ExpensiMark parses the complete input. + const result = parser.replace(input); + + // Then it leaves the whole invalid value unchanged instead of linking only its valid suffix. + expect(result).toBe(input); + }); + + test.each(['foo.-valid.com', 'foo.--valid.com', '--valid.com'])('does not autolink a suffix after invalid leading hyphens in %s', (input) => { + // Given a hostname whose later valid-looking label starts after invalid leading hyphens. + + // When ExpensiMark parses the complete hostname. + const result = parser.replace(input); + + // Then it leaves the whole invalid hostname plain instead of linking the suffix. + expect(result).toBe(input); + }); + + test('keeps a standalone boundary hyphen outside a maximum-length hostname label', () => { + // Given a valid 63-character hostname label preceded by a standalone punctuation hyphen. + const domain = `${'a'.repeat(63)}.com`; + const input = `-${domain}`; + + // When ExpensiMark parses the domain. + const result = parser.replace(input); + + // Then it preserves the hyphen and autolinks the valid hostname after it. + expect(result).toBe(`-${anchor(domain)}`); + }); + + test('still autolinks a separate valid domain after an invalid hostname', () => { + // Given an invalid hostname followed by a separate valid domain. + const invalidHostname = `${'a'.repeat(64)}.example.com`; + const validDomain = 'valid.com'; + const input = `${invalidHostname} ${validDomain}`; + + // When ExpensiMark parses both values. + const result = parser.replace(input); + + // Then it leaves the invalid hostname plain and autolinks only the separate valid domain. + expect(result).toBe(`${invalidHostname} ${anchor(validDomain)}`); + }); + + test.each([ + ['protocol URL followed by repeated dots', 'https://example.com', '..nonsense'], + ['protocol URL followed by several dots', 'https://example.com', '...next'], + ['protocol URL followed by a dot and hyphen', 'https://example.com', '.-nonsense'], + ['protocol URL followed by a hyphen and dot', 'https://example.com', '-.nonsense'], + ['bare URL followed by repeated dots', 'example.com', '..nonsense'], + ['bare URL followed by several dots', 'example.com', '...next'], + ['bare URL followed by a dot and hyphen', 'example.com', '.-nonsense'], + ['bare URL followed by a hyphen and dot', 'example.com', '-.nonsense'], + ['protocol URL followed by long text after repeated dots', 'https://example.com', `..${'a'.repeat(300)}`], + ['bare URL followed by long text after repeated dots', 'example.com', `..${'a'.repeat(300)}`], + ['protocol URL followed by an oversized malformed label', 'https://example.com', `.-${'a'.repeat(63)}`], + ['bare URL followed by an oversized malformed label', 'example.com', `.-${'a'.repeat(63)}`], + ['protocol URL followed by long text after a trailing hyphen', 'https://example.com', `-.${'a'.repeat(300)}`], + ['bare URL followed by long text after a trailing hyphen', 'example.com', `-.${'a'.repeat(300)}`], + ['maximum-length protocol hostname followed by repeated dots', `https://${'a.'.repeat(125)}com`, '..nonsense'], + ['maximum-length bare hostname followed by repeated dots', `${'a.'.repeat(125)}com`, '..nonsense'], + ])('still autolinks a valid %s', (_name, validUrl, continuation) => { + // Given a valid URL followed without whitespace by punctuation that cannot form another hostname label. + const input = `${validUrl}${continuation}`; + + // When ExpensiMark parses the complete hostname-shaped run through candidate scanning and full-text matching. + const result = parser.replace(input); + const rawHtmlResult = parser.replace(`x ${input}`, {shouldEscapeText: false}); + + // Then it links the valid URL and leaves only the malformed continuation as plain text. + expect(result).toBe(`${anchor(validUrl)}${continuation}`); + expect(rawHtmlResult).toBe(`x ${anchor(validUrl)}${continuation}`); + }); + + test.each([ + ['https://example.com', '..', 'http://', true], + ['example.com', '..', 'https://', true], + ['https://example.com', '.-', 'ftp://', true], + ['example.com', '-.', 'ftps://', true], + ['https://example.com', '..nonsense-', 'HTTP://', true], + ['https://example.com', '..nonsense', 'http://', true], + [`https://${'a'.repeat(64)}.com`, '..', 'http://', false], + ])('preserves a separate URL after %s%s with protocol %s', (firstUrl, continuation, protocol, shouldLinkFirstUrl) => { + // Given a malformed hostname continuation followed immediately by a separate protocol URL. + const validUrl = `${protocol}valid.org/path`; + const input = `${firstUrl}${continuation}${validUrl}`; + const firstResult = shouldLinkFirstUrl ? anchor(firstUrl) : firstUrl; + + // When ExpensiMark parses both URLs through candidate scanning and full-text matching. + const result = parser.replace(input); + const rawHtmlResult = parser.replace(`x ${input}`, {shouldEscapeText: false}); + + // Then the next URL keeps its complete protocol instead of being skipped or linked as a bare domain. + const expected = `${firstResult}${continuation}${anchor(`${protocol.toLowerCase()}valid.org/path`, validUrl)}`; + expect(result).toBe(expected); + expect(rawHtmlResult).toBe(`x ${expected}`); + }); + + test.each([ + ['https://example.com..nonsense', 'https://example.com', '/path'], + ['example.com.-nonsense', 'example.com', '/path/example.org'], + ['https://example.com-.nonsense', 'https://example.com', '?next=example.org'], + ['example.com..nonsense', 'example.com', '#example.org'], + [`https://${'a'.repeat(64)}.com`, undefined, '/path/example.org'], + [`${'a'.repeat(64)}.com`, undefined, '/path(a)'], + ])('links a separate domain after an unmatched parenthesis following %s', (invalidUrl, validPrefix, suffix) => { + // Given an invalid URL range followed by an unmatched closing parenthesis and a separate valid domain. + const validDomain = 'valid.org'; + const input = `${invalidUrl}${suffix})${validDomain}`; + + // When ExpensiMark parses the boundary through candidate scanning and full-text matching. + const result = parser.replace(input); + const rawHtmlResult = parser.replace(`x ${input}`, {shouldEscapeText: false}); + + // Then the unmatched parenthesis ends the invalid range, so the separate domain remains linkable. + const firstResult = validPrefix ? `${anchor(validPrefix)}${invalidUrl.slice(validPrefix.length)}` : invalidUrl; + const expected = `${firstResult}${suffix})${anchor(validDomain)}`; + expect(result).toBe(expected); + expect(rawHtmlResult).toBe(`x ${expected}`); + }); + + test.each([ + ['sentence-like text', '.Thanks!'], + ['hyphenated text', '-thanks'], + ['text after repeated hyphens', '--next'], + ])('preserves a bare URL before adjacent %s', (_name, continuation) => { + // Given a bare URL followed without whitespace by text that is not part of its known TLD. + const validUrl = 'example.com'; + const input = `${validUrl}${continuation}`; + + // When ExpensiMark parses the complete hostname-shaped run. + const result = parser.replace(input); + + // Then it preserves the existing link for the known-TLD URL and leaves the adjacent text plain. + expect(result).toBe(`${anchor(validUrl)}${continuation}`); + }); + + test.each([ + ['a sentence-like label', 'example.com.Thanks!'], + ['a hyphenated label', 'example.com-thanks'], + ['a label with repeated hyphens', 'example.com--next'], + ])('preserves protocol URL parsing with %s', (_name, hostname) => { + // Given a protocol URL whose complete hostname remains valid under the loose URL rules. + const input = `https://${hostname}`; + const trailingPunctuation = input.endsWith('!') ? '!' : ''; + const linkedUrl = trailingPunctuation ? input.slice(0, -1) : input; + + // When ExpensiMark parses the complete URL. + const result = parser.replace(input); + + // Then it keeps linking the complete valid hostname rather than stopping at the earlier known TLD. + expect(result).toBe(`${anchor(linkedUrl)}${trailingPunctuation}`); + }); + + test.each(['https://example.com', 'example.com'])('preserves a valid %s before a malformed raw-HTML URL continuation', (validUrl) => { + // Given raw HTML followed by a valid URL, malformed hostname punctuation, a nested domain, and a separate valid domain. + const continuation = '..nonsense/path/example.org'; + const separateDomain = 'valid.net'; + const input = `x ${validUrl}${continuation} ${separateDomain}`; + + // When ExpensiMark parses the input without escaping HTML. + const result = parser.replace(input, {shouldEscapeText: false}); + + // Then it links the valid prefix and separate domain without linking the domain inside the malformed continuation. + expect(result).toBe(`x ${anchor(validUrl)}${continuation} ${anchor(separateDomain)}`); + }); + + test.each([ + ['an oversized dot-separated label', `https://valid.${'a'.repeat(64)}.com`], + ['an oversized hyphenated label', `https://valid-${'a'.repeat(64)}.com`], + ['an oversized complete hostname', `https://valid.${'a.'.repeat(123)}com`], + ])('does not autolink a valid prefix inside a raw-HTML URL with %s', (_name, invalidUrl) => { + // Given raw HTML followed by an invalid URL and a separate valid domain. + const validDomain = 'example.org'; + const input = `x ${invalidUrl} ${validDomain}`; + + // When ExpensiMark parses the input without escaping HTML. + const result = parser.replace(input, {shouldEscapeText: false}); + + // Then it leaves the invalid URL plain and autolinks only the separate valid domain. + expect(result).toBe(`x ${invalidUrl} ${anchor(validDomain)}`); + }); + + test.each([ + ['a path', `https://${'a'.repeat(64)}.com/path/example.org`], + ['a query', `https://${'a'.repeat(64)}.com?next=example.org`], + ['a fragment', `https://${'a'.repeat(64)}.com#example.org`], + ['an oversized complete hostname', `https://aa.${'a.'.repeat(124)}com/path/example.org`], + ])('does not autolink a domain in %s of an oversized raw-HTML URL', (_name, invalidUrl) => { + // Given an invitation heading that generates HTML, followed by an oversized URL and a separate valid domain. + const validDomain = 'valid.net'; + const input = `# Invitation\n\n${invalidUrl} ${validDomain}`; + + // When ExpensiMark parses the invitation without escaping HTML. + const result = parser.replace(input, {shouldEscapeText: false}); + + // Then the oversized URL stays plain while the separate valid domain is still autolinked. + expect(result).toBe(`

Invitation


${invalidUrl} ${anchor(validDomain)}`); + }); + + test('does not autolink inside an oversized raw-HTML URL after a separate URL without whitespace', () => { + // Given a valid URL followed by an oversized URL in the same non-whitespace token. + const validUrl = 'https://valid.com'; + const invalidUrl = `https://${'a'.repeat(64)}.com/path/example.org`; + const input = `# Invitation\n\n${validUrl},${invalidUrl}`; + + // When ExpensiMark parses the invitation without escaping HTML. + const result = parser.replace(input, {shouldEscapeText: false}); + + // Then it autolinks the first URL without linking any part of the oversized URL. + expect(result).toBe(`

Invitation


${anchor(validUrl)},${invalidUrl}`); + }); + + test('still autolinks a valid domain after an invalid protocol URL without whitespace', () => { + // Given an invalid protocol URL followed by a comma and a separate valid domain. + const invalidUrl = `https://${'a'.repeat(64)}.com`; + const validDomain = 'example.org'; + const input = `${invalidUrl},${validDomain}`; + + // When ExpensiMark parses both values as one non-whitespace token. + const result = parser.replace(input); + + // Then it leaves the invalid URL plain and autolinks the valid domain after the comma. + expect(result).toBe(`${invalidUrl},${anchor(validDomain)}`); + }); + + test.each([ + ['https://', ','], + ['https://', ';'], + ['https://', '|'], + ['https://', ')'], + ['https://', ']'], + ['https://', '}'], + ['https://', '_'], + ['http://', ','], + ['ftp://', ','], + ['ftps://', ','], + ])('still autolinks a valid domain after malformed %s followed by "%s"', (protocol, separator) => { + // Given a protocol without a hostname, followed by a URL boundary and a separate valid domain. + const validDomain = 'valid.org'; + const input = `${protocol}${separator}${validDomain}`; + + // When ExpensiMark parses the complete non-whitespace token. + const result = parser.replace(input); + + // Then it leaves the malformed protocol plain and autolinks the separate valid domain. + expect(result).toBe(`${protocol}${separator}${anchor(validDomain)}`); + }); + + test.each([ + ['https://foo..bar.com', 'https://foo'], + ['https://foo.-bar.com', 'https://foo'], + ['https://-bar.com', undefined], + ['https://invalid-.local', 'https://invalid'], + ['https://local.url..nonsense', 'https://local.url'], + ['http://localhost..nonsense', 'http://localhost'], + ['https://example.internal.-nonsense', 'https://example.internal'], + ['https://example.internal-.nonsense', 'https://example.internal'], + ])('preserves valid prefixes and separate URLs when parsing %s', (invalidUrl, validPrefix) => { + // Given malformed hostname punctuation before a path containing another domain and a separate URL after a semicolon. + const validDomain = 'valid.org'; + const suffix = '/path/example.org'; + const input = `${invalidUrl}${suffix};${validDomain}`; + const rawHtmlPrefix = 'x '; + + // When ExpensiMark uses candidate scanning or full-text matching alongside unescaped HTML. + const candidateScanningResult = parser.replace(input); + const fullTextResult = parser.replace(`${rawHtmlPrefix}${input}`, {shouldEscapeText: false}); + + // Then supported prefixes remain linked, connected invalid text stays plain, and the separate domain still links. + const expectedUrl = validPrefix ? `${anchor(validPrefix)}${invalidUrl.slice(validPrefix.length)}` : invalidUrl; + const expected = `${expectedUrl}${suffix};${anchor(validDomain)}`; + expect(candidateScanningResult).toBe(expected); + expect(fullTextResult).toBe(`${rawHtmlPrefix}${expected}`); + }); + + test.each(['https:///path/example.org', 'https://?next=example.org', 'https://#example.org'])('does not autolink a domain inside malformed URL %s', (invalidUrl) => { + // Given a protocol without a hostname whose connected path, query, or fragment contains a domain. + + // When ExpensiMark parses the complete malformed URL. + const result = parser.replace(invalidUrl); + + // Then it keeps the complete connected URL text plain instead of linking the nested domain. + expect(result).toBe(invalidUrl); + }); + + test.each(invalidUrlSuffixesWithNestedDomain)('does not autolink a domain inside an invalid protocol URL suffix in %s', (suffix) => { + // Given an invalid protocol hostname followed by a path, query, or fragment containing another domain, optionally after a valid port. + const input = `https://${'a'.repeat(64)}.com${suffix}`; + + // When ExpensiMark parses the complete invalid URL through candidate scanning and full-text matching. + const result = parser.replace(input); + const rawHtmlResult = parser.replace(`x ${input}`, {shouldEscapeText: false}); + + // Then it leaves the complete URL plain instead of linking a domain that still belongs to that URL. + expect(result).toBe(input); + expect(rawHtmlResult).toBe(`x ${input}`); + }); + + test.each(invalidUrlSuffixesWithNestedDomain)('does not autolink a domain inside an invalid bare URL suffix in %s', (suffix) => { + // Given an invalid bare hostname followed by a path, query, or fragment containing another domain, optionally after a valid port. + const input = `${'a'.repeat(64)}.com${suffix}`; + + // When ExpensiMark parses the complete invalid URL through candidate scanning and full-text matching. + const result = parser.replace(input); + const rawHtmlResult = parser.replace(`x ${input}`, {shouldEscapeText: false}); + + // Then it leaves the complete URL plain instead of linking a domain that still belongs to that URL. + expect(result).toBe(input); + expect(rawHtmlResult).toBe(`x ${input}`); + }); + + test.each([ + ['labeled link', (url) => `[label](${url})`], + ['image', (url) => `![alt](${url}/image.png)`], + ['video', (url) => `![video](${url}/video.mp4)`], + ])('does not autolink a domain inside an invalid bare URL used by a %s', (_name, createInput) => { + // Given URL-based Markdown containing a domain in the path of an oversized bare hostname. + const invalidUrl = `${'a'.repeat(64)}.com/path/example.org`; + const input = createInput(invalidUrl); + + // When ExpensiMark parses the complete Markdown input. + const result = parser.replace(input); + + // Then it leaves the complete value unchanged instead of linking the domain inside the invalid URL. + expect(result).toBe(input); + }); + + test('does not autolink a domain inside an invalid bare URL when raw HTML disables candidate scanning', () => { + // Given raw HTML followed by an oversized bare URL whose path contains another domain. + const invalidUrl = `${'a'.repeat(64)}.com/path/example.org`; + const validDomain = 'valid.net'; + const input = `x ${invalidUrl} ${validDomain}`; + + // When ExpensiMark parses the input without escaping HTML. + const result = parser.replace(input, {shouldEscapeText: false}); + + // Then it keeps the invalid URL plain while still autolinking the separate valid domain. + expect(result).toBe(`x ${invalidUrl} ${anchor(validDomain)}`); + }); + + test.each([ + ['bare URL', `${'a'.repeat(64)}.com`], + ['protocol URL', `https://${'a'.repeat(64)}.com`], + ])('still autolinks a valid domain after an invalid %s path', (_name, invalidUrl) => { + // Given an oversized URL followed by a path boundary and a separate valid domain without whitespace. + const validDomain = 'valid.org'; + const input = `${invalidUrl}/path;${validDomain}`; + + // When ExpensiMark parses the complete non-whitespace token. + const result = parser.replace(input); + + // Then it keeps the invalid URL path plain and autolinks the domain after the URL boundary. + expect(result).toBe(`${invalidUrl}/path;${anchor(validDomain)}`); + }); + + test('still autolinks a valid domain after an invalid raw-HTML URL path', () => { + // Given raw HTML followed by an oversized URL path and a separate valid domain without whitespace. + const invalidUrl = `https://${'a'.repeat(64)}.com/path`; + const validDomain = 'valid.org'; + const input = `x ${invalidUrl};${validDomain}`; + + // When ExpensiMark parses the input without escaping HTML. + const result = parser.replace(input, {shouldEscapeText: false}); + + // Then it keeps the invalid URL path plain and autolinks the domain after the URL boundary. + expect(result).toBe(`x ${invalidUrl};${anchor(validDomain)}`); + }); + + test('does not autolink URLs whose complete hostname exceeds 253 characters', () => { + // Given hostnames at and above the DNS length limit, with every individual label remaining valid. + const validHostname = `${'a.'.repeat(125)}com`; + const invalidHostname = `aa.${'a.'.repeat(124)}com`; + const validProtocolUrl = `https://${validHostname}`; + const trailingHyphens = '-'.repeat(300); + const invalidProtocolUrl = `https://${invalidHostname}/path`; + + // When ExpensiMark parses bare and protocol URLs at those boundaries. + const validResult = parser.replace(validHostname); + const validWithPunctuationResult = parser.replace(`${validHostname}.`); + const validProtocolWithPunctuationResult = parser.replace(`${validProtocolUrl}.`); + const validProtocolWithTrailingHyphensResult = parser.replace(`${validProtocolUrl}${trailingHyphens}`); + const invalidResult = parser.replace(invalidHostname); + const invalidProtocolResult = parser.replace(invalidProtocolUrl); + + // Then it links the valid hostname without counting punctuation and leaves oversized hostnames plain. + expect(validResult).toBe(anchor(validHostname)); + expect(validWithPunctuationResult).toBe(`${anchor(validHostname)}.`); + expect(validProtocolWithPunctuationResult).toBe(`${anchor(validProtocolUrl)}.`); + expect(validProtocolWithTrailingHyphensResult).toBe(`${anchor(validProtocolUrl)}${trailingHyphens}`); + expect(invalidResult).toBe(invalidHostname); + expect(invalidProtocolResult).toBe(invalidProtocolUrl); + }); + + test.each([ + ['labeled link', (url) => `[label](${url}/path)`], + ['image', (url) => `![alt](${url}/image.png)`], + ['video', (url) => `![video](${url}/video.mp4)`], + ])('does not parse a %s whose complete hostname exceeds 253 characters', (_name, createInput) => { + // Given URL-based Markdown whose hostname exceeds the DNS limit without exceeding any individual label limit. + const hostname = `aa.${'a.'.repeat(124)}com`; + const input = createInput(`https://${hostname}`); + + // When ExpensiMark parses the Markdown. + const result = parser.replace(input); + + // Then it leaves the invalid URL-based Markdown unchanged instead of generating HTML with that hostname. + expect(result).toBe(input); + }); + test('preserves the ftps protocol when autolinking a candidate', () => { const input = 'ftps://example.com/file'; expect(parser.replace(input)).toBe('ftps://example.com/file'); diff --git a/__tests__/URL-test.js b/__tests__/URL-test.js index dd42d45a..ec32a218 100644 --- a/__tests__/URL-test.js +++ b/__tests__/URL-test.js @@ -1,4 +1,7 @@ -import {URL_REGEX_WITH_REQUIRED_PROTOCOL, URL_REGEX, LOOSE_URL_REGEX} from '../lib/Url'; +import fs from 'node:fs'; +import * as UrlPatterns from '../lib/Url'; + +const {URL_REGEX_WITH_REQUIRED_PROTOCOL, URL_REGEX, LOOSE_URL_REGEX, MARKDOWN_URL_REGEX} = UrlPatterns; describe('Strict URL validation', () => { describe('Mandatory protocol for URL', () => { @@ -65,3 +68,180 @@ describe('Loose URL validation', () => { expect(regexToTest.test('http://my.localhost....local-domain:8080')).toBeFalsy(); }); }); + +describe('Hostname label length validation', () => { + it.each([ + ['URL_REGEX', URL_REGEX, ''], + ['LOOSE_URL_REGEX', LOOSE_URL_REGEX, 'https://'], + ])('%s preserves valid prefixes before long malformed hostname continuations', (_name, pattern, protocol) => { + // Given valid hostnames followed by malformed punctuation whose text would exceed a DNS length limit. + const regexToTest = new RegExp(pattern, 'i'); + const validUrl = `${protocol}example.com`; + const maximumLengthUrl = `${protocol}${'a.'.repeat(125)}com`; + const inputs = [`${validUrl}..${'a'.repeat(300)}`, `${validUrl}.-${'a'.repeat(63)}`, `${validUrl}-.${'a'.repeat(300)}`, `${maximumLengthUrl}..nonsense`]; + + // When URL matching reaches malformed punctuation after the valid hostname. + const matches = inputs.map((input) => { + const match = regexToTest.exec(input); + return match && match[0]; + }); + + // Then only the actual hostname counts toward the limits, so each valid prefix still matches. + expect(matches).toEqual([validUrl, validUrl, validUrl, maximumLengthUrl]); + }); + + it.each([ + ['URL_REGEX', URL_REGEX, ''], + ['LOOSE_URL_REGEX', LOOSE_URL_REGEX, 'https://'], + ])('%s rejects oversized hostnames even before malformed continuations', (_name, pattern, protocol) => { + // Given an oversized hostname containing a known-TLD prefix, followed by malformed punctuation. + const input = `${protocol}example.com${'.a'.repeat(122)}..nonsense`; + const regexToTest = new RegExp(pattern, 'i'); + + // When URL matching checks the hostname before the malformed continuation. + const match = regexToTest.exec(input); + + // Then the whole oversized hostname remains rejected rather than matching its earlier valid-looking prefix. + expect(match).toBeNull(); + }); + + it.each([ + ['URL_REGEX', URL_REGEX, `${'a'.repeat(63)}.com`, `${'a'.repeat(64)}.com`], + ['URL_REGEX_WITH_REQUIRED_PROTOCOL', URL_REGEX_WITH_REQUIRED_PROTOCOL, `https://${'a'.repeat(63)}.com`, `https://${'a'.repeat(64)}.com`], + ['LOOSE_URL_REGEX', LOOSE_URL_REGEX, `https://${'a'.repeat(63)}.local`, `https://${'a'.repeat(64)}.local`], + ])('%s accepts 63-character labels and rejects longer labels', (_name, pattern, validUrl, invalidUrl) => { + // Given a URL whose hostname label is at the DNS limit and one whose label exceeds it. + const regexToTest = new RegExp(pattern, 'i'); + + // When each complete URL is checked by the corresponding URL pattern. + const validMatch = regexToTest.exec(validUrl); + regexToTest.lastIndex = 0; + const invalidMatch = regexToTest.exec(invalidUrl); + + // Then the valid URL matches fully and the invalid URL cannot be partially matched from inside its hostname label. + expect(validMatch && validMatch[0]).toBe(validUrl); + expect(invalidMatch).toBeNull(); + }); + + it.each([ + ['URL_REGEX', URL_REGEX, ''], + ['URL_REGEX_WITH_REQUIRED_PROTOCOL', URL_REGEX_WITH_REQUIRED_PROTOCOL, 'https://'], + ['LOOSE_URL_REGEX', LOOSE_URL_REGEX, 'https://'], + ])('%s accepts 253-character hostnames and rejects longer hostnames', (_name, pattern, prefix) => { + // Given hostnames at and above the DNS length limit, with every individual label remaining valid. + const validHostname = `${'a.'.repeat(125)}com`; + const invalidHostname = `aa.${'a.'.repeat(124)}com`; + const regexToTest = new RegExp(pattern, 'i'); + + // When each complete URL is checked by the corresponding URL pattern. + const validUrl = `${prefix}${validHostname}`; + const invalidUrl = `${prefix}${invalidHostname}`; + const validMatch = regexToTest.exec(validUrl); + regexToTest.lastIndex = 0; + const invalidMatch = regexToTest.exec(invalidUrl); + + // Then only the URL whose complete hostname is within the limit matches. + expect(validMatch && validMatch[0]).toBe(validUrl); + expect(invalidMatch).toBeNull(); + }); + + it.each([ + ['an invalid dot-separated label', `${'a'.repeat(64)}.example.com`], + ['an invalid hyphenated label after a protocol', `https://${'a'.repeat(64)}-valid.com`], + ['a leading hyphen attached to another hostname label', 'foo.-valid.com'], + ['multiple leading hyphens', '--valid.com'], + ])('does not match a valid hostname suffix inside %s', (_name, invalidUrl) => { + // Given an invalid hostname that contains a shorter valid-looking domain suffix. + const regexToTest = new RegExp(MARKDOWN_URL_REGEX, 'i'); + + // When the URL pattern searches the complete invalid value. + const match = regexToTest.exec(invalidUrl); + + // Then it rejects the whole value instead of matching only the valid suffix. + expect(match).toBeNull(); + }); + + it('keeps matching a domain after a standalone boundary hyphen', () => { + // Given a valid domain preceded by one standalone hyphen used as punctuation. + const input = '-example.com'; + const regexToTest = new RegExp(MARKDOWN_URL_REGEX, 'i'); + + // When the URL pattern searches the complete text. + const match = regexToTest.exec(input); + + // Then it links the valid domain after the boundary without including the hyphen. + expect(match && match[0]).toBe('example.com'); + }); + + it('does not count a URL path toward the hostname limit', () => { + // Given a URL with a valid hostname and a path that makes the complete URL longer than 253 characters. + const url = `https://example.com/${'a'.repeat(1000)}`; + const regexToTest = new RegExp(`^${URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i'); + + // When the complete URL is checked. + const isValid = regexToTest.test(url); + + // Then it remains valid because only the hostname is subject to the 253-character limit. + expect(isValid).toBeTruthy(); + }); + + it('keeps protocol IPv4 URLs valid', () => { + // Given an IPv4 URL that is supported by the loose URL pattern. + const ipv4Url = 'http://127.0.0.1/path'; + const regexToTest = new RegExp(`^${LOOSE_URL_REGEX}$`, 'i'); + + // When the URL is checked after applying hostname-label limits. + const isValid = regexToTest.test(ipv4Url); + + // Then its numeric labels remain valid because none exceeds the new limit. + expect(isValid).toBeTruthy(); + }); +}); + +describe('URL pattern consistency', () => { + it.each([ + ['a one-character label', 'a', true], + ['a hyphen inside a label', 'valid-label', true], + ['a leading hyphen', '-invalid', false], + ['a trailing hyphen', 'invalid-', false], + ['an underscore', 'invalid_label', false], + ])('keeps strict and loose URL patterns aligned for %s', (_name, label, shouldMatch) => { + // Given the same hostname label used by strict and loose URL patterns. + const labelRegex = new RegExp(`^${UrlPatterns.URL_HOSTNAME_LABEL_REGEX}$`, 'i'); + const strictUrlRegex = new RegExp(`^${URL_REGEX}$`, 'i'); + const looseUrlRegex = new RegExp(`^${LOOSE_URL_REGEX}$`, 'i'); + + // When each URL pattern validates the label. + const results = [labelRegex.test(label), strictUrlRegex.test(`${label}.com`), looseUrlRegex.test(`https://${label}.local`)]; + + // Then every pattern applies the same label syntax and length limits. + expect(results).toEqual([shouldMatch, shouldMatch, shouldMatch]); + }); + + it('keeps published URL pattern declarations aligned with their runtime values', () => { + // Given the URL patterns exported at runtime and their published literal declarations. + const declaration = fs.readFileSync('lib/Url.d.ts', 'utf8'); + const literalPatternNames = [ + 'URL_PROTOCOL_REGEX', + 'URL_PORT_REGEX', + 'URL_HOSTNAME_LABEL_REGEX', + 'URL_WEBSITE_REGEX', + 'URL_PATH_REGEX', + 'URL_PARAM_REGEX', + 'URL_FRAGMENT_REGEX', + 'URL_REGEX', + 'LOOSE_URL_WEBSITE_REGEX', + 'LOOSE_URL_REGEX', + 'MARKDOWN_URL_REGEX', + ]; + + // When each runtime value is escaped as the matching TypeScript string literal. + const staleDeclarations = literalPatternNames.filter((name) => { + const escapedPattern = UrlPatterns[name].replaceAll('\\', '\\\\').replaceAll("'", "\\'"); + return !declaration.includes(`declare const ${name}: '${escapedPattern}';`); + }); + + // Then Url.d.ts describes the exact patterns exported by Url.js. + expect(staleDeclarations).toEqual([]); + }); +}); diff --git a/lib/CONST.ts b/lib/CONST.ts index 8d98ca5b..808ca943 100644 --- a/lib/CONST.ts +++ b/lib/CONST.ts @@ -3,6 +3,12 @@ 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))(?.*))\\S{3,254}(?=\\k$)"; +// DNS hostname labels are limited to 63 octets. URL hostnames are ASCII here, so characters and octets are equivalent. +const MAX_URL_HOSTNAME_LABEL_LENGTH = 63; + +// DNS names can use 255 wire octets, which allows at most 253 visible ASCII characters without a trailing root dot. +const MAX_URL_HOSTNAME_LENGTH = 253; + const MOMENT_FORMAT_STRING = 'YYYY-MM-DD'; /** @@ -1940,4 +1946,4 @@ const PUBLIC_DOMAINS_SET = new Set([ 'ymail.com', ]); -export {g_cloudFront, g_cloudFrontImg, CONST, UI, PUBLIC_DOMAINS_SET}; +export {g_cloudFront, g_cloudFrontImg, CONST, UI, PUBLIC_DOMAINS_SET, MAX_URL_HOSTNAME_LABEL_LENGTH, MAX_URL_HOSTNAME_LENGTH}; diff --git a/lib/ExpensiMark.ts b/lib/ExpensiMark.ts index 94ce51f2..bda4c137 100644 --- a/lib/ExpensiMark.ts +++ b/lib/ExpensiMark.ts @@ -51,12 +51,20 @@ const URL_TLD_LIST = TLD_REGEX.toLowerCase().split('|'); const URL_TLDS = new Set(URL_TLD_LIST); // Caps TLD scanning at the longest known TLD so long invalid URL-like text avoids expensive regex work. const MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length)); +const URL_HOSTNAME_LABEL_AT_POSITION_REGEX = new RegExp(UrlPatterns.URL_HOSTNAME_LABEL_REGEX, 'iy'); +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', +); const PROTECTED_TAG_NAMES = new Set(['a', 'code', 'pre', 'video']); type ReplacementFn = (extras: Extras, ...matches: string[]) => string; type Replacement = ReplacementFn | string; type ProcessFn = (textToProcess: string, replacement: Replacement, shouldKeepRawInput: boolean, shouldEscapeText: boolean) => string; -type UrlCandidate = {start: number; end: number}; +type TextRange = {start: number; end: number}; +type UrlCandidate = TextRange; +type HostnameAnalysis = {isValid: boolean; validHostnamePrefixEnd?: number}; +type UrlRangeAnalysis = {hostnameEnd: number; validHostnamePrefixEnd?: number; invalidRange?: TextRange}; type MarkdownMarkerCharacter = '*' | '~'; type MarkdownMarker = {position: number; isProtected: boolean}; type CanOpenMarkdown = (text: string, position: number, isProtected: boolean) => boolean; @@ -263,66 +271,33 @@ function getProtocolAt(text: string, position: number) { } /** - * Returns whether one dot-separated hostname label is valid. + * Finds the valid prefix of a hostname label using the shared URL label pattern. * * @param text - Candidate URL text containing the label. * @param start - Index of the label's first character. - * @param end - Index immediately after the label's last character. + * @returns The index after the valid label prefix, or undefined when the label starts with invalid syntax. */ -function isValidHostnameLabel(text: string, start: number, end: number): boolean { - if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { - return false; - } - - for (let index = start + 1; index < end - 1; index++) { - if (!isAsciiAlphaNumeric(text[index]) && text[index] !== '-') { - return false; - } - } - return true; +function findValidHostnameLabelEnd(text: string, start: number): number | undefined { + URL_HOSTNAME_LABEL_AT_POSITION_REGEX.lastIndex = start; + const match = URL_HOSTNAME_LABEL_AT_POSITION_REGEX.exec(text); + return match === null ? undefined : URL_HOSTNAME_LABEL_AT_POSITION_REGEX.lastIndex; } /** - * Finds the first character of a valid hostname before the dot that starts its top-level domain. + * Finds the first character after a URL's hostname. * - * @param text - Candidate URL text containing the hostname. - * @param dotPosition - Index of the dot immediately before the top-level domain. - * @returns The hostname's first-character index, or undefined when no valid hostname precedes the dot. + * @param text - Text containing the URL candidate. + * @param hostnameStart - Index of the hostname's first character. + * @param candidateEnd - Index immediately after the URL candidate. + * @returns The index immediately after the hostname. */ -function findHostnameStart(text: string, dotPosition: number): number | undefined { - let hostnameStart = dotPosition; - let labelEnd = dotPosition; - - while (labelEnd > 0) { - let rawLabelStart = labelEnd - 1; - while (rawLabelStart >= 0 && text[rawLabelStart] !== '.' && isHostnameCharacter(text[rawLabelStart])) { - rawLabelStart--; - } - rawLabelStart++; - - let labelStart = rawLabelStart; - while (labelStart < labelEnd && text[labelStart] === '-') { - labelStart++; - } - - if (!isValidHostnameLabel(text, labelStart, labelEnd)) { - break; - } - hostnameStart = labelStart; - - // A leading hyphen ends the hostname, but the existing URL regex can still match the valid suffix after it. - if (labelStart !== rawLabelStart) { - break; - } - - const separatorPosition = labelStart - 1; - if (separatorPosition < 0 || text[separatorPosition] !== '.') { - break; - } - labelEnd = separatorPosition; +function findHostnameEnd(text: string, hostnameStart: number, candidateEnd: number): number { + let hostnameEnd = hostnameStart; + while (hostnameEnd < candidateEnd && isHostnameCharacter(text[hostnameEnd])) { + hostnameEnd++; } - return hostnameStart === dotPosition ? undefined : hostnameStart; + return hostnameEnd; } /** @@ -351,6 +326,66 @@ function findKnownTldEnd(text: string, dotPosition: number): number | undefined return undefined; } +/** + * Validates a hostname and records a linkable prefix before a later malformed label. + * Counts only the hostname before malformed punctuation; oversized hostname labels still cannot produce a partial link. + * + * @param text - Text containing the URL candidate. + * @param hostnameStart - Index of the hostname's first character. + * @param hostnameEnd - Index immediately after the hostname. + * @param hasProtocol - Whether the URL allows hostnames without a known TLD, including single-label hosts. + * @returns The hostname validity and any prefix that can be linked before malformed punctuation. + */ +function analyzeHostname(text: string, hostnameStart: number, hostnameEnd: number, hasProtocol: boolean): HostnameAnalysis { + let hostnameContentEnd = hostnameEnd; + while (hostnameContentEnd > hostnameStart && (text[hostnameContentEnd - 1] === '.' || text[hostnameContentEnd - 1] === '-')) { + hostnameContentEnd--; + } + + if (hostnameContentEnd === hostnameStart) { + return {isValid: false}; + } + + let labelStart = hostnameStart; + let validHostnamePrefixEnd: number | undefined; + for (let index = hostnameStart; index <= hostnameContentEnd; index++) { + if (index !== hostnameContentEnd && text[index] !== '.') { + continue; + } + + const validLabelEnd = findValidHostnameLabelEnd(text, labelStart); + if (validLabelEnd === undefined) { + return {isValid: false, validHostnamePrefixEnd}; + } + + // Measure the valid label prefix before any trailing hyphens that end it. + let labelContentEnd = index; + while (labelContentEnd > labelStart && text[labelContentEnd - 1] === '-') { + labelContentEnd--; + } + if (labelContentEnd - labelStart > Constants.MAX_URL_HOSTNAME_LABEL_LENGTH || labelContentEnd - hostnameStart > Constants.MAX_URL_HOSTNAME_LENGTH) { + return {isValid: false}; + } + // Protocol URLs can end at any valid label; bare URLs still need a known TLD. + if (hasProtocol) { + validHostnamePrefixEnd = validLabelEnd; + } + if (validLabelEnd !== index) { + return {isValid: false, validHostnamePrefixEnd}; + } + + if (!hasProtocol && index !== hostnameContentEnd) { + const tldEnd = findKnownTldEnd(text, index); + if (tldEnd !== undefined && tldEnd <= hostnameContentEnd) { + validHostnamePrefixEnd = tldEnd; + } + } + labelStart = index + 1; + } + + return {isValid: true}; +} + /** Expands example.com to include nearby @, *, _, or ~ in any order, plus its path, until whitespace or HTML. */ function extendUrlCandidateBoundaries(text: string, start: number, end: number): UrlCandidate { let candidateStart = start; @@ -376,6 +411,146 @@ function startsWithIgnoreCase(text: string, expected: string, position: number): return text.slice(position, position + expected.length).toLowerCase() === expected; } +/** + * Finds the first unmatched closing parenthesis, which ends a URL rather than belonging to its path. + * + * @param text - Text containing the URL or its suffix. + * @param start - First index to inspect, inclusive. + * @param end - Last index to inspect, exclusive. + * @returns The unmatched parenthesis index, or end when all closing parentheses are paired. + */ +function findUrlEndBeforeUnmatchedParenthesis(text: string, start: number, end: number): number { + let unmatchedOpenParentheses = 0; + for (let index = start; index < end; index++) { + if (text[index] === '(') { + unmatchedOpenParentheses++; + } else if (text[index] === ')') { + if (unmatchedOpenParentheses === 0) { + return index; + } + unmatchedOpenParentheses--; + } + } + return end; +} + +/** + * Finds where a URL ends by matching its optional port, path, query, and fragment after the hostname. + * + * @param text - Text containing the URL candidate. + * @param hostnameEnd - Index immediately after the hostname. + * @returns The first index after the URL recognized by ExpensiMark. + */ +function findUrlEnd(text: string, hostnameEnd: number): number { + URL_SUFFIX_AT_POSITION_REGEX.lastIndex = hostnameEnd; + const match = URL_SUFFIX_AT_POSITION_REGEX.exec(text); + return findUrlEndBeforeUnmatchedParenthesis(text, hostnameEnd, hostnameEnd + (match?.[0].length ?? 0)); +} + +/** + * Analyzes a URL candidate and returns the connected range when its hostname is invalid. + * + * @param text - Text containing the URL candidate. + * @param urlStart - Index where the URL starts, excluding Markdown punctuation. + * @param hostnameStart - Index where the hostname starts. + * @param candidate - Broad candidate range found by the scanner. + * @returns The hostname end and its connected URL range when the hostname is invalid. + */ +function analyzeUrlRange(text: string, urlStart: number, hostnameStart: number, candidate: UrlCandidate): UrlRangeAnalysis { + const hostnameEnd = findHostnameEnd(text, hostnameStart, candidate.end); + const hostnameAnalysis = analyzeHostname(text, hostnameStart, hostnameEnd, hostnameStart !== urlStart); + if (hostnameAnalysis.isValid) { + return {hostnameEnd}; + } + + let invalidRangeEnd = findUrlEnd(text, hostnameEnd); + if (text[hostnameEnd] === ':') { + // The broad hostname scan can include the letters of the next protocol, as in example.com..http://valid.org. + for (const protocol of URL_PROTOCOLS) { + const protocolStart = hostnameEnd - protocol.indexOf(':'); + if (protocolStart >= hostnameStart && startsWithIgnoreCase(text, protocol, protocolStart)) { + invalidRangeEnd = protocolStart; + break; + } + } + } + + return { + hostnameEnd, + validHostnamePrefixEnd: hostnameAnalysis.validHostnamePrefixEnd, + invalidRange: {start: hostnameAnalysis.validHostnamePrefixEnd ?? urlStart, end: invalidRangeEnd}, + }; +} + +/** + * Finds the first hostname character before a known top-level domain, including invalid labels. + * + * @param text - Text containing the bare hostname. + * @param dotPosition - Index of the dot immediately before the known top-level domain. + * @returns The hostname start, excluding a standalone punctuation hyphen. + */ +function findRawHostnameStart(text: string, dotPosition: number): number { + let hostnameStart = dotPosition; + while (hostnameStart > 0 && isHostnameCharacter(text[hostnameStart - 1])) { + hostnameStart--; + } + + const hasStandaloneBoundaryHyphen = text[hostnameStart] === '-' && text[hostnameStart + 1] !== '-' && !isHostnameCharacter(text[hostnameStart - 1]); + if (hasStandaloneBoundaryHyphen) { + hostnameStart++; + } + return hostnameStart; +} + +/** + * Finds URL ranges whose hostname is malformed or exceeds the supported DNS length limits. + * These ranges keep both URL parsing paths from matching another domain inside the same invalid URL. + * + * @param text - Text containing possible protocol and bare URLs. + * @returns Connected invalid URL ranges that must remain plain text. + */ +function findInvalidUrlRanges(text: string): TextRange[] { + const ranges: TextRange[] = []; + let index = 0; + + while (index < text.length) { + const matchedProtocol = getProtocolAt(text, index); + let analysis: UrlRangeAnalysis; + let nextIndex: number; + + if (matchedProtocol) { + const candidate = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + analysis = analyzeUrlRange(text, index, index + matchedProtocol.length, candidate); + nextIndex = Math.max(index + matchedProtocol.length, analysis.hostnameEnd); + } else { + if (text[index] !== '.') { + index++; + continue; + } + + const tldEnd = findKnownTldEnd(text, index); + if (tldEnd === undefined) { + index++; + continue; + } + + const hostnameStart = findRawHostnameStart(text, index); + const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); + analysis = analyzeUrlRange(text, hostnameStart, hostnameStart, candidate); + nextIndex = tldEnd; + } + + if (analysis.invalidRange) { + ranges.push(analysis.invalidRange); + index = analysis.invalidRange.end; + } else { + index = nextIndex; + } + } + + return ranges; +} + /** * Removes candidates that the original full-text URL regex would reject because of later HTML. * It scans right to left so each candidate can use the nearest later HTML boundary without copying a long suffix. @@ -432,7 +607,7 @@ function filterUrlCandidatesBlockedByFollowingHtml(text: string, candidates: Url return validCandidates.reverse(); } -/** Finds possible URL ranges, skips URL-looking text inside protected tags, and leaves final validity to the existing regex. */ +/** Finds possible URL ranges, skips URL-looking text inside protected tags, rejects malformed or oversized hostnames, and leaves remaining validation to the existing regex. */ function findUrlCandidates(text: string): UrlCandidate[] { const candidates: UrlCandidate[] = []; const protectedTags: string[] = []; @@ -457,6 +632,14 @@ function findUrlCandidates(text: string): UrlCandidate[] { const matchedProtocol = getProtocolAt(text, index); if (matchedProtocol) { const candidate = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + const analysis = analyzeUrlRange(text, index, index + matchedProtocol.length, candidate); + if (analysis.invalidRange) { + if (analysis.validHostnamePrefixEnd !== undefined) { + candidates.push({start: candidate.start, end: analysis.validHostnamePrefixEnd}); + } + index = analysis.invalidRange.end; + continue; + } candidates.push(candidate); index = candidate.end; continue; @@ -468,15 +651,24 @@ function findUrlCandidates(text: string): UrlCandidate[] { } const tldEnd = findKnownTldEnd(text, index); - const hostnameStart = tldEnd === undefined ? undefined : findHostnameStart(text, index); - if (tldEnd === undefined || hostnameStart === undefined) { + if (tldEnd === undefined) { index++; continue; } - const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); - candidates.push(candidate); - index = candidate.end; + const rawHostnameStart = findRawHostnameStart(text, index); + const broadCandidate = extendUrlCandidateBoundaries(text, rawHostnameStart, tldEnd); + const analysis = analyzeUrlRange(text, rawHostnameStart, rawHostnameStart, broadCandidate); + if (analysis.invalidRange) { + if (analysis.validHostnamePrefixEnd !== undefined) { + candidates.push({start: broadCandidate.start, end: analysis.validHostnamePrefixEnd}); + } + index = analysis.invalidRange.end; + continue; + } + + candidates.push(broadCandidate); + index = broadCandidate.end; } return filterUrlCandidatesBlockedByFollowingHtml(text, candidates); @@ -1143,7 +1335,7 @@ export default class ExpensiMark { process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, 'gi'); // Raw HTML depends on complete-text lookaheads. Text without user-provided HTML can safely use the faster candidate scanner. - return this.modifyTextForUrlLinks(regex, textToProcess, replacement as ReplacementFn, canUseCandidateScanning(textToProcess, shouldEscapeText)); + return this.modifyTextForUrlLinks(regex, textToProcess, replacement as ReplacementFn, canUseCandidateScanning(textToProcess, shouldEscapeText), true); }, replacement: (_extras, _match, g1, g2) => { @@ -1755,9 +1947,15 @@ export default class ExpensiMark { } /** - * Checks matched URLs for validity and replace valid links with html elements + * Replaces valid URL matches and optionally narrows parsing to URL candidates. + * + * @param regex - The URL pattern used for the final match. + * @param textToCheck - The text containing possible URLs. + * @param replacement - The replacement applied to each accepted URL. + * @param shouldScanForUrls - Whether to scan URL candidates before running the regex. + * @param shouldRejectPartialHostnameMatches - Whether to reject matches inside invalid URL ranges. */ - modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn, shouldScanForUrls = false): string { + modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn, shouldScanForUrls = false, shouldRejectPartialHostnameMatches = false): string { if (shouldScanForUrls) { const candidates = findUrlCandidates(textToCheck); if (candidates.length === 0) { @@ -1772,7 +1970,7 @@ export default class ExpensiMark { const candidate = textToCheck.slice(start, end); candidateRegex.lastIndex = 0; output.push(textToCheck.slice(outputStart, start)); - output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement)); + output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement, false, shouldRejectPartialHostnameMatches)); outputStart = end; } @@ -1780,30 +1978,36 @@ export default class ExpensiMark { return output.join(''); } - let match = regex.exec(textToCheck); + const matchingRegex = regex; + let match = matchingRegex.exec(textToCheck); let replacedText = ''; let startIndex = 0; + const invalidUrlRanges = shouldRejectPartialHostnameMatches ? findInvalidUrlRanges(textToCheck) : []; + let invalidUrlRangeIndex = 0; while (match !== null) { - // We end the link at the last closing parenthesis that matches an opening parenthesis because unmatched closing parentheses are unlikely to be in the url - // and can be part of markdown for example - let unmatchedOpenParentheses = 0; - let url = match[2]; - for (let i = 0; i < url.length; i++) { - if (url[i] === '(') { - unmatchedOpenParentheses++; - } else if (url[i] === ')') { - // Unmatched closing parenthesis - if (unmatchedOpenParentheses <= 0) { - const numberOfCharsToRemove = url.length - i; - match[0] = match[0].substr(0, match[0].length - numberOfCharsToRemove); - url = url.substr(0, url.length - numberOfCharsToRemove); - break; - } - unmatchedOpenParentheses--; + if (shouldRejectPartialHostnameMatches) { + const matchedUrlOffset = match.index + match[1].length; + while (invalidUrlRangeIndex < invalidUrlRanges.length && invalidUrlRanges[invalidUrlRangeIndex].end <= matchedUrlOffset) { + invalidUrlRangeIndex++; + } + const invalidUrlRange = invalidUrlRanges[invalidUrlRangeIndex]; + const isInsideInvalidUrl = invalidUrlRange !== undefined && invalidUrlRange.start <= matchedUrlOffset && matchedUrlOffset < invalidUrlRange.end; + if (isInsideInvalidUrl) { + // The rejected match may extend beyond the invalid range and include a separate URL. + matchingRegex.lastIndex = invalidUrlRange.end; + match = matchingRegex.exec(textToCheck); + continue; } } + let url = match[2]; + const urlEnd = findUrlEndBeforeUnmatchedParenthesis(url, 0, url.length); + if (urlEnd < url.length) { + match[0] = match[0].slice(0, match[0].length - (url.length - urlEnd)); + url = url.slice(0, urlEnd); + } + // Because we are removing ) parenthesis, some special characters that shouldn't be in the href are in the href // 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 // In that case we should also remove the "." @@ -1870,7 +2074,7 @@ export default class ExpensiMark { startIndex = match.index + match[0].length; // Now we move to the next match that the js regex found in the text - match = regex.exec(textToCheck); + match = matchingRegex.exec(textToCheck); } if (startIndex < textToCheck.length) { replacedText = replacedText.concat(textToCheck.substr(startIndex)); diff --git a/lib/Url.d.ts b/lib/Url.d.ts index fadb39de..10da2e40 100644 --- a/lib/Url.d.ts +++ b/lib/Url.d.ts @@ -1,14 +1,17 @@ declare const URL_PROTOCOL_REGEX: '((ht|f)tps?:\\/\\/)'; -declare const URL_WEBSITE_REGEX: '((ht|f)tps?:\\/\\/)?((?:www\\.)?[a-z0-9](?:[-a-z0-9]*[a-z0-9])?\\.)+(?:XN--VERMGENSBERATUNG-PWB|XN--VERMGENSBERATER-CTB|XN--CLCHC0EA0B2G2A9GCD|XN--W4R85EL8FHU5DNRA|TRAVELERSINSURANCE|NORTHWESTERNMUTUAL|XN--XKC2DL3A5EE0H|XN--MGBERP4A5D4AR|XN--MGBAI9AZGQP6J|XN--MGBAH1A3HJKRD|XN--BCK1B9A5DRE4C|XN--5SU34J936BGSG|XN--3OQ18VL8PN36A|XN--XKC2AL3HYE2A|XN--MGBCPQ6GPA1A|XN--MGBA7C0BBN0A|XN--FZYS8D69UVGM|XN--NQV7FS00EMA|XN--MGBC0A9AZCG|XN--MGBAAKC7DVF|XN--MGBA3A4F16A|XN--LGBBAT1AD8J|XN--KCRX77D1X4A|XN--I1B6B1A6A2E|SANDVIKCOROMANT|KERRYPROPERTIES|AMERICANEXPRESS|XN--RVC1E0AM3E|XN--MGBX4CD0AB|XN--MGBI4ECEXP|XN--MGBCA7DZDO|XN--MGBBH1A71E|XN--MGBAYH7GPA|XN--MGBAAM7A8H|XN--MGBA3A3EJT|XN--JLQ61U9W7B|XN--JLQ480N2RG|XN--H2BREG3EVE|XN--FIQ228C5HS|XN--B4W605FERD|XN--80AQECDR1A|XN--6QQ986B3XL|XN--54B7FTA0CC|WEATHERCHANNEL|KERRYLOGISTICS|COOKINGCHANNEL|CANCERRESEARCH|BANANAREPUBLIC|AMERICANFAMILY|AFAMILYCOMPANY|XN--YGBI2AMMX|XN--YFRO4I67O|XN--TIQ49XQYJ|XN--H2BRJ9C8C|XN--FZC2C9E2C|XN--FPCRJ9C3D|XN--ECKVDTC9D|XN--CCKWCXETD|WOLTERSKLUWER|TRAVELCHANNEL|SPREADBETTING|LIFEINSURANCE|INTERNATIONAL|XN--QCKA1PMC|XN--OGBPF8FL|XN--NGBE9E0A|XN--NGBC5AZD|XN--MK1BU44C|XN--MGBT3DHD|XN--MGBPL2FH|XN--MGBGU82A|XN--MGBAB2BD|XN--MGB9AWBF|XN--GCKR3F0F|XN--8Y0A063A|XN--80ASEHDB|XN--80ADXHKS|XN--4DBRK0CE|XN--45BR5CYL|XN--3E0B707E|VERSICHERUNG|SCHOLARSHIPS|LPLFINANCIAL|CONSTRUCTION|XN--ZFR164B|XN--XHQ521B|XN--W4RS40L|XN--VUQ861B|XN--T60B56A|XN--SES554G|XN--S9BRJ9C|XN--ROVU88B|XN--RHQV96G|XN--Q9JYB4C|XN--PGBS0DH|XN--OTU796D|XN--NYQY26A|XN--MIX891F|XN--MGBTX2B|XN--MGBBH1A|XN--KPRY57D|XN--KPRW13D|XN--JVR189M|XN--J6W193G|XN--IMR513N|XN--HXT814E|XN--H2BRJ9C|XN--GK3AT1E|XN--GECRJ9C|XN--G2XX48C|XN--FLW351E|XN--FJQ720A|XN--FCT429K|XN--EFVY88H|XN--D1ACJ3B|XN--CZR694B|XN--CCK2B3B|XN--9KRT00A|XN--80AO21A|XN--6FRZ82G|XN--55QW42G|XN--45BRJ9C|XN--42C2D9A|XN--3HCRJ9C|XN--3DS443G|XN--3BST00M|XN--2SCRJ9C|XN--1QQW23A|XN--1CK2E1B|XN--11B4C3D|WILLIAMHILL|REDUMBRELLA|PROGRESSIVE|PRODUCTIONS|PLAYSTATION|PHOTOGRAPHY|OLAYANGROUP|MOTORCYCLES|LAMBORGHINI|KERRYHOTELS|INVESTMENTS|FOODNETWORK|ENTERPRISES|ENGINEERING|CREDITUNION|CONTRACTORS|CALVINKLEIN|BRIDGESTONE|BLOCKBUSTER|BLACKFRIDAY|BARCLAYCARD|ACCOUNTANTS|XN--Y9A3AQ|XN--WGBL6A|XN--WGBH1C|XN--UNUP4Y|XN--Q7CE6A|XN--PSSY2U|XN--O3CW4H|XN--MXTQ1M|XN--KPUT3I|XN--IO0A7I|XN--FIQZ9S|XN--FIQS8S|XN--FIQ64B|XN--CZRU2D|XN--CZRS0T|XN--CG4BKI|XN--C2BR7G|XN--9ET52U|XN--9DBQ2A|XN--90A3AC|XN--80ASWG|XN--5TZM5G|XN--55QX5D|XN--4GBRIM|XN--45Q11C|XN--3PXU8K|XN--30RR7Y|VOLKSWAGEN|VLAANDEREN|UNIVERSITY|TECHNOLOGY|TATAMOTORS|SWIFTCOVER|SCHAEFFLER|RESTAURANT|REPUBLICAN|REALESTATE|PRUDENTIAL|PROTECTION|PROPERTIES|ONYOURSIDE|NEXTDIRECT|NATIONWIDE|MITSUBISHI|MANAGEMENT|INDUSTRIES|IMMOBILIEN|HEALTHCARE|FOUNDATION|EXTRASPACE|EUROVISION|CUISINELLA|CREDITCARD|CONSULTING|CAPITALONE|BOEHRINGER|BNPPARIBAS|BASKETBALL|ASSOCIATES|APARTMENTS|ACCOUNTANT|YODOBASHI|XN--VHQUV|XN--TCKWE|XN--QXA6A|XN--P1ACF|XN--NQV7F|XN--NGBRX|XN--L1ACC|XN--J1AMH|XN--J1AEF|XN--FHBEI|XN--E1A4C|XN--D1ALF|XN--C1AVG|XN--90AIS|VACATIONS|TRAVELERS|STOCKHOLM|STATEFARM|STATEBANK|SOLUTIONS|SHANGRILA|SCJOHNSON|RICHARDLI|PRAMERICA|PASSAGENS|PANASONIC|MICROSOFT|MELBOURNE|MARSHALLS|MARKETING|LIFESTYLE|LANDROVER|LANCASTER|KUOKGROUP|INSURANCE|INSTITUTE|HOMESENSE|HOMEGOODS|HOMEDEPOT|HISAMITSU|GOLDPOINT|FURNITURE|FUJIXEROX|FRONTDOOR|FRESENIUS|FIRESTONE|FINANCIAL|FAIRWINDS|EQUIPMENT|EDUCATION|DIRECTORY|COMMUNITY|CHRISTMAS|BLOOMBERG|BARCELONA|AQUARELLE|ANALYTICS|AMSTERDAM|ALLFINANZ|ALFAROMEO|ACCENTURE|YOKOHAMA|XN--QXAM|XN--P1AI|XN--NODE|XN--90AE|WOODSIDE|VERISIGN|VENTURES|VANGUARD|TRAINING|SUPPLIES|STCGROUP|SOFTWARE|SOFTBANK|SHOWTIME|SHOPPING|SERVICES|SECURITY|SAMSCLUB|SAARLAND|RELIANCE|REDSTONE|PROPERTY|PLUMBING|PICTURES|PHARMACY|PARTNERS|OBSERVER|MORTGAGE|MERCKMSD|MEMORIAL|MCKINSEY|MASERATI|MARRIOTT|LUNDBECK|LIGHTING|JPMORGAN|ISTANBUL|IPIRANGA|INFINITI|HOSPITAL|HOLDINGS|HELSINKI|HDFCBANK|GUARDIAN|GRAPHICS|GRAINGER|GOODYEAR|FRONTIER|FOOTBALL|FIRMDALE|FIDELITY|FEEDBACK|EXCHANGE|ETISALAT|ERICSSON|ENGINEER|DOWNLOAD|DISCOVER|DISCOUNT|DIAMONDS|DEMOCRAT|DELOITTE|DELIVERY|COMPUTER|COMMBANK|CLOTHING|CLINIQUE|CLEANING|CITYEATS|CIPRIANI|CATHOLIC|CATERING|CAPETOWN|BUSINESS|BUILDERS|BUDAPEST|BRUSSELS|BROADWAY|BRADESCO|BOUTIQUE|BASEBALL|BARGAINS|BAREFOOT|BARCLAYS|ATTORNEY|ALLSTATE|AIRFORCE|ABUDHABI|ZUERICH|YOUTUBE|YAMAXUN|XFINITY|WINNERS|WINDOWS|WHOSWHO|WEDDING|WEBSITE|WEATHER|WATCHES|WANGGOU|WALMART|TRADING|TOSHIBA|TIFFANY|TICKETS|THEATRE|THEATER|TEMASEK|SYSTEMS|SURGERY|SUPPORT|STORAGE|STAPLES|SINGLES|SHIKSHA|SCIENCE|SCHWARZ|SCHMIDT|SANDVIK|SAMSUNG|REXROTH|REVIEWS|RENTALS|RECIPES|REALTOR|POLITIE|PIONEER|PHILIPS|ORIGINS|ORGANIC|OLDNAVY|OKINAWA|NEUSTAR|NETWORK|NETFLIX|NETBANK|MONSTER|MARKETS|LINCOLN|LIMITED|LECLERC|LATROBE|LASALLE|LANXESS|LACAIXA|KOMATSU|KITCHEN|JUNIPER|JEWELRY|ISMAILI|HYUNDAI|HOTMAIL|HOTELES|HOSTING|HOLIDAY|HITACHI|HANGOUT|HAMBURG|GUITARS|GROCERY|GODADDY|GENTING|GALLERY|FUJITSU|FROGANS|FORSALE|FLOWERS|FLORIST|FLIGHTS|FITNESS|FISHING|FINANCE|FERRERO|FERRARI|FASHION|FARMERS|EXPRESS|EXPOSED|DOMAINS|DIGITAL|DENTIST|CRUISES|CRICKET|COURSES|COUPONS|COUNTRY|CORSICA|COOKING|CONTACT|COMPARE|COMPANY|COMCAST|COLOGNE|COLLEGE|CLUBMED|CITADEL|CHINTAI|CHARITY|CHANNEL|CAREERS|CARAVAN|CAPITAL|BUGATTI|BROTHER|BOOKING|BESTBUY|BENTLEY|BAUHAUS|BANAMEX|AVIANCA|AUSPOST|AUDIBLE|AUCTION|ATHLETA|ANDROID|ALIBABA|AGAKHAN|ACADEMY|ABOGADO|ZAPPOS|YANDEX|YACHTS|XIHUAN|WEBCAM|WALTER|VUELOS|VOYAGE|VOTING|VISION|VIRGIN|VILLAS|VIKING|VIAJES|UNICOM|TRAVEL|TOYOTA|TKMAXX|TJMAXX|TIENDA|TENNIS|TATTOO|TARGET|TAOBAO|TAIPEI|SYDNEY|SWATCH|SUZUKI|SUPPLY|STUDIO|STREAM|SOCIAL|SOCCER|SHOUJI|SELECT|SECURE|SEARCH|SCHULE|SCHOOL|SANOFI|SAKURA|SAFETY|RYUKYU|ROGERS|ROCHER|REVIEW|REPORT|REPAIR|REISEN|REALTY|RACING|QUEBEC|PICTET|PHYSIO|PHOTOS|PFIZER|OTSUKA|ORANGE|ORACLE|ONLINE|OLAYAN|OFFICE|NOWRUZ|NORTON|NISSAY|NISSAN|NATURA|NAGOYA|MUTUAL|MUSEUM|MOSCOW|MORMON|MONASH|MOBILE|MATTEL|MARKET|MAKEUP|MAISON|MADRID|LUXURY|LONDON|LOCKER|LIVING|LEFRAK|LAWYER|LATINO|LANCIA|KOSHER|KINDLE|KINDER|KAUFEN|JUEGOS|JOBURG|JAGUAR|INTUIT|INSURE|IMAMAT|HUGHES|HOTELS|HOCKEY|HIPHOP|HERMES|HEALTH|GRATIS|GOOGLE|GLOBAL|GIVING|GEORGE|GARDEN|GALLUP|FUTBOL|FLICKR|FAMILY|EXPERT|EVENTS|ESTATE|ENERGY|EMERCK|DURBAN|DUPONT|DUNLOP|DOCTOR|DIRECT|DESIGN|DENTAL|DEGREE|DEALER|DATSUN|DATING|CRUISE|CREDIT|COUPON|CONDOS|COMSEC|COFFEE|CLINIC|CLAIMS|CIRCLE|CHURCH|CHROME|CHANEL|CENTER|CASINO|CAREER|CAMERA|BROKER|BOSTON|BOSTIK|BHARTI|BERLIN|BEAUTY|BAYERN|AUTHOR|ARAMCO|ANQUAN|AMAZON|ALSTOM|ALSACE|ALIPAY|AIRTEL|AIRBUS|AGENCY|AFRICA|ABBVIE|ABBOTT|ABARTH|YAHOO|XEROX|WORLD|WORKS|WEIBO|WEBER|WATCH|WALES|VOLVO|VODKA|VIDEO|VEGAS|UBANK|TUSHU|TUNES|TRUST|TRADE|TOURS|TOTAL|TORAY|TOOLS|TOKYO|TODAY|TMALL|TIROL|TIRES|TATAR|SWISS|SUCKS|STYLE|STUDY|STORE|STADA|SPORT|SPACE|SOLAR|SMILE|SMART|SLING|SKYPE|SHOES|SHELL|SHARP|SEVEN|SENER|SALON|RUGBY|RODEO|ROCKS|RICOH|REISE|REHAB|RADIO|QUEST|PROMO|PRIME|PRESS|PRAXI|POKER|PLACE|PIZZA|PHOTO|PHONE|PARTY|PARTS|PARIS|OSAKA|OMEGA|NOWTV|NOKIA|NINJA|NIKON|NEXUS|MOVIE|MONEY|MIAMI|MEDIA|MANGO|MACYS|LOTTO|LOTTE|LOCUS|LOANS|LIXIL|LIPSY|LINDE|LILLY|LEXUS|LEGAL|LEASE|LAMER|KYOTO|KOELN|JETZT|IVECO|IRISH|IKANO|HYATT|HOUSE|HORSE|HONDA|HOMES|GUIDE|GUCCI|GROUP|GRIPE|GREEN|GMAIL|GLOBO|GLASS|GLADE|GIVES|GIFTS|GAMES|GALLO|FORUM|FOREX|FINAL|FEDEX|FAITH|EPSON|EMAIL|EDEKA|EARTH|DUBAI|DRIVE|DELTA|DEALS|DANCE|DABUR|CYMRU|CROWN|CODES|COACH|CLOUD|CLICK|CITIC|CISCO|CHEAP|CHASE|CARDS|CANON|BUILD|BOSCH|BOATS|BLACK|BINGO|BIBLE|BEATS|BAIDU|AZURE|AUTOS|AUDIO|ARCHI|APPLE|AMICA|AMFAM|AETNA|ADULT|ACTOR|ZONE|ZERO|ZARA|YOGA|XBOX|WORK|WINE|WIKI|WIEN|WEIR|WANG|VOTO|VOTE|VIVO|VIVA|VISA|VANA|TUBE|TOYS|TOWN|TIPS|TIAA|TEVA|TECH|TEAM|TAXI|TALK|SURF|STAR|SPOT|SONY|SONG|SOHU|SNCF|SKIN|SITE|SINA|SILK|SHOW|SHOP|SHIA|SHAW|SEXY|SEEK|SEAT|SCOT|SAXO|SAVE|SARL|SALE|SAFE|RUHR|RSVP|ROOM|RMIT|RICH|REST|RENT|REIT|READ|RAID|QPON|PROF|PROD|POST|PORN|POHL|PLUS|PLAY|PINK|PING|PICS|PCCW|PARS|PAGE|OPEN|OLLO|NIKE|NICO|NEXT|NEWS|NAVY|NAME|MOTO|MODA|MOBI|MINT|MINI|MENU|MEME|MEET|MAIF|LUXE|LTDA|LOVE|LOFT|LOAN|LIVE|LINK|LIMO|LIKE|LIFE|LIDL|LGBT|LEGO|LAND|KRED|KPMG|KIWI|KDDI|JPRS|JOBS|JEEP|JAVA|ITAU|INFO|IMMO|IMDB|IEEE|ICBC|HSBC|HOST|HGTV|HERE|HELP|HDFC|HAUS|HAIR|GURU|GUGE|GOOG|GOLF|GOLD|GMBH|GIFT|GGEE|GENT|GBIZ|GAME|FUND|FREE|FORD|FOOD|FLIR|FISH|FIRE|FILM|FIDO|FIAT|FAST|FARM|FANS|FAIL|FAGE|ERNI|DVAG|DUCK|DOCS|DISH|DIET|DESI|DELL|DEAL|DCLK|DATE|DATA|CYOU|COOP|COOL|CLUB|CITY|CITI|CHAT|CERN|CBRE|CASH|CASE|CASA|CARS|CARE|CAMP|CALL|CAFE|BUZZ|BOOK|BOND|BOFA|BLUE|BLOG|BING|BIKE|BEST|BEER|BBVA|BANK|BAND|BABY|AUTO|AUDI|ASIA|ASDA|ARTE|ARPA|ARMY|ARAB|AMEX|ALLY|AKDN|AERO|ADAC|ABLE|AARP|ZIP|YUN|YOU|XYZ|XXX|XIN|WTF|WTC|WOW|WME|WIN|WED|VIP|VIN|VIG|VET|UPS|UOL|UNO|UBS|TVS|TUI|TRV|TOP|TJX|THD|TEL|TDK|TCI|TAX|TAB|STC|SRL|SPA|SOY|SKY|SKI|SFR|SEX|SEW|SES|SCB|SCA|SBS|SBI|SAS|SAP|RWE|RUN|RIP|RIO|RIL|REN|RED|QVC|PWC|PUB|PRU|PRO|PNC|PIN|PID|PHD|PET|PAY|OVH|OTT|ORG|OOO|ONL|ONG|ONE|OFF|OBI|NYC|NTT|NRW|NRA|NOW|NHK|NGO|NFL|NEW|NET|NEC|NBA|NAB|MTR|MTN|MSD|MOV|MOM|MOI|MOE|MMA|MLS|MLB|MIT|MIL|MEN|MED|MBA|MAP|MAN|LTD|LPL|LOL|LLP|LLC|LDS|LAW|LAT|KRD|KPN|KIM|KIA|KFH|JOY|JOT|JNJ|JMP|JLL|JIO|JCB|ITV|IST|INT|INK|ING|INC|IFM|ICU|ICE|IBM|HOW|HOT|HKT|HIV|HBO|GOV|GOT|GOP|GOO|GMX|GMO|GLE|GEA|GDN|GAY|GAP|GAL|FYI|FUN|FTR|FRL|FOX|FOO|FLY|FIT|FAN|EUS|ESQ|EDU|ECO|EAT|DVR|DTV|DOT|DOG|DNP|DIY|DHL|DEV|DDS|DAY|DAD|CSC|CRS|CPA|COM|CFD|CFA|CEO|CBS|CBN|CBA|CAT|CAR|CAM|CAL|CAB|BZH|BUY|BOX|BOT|BOO|BOM|BMW|BMS|BIZ|BIO|BID|BET|BCN|BCG|BBT|BBC|BAR|AXA|AWS|ART|APP|AOL|ANZ|AIG|AFL|AEG|ADS|ACO|ABC|ABB|AAA|ZW|ZM|ZA|YT|YE|WS|WF|VU|VN|VI|VG|VE|VC|VA|UZ|UY|US|UK|UG|UA|TZ|TW|TV|TT|TR|TO|TN|TM|TL|TK|TJ|TH|TG|TF|TD|TC|SZ|SY|SX|SV|SU|ST|SS|SR|SO|SN|SM|SL|SK|SJ|SI|SH|SG|SE|SD|SC|SB|SA|RW|RU|RS|RO|RE|QA|PY|PW|PT|PS|PR|PN|PM|PL|PK|PH|PG|PF|PE|PA|OM|NZ|NU|NR|NP|NO|NL|NI|NG|NF|NE|NC|NA|MZ|MY|MX|MW|MV|MU|MT|MS|MR|MQ|MP|MO|MN|MM|ML|MK|MH|MG|ME|MD|MC|MA|LY|LV|LU|LT|LS|LR|LK|LI|LC|LB|LA|KZ|KY|KW|KR|KP|KN|KM|KI|KH|KG|KE|JP|JO|JM|JE|IT|IS|IR|IQ|IO|IN|IM|IL|IE|ID|HU|HT|HR|HN|HM|HK|GY|GW|GU|GT|GS|GR|GQ|GP|GN|GM|GL|GI|GH|GG|GF|GE|GD|GB|GA|FR|FO|FM|FK|FJ|FI|EU|ET|ES|ER|EG|EE|EC|DZ|DO|DM|DK|DJ|DE|CZ|CY|CX|CW|CV|CU|CR|CO|CN|CM|CL|CK|CI|CH|CG|CF|CD|CC|CA|BZ|BY|BW|BV|BT|BS|BR|BO|BN|BM|BJ|BI|BH|BG|BF|BE|BD|BB|BA|AZ|AX|AW|AU|AT|AS|AR|AQ|AO|AM|AL|AI|AG|AF|AE|AD|AC|SJC|RNO|LAX)(?:\\:([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|(?=_))'; -declare const URL_PATH_REGEX: '(?:(?:[.,=(+$!*]|&(?:amp|quot|#x27);)?\\/(?:[-\\w$@.+!*:(),=%~]|&(?:amp|quot|#x27);)*(?:[-\\w~@:%)]|&(?:amp|quot|#x27);)|\\/)*'; -declare const URL_PARAM_REGEX: '(?:\\?(?:[-\\w$@.+!*()\\/,=%{}:;\\[\\]\\|_|~]|&(?:amp|quot|#x27);)*)?'; -declare const URL_FRAGMENT_REGEX: '(?:#(?:[-\\w$@.+!*()[\\],=%;\\/:~]|&(?:amp|quot|#x27);)*)?'; -declare const URL_REGEX: '((((ht|f)tps?:\\/\\/)?((?:www\\.)?[a-z0-9](?:[-a-z0-9]*[a-z0-9])?\\.)+(?:XN--VERMGENSBERATUNG-PWB|XN--VERMGENSBERATER-CTB|XN--CLCHC0EA0B2G2A9GCD|XN--W4R85EL8FHU5DNRA|TRAVELERSINSURANCE|NORTHWESTERNMUTUAL|XN--XKC2DL3A5EE0H|XN--MGBERP4A5D4AR|XN--MGBAI9AZGQP6J|XN--MGBAH1A3HJKRD|XN--BCK1B9A5DRE4C|XN--5SU34J936BGSG|XN--3OQ18VL8PN36A|XN--XKC2AL3HYE2A|XN--MGBCPQ6GPA1A|XN--MGBA7C0BBN0A|XN--FZYS8D69UVGM|XN--NQV7FS00EMA|XN--MGBC0A9AZCG|XN--MGBAAKC7DVF|XN--MGBA3A4F16A|XN--LGBBAT1AD8J|XN--KCRX77D1X4A|XN--I1B6B1A6A2E|SANDVIKCOROMANT|KERRYPROPERTIES|AMERICANEXPRESS|XN--RVC1E0AM3E|XN--MGBX4CD0AB|XN--MGBI4ECEXP|XN--MGBCA7DZDO|XN--MGBBH1A71E|XN--MGBAYH7GPA|XN--MGBAAM7A8H|XN--MGBA3A3EJT|XN--JLQ61U9W7B|XN--JLQ480N2RG|XN--H2BREG3EVE|XN--FIQ228C5HS|XN--B4W605FERD|XN--80AQECDR1A|XN--6QQ986B3XL|XN--54B7FTA0CC|WEATHERCHANNEL|KERRYLOGISTICS|COOKINGCHANNEL|CANCERRESEARCH|BANANAREPUBLIC|AMERICANFAMILY|AFAMILYCOMPANY|XN--YGBI2AMMX|XN--YFRO4I67O|XN--TIQ49XQYJ|XN--H2BRJ9C8C|XN--FZC2C9E2C|XN--FPCRJ9C3D|XN--ECKVDTC9D|XN--CCKWCXETD|WOLTERSKLUWER|TRAVELCHANNEL|SPREADBETTING|LIFEINSURANCE|INTERNATIONAL|XN--QCKA1PMC|XN--OGBPF8FL|XN--NGBE9E0A|XN--NGBC5AZD|XN--MK1BU44C|XN--MGBT3DHD|XN--MGBPL2FH|XN--MGBGU82A|XN--MGBAB2BD|XN--MGB9AWBF|XN--GCKR3F0F|XN--8Y0A063A|XN--80ASEHDB|XN--80ADXHKS|XN--4DBRK0CE|XN--45BR5CYL|XN--3E0B707E|VERSICHERUNG|SCHOLARSHIPS|LPLFINANCIAL|CONSTRUCTION|XN--ZFR164B|XN--XHQ521B|XN--W4RS40L|XN--VUQ861B|XN--T60B56A|XN--SES554G|XN--S9BRJ9C|XN--ROVU88B|XN--RHQV96G|XN--Q9JYB4C|XN--PGBS0DH|XN--OTU796D|XN--NYQY26A|XN--MIX891F|XN--MGBTX2B|XN--MGBBH1A|XN--KPRY57D|XN--KPRW13D|XN--JVR189M|XN--J6W193G|XN--IMR513N|XN--HXT814E|XN--H2BRJ9C|XN--GK3AT1E|XN--GECRJ9C|XN--G2XX48C|XN--FLW351E|XN--FJQ720A|XN--FCT429K|XN--EFVY88H|XN--D1ACJ3B|XN--CZR694B|XN--CCK2B3B|XN--9KRT00A|XN--80AO21A|XN--6FRZ82G|XN--55QW42G|XN--45BRJ9C|XN--42C2D9A|XN--3HCRJ9C|XN--3DS443G|XN--3BST00M|XN--2SCRJ9C|XN--1QQW23A|XN--1CK2E1B|XN--11B4C3D|WILLIAMHILL|REDUMBRELLA|PROGRESSIVE|PRODUCTIONS|PLAYSTATION|PHOTOGRAPHY|OLAYANGROUP|MOTORCYCLES|LAMBORGHINI|KERRYHOTELS|INVESTMENTS|FOODNETWORK|ENTERPRISES|ENGINEERING|CREDITUNION|CONTRACTORS|CALVINKLEIN|BRIDGESTONE|BLOCKBUSTER|BLACKFRIDAY|BARCLAYCARD|ACCOUNTANTS|XN--Y9A3AQ|XN--WGBL6A|XN--WGBH1C|XN--UNUP4Y|XN--Q7CE6A|XN--PSSY2U|XN--O3CW4H|XN--MXTQ1M|XN--KPUT3I|XN--IO0A7I|XN--FIQZ9S|XN--FIQS8S|XN--FIQ64B|XN--CZRU2D|XN--CZRS0T|XN--CG4BKI|XN--C2BR7G|XN--9ET52U|XN--9DBQ2A|XN--90A3AC|XN--80ASWG|XN--5TZM5G|XN--55QX5D|XN--4GBRIM|XN--45Q11C|XN--3PXU8K|XN--30RR7Y|VOLKSWAGEN|VLAANDEREN|UNIVERSITY|TECHNOLOGY|TATAMOTORS|SWIFTCOVER|SCHAEFFLER|RESTAURANT|REPUBLICAN|REALESTATE|PRUDENTIAL|PROTECTION|PROPERTIES|ONYOURSIDE|NEXTDIRECT|NATIONWIDE|MITSUBISHI|MANAGEMENT|INDUSTRIES|IMMOBILIEN|HEALTHCARE|FOUNDATION|EXTRASPACE|EUROVISION|CUISINELLA|CREDITCARD|CONSULTING|CAPITALONE|BOEHRINGER|BNPPARIBAS|BASKETBALL|ASSOCIATES|APARTMENTS|ACCOUNTANT|YODOBASHI|XN--VHQUV|XN--TCKWE|XN--QXA6A|XN--P1ACF|XN--NQV7F|XN--NGBRX|XN--L1ACC|XN--J1AMH|XN--J1AEF|XN--FHBEI|XN--E1A4C|XN--D1ALF|XN--C1AVG|XN--90AIS|VACATIONS|TRAVELERS|STOCKHOLM|STATEFARM|STATEBANK|SOLUTIONS|SHANGRILA|SCJOHNSON|RICHARDLI|PRAMERICA|PASSAGENS|PANASONIC|MICROSOFT|MELBOURNE|MARSHALLS|MARKETING|LIFESTYLE|LANDROVER|LANCASTER|KUOKGROUP|INSURANCE|INSTITUTE|HOMESENSE|HOMEGOODS|HOMEDEPOT|HISAMITSU|GOLDPOINT|FURNITURE|FUJIXEROX|FRONTDOOR|FRESENIUS|FIRESTONE|FINANCIAL|FAIRWINDS|EQUIPMENT|EDUCATION|DIRECTORY|COMMUNITY|CHRISTMAS|BLOOMBERG|BARCELONA|AQUARELLE|ANALYTICS|AMSTERDAM|ALLFINANZ|ALFAROMEO|ACCENTURE|YOKOHAMA|XN--QXAM|XN--P1AI|XN--NODE|XN--90AE|WOODSIDE|VERISIGN|VENTURES|VANGUARD|TRAINING|SUPPLIES|STCGROUP|SOFTWARE|SOFTBANK|SHOWTIME|SHOPPING|SERVICES|SECURITY|SAMSCLUB|SAARLAND|RELIANCE|REDSTONE|PROPERTY|PLUMBING|PICTURES|PHARMACY|PARTNERS|OBSERVER|MORTGAGE|MERCKMSD|MEMORIAL|MCKINSEY|MASERATI|MARRIOTT|LUNDBECK|LIGHTING|JPMORGAN|ISTANBUL|IPIRANGA|INFINITI|HOSPITAL|HOLDINGS|HELSINKI|HDFCBANK|GUARDIAN|GRAPHICS|GRAINGER|GOODYEAR|FRONTIER|FOOTBALL|FIRMDALE|FIDELITY|FEEDBACK|EXCHANGE|ETISALAT|ERICSSON|ENGINEER|DOWNLOAD|DISCOVER|DISCOUNT|DIAMONDS|DEMOCRAT|DELOITTE|DELIVERY|COMPUTER|COMMBANK|CLOTHING|CLINIQUE|CLEANING|CITYEATS|CIPRIANI|CATHOLIC|CATERING|CAPETOWN|BUSINESS|BUILDERS|BUDAPEST|BRUSSELS|BROADWAY|BRADESCO|BOUTIQUE|BASEBALL|BARGAINS|BAREFOOT|BARCLAYS|ATTORNEY|ALLSTATE|AIRFORCE|ABUDHABI|ZUERICH|YOUTUBE|YAMAXUN|XFINITY|WINNERS|WINDOWS|WHOSWHO|WEDDING|WEBSITE|WEATHER|WATCHES|WANGGOU|WALMART|TRADING|TOSHIBA|TIFFANY|TICKETS|THEATRE|THEATER|TEMASEK|SYSTEMS|SURGERY|SUPPORT|STORAGE|STAPLES|SINGLES|SHIKSHA|SCIENCE|SCHWARZ|SCHMIDT|SANDVIK|SAMSUNG|REXROTH|REVIEWS|RENTALS|RECIPES|REALTOR|POLITIE|PIONEER|PHILIPS|ORIGINS|ORGANIC|OLDNAVY|OKINAWA|NEUSTAR|NETWORK|NETFLIX|NETBANK|MONSTER|MARKETS|LINCOLN|LIMITED|LECLERC|LATROBE|LASALLE|LANXESS|LACAIXA|KOMATSU|KITCHEN|JUNIPER|JEWELRY|ISMAILI|HYUNDAI|HOTMAIL|HOTELES|HOSTING|HOLIDAY|HITACHI|HANGOUT|HAMBURG|GUITARS|GROCERY|GODADDY|GENTING|GALLERY|FUJITSU|FROGANS|FORSALE|FLOWERS|FLORIST|FLIGHTS|FITNESS|FISHING|FINANCE|FERRERO|FERRARI|FASHION|FARMERS|EXPRESS|EXPOSED|DOMAINS|DIGITAL|DENTIST|CRUISES|CRICKET|COURSES|COUPONS|COUNTRY|CORSICA|COOKING|CONTACT|COMPARE|COMPANY|COMCAST|COLOGNE|COLLEGE|CLUBMED|CITADEL|CHINTAI|CHARITY|CHANNEL|CAREERS|CARAVAN|CAPITAL|BUGATTI|BROTHER|BOOKING|BESTBUY|BENTLEY|BAUHAUS|BANAMEX|AVIANCA|AUSPOST|AUDIBLE|AUCTION|ATHLETA|ANDROID|ALIBABA|AGAKHAN|ACADEMY|ABOGADO|ZAPPOS|YANDEX|YACHTS|XIHUAN|WEBCAM|WALTER|VUELOS|VOYAGE|VOTING|VISION|VIRGIN|VILLAS|VIKING|VIAJES|UNICOM|TRAVEL|TOYOTA|TKMAXX|TJMAXX|TIENDA|TENNIS|TATTOO|TARGET|TAOBAO|TAIPEI|SYDNEY|SWATCH|SUZUKI|SUPPLY|STUDIO|STREAM|SOCIAL|SOCCER|SHOUJI|SELECT|SECURE|SEARCH|SCHULE|SCHOOL|SANOFI|SAKURA|SAFETY|RYUKYU|ROGERS|ROCHER|REVIEW|REPORT|REPAIR|REISEN|REALTY|RACING|QUEBEC|PICTET|PHYSIO|PHOTOS|PFIZER|OTSUKA|ORANGE|ORACLE|ONLINE|OLAYAN|OFFICE|NOWRUZ|NORTON|NISSAY|NISSAN|NATURA|NAGOYA|MUTUAL|MUSEUM|MOSCOW|MORMON|MONASH|MOBILE|MATTEL|MARKET|MAKEUP|MAISON|MADRID|LUXURY|LONDON|LOCKER|LIVING|LEFRAK|LAWYER|LATINO|LANCIA|KOSHER|KINDLE|KINDER|KAUFEN|JUEGOS|JOBURG|JAGUAR|INTUIT|INSURE|IMAMAT|HUGHES|HOTELS|HOCKEY|HIPHOP|HERMES|HEALTH|GRATIS|GOOGLE|GLOBAL|GIVING|GEORGE|GARDEN|GALLUP|FUTBOL|FLICKR|FAMILY|EXPERT|EVENTS|ESTATE|ENERGY|EMERCK|DURBAN|DUPONT|DUNLOP|DOCTOR|DIRECT|DESIGN|DENTAL|DEGREE|DEALER|DATSUN|DATING|CRUISE|CREDIT|COUPON|CONDOS|COMSEC|COFFEE|CLINIC|CLAIMS|CIRCLE|CHURCH|CHROME|CHANEL|CENTER|CASINO|CAREER|CAMERA|BROKER|BOSTON|BOSTIK|BHARTI|BERLIN|BEAUTY|BAYERN|AUTHOR|ARAMCO|ANQUAN|AMAZON|ALSTOM|ALSACE|ALIPAY|AIRTEL|AIRBUS|AGENCY|AFRICA|ABBVIE|ABBOTT|ABARTH|YAHOO|XEROX|WORLD|WORKS|WEIBO|WEBER|WATCH|WALES|VOLVO|VODKA|VIDEO|VEGAS|UBANK|TUSHU|TUNES|TRUST|TRADE|TOURS|TOTAL|TORAY|TOOLS|TOKYO|TODAY|TMALL|TIROL|TIRES|TATAR|SWISS|SUCKS|STYLE|STUDY|STORE|STADA|SPORT|SPACE|SOLAR|SMILE|SMART|SLING|SKYPE|SHOES|SHELL|SHARP|SEVEN|SENER|SALON|RUGBY|RODEO|ROCKS|RICOH|REISE|REHAB|RADIO|QUEST|PROMO|PRIME|PRESS|PRAXI|POKER|PLACE|PIZZA|PHOTO|PHONE|PARTY|PARTS|PARIS|OSAKA|OMEGA|NOWTV|NOKIA|NINJA|NIKON|NEXUS|MOVIE|MONEY|MIAMI|MEDIA|MANGO|MACYS|LOTTO|LOTTE|LOCUS|LOANS|LIXIL|LIPSY|LINDE|LILLY|LEXUS|LEGAL|LEASE|LAMER|KYOTO|KOELN|JETZT|IVECO|IRISH|IKANO|HYATT|HOUSE|HORSE|HONDA|HOMES|GUIDE|GUCCI|GROUP|GRIPE|GREEN|GMAIL|GLOBO|GLASS|GLADE|GIVES|GIFTS|GAMES|GALLO|FORUM|FOREX|FINAL|FEDEX|FAITH|EPSON|EMAIL|EDEKA|EARTH|DUBAI|DRIVE|DELTA|DEALS|DANCE|DABUR|CYMRU|CROWN|CODES|COACH|CLOUD|CLICK|CITIC|CISCO|CHEAP|CHASE|CARDS|CANON|BUILD|BOSCH|BOATS|BLACK|BINGO|BIBLE|BEATS|BAIDU|AZURE|AUTOS|AUDIO|ARCHI|APPLE|AMICA|AMFAM|AETNA|ADULT|ACTOR|ZONE|ZERO|ZARA|YOGA|XBOX|WORK|WINE|WIKI|WIEN|WEIR|WANG|VOTO|VOTE|VIVO|VIVA|VISA|VANA|TUBE|TOYS|TOWN|TIPS|TIAA|TEVA|TECH|TEAM|TAXI|TALK|SURF|STAR|SPOT|SONY|SONG|SOHU|SNCF|SKIN|SITE|SINA|SILK|SHOW|SHOP|SHIA|SHAW|SEXY|SEEK|SEAT|SCOT|SAXO|SAVE|SARL|SALE|SAFE|RUHR|RSVP|ROOM|RMIT|RICH|REST|RENT|REIT|READ|RAID|QPON|PROF|PROD|POST|PORN|POHL|PLUS|PLAY|PINK|PING|PICS|PCCW|PARS|PAGE|OPEN|OLLO|NIKE|NICO|NEXT|NEWS|NAVY|NAME|MOTO|MODA|MOBI|MINT|MINI|MENU|MEME|MEET|MAIF|LUXE|LTDA|LOVE|LOFT|LOAN|LIVE|LINK|LIMO|LIKE|LIFE|LIDL|LGBT|LEGO|LAND|KRED|KPMG|KIWI|KDDI|JPRS|JOBS|JEEP|JAVA|ITAU|INFO|IMMO|IMDB|IEEE|ICBC|HSBC|HOST|HGTV|HERE|HELP|HDFC|HAUS|HAIR|GURU|GUGE|GOOG|GOLF|GOLD|GMBH|GIFT|GGEE|GENT|GBIZ|GAME|FUND|FREE|FORD|FOOD|FLIR|FISH|FIRE|FILM|FIDO|FIAT|FAST|FARM|FANS|FAIL|FAGE|ERNI|DVAG|DUCK|DOCS|DISH|DIET|DESI|DELL|DEAL|DCLK|DATE|DATA|CYOU|COOP|COOL|CLUB|CITY|CITI|CHAT|CERN|CBRE|CASH|CASE|CASA|CARS|CARE|CAMP|CALL|CAFE|BUZZ|BOOK|BOND|BOFA|BLUE|BLOG|BING|BIKE|BEST|BEER|BBVA|BANK|BAND|BABY|AUTO|AUDI|ASIA|ASDA|ARTE|ARPA|ARMY|ARAB|AMEX|ALLY|AKDN|AERO|ADAC|ABLE|AARP|ZIP|YUN|YOU|XYZ|XXX|XIN|WTF|WTC|WOW|WME|WIN|WED|VIP|VIN|VIG|VET|UPS|UOL|UNO|UBS|TVS|TUI|TRV|TOP|TJX|THD|TEL|TDK|TCI|TAX|TAB|STC|SRL|SPA|SOY|SKY|SKI|SFR|SEX|SEW|SES|SCB|SCA|SBS|SBI|SAS|SAP|RWE|RUN|RIP|RIO|RIL|REN|RED|QVC|PWC|PUB|PRU|PRO|PNC|PIN|PID|PHD|PET|PAY|OVH|OTT|ORG|OOO|ONL|ONG|ONE|OFF|OBI|NYC|NTT|NRW|NRA|NOW|NHK|NGO|NFL|NEW|NET|NEC|NBA|NAB|MTR|MTN|MSD|MOV|MOM|MOI|MOE|MMA|MLS|MLB|MIT|MIL|MEN|MED|MBA|MAP|MAN|LTD|LPL|LOL|LLP|LLC|LDS|LAW|LAT|KRD|KPN|KIM|KIA|KFH|JOY|JOT|JNJ|JMP|JLL|JIO|JCB|ITV|IST|INT|INK|ING|INC|IFM|ICU|ICE|IBM|HOW|HOT|HKT|HIV|HBO|GOV|GOT|GOP|GOO|GMX|GMO|GLE|GEA|GDN|GAY|GAP|GAL|FYI|FUN|FTR|FRL|FOX|FOO|FLY|FIT|FAN|EUS|ESQ|EDU|ECO|EAT|DVR|DTV|DOT|DOG|DNP|DIY|DHL|DEV|DDS|DAY|DAD|CSC|CRS|CPA|COM|CFD|CFA|CEO|CBS|CBN|CBA|CAT|CAR|CAM|CAL|CAB|BZH|BUY|BOX|BOT|BOO|BOM|BMW|BMS|BIZ|BIO|BID|BET|BCN|BCG|BBT|BBC|BAR|AXA|AWS|ART|APP|AOL|ANZ|AIG|AFL|AEG|ADS|ACO|ABC|ABB|AAA|ZW|ZM|ZA|YT|YE|WS|WF|VU|VN|VI|VG|VE|VC|VA|UZ|UY|US|UK|UG|UA|TZ|TW|TV|TT|TR|TO|TN|TM|TL|TK|TJ|TH|TG|TF|TD|TC|SZ|SY|SX|SV|SU|ST|SS|SR|SO|SN|SM|SL|SK|SJ|SI|SH|SG|SE|SD|SC|SB|SA|RW|RU|RS|RO|RE|QA|PY|PW|PT|PS|PR|PN|PM|PL|PK|PH|PG|PF|PE|PA|OM|NZ|NU|NR|NP|NO|NL|NI|NG|NF|NE|NC|NA|MZ|MY|MX|MW|MV|MU|MT|MS|MR|MQ|MP|MO|MN|MM|ML|MK|MH|MG|ME|MD|MC|MA|LY|LV|LU|LT|LS|LR|LK|LI|LC|LB|LA|KZ|KY|KW|KR|KP|KN|KM|KI|KH|KG|KE|JP|JO|JM|JE|IT|IS|IR|IQ|IO|IN|IM|IL|IE|ID|HU|HT|HR|HN|HM|HK|GY|GW|GU|GT|GS|GR|GQ|GP|GN|GM|GL|GI|GH|GG|GF|GE|GD|GB|GA|FR|FO|FM|FK|FJ|FI|EU|ET|ES|ER|EG|EE|EC|DZ|DO|DM|DK|DJ|DE|CZ|CY|CX|CW|CV|CU|CR|CO|CN|CM|CL|CK|CI|CH|CG|CF|CD|CC|CA|BZ|BY|BW|BV|BT|BS|BR|BO|BN|BM|BJ|BI|BH|BG|BF|BE|BD|BB|BA|AZ|AX|AW|AU|AT|AS|AR|AQ|AO|AM|AL|AI|AG|AF|AE|AD|AC|SJC|RNO|LAX)(?:\\:([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|(?=_)))(?:(?:[.,=(+$!*]|&(?:amp|quot|#x27);)?\\/(?:[-\\w$@.+!*:(),=%~]|&(?:amp|quot|#x27);)*(?:[-\\w~@:%)]|&(?:amp|quot|#x27);)|\\/)*(?:(?:\\?(?:[-\\w$@.+!*()\\/,=%{}:;\\[\\]\\|_|~]|&(?:amp|quot|#x27);)*)?|(?:#(?:[-\\w$@.+!*()[\\],=%;\\/:~]|&(?:amp|quot|#x27);)*)?)*)'; +declare 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])'; +declare const URL_HOSTNAME_LABEL_REGEX: '[a-z0-9](?=(?