interweave
Version:
React library to safely render HTML, filter attributes, autowrap text, autolink, and much more.
771 lines (591 loc) • 22.8 kB
JavaScript
// Bundled with Packemon: https://packemon.dev
// Platform: browser, Support: stable, Format: lib
'use strict';
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
Object.defineProperties(exports, {
__esModule: {
value: true
},
[Symbol.toStringTag]: {
value: 'Module'
}
});
const Matcher = require('./bundle-707b635b.js');
const React = require('react');
const escapeHtml = require('escape-html');
const _interopDefault = e => e && e.__esModule ? e : {
default: e
};
const React__default = /*#__PURE__*/_interopDefault(React);
const escapeHtml__default = /*#__PURE__*/_interopDefault(escapeHtml);
const INVALID_STYLES = /(url|image|image-set)\(/i;
class StyleFilter extends Matcher.Filter {
attribute(name, value) {
if (name === 'style') {
Object.keys(value).forEach(key => {
if (String(value[key]).match(INVALID_STYLES)) {
// eslint-disable-next-line no-param-reassign
delete value[key];
}
});
} // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return value;
}
}
/* eslint-disable no-bitwise, no-cond-assign, complexity, @typescript-eslint/no-unsafe-return */
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
const INVALID_ROOTS = /^<(!doctype|(html|head|body)(\s|>))/i;
const ALLOWED_ATTRS = /^(aria-|data-|\w+:)/iu;
const OPEN_TOKEN = /{{{(\w+)\/?}}}/;
function createDocument() {
// Maybe SSR? Just do nothing instead of crashing!
if (typeof window === 'undefined' || typeof document === 'undefined') {
return undefined;
}
return document.implementation.createHTMLDocument('Interweave');
}
class Parser {
constructor(markup, props = {}, matchers = [], filters = []) {
var _props$allowList;
_defineProperty(this, "allowed", void 0);
_defineProperty(this, "banned", void 0);
_defineProperty(this, "blocked", void 0);
_defineProperty(this, "container", void 0);
_defineProperty(this, "content", []);
_defineProperty(this, "props", void 0);
_defineProperty(this, "matchers", void 0);
_defineProperty(this, "filters", void 0);
_defineProperty(this, "keyIndex", void 0);
if (process.env.NODE_ENV !== "production" && markup && typeof markup !== 'string') {
throw new TypeError('Interweave parser requires a valid string.');
}
this.props = props;
this.matchers = matchers;
this.filters = [...filters, new StyleFilter()];
this.keyIndex = -1;
this.container = this.createContainer(markup || '');
this.allowed = new Set((_props$allowList = props.allowList) !== null && _props$allowList !== void 0 ? _props$allowList : Matcher.ALLOWED_TAG_LIST);
this.banned = new Set(Matcher.BANNED_TAG_LIST);
this.blocked = new Set(props.blockList);
}
/**
* Loop through and apply all registered attribute filters.
*/
applyAttributeFilters(name, value) {
return this.filters.reduce((nextValue, filter) => nextValue !== null && typeof filter.attribute === 'function' ? filter.attribute(name, nextValue) : nextValue, value);
}
/**
* Loop through and apply all registered node filters.
*/
applyNodeFilters(name, node) {
// Allow null to be returned
return this.filters.reduce((nextNode, filter) => nextNode !== null && typeof filter.node === 'function' ? filter.node(name, nextNode) : nextNode, node);
}
/**
* Loop through and apply all registered matchers to the string.
* If a match is found, create a React element, and build a new array.
* This array allows React to interpolate and render accordingly.
*/
applyMatchers(string, parentConfig) {
const elements = {};
const {
props
} = this;
let matchedString = string;
let elementIndex = 0;
let parts = null;
this.matchers.forEach(matcher => {
const tagName = matcher.asTag().toLowerCase();
const config = this.getTagConfig(tagName); // Skip matchers that have been disabled from props or are not supported
if (props[matcher.inverseName] || !this.isTagAllowed(tagName)) {
return;
} // Skip matchers in which the child cannot be rendered
if (!this.canRenderChild(parentConfig, config)) {
return;
} // Continuously trigger the matcher until no matches are found
let tokenizedString = '';
while (matchedString && (parts = matcher.match(matchedString))) {
const {
index,
length,
match,
valid,
void: isVoid,
...partProps
} = parts;
const tokenName = matcher.propName + String(elementIndex); // Piece together a new string with interpolated tokens
if (index > 0) {
tokenizedString += matchedString.slice(0, index);
}
if (valid) {
tokenizedString += isVoid ? `{{{${tokenName}/}}}` : `{{{${tokenName}}}}${match}{{{/${tokenName}}}}`;
this.keyIndex += 1;
elementIndex += 1;
elements[tokenName] = {
children: match,
matcher,
props: { ...props,
...partProps,
key: this.keyIndex
}
};
} else {
tokenizedString += match;
} // Reduce the string being matched against,
// otherwise we end up in an infinite loop!
if (matcher.greedy) {
matchedString = tokenizedString + matchedString.slice(index + length);
tokenizedString = '';
} else {
// eslint-disable-next-line unicorn/explicit-length-check
matchedString = matchedString.slice(index + (length || match.length));
}
} // Update the matched string with the tokenized string,
// so that the next matcher can apply to it.
if (!matcher.greedy) {
matchedString = tokenizedString + matchedString;
}
});
if (elementIndex === 0) {
return string;
}
return this.replaceTokens(matchedString, elements);
}
/**
* Determine whether the child can be rendered within the parent.
*/
canRenderChild(parentConfig, childConfig) {
if (!parentConfig.tagName || !childConfig.tagName) {
return false;
} // No children
if (parentConfig.void) {
return false;
} // Valid children
if (parentConfig.children.length > 0) {
return parentConfig.children.includes(childConfig.tagName);
}
if (parentConfig.invalid.length > 0 && parentConfig.invalid.includes(childConfig.tagName)) {
return false;
} // Valid parent
if (childConfig.parent.length > 0) {
return childConfig.parent.includes(parentConfig.tagName);
} // Self nesting
if (!parentConfig.self && parentConfig.tagName === childConfig.tagName) {
return false;
} // Content category type
return Boolean(parentConfig && parentConfig.content & childConfig.type);
}
/**
* Convert line breaks in a string to HTML `<br/>` tags.
* If the string contains HTML, we should not convert anything,
* as line breaks should be handled by `<br/>`s in the markup itself.
*/
convertLineBreaks(markup) {
const {
noHtml,
disableLineBreaks
} = this.props;
if (noHtml || disableLineBreaks || markup.match(/<((?:\/[ a-z]+)|(?:[ a-z]+\/))>/gi)) {
return markup;
} // Replace carriage returns
let nextMarkup = markup.replace(/\r\n/g, '\n'); // Replace long line feeds
nextMarkup = nextMarkup.replace(/\n{3,}/g, '\n\n\n'); // Replace line feeds with `<br/>`s
nextMarkup = nextMarkup.replace(/\n/g, '<br/>');
return nextMarkup;
}
/**
* Create a detached HTML document that allows for easy HTML
* parsing while not triggering scripts or loading external
* resources.
*/
createContainer(markup) {
var _this$props$container;
const factory = typeof global !== 'undefined' && global.INTERWEAVE_SSR_POLYFILL || createDocument;
const doc = factory();
if (!doc) {
return undefined;
}
const tag = (_this$props$container = this.props.containerTagName) !== null && _this$props$container !== void 0 ? _this$props$container : 'body';
const el = tag === 'body' || tag === 'fragment' ? doc.body : doc.createElement(tag);
if (markup.match(INVALID_ROOTS)) {
if (process.env.NODE_ENV !== "production") {
throw new Error('HTML documents as Interweave content are not supported.');
}
} else {
el.innerHTML = this.convertLineBreaks(this.props.escapeHtml ? escapeHtml__default.default(markup) : markup);
}
return el;
}
/**
* Convert an elements attribute map to an object map.
* Returns null if no attributes are defined.
*/
extractAttributes(node) {
const {
allowAttributes
} = this.props;
const attributes = {};
let count = 0;
if (node.nodeType !== ELEMENT_NODE || !node.attributes) {
return null;
} // @ts-expect-error Cant type iterator
[...node.attributes].forEach(attr => {
const {
name,
value
} = attr;
const newName = name.toLowerCase();
const filter = Matcher.ATTRIBUTES[newName] || Matcher.ATTRIBUTES[name]; // Verify the node is safe from attacks
if (!this.isSafe(node)) {
return;
} // Do not allow denied attributes, excluding ARIA attributes
// Do not allow events or XSS injections
if (!newName.match(ALLOWED_ATTRS) && (!allowAttributes && (!filter || filter === Matcher.FILTER_DENY) || newName.startsWith('on') || value.replace(/(\s|\0|�([9AD]);)/, '').match(/(javascript|vbscript|livescript|xss):/i))) {
return;
} // Apply attribute filters
let newValue = newName === 'style' ? this.extractStyleAttribute(node) : value; // Cast to boolean
if (filter === Matcher.FILTER_CAST_BOOL) {
newValue = true; // Cast to number
} else if (filter === Matcher.FILTER_CAST_NUMBER) {
newValue = Number.parseFloat(String(newValue)); // Cast to string
} else if (filter !== Matcher.FILTER_NO_CAST) {
newValue = String(newValue);
}
attributes[Matcher.ATTRIBUTES_TO_PROPS[newName] || newName] = this.applyAttributeFilters(newName, newValue);
count += 1;
});
if (count === 0) {
return null;
}
return attributes;
}
/**
* Extract the style attribute as an object and remove values that allow for attack vectors.
*/
extractStyleAttribute(node) {
const styles = {}; // eslint-disable-next-line unicorn/prefer-spread
Array.from(node.style).forEach(key => {
const value = node.style[key];
if (typeof value === 'string' || typeof value === 'number') {
styles[key.replace(/-([a-z])/g, (match, letter) => String(letter).toUpperCase())] = value;
}
});
return styles;
}
/**
* Return configuration for a specific tag.
*/
getTagConfig(tagName) {
const common = {
children: [],
content: 0,
invalid: [],
parent: [],
self: true,
tagName: '',
type: 0,
void: false
}; // Only spread when a tag config exists,
// otherwise we use the empty `tagName`
// for parent config inheritance.
if (Matcher.TAGS[tagName]) {
return { ...common,
...Matcher.TAGS[tagName],
tagName
};
}
return common;
}
/**
* Verify that a node is safe from XSS and injection attacks.
*/
isSafe(node) {
// URLs should only support HTTP, email and phone numbers
if (typeof HTMLAnchorElement !== 'undefined' && node instanceof HTMLAnchorElement) {
const href = node.getAttribute('href'); // Fragment protocols start with about:
// So let's just allow them
if (href !== null && href !== void 0 && href.startsWith('#')) {
return true;
}
const protocol = node.protocol.toLowerCase();
return protocol === ':' || protocol === 'http:' || protocol === 'https:' || protocol === 'mailto:' || protocol === 'tel:';
}
return true;
}
/**
* Verify that an HTML tag is allowed to render.
*/
isTagAllowed(tagName) {
if (this.banned.has(tagName) || this.blocked.has(tagName)) {
return false;
} // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return this.props.allowElements || this.allowed.has(tagName);
}
/**
* Parse the markup by injecting it into a detached document,
* while looping over all child nodes and generating an
* array to interpolate into JSX.
*/
parse() {
if (!this.container) {
return [];
}
return this.parseNode(this.container, this.getTagConfig(this.container.nodeName.toLowerCase()));
}
/**
* Loop over the nodes children and generate a
* list of text nodes and React elements.
*/
parseNode(parentNode, parentConfig) {
const {
noHtml,
noHtmlExceptMatchers,
allowElements,
transform,
transformOnlyAllowList
} = this.props;
let content = [];
let mergedText = ''; // @ts-expect-error Cant type iterator
[...parentNode.childNodes].forEach(node => {
// Create React elements from HTML elements
if (node.nodeType === ELEMENT_NODE) {
const tagName = node.nodeName.toLowerCase();
const config = this.getTagConfig(tagName); // Persist any previous text
if (mergedText) {
content.push(mergedText);
mergedText = '';
} // Apply node filters first
const nextNode = this.applyNodeFilters(tagName, node);
if (!nextNode) {
return;
} // Apply transformation second
let children;
if (transform && !(transformOnlyAllowList && !this.isTagAllowed(tagName))) {
this.keyIndex += 1;
const key = this.keyIndex; // Must occur after key is set
children = this.parseNode(nextNode, config);
const transformed = transform(nextNode, children, config);
if (transformed === null) {
return;
}
if (typeof transformed !== 'undefined') {
content.push( /*#__PURE__*/React__default.default.cloneElement(transformed, {
key
}));
return;
} // Reset as we're not using the transformation
this.keyIndex = key - 1;
} // Never allow these tags (except via a transformer)
if (this.banned.has(tagName)) {
return;
} // Only render when the following criteria is met:
// - HTML has not been disabled
// - Tag is allowed
// - Child is valid within the parent
if (!(noHtml || noHtmlExceptMatchers && tagName !== 'br') && this.isTagAllowed(tagName) && (allowElements || this.canRenderChild(parentConfig, config))) {
var _children;
this.keyIndex += 1; // Build the props as it makes it easier to test
const attributes = this.extractAttributes(nextNode);
const elementProps = {
tagName
};
if (attributes) {
elementProps.attributes = attributes;
}
if (config.void) {
elementProps.selfClose = config.void;
}
content.push( /*#__PURE__*/React__default.default.createElement(Matcher.Element, { ...elementProps,
key: this.keyIndex
}, (_children = children) !== null && _children !== void 0 ? _children : this.parseNode(nextNode, config))); // Render the children of the current element only.
// Important: If the current element is not allowed,
// use the parent element for the next scope.
} else {
content = [...content, ...this.parseNode(nextNode, config.tagName ? config : parentConfig)];
} // Apply matchers if a text node
} else if (node.nodeType === TEXT_NODE) {
const text = noHtml && !noHtmlExceptMatchers ? node.textContent : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
this.applyMatchers(node.textContent || '', parentConfig);
if (Array.isArray(text)) {
content = [...content, ...text];
} else {
mergedText += text;
}
}
});
if (mergedText) {
content.push(mergedText);
}
return content;
}
/**
* Deconstruct the string into an array, by replacing custom tokens with React elements,
* so that React can render it correctly.
*/
replaceTokens(tokenizedString, elements) {
if (!tokenizedString.includes('{{{')) {
return tokenizedString;
}
const nodes = [];
let text = tokenizedString;
let open = null; // Find an open token tag
while (open = text.match(OPEN_TOKEN)) {
const [match, tokenName] = open;
const startIndex = open.index;
const isVoid = match.includes('/');
if (process.env.NODE_ENV !== "production" && !elements[tokenName]) {
throw new Error(`Token "${tokenName}" found but no matching element to replace with.`);
} // Extract the previous non-token text
if (startIndex > 0) {
nodes.push(text.slice(0, startIndex)); // Reduce text so that the closing tag will be found after the opening
text = text.slice(startIndex);
}
const {
children,
matcher,
props: elementProps
} = elements[tokenName];
let endIndex; // Use tag as-is if void
if (isVoid) {
endIndex = match.length;
nodes.push(matcher.createElement(children, elementProps)); // Find the closing tag if not void
} else {
const close = text.match(new RegExp(`{{{/${tokenName}}}}`));
if (process.env.NODE_ENV !== "production" && !close) {
throw new Error(`Closing token missing for interpolated element "${tokenName}".`);
}
endIndex = close.index + close[0].length;
nodes.push(matcher.createElement(this.replaceTokens(text.slice(match.length, close.index), elements), elementProps));
} // Reduce text for the next interation
text = text.slice(endIndex);
} // Extra the remaining text
if (text.length > 0) {
nodes.push(text);
} // Reduce to a string if possible
if (nodes.length === 0) {
return '';
}
if (nodes.length === 1 && typeof nodes[0] === 'string') {
return nodes[0];
}
return nodes;
}
}
/* eslint-disable react/jsx-fragments */
function Markup(props) {
var _ref;
const {
attributes,
className,
containerTagName,
content,
emptyContent,
parsedContent,
tagName,
noWrap: baseNoWrap
} = props;
const tag = (_ref = containerTagName !== null && containerTagName !== void 0 ? containerTagName : tagName) !== null && _ref !== void 0 ? _ref : 'span';
const noWrap = tag === 'fragment' ? true : baseNoWrap;
let mainContent;
if (parsedContent) {
mainContent = parsedContent;
} else {
const markup = new Parser(content !== null && content !== void 0 ? content : '', props).parse();
if (markup.length > 0) {
mainContent = markup;
}
}
if (!mainContent) {
mainContent = emptyContent;
}
if (noWrap) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return /*#__PURE__*/React__default.default.createElement(React__default.default.Fragment, null, mainContent);
}
return /*#__PURE__*/React__default.default.createElement(Matcher.Element, {
attributes: attributes,
className: className,
tagName: tag
}, mainContent);
}
/* eslint-disable promise/prefer-await-to-callbacks */
function Interweave(props) {
const {
attributes,
className,
content = '',
disableFilters = false,
disableMatchers = false,
emptyContent = null,
filters = [],
matchers = [],
onAfterParse = null,
onBeforeParse = null,
tagName = 'span',
noWrap = false,
...parserProps
} = props;
const allMatchers = disableMatchers ? [] : matchers;
const allFilters = disableFilters ? [] : filters;
const beforeCallbacks = onBeforeParse ? [onBeforeParse] : [];
const afterCallbacks = onAfterParse ? [onAfterParse] : []; // Inherit callbacks from matchers
allMatchers.forEach(matcher => {
if (matcher.onBeforeParse) {
beforeCallbacks.push(matcher.onBeforeParse.bind(matcher));
}
if (matcher.onAfterParse) {
afterCallbacks.push(matcher.onAfterParse.bind(matcher));
}
}); // Trigger before callbacks
const markup = beforeCallbacks.reduce((string, callback) => {
const nextString = callback(string, props);
if (process.env.NODE_ENV !== "production" && typeof nextString !== 'string') {
throw new TypeError('Interweave `onBeforeParse` must return a valid HTML string.');
}
return nextString;
}, content !== null && content !== void 0 ? content : ''); // Parse the markup
const parser = new Parser(markup, parserProps, allMatchers, allFilters); // Trigger after callbacks
const nodes = afterCallbacks.reduce((parserNodes, callback) => {
const nextNodes = callback(parserNodes, props);
if (process.env.NODE_ENV !== "production" && !Array.isArray(nextNodes)) {
throw new TypeError('Interweave `onAfterParse` must return an array of strings and React elements.');
}
return nextNodes;
}, parser.parse());
return /*#__PURE__*/React__default.default.createElement(Markup, {
attributes: attributes,
className: className // eslint-disable-next-line react/destructuring-assignment
,
containerTagName: props.containerTagName,
emptyContent: emptyContent,
noWrap: noWrap,
parsedContent: nodes.length === 0 ? undefined : nodes,
tagName: tagName
});
}
exports.ALLOWED_TAG_LIST = Matcher.ALLOWED_TAG_LIST;
exports.ATTRIBUTES = Matcher.ATTRIBUTES;
exports.ATTRIBUTES_TO_PROPS = Matcher.ATTRIBUTES_TO_PROPS;
exports.BANNED_TAG_LIST = Matcher.BANNED_TAG_LIST;
exports.Element = Matcher.Element;
exports.FILTER_ALLOW = Matcher.FILTER_ALLOW;
exports.FILTER_CAST_BOOL = Matcher.FILTER_CAST_BOOL;
exports.FILTER_CAST_NUMBER = Matcher.FILTER_CAST_NUMBER;
exports.FILTER_DENY = Matcher.FILTER_DENY;
exports.FILTER_NO_CAST = Matcher.FILTER_NO_CAST;
exports.Filter = Matcher.Filter;
exports.Matcher = Matcher.Matcher;
exports.TAGS = Matcher.TAGS;
exports.TYPE_EMBEDDED = Matcher.TYPE_EMBEDDED;
exports.TYPE_FLOW = Matcher.TYPE_FLOW;
exports.TYPE_HEADING = Matcher.TYPE_HEADING;
exports.TYPE_INTERACTIVE = Matcher.TYPE_INTERACTIVE;
exports.TYPE_PALPABLE = Matcher.TYPE_PALPABLE;
exports.TYPE_PHRASING = Matcher.TYPE_PHRASING;
exports.TYPE_SECTION = Matcher.TYPE_SECTION;
exports.match = Matcher.match;
exports.Interweave = Interweave;
exports.Markup = Markup;
exports.Parser = Parser;
//# sourceMappingURL=index.js.map