UNPKG

interweave

Version:

React library to safely render HTML, filter attributes, autowrap text, autolink, and much more.

727 lines (555 loc) 21.9 kB
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; } // Bundled with Packemon: https://packemon.dev // Platform: browser, Support: stable, Format: esm import { F as Filter, A as ALLOWED_TAG_LIST, B as BANNED_TAG_LIST, a as ATTRIBUTES, b as FILTER_DENY, c as ATTRIBUTES_TO_PROPS, T as TAGS, E as Element, d as FILTER_CAST_BOOL, e as FILTER_CAST_NUMBER, f as FILTER_NO_CAST } from './bundle-7aab7250.js'; export { A as ALLOWED_TAG_LIST, a as ATTRIBUTES, c as ATTRIBUTES_TO_PROPS, B as BANNED_TAG_LIST, E as Element, n as FILTER_ALLOW, d as FILTER_CAST_BOOL, e as FILTER_CAST_NUMBER, b as FILTER_DENY, f as FILTER_NO_CAST, F as Filter, M as Matcher, T as TAGS, k as TYPE_EMBEDDED, g as TYPE_FLOW, i as TYPE_HEADING, l as TYPE_INTERACTIVE, m as TYPE_PALPABLE, j as TYPE_PHRASING, h as TYPE_SECTION, o as match } from './bundle-7aab7250.js'; import React from 'react'; import escapeHtml from 'escape-html'; const INVALID_STYLES = /(url|image|image-set)\(/i; class StyleFilter extends 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 : ALLOWED_TAG_LIST); this.banned = new Set(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(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 = ATTRIBUTES[newName] || 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 === FILTER_DENY) || newName.startsWith('on') || value.replace(/(\s|\0|&#x0([9AD]);)/, '').match(/(javascript|vbscript|livescript|xss):/i))) { return; } // Apply attribute filters let newValue = newName === 'style' ? this.extractStyleAttribute(node) : value; // Cast to boolean if (filter === FILTER_CAST_BOOL) { newValue = true; // Cast to number } else if (filter === FILTER_CAST_NUMBER) { newValue = Number.parseFloat(String(newValue)); // Cast to string } else if (filter !== FILTER_NO_CAST) { newValue = String(newValue); } attributes[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 (TAGS[tagName]) { return { ...common, ...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.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.createElement(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.createElement(React.Fragment, null, mainContent); } return /*#__PURE__*/React.createElement(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.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 }); } export { Interweave, Markup, Parser }; //# sourceMappingURL=index.js.map