From fc2e7d0c282b55c28c56718848c545dd5902662f Mon Sep 17 00:00:00 2001 From: Nick Frasser Date: Tue, 23 Nov 2021 19:07:23 -0500 Subject: [PATCH] Unified Token Formatting and Rendering (#364) - New toFormatted* token methods that accept options - New render() token method to return final HTML tag intermediate representation - New Options implementation to support token rendering - Removed Options#resolve() method in favour of render - Update interfaces to use new unified rendering --- .../linkify-element/src/linkify-element.js | 78 +++++------ packages/linkify-jquery/src/linkify-jquery.js | 2 +- packages/linkify-react/src/linkify-react.js | 68 ++++------ packages/linkifyjs/src/core/options.js | 117 +++++++++-------- packages/linkifyjs/src/core/parser.js | 2 +- packages/linkifyjs/src/core/tokens/multi.js | 122 ++++++++++++++---- packages/linkifyjs/src/linkify-html.js | 83 +++++------- packages/linkifyjs/src/linkify-string.js | 44 ++----- packages/linkifyjs/src/linkify.js | 11 +- test/index.js | 11 ++ test/qunit/main.js | 17 ++- test/spec/core/options.test.js | 114 ++++++++-------- test/spec/core/tokens/multi.test.js | 86 +++++++++++- test/spec/linkify-react.test.js | 4 +- test/spec/linkify-string.test.js | 20 --- 15 files changed, 439 insertions(+), 340 deletions(-) diff --git a/packages/linkify-element/src/linkify-element.js b/packages/linkify-element/src/linkify-element.js index e8a427df..c4d13edb 100644 --- a/packages/linkify-element/src/linkify-element.js +++ b/packages/linkify-element/src/linkify-element.js @@ -29,52 +29,13 @@ function tokensToNodes(tokens, opts, doc) { const result = []; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; - if (token.t === 'nl' && opts.nl2br) { + if (token.t === 'nl' && opts.get('nl2br')) { result.push(doc.createElement('br')); - continue; } else if (!token.isLink || !opts.check(token)) { result.push(doc.createTextNode(token.toString())); - continue; + } else { + result.push(opts.render(token)); } - - const { - formatted, - formattedHref, - tagName, - className, - target, - rel, - events, - attributes, - } = opts.resolve(token); - - // Build the link - const link = doc.createElement(tagName); - link.setAttribute('href', formattedHref); - - if (className) { link.setAttribute('class', className); } - if (target) { link.setAttribute('target', target); } - if (rel) { link.setAttribute('rel', rel); } - - // Build up additional attributes - if (attributes) { - for (const attr in attributes) { - link.setAttribute(attr, attributes[attr]); - } - } - - if (events) { - for (const event in events) { - if (link.addEventListener) { - link.addEventListener(event, events[event]); - } else if (link.attachEvent) { - link.attachEvent('on' + event, events[event]); - } - } - } - - link.appendChild(doc.createTextNode(formatted)); - result.push(link); } return result; @@ -83,7 +44,7 @@ function tokensToNodes(tokens, opts, doc) { /** * Requires document.createElement * @param {HTMLElement} element - * @param {Object} opts + * @param {Options} opts * @param {Document} doc * @returns {HTMLElement} */ @@ -94,10 +55,8 @@ function linkifyElementHelper(element, opts, doc) { throw new Error(`Cannot linkify ${element} - Invalid DOM Node type`); } - const { ignoreTags } = opts; - // Is this element already a link? - if (element.tagName === 'A' || ignoreTags.indexOf(element.tagName) >= 0) { + if (element.tagName === 'A' || opts.ignoreTags.indexOf(element.tagName) >= 0) { // No need to linkify return element; } @@ -138,6 +97,27 @@ function linkifyElementHelper(element, opts, doc) { return element; } +/** + * @param {Document} doc The document implementaiton + */ +function getDefaultRender(doc) { + return ({ tagName, attributes, content, eventListeners }) => { + const link = doc.createElement(tagName); + for (const attr in attributes) { + link.setAttribute(attr, attributes[attr]); + } + + if (eventListeners && link.addEventListener) { + for (const event in eventListeners) { + link.addEventListener(event, eventListeners[event]); + } + } + + link.appendChild(doc.createTextNode(content)); + return link; + }; +} + /** * Recursively traverse the given DOM node, find all links in the text and * convert them to anchor tags. @@ -160,10 +140,12 @@ export default function linkifyElement(element, opts, doc = null) { ); } - opts = new Options(opts); + opts = new Options(opts, getDefaultRender(doc)); return linkifyElementHelper(element, opts, doc); } // Maintain reference to the recursive helper to cache option-normalization linkifyElement.helper = linkifyElementHelper; -linkifyElement.normalize = (opts) => new Options(opts); +linkifyElement.getDefaultRender = getDefaultRender; +linkifyElement.normalize = (opts, doc) => new Options(opts, getDefaultRender(doc)); + diff --git a/packages/linkify-jquery/src/linkify-jquery.js b/packages/linkify-jquery/src/linkify-jquery.js index 162ac3cd..08964e58 100644 --- a/packages/linkify-jquery/src/linkify-jquery.js +++ b/packages/linkify-jquery/src/linkify-jquery.js @@ -29,7 +29,7 @@ export default function apply($, doc = false) { } function jqLinkify(opts) { - opts = linkifyElement.normalize(opts); + opts = linkifyElement.normalize(opts, doc); return this.each(function () { linkifyElement.helper(this, opts, doc); }); diff --git a/packages/linkify-react/src/linkify-react.js b/packages/linkify-react/src/linkify-react.js index 3055183a..5b30763c 100644 --- a/packages/linkify-react/src/linkify-react.js +++ b/packages/linkify-react/src/linkify-react.js @@ -5,52 +5,26 @@ import { tokenize, Options } from 'linkifyjs'; * Given a string, converts to an array of valid React components * (which may include strings) * @param {string} str - * @param {any} opts + * @param {Options} opts * @returns {React.ReactNodeArray} */ -function stringToElements(str, opts) { +function stringToElements(str, opts, parentElementId) { const tokens = tokenize(str); const elements = []; - let linkId = 0; + let nlId = 0; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; - if (token.t === 'nl' && opts.nl2br) { - elements.push(React.createElement('br', {key: `linkified-${++linkId}`})); - continue; + if (token.t === 'nl' && opts.get('nl2br')) { + elements.push(React.createElement('br', { key: `__linkify-el-${parentElementId}-nl-${nlId++}` })); } else if (!token.isLink || !opts.check(token)) { // Regular text elements.push(token.toString()); - continue; - } - - const { - formatted, - formattedHref, - tagName, - className, - target, - rel, - attributes - } = opts.resolve(token); - - const props = { key: `linkified-${++linkId}`, href: formattedHref }; - - if (className) { props.className = className; } - if (target) { props.target = target; } - if (rel) { props.rel = rel; } - - // Build up additional attributes - // Support for events via attributes hash - if (attributes) { - for (var attr in attributes) { - props[attr] = attributes[attr]; - } + } else { + elements.push(opts.render(token)); } - - elements.push(React.createElement(tagName, props, formatted)); } return elements; @@ -61,7 +35,7 @@ function stringToElements(str, opts) { * @template P * @template {string | React.JSXElementConstructor

} T * @param {React.ReactElement} element - * @param {Object} opts + * @param {Options} opts * @param {number} elementId * @returns {React.ReactElement} */ @@ -76,8 +50,7 @@ function linkifyReactElement(element, opts, elementId = 0) { React.Children.forEach(element.props.children, (child) => { if (typeof child === 'string') { // ensure that we always generate unique element IDs for keys - elementId = elementId + 1; - children.push(...stringToElements(child, opts)); + children.push.apply(children, stringToElements(child, opts, elementId)); } else if (React.isValidElement(child)) { if (typeof child.type === 'string' && opts.ignoreTags.indexOf(child.type.toUpperCase()) >= 0 @@ -85,7 +58,7 @@ function linkifyReactElement(element, opts, elementId = 0) { // Don't linkify this element children.push(child); } else { - children.push(linkifyReactElement(child, opts, ++elementId)); + children.push(linkifyReactElement(child, opts, elementId + 1)); } } else { // Unknown element type, just push @@ -94,11 +67,7 @@ function linkifyReactElement(element, opts, elementId = 0) { }); // Set a default unique key, copy over remaining props - const newProps = { key: `linkified-element-${elementId}` }; - for (const prop in element.props) { - newProps[prop] = element.props[prop]; - } - + const newProps = Object.assign({ key: `__linkify-el-${elementId}` }, element.props); return React.cloneElement(element, newProps, children); } @@ -110,14 +79,25 @@ function linkifyReactElement(element, opts, elementId = 0) { */ const Linkify = (props) => { // Copy over all non-linkify-specific props - const newProps = { key: 'linkified-element-wrapper' }; + let linkId = 0; + + const defaultLinkRender = ({ tagName, attributes, content }) => { + attributes.key = `__linkify-lnk-${++linkId}`; + if (attributes.class) { + attributes.className = attributes.class; + delete attributes.class; + } + return React.createElement(tagName, attributes, content); + }; + + const newProps = { key: '__linkify-wrapper' }; for (const prop in props) { if (prop !== 'options' && prop !== 'tagName' && prop !== 'children') { newProps[prop] = props[prop]; } } - const opts = new Options(props.options); + const opts = new Options(props.options, defaultLinkRender); const tagName = props.tagName || React.Fragment || 'span'; const children = props.children; const element = React.createElement(tagName, newProps, children); diff --git a/packages/linkifyjs/src/core/options.js b/packages/linkifyjs/src/core/options.js index 58a69b62..27f11173 100644 --- a/packages/linkifyjs/src/core/options.js +++ b/packages/linkifyjs/src/core/options.js @@ -12,62 +12,54 @@ export const defaults = { target: null, rel: null, validate: true, - truncate: 0, + truncate: Infinity, className: null, attributes: null, - ignoreTags: [] + ignoreTags: [], + render: null }; /** - * @class Options - * @param {Object} [opts] Set option properties besides the defaults + * @typedef {null | {[string]: Function}} LinkifyEventListeners */ -export function Options(opts) { - opts = opts || {}; - this.defaultProtocol = 'defaultProtocol' in opts ? opts.defaultProtocol : defaults.defaultProtocol; - this.events = 'events' in opts ? opts.events : defaults.events; - this.format = 'format' in opts ? opts.format : defaults.format; - this.formatHref = 'formatHref' in opts ? opts.formatHref : defaults.formatHref; - this.nl2br = 'nl2br' in opts ? opts.nl2br : defaults.nl2br; - this.tagName = 'tagName' in opts ? opts.tagName : defaults.tagName; - this.target = 'target' in opts ? opts.target : defaults.target; - this.rel = 'rel' in opts ? opts.rel : defaults.rel; - this.validate = 'validate' in opts ? opts.validate : defaults.validate; - this.truncate = 'truncate' in opts ? opts.truncate : defaults.truncate; - this.className = 'className' in opts ? opts.className : defaults.className; - this.attributes = opts.attributes || defaults.attributes; - this.ignoreTags = []; +/** + * @class Options + * @param {Object | Options} [opts] Set option properties besides the defaults + * @param {({ tagName: any, attributes: any, content: string, events: LinkifyEventListeners }) => any} [defaultRender] + * (For internal use) default render function that determines how to generate + * an HTML element based on a link token's derived tagName, attributes and + * HTML. Similar to render option. + */ +export function Options(opts, defaultRender = null) { + const o = {}; + Object.assign(o, defaults); + if (opts) { Object.assign(o, opts instanceof Options ? opts.o : opts); } - // Make all tags names upper case - const ignoredTags = 'ignoreTags' in opts ? opts.ignoreTags : defaults.ignoreTags; + // Ensure all ignored tags are uppercase + const ignoredTags = o.ignoreTags; + const uppercaseIgnoredTags = []; for (let i = 0; i < ignoredTags.length; i++) { - this.ignoreTags.push(ignoredTags[i].toUpperCase()); + uppercaseIgnoredTags.push(ignoredTags[i].toUpperCase()); } + this.o = o; + this.defaultRender = defaultRender; + this.ignoreTags = uppercaseIgnoredTags; } Options.prototype = { + o: {}, + /** - * Given the token, return all options for how it should be displayed + * @property {({ tagName: any, attributes: any, content: string, events: ?{[string]: Function} }) => any} [defaultRender] */ - resolve(token) { - const href = token.toHref(this.defaultProtocol); - return { - formatted: this.get('format', token.toString(), token), - formattedHref: this.get('formatHref', href, token), - tagName: this.get('tagName', href, token), - className: this.get('className', href, token), - target: this.get('target', href, token), - rel: this.get('rel', href, token), - events: this.getObject('events', href, token), - attributes: this.getObject('attributes', href, token), - truncate: this.get('truncate', href, token), - }; - }, + defaultRender: null, /** * Returns true or false based on whether a token should be displayed as a - * link based on the user options. By default, + * link based on the user options. + * @param {MultiToken} token + * @returns {boolean} */ check(token) { return this.get('validate', token.toString(), token); @@ -77,30 +69,49 @@ Options.prototype = { /** * Resolve an option's value based on the value of the option and the given - * params. + * params. If operator and token are specified and the target option is + * callable, automatically calls the function with the given argument. * @param {string} key Name of option to use - * @param operator will be passed to the target option if it's method - * @param {MultiToken} token The token from linkify.tokenize + * @param {any} [operator] will be passed to the target option if it's a + * function. If not specified, RAW function value gets returned + * @param {MultiToken} [token] The token from linkify.tokenize + * @returns {any} Resolved option value */ get(key, operator, token) { - const option = this[key]; + const isCallable = operator != null; + let option = this.o[key]; if (!option) { return option; } - - let optionValue; - switch (typeof option) { - case 'function': - return option(operator, token.t); - case 'object': - optionValue = token.t in option ? option[token.t] : defaults[key]; - return typeof optionValue === 'function' ? optionValue(operator, token.t) : optionValue; + if (typeof option === 'object') { + option = token.t in option ? option[token.t] : defaults[key]; + if (typeof option === 'function' && isCallable) { + option = option(operator, token); + } + } else if (typeof option === 'function' && isCallable) { + option = option(operator, token.t, token); } return option; }, - getObject(key, operator, token) { - const option = this[key]; - return typeof option === 'function' ? option(operator, token.t) : option; + getObj(key, operator, token) { + let obj = this.o[key]; + if (typeof obj === 'function' && operator != null) { + obj = obj(operator, token.t, token); + } + return obj; + }, + + /** + * Convert the given token to a rendered element that may be added to the + * calling-interface's DOM + * @param {MultiToken} token Token to render to an HTML element + * @returns {any} Render result; e.g., HTML string, DOM element, React + * Component, etc. + */ + render(token) { + const ir = token.render(this); // intermediate representation + const renderFn = this.get('render', null, token) || this.defaultRender; + return renderFn ? renderFn(ir, token.t, token) : ir; } }; diff --git a/packages/linkifyjs/src/core/parser.js b/packages/linkifyjs/src/core/parser.js index 337cf739..ffea8891 100644 --- a/packages/linkifyjs/src/core/parser.js +++ b/packages/linkifyjs/src/core/parser.js @@ -263,7 +263,7 @@ export function init() { // Some of the tokens in `localpartAccepting` are already accounted for here and // will not be overwritten - makeT(Start, tk.TILDE, Localpart) + makeT(Start, tk.TILDE, Localpart); makeMultiT(Domain, localpartAccepting, Localpart); makeT(Domain, tk.AT, LocalpartAt); makeMultiT(DomainDotTld, localpartAccepting, Localpart); diff --git a/packages/linkifyjs/src/core/tokens/multi.js b/packages/linkifyjs/src/core/tokens/multi.js index df1ebc0a..0f8c7de7 100644 --- a/packages/linkifyjs/src/core/tokens/multi.js +++ b/packages/linkifyjs/src/core/tokens/multi.js @@ -33,39 +33,60 @@ export function MultiToken() {} MultiToken.prototype = { /** - String representing the type for this token - @property t - @default 'token' - */ + * String representing the type for this token + * @property t + * @default 'token' + */ t: 'token', /** - Is this multitoken a link? - @property isLink - @default false - */ + * Is this multitoken a link? + * @property isLink + * @default false + */ isLink: false, /** - Return the string this token represents. - @method toString - @return {string} - */ + * Return the string this token represents. + * @param {Options | string} [_opts] Formatting options + * @return {string} + */ toString() { return this.v; }, /** - What should the value for this token be in the `href` HTML attribute? - Returns the `.toString` value by default. - - @method toHref - @return {string} + * What should the value for this token be in the `href` HTML attribute? + * Returns the `.toString` value by default. + * @param {Options | string} [_opts] Formatting options + * @return {string} */ toHref() { return this.toString(); }, + /** + * @param {Options} opts Formatting options + * @returns {string} + */ + toFormattedString(opts) { + const val = this.toString(); + const truncate = opts.get('truncate', val, this); + const formatted = opts.get('format', val, this); + return (truncate && formatted.length > truncate) + ? formatted.substring(0, truncate) + '…' + : formatted; + }, + + /** + * + * @param {Options} [opts] + * @returns {string} + */ + toFormattedHref(opts) { + return opts.get('formatHref', this.toHref(opts.get('defaultProtocol')), this); + }, + /** * The start index of this token in the original input string * @returns {number} @@ -84,7 +105,7 @@ MultiToken.prototype = { }, /** - Returns a hash of relevant values for this token, which includes keys + Returns an object of relevant values for this token, which includes keys * type - Kind of token ('url', 'email', etc.) * value - Original text * href - The value that should be added to the anchor tag's href @@ -96,12 +117,62 @@ MultiToken.prototype = { toObject(protocol = defaults.defaultProtocol) { return { type: this.t, - value: this.v, + value: this.toString(), isLink: this.isLink, href: this.toHref(protocol), start: this.startIndex(), end: this.endIndex() }; + }, + + /** + * + * @param {Options} opts Formatting option + */ + toFormattedObject(opts) { + return { + type: this.t, + value: this.toFormattedString(opts), + isLink: this.isLink, + href: this.toFormattedHref(opts), + start: this.startIndex(), + end: this.endIndex() + }; + }, + + /** + * Whether this token should be rendered as a link according to the given options + * @param {Options} opts + * @returns {boolean} + */ + validate(opts) { + return opts.get('validate', this.toString(), this); + }, + + /** + * Return an object that represents how this link should be rendered. + * @param {Options} opts Formattinng options + */ + render(opts) { + const token = this; + const tagName = opts.get('tagName', href, token); + const href = this.toFormattedHref(opts); + const content = this.toFormattedString(opts); + + const attributes = {}; + const className = opts.get('className', href, token); + const target = opts.get('target', href, token); + const rel = opts.get('rel', href, token); + const attrs = opts.getObj('attributes', href, token); + const eventListeners = opts.getObj('events', href, token); + + attributes.href = href; + if (className) { attributes.class = className; } + if (target) { attributes.target = target; } + if (rel) { attributes.rel = rel; } + if (attrs) { Object.assign(attributes, attrs); } + + return { tagName, attributes, content, eventListeners }; } }; @@ -163,15 +234,18 @@ export const Url = createTokenClass('url', { required. Note that this will not escape unsafe HTML characters in the URL. - @method href - @param {string} protocol - @return {string} + @param {string} [scheme] default scheme (e.g., 'https') + @return {string} the full href */ - toHref(protocol = defaults.defaultProtocol) { + toHref(scheme = defaults.defaultProtocol) { // Check if already has a prefix scheme - return this.hasProtocol() ? this.v : `${protocol}://${this.v}`; + return this.hasProtocol() ? this.v : `${scheme}://${this.v}`; }, + /** + * Check whether this URL token has a protocol + * @return {boolean} + */ hasProtocol() { const tokens = this.tk; return tokens.length >= 2 && scheme.indexOf(tokens[0].t) >= 0 && tokens[1].t === COLON; diff --git a/packages/linkifyjs/src/linkify-html.js b/packages/linkifyjs/src/linkify-html.js index 626a5875..ec28f071 100644 --- a/packages/linkifyjs/src/linkify-html.js +++ b/packages/linkifyjs/src/linkify-html.js @@ -2,6 +2,7 @@ import { tokenize } from '@nfrasser/simple-html-tokenizer'; import * as linkify from 'linkifyjs'; const { Options } = linkify; +const LinkifyResult = 'LinkifyResult'; const StartTag = 'StartTag'; const EndTag = 'EndTag'; const Chars = 'Chars'; @@ -20,7 +21,7 @@ export default function linkifyHtml(str, opts = {}) { const linkifiedTokens = []; const linkified = []; - opts = new Options(opts); + opts = new Options(opts, defaultRender); // Linkify the tokens given by the parser for (let i = 0; i < tokens.length; i++) { @@ -30,35 +31,34 @@ export default function linkifyHtml(str, opts = {}) { linkifiedTokens.push(token); // Ignore all the contents of ignored tags - let tagName = token.tagName.toUpperCase(); - let isIgnored = tagName === 'A' || opts.ignoreTags.indexOf(tagName) >= 0; + const tagName = token.tagName.toUpperCase(); + const isIgnored = tagName === 'A' || opts.ignoreTags.indexOf(tagName) >= 0; if (!isIgnored) { continue; } let preskipLen = linkifiedTokens.length; skipTagTokens(tagName, tokens, ++i, linkifiedTokens); i += linkifiedTokens.length - preskipLen - 1; - continue; - } else if (token.type !== Chars) { // Skip this token, it's not important linkifiedTokens.push(token); - continue; + } else { + // Valid text token, linkify it! + const linkifedChars = linkifyChars(token.chars, opts); + linkifiedTokens.push.apply(linkifiedTokens, linkifedChars); } - - // Valid text token, linkify it! - const linkifedChars = linkifyChars(token.chars, opts); - linkifiedTokens.push.apply(linkifiedTokens, linkifedChars); } // Convert the tokens back into a string for (let i = 0; i < linkifiedTokens.length; i++) { const token = linkifiedTokens[i]; switch (token.type) { + case LinkifyResult: + linkified.push(token.rendered); + break; case StartTag: { let link = '<' + token.tagName; if (token.attributes.length > 0) { - let attrs = attrsToStrings(token.attributes); - link += ' ' + attrs.join(' '); + link += ' ' + attributeArrayToStrings(token.attributes).join(' '); } link += '>'; linkified.push(link); @@ -105,44 +105,14 @@ function linkifyChars(str, opts) { attributes: [], selfClosing: true }); - continue; } else if (!token.isLink || !opts.check(token)) { - result.push({type: Chars, chars: token.toString()}); - continue; - } - - let { - formatted, - formattedHref, - tagName, - className, - target, - rel, - attributes, - truncate - } = opts.resolve(token); - - // Build up attributes - const attributeArray = [['href', formattedHref]]; - - if (className) { attributeArray.push(['class', className]); } - if (target) { attributeArray.push(['target', target]); } - if (rel) { attributeArray.push(['rel', rel]); } - if (truncate && formatted.length > truncate) { formatted = formatted.substring(0, truncate) + '…'; } - - for (const attr in attributes) { - attributeArray.push([attr, attributes[attr]]); + result.push({ type: Chars, chars: token.toString() }); + } else { + result.push({ + type: LinkifyResult, + rendered: opts.render(token) + }); } - - // Add the required tokens - result.push({ - type: StartTag, - tagName: tagName, - attributes: attributeArray, - selfClosing: false - }); - result.push({ type: Chars, chars: formatted }); - result.push({ type: EndTag, tagName: tagName }); } return result; @@ -186,6 +156,10 @@ function skipTagTokens(tagName, tokens, i, skippedTokens) { return skippedTokens; } +function defaultRender({ tagName, attributes, content }) { + return `<${tagName} ${attributesToString(attributes)}>${escapeText(content)}`; +} + function escapeText(text) { return text .replace(/&/g, '&') @@ -197,11 +171,20 @@ function escapeAttr(attr) { return attr.replace(/"/g, '"'); } -function attrsToStrings(attrs) { +function attributesToString(attributes) { + const result = []; + for (const attr in attributes) { + const val = attributes[attr] + ''; + result.push(`${attr}="${escapeAttr(val)}"`); + } + return result.join(' '); +} + +function attributeArrayToStrings(attrs) { const attrStrs = []; for (let i = 0; i < attrs.length; i++) { const name = attrs[i][0]; - const value = attrs[i][1]; + const value = attrs[i][1] + ''; attrStrs.push(`${name}="${escapeAttr(value)}"`); } return attrStrs; diff --git a/packages/linkifyjs/src/linkify-string.js b/packages/linkifyjs/src/linkify-string.js index 61cadc91..e250b946 100644 --- a/packages/linkifyjs/src/linkify-string.js +++ b/packages/linkifyjs/src/linkify-string.js @@ -15,16 +15,18 @@ function escapeAttr(href) { } function attributesToString(attributes) { - if (!attributes) { return ''; } - let result = []; - - for (let attr in attributes) { + const result = []; + for (const attr in attributes) { let val = attributes[attr] + ''; result.push(`${attr}="${escapeAttr(val)}"`); } return result.join(' '); } +function defaultRender({ tagName, attributes, content }) { + return `<${tagName} ${attributesToString(attributes)}>${escapeText(content)}`; +} + /** * Convert a plan text string to an HTML string with links. Expects that the * given strings does not contain any HTML entities. Use the linkify-html @@ -35,41 +37,21 @@ function attributesToString(attributes) { * @returns {string} */ function linkifyStr(str, opts = {}) { - opts = new Options(opts); + opts = new Options(opts, defaultRender); - let tokens = tokenize(str); - let result = []; + const tokens = tokenize(str); + const result = []; for (let i = 0; i < tokens.length; i++) { - let token = tokens[i]; + const token = tokens[i]; - if (token.t === 'nl' && opts.nl2br) { + if (token.t === 'nl' && opts.get('nl2br')) { result.push('
\n'); - continue; } else if (!token.isLink || !opts.check(token)) { result.push(escapeText(token.toString())); - continue; + } else { + result.push(opts.render(token)); } - - const { - formatted, - formattedHref, - tagName, - className, - target, - rel, - attributes, - } = opts.resolve(token); - - const link = [`<${tagName} href="${escapeAttr(formattedHref)}"`]; - - if (className) { link.push(` class="${escapeAttr(className)}"`); } - if (target) { link.push(` target="${escapeAttr(target)}"`); } - if (rel) { link.push(` rel="${escapeAttr(rel)}"`); } - if (attributes) { link.push(` ${attributesToString(attributes)}`); } - - link.push(`>${escapeText(formatted)}`); - result.push(link.join('')); } return result.join(''); diff --git a/packages/linkifyjs/src/linkify.js b/packages/linkifyjs/src/linkify.js index b52e6a64..1c17a661 100644 --- a/packages/linkifyjs/src/linkify.js +++ b/packages/linkifyjs/src/linkify.js @@ -1,5 +1,6 @@ import * as scanner from './core/scanner'; import * as parser from './core/parser'; +import { Options } from './core/options'; const warn = typeof console !== 'undefined' && console && console.warn || (() => {}); @@ -96,16 +97,18 @@ export function tokenize(str) { Find a list of linkable items in the given string. @param {string} str string to find links in @param {string} [type] (optional) only find links of a specific type, e.g., - 'url' or 'email' + 'url' or 'email' + @param {Options|Object} [options] (optional) formatting options for final output */ -export function find(str, type = null) { +export function find(str, type = null, options = {}) { + const opts = new Options(options); const tokens = tokenize(str); const filtered = []; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (token.isLink && (!type || token.t === type)) { - filtered.push(token.toObject()); + filtered.push(token.toFormattedObject(opts)); } } @@ -136,4 +139,4 @@ export function test(str, type = null) { } export * as options from './core/options'; -export { Options } from './core/options'; +export { Options }; diff --git a/test/index.js b/test/index.js index be7a90f5..837c2e31 100644 --- a/test/index.js +++ b/test/index.js @@ -6,6 +6,17 @@ import { expect } from 'chai'; const Module = require('module'); const originalRequire = Module.prototype.require; +/** + Gracefully truncate a string to a given limit. Will replace extraneous + text with a single ellipsis character (`…`). +*/ +String.prototype.truncate = function (limit) { + limit = limit || Infinity; + return this.length > limit + ? this.substring(0, limit) + '…' + : this +}; + Module.prototype.require = function (mod, ...args) { if (mod === 'linkifyjs') { mod = 'linkifyjs/src/linkify'; diff --git a/test/qunit/main.js b/test/qunit/main.js index 6b484166..7a834685 100644 --- a/test/qunit/main.js +++ b/test/qunit/main.js @@ -98,15 +98,14 @@ QUnit.module('linkify.options.Options'); QUnit.test('returns object of default options when given an empty object', function (assert) { var result = new w.linkify.options.Options({}); - assert.propEqual(result, w.linkify.options.defaults); - - assert.equal(typeof result.format, 'function'); - assert.equal(typeof result.validate, 'boolean'); - assert.equal(result.format('test'), 'test'); - assert.equal(typeof result.formatHref, 'function'); - assert.equal(result.formatHref('test'), 'test'); - assert.equal(result.target, null); - assert.equal(result.rel, null); + + assert.equal(typeof result.get('format'), 'function'); + assert.equal(typeof result.get('validate'), 'boolean'); + assert.equal(result.get('format')('test'), 'test'); + assert.equal(typeof result.get('formatHref'), 'function'); + assert.equal(result.get('formatHref')('test'), 'test'); + assert.equal(result.get('target'), null); + assert.equal(result.get('rel'), null); }); diff --git a/test/spec/core/options.test.js b/test/spec/core/options.test.js index 83992bd5..77e1cd01 100644 --- a/test/spec/core/options.test.js +++ b/test/spec/core/options.test.js @@ -1,4 +1,8 @@ -const options = require('linkifyjs/src/core/options'); +import { expect } from 'chai'; +import * as options from 'linkifyjs/src/core/options'; +import * as scanner from 'linkifyjs/src/core/scanner'; +import { multi as mtk } from 'linkifyjs/src/core/tokens'; + const Options = options.Options; describe('linkifyjs/core/options', () => { @@ -17,21 +21,33 @@ describe('linkifyjs/core/options', () => { var opts = new Options(); options.defaults.defaultProtocol = 'https'; var newOpts = new Options(); - expect(opts.defaultProtocol).to.equal('http'); - expect(newOpts.defaultProtocol).to.equal('https'); + expect(opts.get('defaultProtocol')).to.equal('http'); + expect(newOpts.get('defaultProtocol')).to.equal('https'); }); }); describe('Options', () => { - let opts, urlToken, emailToken; + const events = { click: () => alert('clicked!') }; + let urlToken, emailToken, scannerStart; + let opts, renderOpts; + + before(() => { + scannerStart = scanner.init(); + const inputUrl = 'github.com'; + const inputEmail = 'test@example.com'; + + const urlTextTokens = scanner.run(scannerStart, inputUrl); + const emailTextTokens = scanner.run(scannerStart, inputEmail); + + urlToken = new mtk.Url(inputUrl, urlTextTokens); + emailToken = new mtk.Email(inputEmail, emailTextTokens); + }); beforeEach(() => { opts = new Options({ defaultProtocol: 'https', - events: { - click: () => alert('clicked!') - }, + events, format: (text) => `<${text}>`, formatHref: { url: (url) => `${url}/?from=linkify`, @@ -48,59 +64,53 @@ describe('linkifyjs/core/options', () => { truncate: 40 }); - urlToken = { - t: 'url', - isLink: true, - toString: () => 'github.com', - toHref: (protocol) => `${protocol}://github.com`, - hasProtocol: () => false - }; - - emailToken = { - t: 'email', - isLink: true, - toString: () => 'test@example.com', - toHref: () => 'mailto:test@example.com' - }; + renderOpts = new Options({ + tagName: 'b', + className: 'linkified', + render: { + email: ({ attributes, content }) => ( + // Ignore tagname and most attributes + `${content}` + ) + } + }, ({ tagName, attributes, content }) => { + const attrStrs = Object.keys(attributes) + .reduce((a, attr) => a.concat(`${attr}="${attributes[attr]}"`), []); + return `<${tagName} ${attrStrs.join(' ')}>${content}`; + }); }); - describe('#resolve', () => { - it('returns the correct set of options for a url token', () => { - expect(opts.resolve(urlToken)).to.deep.equal({ - formatted: '', - formattedHref: 'https://github.com/?from=linkify', - tagName: 'a', - className: 'custom-class-name', - target: null, - events: opts.events, - rel: 'nofollow', - attributes: { type: 'text/html' }, - truncate: 40 - }); + describe('#check()', () => { + it('returns false for url token', () => { + expect(opts.check(urlToken)).not.to.be.ok; }); - it('returns the correct set of options for an email token', () => { - expect(opts.resolve(emailToken)).to.deep.equal({ - formatted: '', - formattedHref: 'mailto:test@example.com?subject=Hello+from+Linkify', + it('returns true for email token', () => { + expect(opts.check(emailToken)).to.be.ok; + }); + }); + + describe('#render()', () => { + it('Returns intermediate representation when render option not specified', () => { + expect(opts.render(urlToken)).to.eql({ tagName: 'a', - className: 'custom-class-name', - target: null, - events: opts.events, - rel: 'nofollow', - attributes: { type: 'text/html' }, - truncate: 40 + attributes: { + href: 'https://github.com/?from=linkify', + class: 'custom-class-name', + rel: 'nofollow', + type: 'text/html' + }, + content: '', + eventListeners: events }); }); - }); - describe('#check', () => { - it('returns false for url token', () => { - expect(opts.check(urlToken)).not.to.be.ok; + it('renders a URL', () => { + expect(renderOpts.render(urlToken)).to.eql('github.com'); }); - it('returns true for email token', () => { - expect(opts.check(emailToken)).to.be.ok; + it('renders an email address', () => { + expect(renderOpts.render(emailToken)).to.eql('test@example.com'); }); }); }); @@ -114,13 +124,13 @@ describe('linkifyjs/core/options', () => { describe('target', () => { it('should be nulled', () => { - expect(opts.target).to.be.null; + expect(opts.get('target')).to.be.null; }); }); describe('className', () => { it('should be nulled', () => { - expect(opts.className).to.be.null; + expect(opts.get('className')).to.be.null; }); }); }); diff --git a/test/spec/core/tokens/multi.test.js b/test/spec/core/tokens/multi.test.js index 44952cb3..bdb6cbc9 100644 --- a/test/spec/core/tokens/multi.test.js +++ b/test/spec/core/tokens/multi.test.js @@ -1,3 +1,4 @@ +const { Options } = require('linkifyjs/src/core/options'); const tokens = require('linkifyjs/src/core/tokens'); const scanner = require('linkifyjs/src/core/scanner'); const { expect } = require('chai'); @@ -6,6 +7,22 @@ const mtk = tokens.multi; describe('linkifyjs/core/tokens/multi', () => { let scannerStart; + const defaultOpts = new Options(); + const opts = new Options({ + tagName: 'Link', + target: '_parent', + nl2br: true, + className: 'my-linkify-class', + defaultProtocol: 'https', + rel: 'nofollow', + attributes: { onclick: 'console.log(\'Hello World!\')' }, + truncate: 40, + format: (val) => val.replace(/^(ht|f)tps?:\/\/(www\.)?/i, ''), + formatHref: { + email: (href) => href + '?subject=Hello%20from%20Linkify' + }, + }); + before(() => { scannerStart = scanner.init(); }); describe('Multitoken', () => { @@ -17,14 +34,17 @@ describe('linkifyjs/core/tokens/multi', () => { describe('Url', () => { let input1 = 'Ftps://www.github.com/Hypercontext/linkify'; let input2 = 'co.co/?o=%2D&p=@gc#wat'; - let url1, url2; + let input3 = 'https://www.google.com/maps/place/The+DMZ/@43.6578984,-79.3819437,17z/data=!4m9!1m2!2m1!1sRyerson+DMZ!3m5!1s0x882b34cad13907bf:0x393038cf922e1378!8m2!3d43.6563702!4d-79.3793919!15sCgtSeWVyc29uIERNWloNIgtyeWVyc29uIGRtepIBHmJ1c2luZXNzX21hbmFnZW1lbnRfY29uc3VsdGFudA'; + let url1, url2, url3; before(() => { const urlTextTokens1 = scanner.run(scannerStart, input1); const urlTextTokens2 = scanner.run(scannerStart, input2); + const urlTextTokens3 = scanner.run(scannerStart, input3); url1 = new mtk.Url(input1, urlTextTokens1); url2 = new mtk.Url(input2, urlTextTokens2); + url3 = new mtk.Url(input3, urlTextTokens3); }); describe('#isLink', () => { @@ -83,6 +103,70 @@ describe('linkifyjs/core/tokens/multi', () => { }); }); + describe('#toFormattedString()', () => { + it('Formats with default options', () => { + expect(url1.toFormattedString(defaultOpts)).to.eql('Ftps://www.github.com/Hypercontext/linkify'); + }); + it('Formats short link', () => { + expect(url1.toFormattedString(opts)).to.eql('github.com/Hypercontext/linkify'); + }); + it('Formats long link', () => { + expect(url3.toFormattedString(opts)).to.eql('google.com/maps/place/The+DMZ/@43.657898…'); + }); + }); + + describe('#toFormattedHref()', () => { + it('Formats href with scheme', () => { + expect(url1.toFormattedHref(opts)).to.eql('Ftps://www.github.com/Hypercontext/linkify'); + }); + it('Formats href without scheme', () => { + expect(url2.toFormattedHref(opts)).to.eql('https://co.co/?o=%2D&p=@gc#wat'); + }); + }); + + describe('#toFormattedObject()', () => { + it('Returns correctly formatted object', () => { + expect(url1.toFormattedObject(opts)).to.eql({ + type: 'url', + value: 'github.com/Hypercontext/linkify', + isLink: true, + href: 'Ftps://www.github.com/Hypercontext/linkify', + start: 0, + end: 42 + }); + }); + }); + + describe('#validate()', () => { + it('Returns true for URL', () => { + expect(url1.validate(opts)).to.be.ok; + }); + }); + + describe('#render()', () => { + it('Works with default options', () => { + expect(url1.render(defaultOpts)).to.eql({ + tagName: 'a', + attributes: { href: 'Ftps://www.github.com/Hypercontext/linkify' }, + content: 'Ftps://www.github.com/Hypercontext/linkify', + eventListeners: null + }); + }); + it('Works with overriden options', () => { + expect(url1.render(opts)).to.eql({ + tagName: 'Link', + attributes: { + href: 'Ftps://www.github.com/Hypercontext/linkify' , + class: 'my-linkify-class', + target: '_parent', + rel: 'nofollow', + onclick: 'console.log(\'Hello World!\')' + }, + content: 'github.com/Hypercontext/linkify', + eventListeners: null + }); + }); + }); }); describe('Email', () => { diff --git a/test/spec/linkify-react.test.js b/test/spec/linkify-react.test.js index 542a9420..bb9ce3a3 100644 --- a/test/spec/linkify-react.test.js +++ b/test/spec/linkify-react.test.js @@ -33,11 +33,11 @@ describe('linkify-react', () => { ], [ 'The URL is google.com and the email is test@example.com', 'The URL is google.com and the email is test@example.com', - '

The URL is google.com and the email is test@example.com
', + '
The URL is google.com and the email is test@example.com
', ], [ 'Super long maps URL https://www.google.ca/maps/@43.472082,-80.5426668,18z?hl=en, a #hash-tag, and an email: test.wut.yo@gmail.co.uk!\n', 'Super long maps URL https://www.google.ca/maps/@43.472082,-80.5426668,18z?hl=en, a #hash-tag, and an email: test.wut.yo@gmail.co.uk!\n', - '
Super long maps URL https://www.google.ca/maps/@43.472082,-8…, a #hash-tag, and an email: test.wut.yo@gmail.co.uk!
', + '
Super long maps URL https://www.google.ca/maps/@43.472082,-8…, a #hash-tag, and an email: test.wut.yo@gmail.co.uk!
', ], [ 'Link with @some.username should not work as a link', 'Link with @some.username should not work as a link', diff --git a/test/spec/linkify-string.test.js b/test/spec/linkify-string.test.js index 70be32c2..6a2aeb1a 100644 --- a/test/spec/linkify-string.test.js +++ b/test/spec/linkify-string.test.js @@ -1,25 +1,5 @@ import linkifyStr from 'linkifyjs/src/linkify-string'; -/** - Gracefully truncate a string to a given limit. Will replace extraneous - text with a single ellipsis character (`…`). -*/ -String.prototype.truncate = function (limit) { - var string = this.toString(); - limit = limit || Infinity; - - if (limit <= 3) { - string = '…'; - } else if (string.length > limit) { - string = string.slice(0, limit).split(/\s/); - if (string.length > 1) { - string.pop(); - } - string = string.join(' ') + '…'; - } - return string; -}; - describe('linkify-string', () => { // For each element in this array // [0] - Original text