Skip to content

Commit

Permalink
Initial unified token rendering
Browse files Browse the repository at this point in the history
- 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

Still need to update the interfaces to take advantage of this new
paradigm
  • Loading branch information
Nick Frasser committed Nov 23, 2021
1 parent 1ae5f69 commit 7587c27
Show file tree
Hide file tree
Showing 9 changed files with 331 additions and 157 deletions.
4 changes: 2 additions & 2 deletions packages/linkify-react/src/linkify-react.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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) {
Expand Down Expand Up @@ -61,7 +61,7 @@ function stringToElements(str, opts) {
* @template P
* @template {string | React.JSXElementConstructor<P>} T
* @param {React.ReactElement<P, T>} element
* @param {Object} opts
* @param {Options} opts
* @param {number} elementId
* @returns {React.ReactElement<P, T>}
*/
Expand Down
118 changes: 65 additions & 53 deletions packages/linkifyjs/src/core/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,62 +12,55 @@ 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, innerHTML: 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());
}
o.ignoreTags = uppercaseIgnoredTags;

this.o = o;
this.defaultRender = defaultRender;
}

Options.prototype = {
o: {},

/**
* Given the token, return all options for how it should be displayed
* @property {({ tagName: any, attributes: any, innerHTML: 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);
Expand All @@ -77,30 +70,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) : ir;
}
};

Expand Down
2 changes: 1 addition & 1 deletion packages/linkifyjs/src/core/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
122 changes: 98 additions & 24 deletions packages/linkifyjs/src/core/tokens/multi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand All @@ -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 innerHTML = 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, innerHTML, eventListeners };
}
};

Expand Down Expand Up @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions packages/linkifyjs/src/linkify.js
Original file line number Diff line number Diff line change
@@ -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 || (() => {});

Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -136,4 +139,4 @@ export function test(str, type = null) {
}

export * as options from './core/options';
export { Options } from './core/options';
export { Options };
Loading

0 comments on commit 7587c27

Please sign in to comment.