UNPKG

autolinker

Version:

Utility to automatically link the URLs, email addresses, phone numbers, hashtags, and mentions (Twitter, Instagram) in a given block of text/HTML

1,154 lines 51.4 kB
import { UrlMatch } from '../match/url-match'; import { assertNever } from '../utils'; import { httpSchemeRe, isDomainLabelChar, isDomainLabelStartChar, isPathChar, isSchemeChar, isSchemeStartChar, isUrlSuffixStartChar, isValidIpV4Address, isValidSchemeUrl, isValidTldMatch, } from './uri-utils'; import { isEmailLocalPartChar, isEmailLocalPartStartChar, isValidEmail, mailtoSchemePrefixRe, } from './email-utils'; import { EmailMatch } from '../match/email-match'; import { isHashtagTextChar, isValidHashtag } from './hashtag-utils'; import { HashtagMatch } from '../match/hashtag-match'; import { isMentionTextChar, isValidMention } from './mention-utils'; import { MentionMatch } from '../match/mention-match'; import { isPhoneNumberSeparatorChar, isPhoneNumberControlChar, isValidPhoneNumber, } from './phone-number-utils'; import { PhoneMatch } from '../match/phone-match'; import { isAlphaNumericOrMarkChar, isCloseBraceChar, isDigitChar, isOpenBraceChar, isUrlSuffixNotAllowedAsFinalChar, } from '../char-utils'; // For debugging: search for and uncomment other "For debugging" lines // import CliTable from 'cli-table'; /** * Context object containing all the state needed by the state machine functions. * * ## Historical note * * In v4.1.1, we used nested functions to handle the context via closures, but * this necessitated re-creating the functions for each call to `parseMatches()`, * which made them difficult for v8 to JIT optimize. In v4.1.2, we lifted all of * the functions to the top-level scope and passed the context object between * them, which allows the functions to be JIT compiled once and reused. */ var ParseMatchesContext = /** @class */ (function () { function ParseMatchesContext(text, args) { this.charIdx = 0; // Current character index being processed this.matches = []; // Collection of matches found this._stateMachines = []; // Array of active state machines this.schemeUrlMachinesCount = 0; // part of an optimization to remove the need to go into a slow code block when unnecessary. Since it's been so long since the initial implementation, not sure that this can ever go above 1, but keeping it as a counter to be safe this.text = text; this.tagBuilder = args.tagBuilder; this.stripPrefix = args.stripPrefix; this.stripTrailingSlash = args.stripTrailingSlash; this.decodePercentEncoding = args.decodePercentEncoding; this.hashtagServiceName = args.hashtagServiceName; this.mentionServiceName = args.mentionServiceName; } Object.defineProperty(ParseMatchesContext.prototype, "stateMachines", { get: function () { return this._stateMachines; }, enumerable: false, configurable: true }); ParseMatchesContext.prototype.addMachine = function (stateMachine) { this._stateMachines.push(stateMachine); if (isSchemeUrlStateMachine(stateMachine)) { this.schemeUrlMachinesCount++; } }; ParseMatchesContext.prototype.removeMachine = function (stateMachine) { // Performance note: this was originally implemented with Array.prototype.splice() // and mutated the array in place. Switching to filter added ~280ops/sec // on the benchmark, although likely at the expense of GC time. Perhaps // in the future, we implement a rotating array so we never need to move // or clean anything up this._stateMachines = this._stateMachines.filter(function (m) { return m !== stateMachine; }); // If we've removed the URL state machine, set the flag to false. // This flag is a quick test that helps us skip a slow section of // code when there is already a URL state machine present. if (isSchemeUrlStateMachine(stateMachine)) { this.schemeUrlMachinesCount--; } }; ParseMatchesContext.prototype.hasSchemeUrlMachine = function () { return this.schemeUrlMachinesCount > 0; }; return ParseMatchesContext; }()); /** * Parses URL, email, twitter, mention, and hashtag matches from the given * `text`. */ export function parseMatches(text, args) { // Create the context object that will be passed to all state functions var context = new ParseMatchesContext(text, args); // For debugging: search for and uncomment other "For debugging" lines // const table = new CliTable({ // head: ['charIdx', 'char', 'code', 'type', 'states', 'startIdx', 'reached accept state'], // }); for (; context.charIdx < context.text.length; context.charIdx++) { var char = text.charAt(context.charIdx); var charCode = text.charCodeAt(context.charIdx); if (context.stateMachines.length === 0) { stateNoMatch(context, char, charCode); } else { // Must loop through the state machines backwards for when one // is removed for (var stateIdx = context.stateMachines.length - 1; stateIdx >= 0; stateIdx--) { var stateMachine = context.stateMachines[stateIdx]; switch (stateMachine.state) { // Protocol-relative URL states case 11 /* State.ProtocolRelativeSlash1 */: stateProtocolRelativeSlash1(context, stateMachine, charCode); break; case 12 /* State.ProtocolRelativeSlash2 */: stateProtocolRelativeSlash2(context, stateMachine, charCode); break; case 0 /* State.SchemeChar */: stateSchemeChar(context, stateMachine, charCode); break; case 1 /* State.SchemeHyphen */: stateSchemeHyphen(context, stateMachine, charCode); break; case 2 /* State.SchemeColon */: stateSchemeColon(context, stateMachine, charCode); break; case 3 /* State.SchemeSlash1 */: stateSchemeSlash1(context, stateMachine, charCode); break; case 4 /* State.SchemeSlash2 */: stateSchemeSlash2(context, stateMachine, char, charCode); break; case 5 /* State.DomainLabelChar */: stateDomainLabelChar(context, stateMachine, charCode); break; case 6 /* State.DomainHyphen */: stateDomainHyphen(context, stateMachine, char, charCode); break; case 7 /* State.DomainDot */: stateDomainDot(context, stateMachine, char, charCode); break; case 13 /* State.IpV4Digit */: stateIpV4Digit(context, stateMachine, charCode); break; case 14 /* State.IpV4Dot */: stateIpV4Dot(context, stateMachine, charCode); break; case 8 /* State.PortColon */: statePortColon(context, stateMachine, charCode); break; case 9 /* State.PortNumber */: statePortNumber(context, stateMachine, charCode); break; case 10 /* State.Path */: statePath(context, stateMachine, charCode); break; // Email States case 15 /* State.EmailMailto_M */: stateEmailMailto_M(context, stateMachine, char, charCode); break; case 16 /* State.EmailMailto_A */: stateEmailMailto_A(context, stateMachine, char, charCode); break; case 17 /* State.EmailMailto_I */: stateEmailMailto_I(context, stateMachine, char, charCode); break; case 18 /* State.EmailMailto_L */: stateEmailMailto_L(context, stateMachine, char, charCode); break; case 19 /* State.EmailMailto_T */: stateEmailMailto_T(context, stateMachine, char, charCode); break; case 20 /* State.EmailMailto_O */: stateEmailMailto_O(context, stateMachine, charCode); break; case 21 /* State.EmailMailto_Colon */: stateEmailMailtoColon(context, stateMachine, charCode); break; case 22 /* State.EmailLocalPart */: stateEmailLocalPart(context, stateMachine, charCode); break; case 23 /* State.EmailLocalPartDot */: stateEmailLocalPartDot(context, stateMachine, charCode); break; case 24 /* State.EmailAtSign */: stateEmailAtSign(context, stateMachine, charCode); break; case 25 /* State.EmailDomainChar */: stateEmailDomainChar(context, stateMachine, charCode); break; case 26 /* State.EmailDomainHyphen */: stateEmailDomainHyphen(context, stateMachine, charCode); break; case 27 /* State.EmailDomainDot */: stateEmailDomainDot(context, stateMachine, charCode); break; // Hashtag states case 28 /* State.HashtagHashChar */: stateHashtagHashChar(context, stateMachine, charCode); break; case 29 /* State.HashtagTextChar */: stateHashtagTextChar(context, stateMachine, charCode); break; // Mention states case 30 /* State.MentionAtChar */: stateMentionAtChar(context, stateMachine, charCode); break; case 31 /* State.MentionTextChar */: stateMentionTextChar(context, stateMachine, charCode); break; // Phone number states case 32 /* State.PhoneNumberOpenParen */: statePhoneNumberOpenParen(context, stateMachine, char, charCode); break; case 33 /* State.PhoneNumberAreaCodeDigit1 */: statePhoneNumberAreaCodeDigit1(context, stateMachine, charCode); break; case 34 /* State.PhoneNumberAreaCodeDigit2 */: statePhoneNumberAreaCodeDigit2(context, stateMachine, charCode); break; case 35 /* State.PhoneNumberAreaCodeDigit3 */: statePhoneNumberAreaCodeDigit3(context, stateMachine, charCode); break; case 36 /* State.PhoneNumberCloseParen */: statePhoneNumberCloseParen(context, stateMachine, char, charCode); break; case 37 /* State.PhoneNumberPlus */: statePhoneNumberPlus(context, stateMachine, char, charCode); break; case 38 /* State.PhoneNumberDigit */: statePhoneNumberDigit(context, stateMachine, char, charCode); break; case 39 /* State.PhoneNumberSeparator */: statePhoneNumberSeparator(context, stateMachine, char, charCode); break; case 40 /* State.PhoneNumberControlChar */: statePhoneNumberControlChar(context, stateMachine, charCode); break; case 41 /* State.PhoneNumberPoundChar */: statePhoneNumberPoundChar(context, stateMachine, charCode); break; /* istanbul ignore next */ default: assertNever(stateMachine.state); } } // Special case for handling a colon (or other non-alphanumeric) // when preceded by another character, such as in the text: // Link 1:http://google.com // In this case, the 'h' character after the colon wouldn't start a // new scheme url because we'd be in a ipv4 or tld url and the colon // would be interpreted as a port ':' char. Also, only start a new // scheme url machine if there isn't currently one so we don't start // new ones for colons inside a url // // TODO: The addition of this snippet (to fix the bug) in 4.0.1 lost // us ~500 ops/sec on the benchmarks. Optimizing it with the // hasSchemeUrlMachine() flag and optimizing the isSchemeStartChar() // method for 4.1.3 got us back about ~400ops/sec. One potential way // to improve this even ore is to add this snippet to individual // state handler functions where it can occur to prevent running it // on every loop interation. if (!context.hasSchemeUrlMachine() && context.charIdx > 0 && isSchemeStartChar(charCode)) { var prevCharCode = context.text.charCodeAt(context.charIdx - 1); if (!isSchemeStartChar(prevCharCode)) { context.addMachine(createSchemeUrlStateMachine(context.charIdx, 0 /* State.SchemeChar */)); } } } // For debugging: search for and uncomment other "For debugging" lines // table.push([ // String(context.charIdx), // char, // `10: ${char.charCodeAt(0)}\n0x: ${char.charCodeAt(0).toString(16)}\nU+${char.codePointAt(0)}`, // context.stateMachines.map(machine => `${StateMachineType[machine.type]}${'matchType' in machine ? ` (${UrlStateMachineMatchType[machine.matchType]})` : ''}`).join('\n') || '(none)', // context.stateMachines.map(machine => State[machine.state]).join('\n') || '(none)', // context.stateMachines.map(m => m.startIdx).join('\n'), // context.stateMachines.map(m => m.acceptStateReached).join('\n'), // ]); } // Capture any valid match at the end of the string // Note: this loop must happen in reverse because // captureMatchIfValidAndRemove() removes state machines from the array // and we'll end up skipping every other one if we remove while looping // forward for (var i = context.stateMachines.length - 1; i >= 0; i--) { context.stateMachines.forEach(function (stateMachine) { return captureMatchIfValidAndRemove(context, stateMachine); }); } // For debugging: search for and uncomment other "For debugging" lines // console.log(`\nRead string:\n ${text}`); // console.log(table.toString()); return context.matches; } /** * Handles the state when we're not in a URL/email/etc. (i.e. when no state machines exist) */ function stateNoMatch(context, char, charCode) { var charIdx = context.charIdx; if (charCode === 35 /* Char.NumberSign */ /* '#' */) { // Hash char, start a Hashtag match context.addMachine(createHashtagStateMachine(charIdx, 28 /* State.HashtagHashChar */)); } else if (charCode === 64 /* Char.AtSign */ /* '@' */) { // '@' char, start a Mention match context.addMachine(createMentionStateMachine(charIdx, 30 /* State.MentionAtChar */)); } else if (charCode === 47 /* Char.Slash */ /* '/' */) { // A slash could begin a protocol-relative URL context.addMachine(createTldUrlStateMachine(charIdx, 11 /* State.ProtocolRelativeSlash1 */)); } else if (charCode === 43 /* Char.Plus */ /* '+' */) { // A '+' char can start a Phone number context.addMachine(createPhoneNumberStateMachine(charIdx, 37 /* State.PhoneNumberPlus */)); } else if (charCode === 40 /* Char.OpenParen */ /* '(' */) { context.addMachine(createPhoneNumberStateMachine(charIdx, 32 /* State.PhoneNumberOpenParen */)); } else { if (isDigitChar(charCode)) { // A digit could start a phone number context.addMachine(createPhoneNumberStateMachine(charIdx, 38 /* State.PhoneNumberDigit */)); // A digit could start an IP address context.addMachine(createIpV4UrlStateMachine(charIdx, 13 /* State.IpV4Digit */)); } if (isEmailLocalPartStartChar(charCode)) { // Any email local part. An 'm' character in particular could // start a 'mailto:' match var startState = char.toLowerCase() === 'm' ? 15 /* State.EmailMailto_M */ : 22 /* State.EmailLocalPart */; context.addMachine(createEmailStateMachine(charIdx, startState)); } if (isSchemeStartChar(charCode)) { // An uppercase or lowercase letter may start a scheme match context.addMachine(createSchemeUrlStateMachine(charIdx, 0 /* State.SchemeChar */)); } if (isAlphaNumericOrMarkChar(charCode)) { // A unicode alpha character or digit could start a domain name // label for a TLD match context.addMachine(createTldUrlStateMachine(charIdx, 5 /* State.DomainLabelChar */)); } } // Anything else, remain in the "non-url" state by not creating any // state machines } // Implements ABNF: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) function stateSchemeChar(context, stateMachine, charCode) { if (charCode === 58 /* Char.Colon */ /* ':' */) { stateMachine.state = 2 /* State.SchemeColon */; } else if (charCode === 45 /* Char.Dash */ /* '-' */) { stateMachine.state = 1 /* State.SchemeHyphen */; } else if (isSchemeChar(charCode)) { // Stay in SchemeChar state } else { // Any other character, not a scheme context.removeMachine(stateMachine); } } function stateSchemeHyphen(context, stateMachine, charCode) { var charIdx = context.charIdx; if (charCode === 45 /* Char.Dash */ /* '-' */) { // Stay in SchemeHyphen state // TODO: Should a colon following a dash be counted as the end of the scheme? // } else if (char === ':') { // stateMachine.state = State.SchemeColon; } else if (charCode === 47 /* Char.Slash */ /* '/' */) { // Not a valid scheme match, but may be the start of a // protocol-relative match (such as //google.com) context.removeMachine(stateMachine); context.addMachine(createTldUrlStateMachine(charIdx, 11 /* State.ProtocolRelativeSlash1 */)); } else if (isSchemeChar(charCode)) { stateMachine.state = 0 /* State.SchemeChar */; } else { // Any other character, not a scheme context.removeMachine(stateMachine); } } // https://tools.ietf.org/html/rfc3986#appendix-A function stateSchemeColon(context, stateMachine, charCode) { var charIdx = context.charIdx; if (charCode === 47 /* Char.Slash */ /* '/' */) { stateMachine.state = 3 /* State.SchemeSlash1 */; } else if (charCode === 46 /* Char.Dot */ /* '.' */) { // We've read something like 'hello:.' - don't capture context.removeMachine(stateMachine); } else if (isDomainLabelStartChar(charCode)) { stateMachine.state = 5 /* State.DomainLabelChar */; // It's possible that we read an "introduction" piece of text, // and the character after the current colon actually starts an // actual scheme. An example of this is: // "The link:http://google.com" // Hence, start a new machine to capture this match if so if (isSchemeStartChar(charCode)) { context.addMachine(createSchemeUrlStateMachine(charIdx, 0 /* State.SchemeChar */)); } } else { context.removeMachine(stateMachine); } } // https://tools.ietf.org/html/rfc3986#appendix-A function stateSchemeSlash1(context, stateMachine, charCode) { if (charCode === 47 /* Char.Slash */ /* '/' */) { stateMachine.state = 4 /* State.SchemeSlash2 */; } else if (isPathChar(charCode)) { stateMachine.state = 10 /* State.Path */; stateMachine.acceptStateReached = true; } else { captureMatchIfValidAndRemove(context, stateMachine); } } function stateSchemeSlash2(context, stateMachine, char, charCode) { if (charCode === 47 /* Char.Slash */ /* '/' */) { // 3rd slash, must be an absolute path (`path-absolute` in the // ABNF), such as in "file:///c:/windows/etc". See // https://tools.ietf.org/html/rfc3986#appendix-A stateMachine.state = 10 /* State.Path */; stateMachine.acceptStateReached = true; } else if (isDomainLabelStartChar(charCode)) { // start of "authority" section - see https://tools.ietf.org/html/rfc3986#appendix-A stateMachine.state = 5 /* State.DomainLabelChar */; stateMachine.acceptStateReached = true; } else { // not valid context.removeMachine(stateMachine); } } // Handles after we've read a '/' from the NonUrl state function stateProtocolRelativeSlash1(context, stateMachine, charCode) { if (charCode === 47 /* Char.Slash */ /* '/' */) { stateMachine.state = 12 /* State.ProtocolRelativeSlash2 */; } else { // Anything else, cannot be the start of a protocol-relative // URL. context.removeMachine(stateMachine); } } // Handles after we've read a second '/', which could start a protocol-relative URL function stateProtocolRelativeSlash2(context, stateMachine, charCode) { if (isDomainLabelStartChar(charCode)) { stateMachine.state = 5 /* State.DomainLabelChar */; } else { // Anything else, not a URL context.removeMachine(stateMachine); } } // Handles when we have read a domain label character function stateDomainLabelChar(context, stateMachine, charCode) { if (charCode === 46 /* Char.Dot */ /* '.' */) { stateMachine.state = 7 /* State.DomainDot */; } else if (charCode === 45 /* Char.Dash */ /* '-' */) { stateMachine.state = 6 /* State.DomainHyphen */; } else if (charCode === 58 /* Char.Colon */ /* ':' */) { // Beginning of a port number, end the domain name stateMachine.state = 8 /* State.PortColon */; } else if (isUrlSuffixStartChar(charCode)) { // '/', '?', or '#' stateMachine.state = 10 /* State.Path */; } else if (isDomainLabelChar(charCode)) { // Stay in the DomainLabelChar state } else { // Anything else, end the domain name captureMatchIfValidAndRemove(context, stateMachine); } } function stateDomainHyphen(context, stateMachine, char, charCode) { if (charCode === 45 /* Char.Dash */ /* '-' */) { // Remain in the DomainHyphen state } else if (charCode === 46 /* Char.Dot */ /* '.' */) { // Not valid to have a '-.' in a domain label captureMatchIfValidAndRemove(context, stateMachine); } else if (isDomainLabelStartChar(charCode)) { stateMachine.state = 5 /* State.DomainLabelChar */; } else { captureMatchIfValidAndRemove(context, stateMachine); } } function stateDomainDot(context, stateMachine, char, charCode) { if (charCode === 46 /* Char.Dot */ /* '.' */) { // domain names cannot have multiple '.'s next to each other. // It's possible we've already read a valid domain name though, // and that the '..' sequence just forms an ellipsis at the end // of a sentence captureMatchIfValidAndRemove(context, stateMachine); } else if (isDomainLabelStartChar(charCode)) { stateMachine.state = 5 /* State.DomainLabelChar */; stateMachine.acceptStateReached = true; // after hitting a dot, and then another domain label, we've reached an accept state } else { // Anything else, end the domain name captureMatchIfValidAndRemove(context, stateMachine); } } function stateIpV4Digit(context, stateMachine, charCode) { if (charCode === 46 /* Char.Dot */ /* '.' */) { stateMachine.state = 14 /* State.IpV4Dot */; } else if (charCode === 58 /* Char.Colon */ /* ':' */) { // Beginning of a port number stateMachine.state = 8 /* State.PortColon */; } else if (isDigitChar(charCode)) { // stay in the IPv4 digit state } else if (isUrlSuffixStartChar(charCode)) { stateMachine.state = 10 /* State.Path */; } else if (isAlphaNumericOrMarkChar(charCode)) { // If we hit an alpha character, must not be an IPv4 // Example of this: 1.2.3.4abc context.removeMachine(stateMachine); } else { captureMatchIfValidAndRemove(context, stateMachine); } } function stateIpV4Dot(context, stateMachine, charCode) { if (isDigitChar(charCode)) { stateMachine.octetsEncountered++; // Once we have encountered 4 octets, it's *potentially* a valid // IPv4 address. Our IPv4 regex will confirm the match later // though to make sure each octet is in the 0-255 range, and // there's exactly 4 octets (not 5 or more) if (stateMachine.octetsEncountered === 4) { stateMachine.acceptStateReached = true; } stateMachine.state = 13 /* State.IpV4Digit */; } else { captureMatchIfValidAndRemove(context, stateMachine); } } function statePortColon(context, stateMachine, charCode) { if (isDigitChar(charCode)) { stateMachine.state = 9 /* State.PortNumber */; } else { captureMatchIfValidAndRemove(context, stateMachine); } } function statePortNumber(context, stateMachine, charCode) { if (isDigitChar(charCode)) { // Stay in port number state } else if (isUrlSuffixStartChar(charCode)) { // '/', '?', or '#' stateMachine.state = 10 /* State.Path */; } else { captureMatchIfValidAndRemove(context, stateMachine); } } function statePath(context, stateMachine, charCode) { if (isPathChar(charCode)) { // Stay in the path state } else { captureMatchIfValidAndRemove(context, stateMachine); } } // Handles if we're reading a 'mailto:' prefix on the string function stateEmailMailto_M(context, stateMachine, char, charCode) { if (char.toLowerCase() === 'a') { stateMachine.state = 16 /* State.EmailMailto_A */; } else { stateEmailLocalPart(context, stateMachine, charCode); } } function stateEmailMailto_A(context, stateMachine, char, charCode) { if (char.toLowerCase() === 'i') { stateMachine.state = 17 /* State.EmailMailto_I */; } else { stateEmailLocalPart(context, stateMachine, charCode); } } function stateEmailMailto_I(context, stateMachine, char, charCode) { if (char.toLowerCase() === 'l') { stateMachine.state = 18 /* State.EmailMailto_L */; } else { stateEmailLocalPart(context, stateMachine, charCode); } } function stateEmailMailto_L(context, stateMachine, char, charCode) { if (char.toLowerCase() === 't') { stateMachine.state = 19 /* State.EmailMailto_T */; } else { stateEmailLocalPart(context, stateMachine, charCode); } } function stateEmailMailto_T(context, stateMachine, char, charCode) { if (char.toLowerCase() === 'o') { stateMachine.state = 20 /* State.EmailMailto_O */; } else { stateEmailLocalPart(context, stateMachine, charCode); } } function stateEmailMailto_O(context, stateMachine, charCode) { if (charCode === 58 /* Char.Colon */ /* ':' */) { stateMachine.state = 21 /* State.EmailMailto_Colon */; } else { stateEmailLocalPart(context, stateMachine, charCode); } } function stateEmailMailtoColon(context, stateMachine, charCode) { if (isEmailLocalPartChar(charCode)) { stateMachine.state = 22 /* State.EmailLocalPart */; } else { context.removeMachine(stateMachine); } } // Handles the state when we're currently in the "local part" of an // email address (as opposed to the "domain part") function stateEmailLocalPart(context, stateMachine, charCode) { if (charCode === 46 /* Char.Dot */ /* '.' */) { stateMachine.state = 23 /* State.EmailLocalPartDot */; } else if (charCode === 64 /* Char.AtSign */ /* '@' */) { stateMachine.state = 24 /* State.EmailAtSign */; } else if (isEmailLocalPartChar(charCode)) { // stay in the "local part" of the email address // Note: because stateEmailLocalPart() is called from the // 'mailto' states (when the 'mailto' prefix itself has been // broken), make sure to set the state to EmailLocalPart stateMachine.state = 22 /* State.EmailLocalPart */; } else { // not an email address character context.removeMachine(stateMachine); } } // Handles the state where we've read a '.' character in the local part of // the email address (i.e. the part before the '@' character) function stateEmailLocalPartDot(context, stateMachine, charCode) { if (charCode === 46 /* Char.Dot */ /* '.' */) { // We read a second '.' in a row, not a valid email address // local part context.removeMachine(stateMachine); } else if (charCode === 64 /* Char.AtSign */ /* '@' */) { // We read the '@' character immediately after a dot ('.'), not // an email address context.removeMachine(stateMachine); } else if (isEmailLocalPartChar(charCode)) { stateMachine.state = 22 /* State.EmailLocalPart */; } else { // Anything else, not an email address context.removeMachine(stateMachine); } } function stateEmailAtSign(context, stateMachine, charCode) { if (isDomainLabelStartChar(charCode)) { stateMachine.state = 25 /* State.EmailDomainChar */; } else { // Anything else, not an email address context.removeMachine(stateMachine); } } function stateEmailDomainChar(context, stateMachine, charCode) { if (charCode === 46 /* Char.Dot */ /* '.' */) { stateMachine.state = 27 /* State.EmailDomainDot */; } else if (charCode === 45 /* Char.Dash */ /* '-' */) { stateMachine.state = 26 /* State.EmailDomainHyphen */; } else if (isDomainLabelChar(charCode)) { // Stay in the DomainChar state } else { // Anything else, we potentially matched if the criteria has // been met captureMatchIfValidAndRemove(context, stateMachine); } } function stateEmailDomainHyphen(context, stateMachine, charCode) { if (charCode === 45 /* Char.Dash */ /* '-' */ || charCode === 46 /* Char.Dot */ /* '.' */) { // Not valid to have two hyphens ("--") or hypen+dot ("-.") captureMatchIfValidAndRemove(context, stateMachine); } else if (isDomainLabelChar(charCode)) { stateMachine.state = 25 /* State.EmailDomainChar */; } else { // Anything else captureMatchIfValidAndRemove(context, stateMachine); } } function stateEmailDomainDot(context, stateMachine, charCode) { if (charCode === 46 /* Char.Dot */ /* '.' */ || charCode === 45 /* Char.Dash */ /* '-' */) { // not valid to have two dots ("..") or dot+hypen (".-") captureMatchIfValidAndRemove(context, stateMachine); } else if (isDomainLabelStartChar(charCode)) { stateMachine.state = 25 /* State.EmailDomainChar */; // After having read a '.' and then a valid domain character, // we now know that the domain part of the email is valid, and // we have found at least a partial EmailMatch (however, the // email address may have additional characters from this point) stateMachine.acceptStateReached = true; } else { // Anything else captureMatchIfValidAndRemove(context, stateMachine); } } // Handles the state when we've just encountered a '#' character function stateHashtagHashChar(context, stateMachine, charCode) { if (isHashtagTextChar(charCode)) { // '#' char with valid hash text char following stateMachine.state = 29 /* State.HashtagTextChar */; stateMachine.acceptStateReached = true; } else { context.removeMachine(stateMachine); } } // Handles the state when we're currently in the hash tag's text chars function stateHashtagTextChar(context, stateMachine, charCode) { if (isHashtagTextChar(charCode)) { // Continue reading characters in the HashtagText state } else { captureMatchIfValidAndRemove(context, stateMachine); } } // Handles the state when we've just encountered a '@' character function stateMentionAtChar(context, stateMachine, charCode) { if (isMentionTextChar(charCode)) { // '@' char with valid mention text char following stateMachine.state = 31 /* State.MentionTextChar */; stateMachine.acceptStateReached = true; } else { context.removeMachine(stateMachine); } } // Handles the state when we're currently in the mention's text chars function stateMentionTextChar(context, stateMachine, charCode) { if (isMentionTextChar(charCode)) { // Continue reading characters in the HashtagText state } else if (isAlphaNumericOrMarkChar(charCode)) { // Char is invalid for a mention text char, not a valid match. // Note that ascii alphanumeric chars are okay (which are tested // in the previous 'if' statement, but others are not) context.removeMachine(stateMachine); } else { captureMatchIfValidAndRemove(context, stateMachine); } } function statePhoneNumberPlus(context, stateMachine, char, charCode) { if (isDigitChar(charCode)) { stateMachine.state = 38 /* State.PhoneNumberDigit */; } else { context.removeMachine(stateMachine); // This character may start a new match. Add states for it stateNoMatch(context, char, charCode); } } function statePhoneNumberOpenParen(context, stateMachine, char, charCode) { if (isDigitChar(charCode)) { stateMachine.state = 33 /* State.PhoneNumberAreaCodeDigit1 */; } else { context.removeMachine(stateMachine); } // It's also possible that the paren was just an open brace for // a piece of text. Start other machines stateNoMatch(context, char, charCode); } function statePhoneNumberAreaCodeDigit1(context, stateMachine, charCode) { if (isDigitChar(charCode)) { stateMachine.state = 34 /* State.PhoneNumberAreaCodeDigit2 */; } else { context.removeMachine(stateMachine); } } function statePhoneNumberAreaCodeDigit2(context, stateMachine, charCode) { if (isDigitChar(charCode)) { stateMachine.state = 35 /* State.PhoneNumberAreaCodeDigit3 */; } else { context.removeMachine(stateMachine); } } function statePhoneNumberAreaCodeDigit3(context, stateMachine, charCode) { if (charCode === 41 /* Char.CloseParen */ /* ')' */) { stateMachine.state = 36 /* State.PhoneNumberCloseParen */; } else { context.removeMachine(stateMachine); } } function statePhoneNumberCloseParen(context, stateMachine, char, charCode) { if (isDigitChar(charCode)) { stateMachine.state = 38 /* State.PhoneNumberDigit */; } else if (isPhoneNumberSeparatorChar(charCode)) { stateMachine.state = 39 /* State.PhoneNumberSeparator */; } else { context.removeMachine(stateMachine); } } function statePhoneNumberDigit(context, stateMachine, char, charCode) { var charIdx = context.charIdx; // For now, if we've reached any digits, we'll say that the machine // has reached its accept state. The phone regex will confirm the // match later. // Alternatively, we could count the number of digits to avoid // invoking the phone number regex stateMachine.acceptStateReached = true; if (isPhoneNumberControlChar(charCode)) { stateMachine.state = 40 /* State.PhoneNumberControlChar */; } else if (charCode === 35 /* Char.NumberSign */ /* '#' */) { stateMachine.state = 41 /* State.PhoneNumberPoundChar */; } else if (isDigitChar(charCode)) { // Stay in the phone number digit state } else if (charCode === 40 /* Char.OpenParen */ /* '(' */) { stateMachine.state = 32 /* State.PhoneNumberOpenParen */; } else if (isPhoneNumberSeparatorChar(charCode)) { stateMachine.state = 39 /* State.PhoneNumberSeparator */; } else { captureMatchIfValidAndRemove(context, stateMachine); // The transition from a digit character to a letter can be the // start of a new scheme URL match if (isSchemeStartChar(charCode)) { context.addMachine(createSchemeUrlStateMachine(charIdx, 0 /* State.SchemeChar */)); } } } function statePhoneNumberSeparator(context, stateMachine, char, charCode) { if (isDigitChar(charCode)) { stateMachine.state = 38 /* State.PhoneNumberDigit */; } else if (charCode === 40 /* Char.OpenParen */ /* '(' */) { stateMachine.state = 32 /* State.PhoneNumberOpenParen */; } else { captureMatchIfValidAndRemove(context, stateMachine); // This character may start a new match. Add states for it stateNoMatch(context, char, charCode); } } // The ";" characters is "wait" in a phone number // The "," characters is "pause" in a phone number function statePhoneNumberControlChar(context, stateMachine, charCode) { if (isPhoneNumberControlChar(charCode)) { // Stay in the "control char" state } else if (charCode === 35 /* Char.NumberSign */ /* '#' */) { stateMachine.state = 41 /* State.PhoneNumberPoundChar */; } else if (isDigitChar(charCode)) { stateMachine.state = 38 /* State.PhoneNumberDigit */; } else { captureMatchIfValidAndRemove(context, stateMachine); } } // The "#" characters is "pound" in a phone number function statePhoneNumberPoundChar(context, stateMachine, charCode) { if (isPhoneNumberControlChar(charCode)) { stateMachine.state = 40 /* State.PhoneNumberControlChar */; } else if (isDigitChar(charCode)) { // According to some of the older tests, if there's a digit // after a '#' sign, the match is invalid. TODO: Revisit if this is true context.removeMachine(stateMachine); } else { captureMatchIfValidAndRemove(context, stateMachine); } } /* * Captures a match if it is valid (i.e. has a full domain name for a * TLD match). If a match is not valid, it is possible that we want to * keep reading characters in order to make a full match. */ function captureMatchIfValidAndRemove(context, stateMachine) { var matches = context.matches, text = context.text, charIdx = context.charIdx, tagBuilder = context.tagBuilder, stripPrefix = context.stripPrefix, stripTrailingSlash = context.stripTrailingSlash, decodePercentEncoding = context.decodePercentEncoding, hashtagServiceName = context.hashtagServiceName, mentionServiceName = context.mentionServiceName; // Remove the state machine first. There are a number of code paths // which return out of this function early, so make sure we have // this done context.removeMachine(stateMachine); // Make sure the state machine being checked has actually reached an // "accept" state. If it hasn't reach one, it can't be a match if (!stateMachine.acceptStateReached) { return; } var startIdx = stateMachine.startIdx; var matchedText = text.slice(stateMachine.startIdx, charIdx); // Handle any unbalanced braces (parens, square brackets, or curly // brackets) inside the URL. This handles situations like: // The link (google.com) // and // Check out this link here (en.wikipedia.org/wiki/IANA_(disambiguation)) // // And also remove any punctuation chars at the end such as: // '?', ',', ':', '.', etc. matchedText = excludeUnbalancedTrailingBracesAndPunctuation(matchedText); switch (stateMachine.type) { case 0 /* StateMachineType.Url */: { // We don't want to accidentally match a URL that is preceded by an // '@' character, which would be an email address var charBeforeUrlMatch = text.charCodeAt(stateMachine.startIdx - 1); if (charBeforeUrlMatch === 64 /* Char.AtSign */ /* '@' */) { return; } switch (stateMachine.matchType) { case 0 /* UrlStateMachineMatchType.Scheme */: { // Autolinker accepts many characters in a url's scheme (like `fake://test.com`). // However, in cases where a URL is missing whitespace before an obvious link, // (for example: `nowhitespacehttp://www.test.com`), we only want the match to start // at the http:// part. We will check if the match contains a common scheme and then // shift the match to start from there. var httpSchemeMatch = httpSchemeRe.exec(matchedText); if (httpSchemeMatch) { // If we found an overmatched URL, we want to find the index // of where the match should start and shift the match to // start from the beginning of the common scheme startIdx = startIdx + httpSchemeMatch.index; matchedText = matchedText.slice(httpSchemeMatch.index); } if (!isValidSchemeUrl(matchedText)) { return; // not a valid match } break; } case 1 /* UrlStateMachineMatchType.Tld */: { if (!isValidTldMatch(matchedText)) { return; // not a valid match } break; } case 2 /* UrlStateMachineMatchType.IpV4 */: { if (!isValidIpV4Address(matchedText)) { return; // not a valid match } break; } /* istanbul ignore next */ default: assertNever(stateMachine); } matches.push(new UrlMatch({ tagBuilder: tagBuilder, matchedText: matchedText, offset: startIdx, urlMatchType: toUrlMatchType(stateMachine.matchType), url: matchedText, protocolRelativeMatch: matchedText.slice(0, 2) === '//', // TODO: Do these settings need to be passed to the match, // or should we handle them here in UrlMatcher? stripPrefix: stripPrefix, stripTrailingSlash: stripTrailingSlash, decodePercentEncoding: decodePercentEncoding, })); break; } case 1 /* StateMachineType.Email */: { // if the email address has a valid TLD, add it to the list of matches if (isValidEmail(matchedText)) { matches.push(new EmailMatch({ tagBuilder: tagBuilder, matchedText: matchedText, offset: startIdx, email: matchedText.replace(mailtoSchemePrefixRe, ''), })); } break; } case 2 /* StateMachineType.Hashtag */: { if (isValidHashtag(matchedText)) { matches.push(new HashtagMatch({ tagBuilder: tagBuilder, matchedText: matchedText, offset: startIdx, serviceName: hashtagServiceName, hashtag: matchedText.slice(1), })); } break; } case 3 /* StateMachineType.Mention */: { if (isValidMention(matchedText, mentionServiceName)) { matches.push(new MentionMatch({ tagBuilder: tagBuilder, matchedText: matchedText, offset: startIdx, serviceName: mentionServiceName, mention: matchedText.slice(1), // strip off the '@' character at the beginning })); } break; } case 4 /* StateMachineType.Phone */: { // remove any trailing spaces that were considered as "separator" // chars by the state machine matchedText = matchedText.replace(/ +$/g, ''); if (isValidPhoneNumber(matchedText)) { var cleanNumber = matchedText.replace(/[^0-9,;#]/g, ''); // strip out non-digit characters exclude comma semicolon and # matches.push(new PhoneMatch({ tagBuilder: tagBuilder, matchedText: matchedText, offset: startIdx, number: cleanNumber, plusSign: matchedText.charAt(0) === '+', })); } break; } /* istanbul ignore next */ default: assertNever(stateMachine); } } /** * Helper function to convert a UrlStateMachineMatchType value to its * UrlMatchType equivalent. */ function toUrlMatchType(stateMachineMatchType) { switch (stateMachineMatchType) { case 0 /* UrlStateMachineMatchType.Scheme */: return 'scheme'; case 1 /* UrlStateMachineMatchType.Tld */: return 'tld'; case 2 /* UrlStateMachineMatchType.IpV4 */: return 'ipV4'; /* istanbul ignore next */ default: assertNever(stateMachineMatchType); } } var oppositeBrace = { ')': '(', '}': '{', ']': '[', }; /** * Determines if a match found has unmatched closing parenthesis, * square brackets or curly brackets. If so, these unbalanced symbol(s) will be * removed from the URL match itself. * * A match may have an extra closing parenthesis/square brackets/curly brackets * at the end of the match because these are valid URL path characters. For * example, "wikipedia.com/something_(disambiguation)" should be auto-linked. * * However, an extra parenthesis *will* be included when the URL itself is * wrapped in parenthesis, such as in the case of: * * "(wikipedia.com/something_(disambiguation))" * * In this case, the last closing parenthesis should *not* be part of the * URL itself, and this method will exclude it from the returned URL. * * For square brackets in URLs such as in PHP arrays, the same behavior as * parenthesis discussed above should happen: * * "[http://www.example.com/foo.php?bar[]=1&bar[]=2&bar[]=3]" * * The very last closing square bracket should not be part of the URL itself, * and therefore this method will remove it. * * @param matchedText The full matched URL/email/hashtag/etc. from the state * machine parser. * @return The updated matched text with extraneous suffix characters removed. */ export function excludeUnbalancedTrailingBracesAndPunctuation(matchedText) { var braceCounts = { '(': 0, '{': 0, '[': 0, }; for (var i = 0; i < matchedText.length; i++) { var char = matchedText.charAt(i); var charCode = matchedText.charCodeAt(i); if (isOpenBraceChar(charCode)) { braceCounts[char]++; } else if (isCloseBraceChar(charCode)) { braceCounts[oppositeBrace[char]]--; } } var endIdx = matchedText.length - 1; while (endIdx >= 0) { var char = matchedText.charAt(endIdx); var charCode = matchedText.charCodeAt(endIdx); if (isCloseBraceChar(charCode)) { var oppositeBraceChar = oppositeBrace[char]; if (braceCounts[oppositeBraceChar] < 0) { braceCounts[oppositeBraceChar]++; endIdx--; } else { break; } } else if (isUrlSuffixNotAllowedAsFinalChar(charCode)) { // Walk back a punctuation char like '?', ',', ':', '.', etc. endIdx--; } else { break; } } return matchedText.slice(0, endIdx + 1); } function createSchemeUrlStateMachine(startIdx, state) { return { type: 0 /* StateMachineType.Url */, startIdx: startIdx, state: state, acceptStateReached: false, matchType: 0 /* UrlStateMachineMatchType.Scheme */, }; } function createTldUrlStateMachine(startIdx, state) { return { type: 0 /* StateMachineType.Url */, startIdx: startIdx, state: state, acceptStateReached: false, matchType: 1 /* UrlStateMachineMatchType.Tld */, }; } function createIpV4U