diff --git a/package.json b/package.json index fa8e61d0..fb751b67 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "plugin:rut/recommended" ], "rules": { + "no-param-reassign": "off", "import/no-named-as-default": "off", "require-unicode-regexp": "off", "react/jsx-no-literals": "off", diff --git a/packages/autolink/src/Email.tsx b/packages/autolink/src/Email.tsx index 47b70b08..88e5bb75 100644 --- a/packages/autolink/src/Email.tsx +++ b/packages/autolink/src/Email.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Link from './Link'; import { EmailProps } from './types'; -export default function Email({ children, email, emailParts, ...props }: EmailProps) { +export default function Email({ children, email, ...props }: EmailProps) { return ( {children} diff --git a/packages/autolink/src/EmailMatcher.ts b/packages/autolink/src/EmailMatcher.ts deleted file mode 100644 index eb53758d..00000000 --- a/packages/autolink/src/EmailMatcher.ts +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { Matcher, MatchResponse, Node, ChildrenNode } from 'interweave'; -import Email from './Email'; -import { EMAIL_PATTERN } from './constants'; -import { EmailProps } from './types'; - -export type EmailMatch = Pick; - -export default class EmailMatcher extends Matcher { - replaceWith(children: ChildrenNode, props: EmailProps): Node { - return React.createElement(Email, props, children); - } - - asTag(): string { - return 'a'; - } - - match(string: string): MatchResponse | null { - return this.doMatch(string, EMAIL_PATTERN, matches => ({ - email: matches[0], - emailParts: { - host: matches[2], - username: matches[1], - }, - })); - } -} diff --git a/packages/autolink/src/Hashtag.tsx b/packages/autolink/src/Hashtag.tsx index 0be64b3f..0989a75e 100644 --- a/packages/autolink/src/Hashtag.tsx +++ b/packages/autolink/src/Hashtag.tsx @@ -4,34 +4,34 @@ import { HashtagProps } from './types'; export default function Hashtag({ children, - encodeHashtag = false, + encoded = false, hashtag, - hashtagUrl = '{{hashtag}}', - preserveHash = false, + preserved = false, + url = '{{hashtag}}', ...props }: HashtagProps) { let tag = hashtag; // Prepare the hashtag - if (!preserveHash && tag.charAt(0) === '#') { + if (!preserved && tag.charAt(0) === '#') { tag = tag.slice(1); } - if (encodeHashtag) { + if (encoded) { tag = encodeURIComponent(tag); } // Determine the URL - let url = hashtagUrl || '{{hashtag}}'; + let href = url || '{{hashtag}}'; - if (typeof url === 'function') { - url = url(tag); + if (typeof href === 'function') { + href = href(tag); } else { - url = url.replace('{{hashtag}}', tag); + href = href.replace('{{hashtag}}', tag); } return ( - + {children} ); diff --git a/packages/autolink/src/HashtagMatcher.ts b/packages/autolink/src/HashtagMatcher.ts deleted file mode 100644 index ca69e1d2..00000000 --- a/packages/autolink/src/HashtagMatcher.ts +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { Matcher, MatchResponse, Node, ChildrenNode } from 'interweave'; -import Hashtag from './Hashtag'; -import { HASHTAG_PATTERN } from './constants'; -import { HashtagProps } from './types'; - -export type HashtagMatch = Pick; - -export default class HashtagMatcher extends Matcher { - replaceWith(children: ChildrenNode, props: HashtagProps): Node { - return React.createElement(Hashtag, props, children); - } - - asTag(): string { - return 'a'; - } - - match(string: string): MatchResponse | null { - return this.doMatch(string, HASHTAG_PATTERN, matches => ({ - hashtag: matches[0], - })); - } -} diff --git a/packages/autolink/src/IpMatcher.ts b/packages/autolink/src/IpMatcher.ts deleted file mode 100644 index b22396c3..00000000 --- a/packages/autolink/src/IpMatcher.ts +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { MatchResponse } from 'interweave'; -import UrlMatcher, { UrlMatch } from './UrlMatcher'; -import { IP_PATTERN } from './constants'; -import { UrlMatcherOptions, UrlProps } from './types'; - -export default class IpMatcher extends UrlMatcher { - constructor( - name: string, - options?: UrlMatcherOptions, - factory?: React.ComponentType | null, - ) { - super( - name, - { - ...options, - // IPs dont have TLDs - validateTLD: false, - }, - factory, - ); - } - - match(string: string): MatchResponse | null { - return this.doMatch(string, IP_PATTERN, this.handleMatches); - } -} diff --git a/packages/autolink/src/Url.tsx b/packages/autolink/src/Url.tsx index c74f7155..2b98fcbe 100644 --- a/packages/autolink/src/Url.tsx +++ b/packages/autolink/src/Url.tsx @@ -2,15 +2,15 @@ import React from 'react'; import Link from './Link'; import { UrlProps } from './types'; -export default function Url({ children, url, urlParts, ...props }: UrlProps) { - let href = url; +export default function Url({ children, href, ...props }: UrlProps) { + let ref = href; - if (!href.match(/^https?:\/\//)) { - href = `http://${href}`; + if (!ref.match(/^https?:\/\//)) { + ref = `http://${ref}`; } return ( - + {children} ); diff --git a/packages/autolink/src/UrlMatcher.ts b/packages/autolink/src/UrlMatcher.ts deleted file mode 100644 index c63630e6..00000000 --- a/packages/autolink/src/UrlMatcher.ts +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import { Matcher, MatchResponse, Node, ChildrenNode } from 'interweave'; -import Url from './Url'; -import { URL_PATTERN, TOP_LEVEL_TLDS, EMAIL_DISTINCT_PATTERN } from './constants'; -import { UrlProps, UrlMatcherOptions } from './types'; - -export type UrlMatch = Pick; - -export default class UrlMatcher extends Matcher { - constructor( - name: string, - options?: UrlMatcherOptions, - factory?: React.ComponentType | null, - ) { - super( - name, - { - customTLDs: [], - validateTLD: true, - ...options, - }, - factory, - ); - } - - replaceWith(children: ChildrenNode, props: UrlProps): Node { - return React.createElement(Url, props, children); - } - - asTag(): string { - return 'a'; - } - - match(string: string): MatchResponse | null { - const response = this.doMatch(string, URL_PATTERN, this.handleMatches); - - // False positives with URL auth scheme - if (response && response.match.match(EMAIL_DISTINCT_PATTERN)) { - response.valid = false; - } - - if (response && this.options.validateTLD) { - const { host } = (response.urlParts as unknown) as UrlProps['urlParts']; - const validList = TOP_LEVEL_TLDS.concat(this.options.customTLDs || []); - const tld = host.slice(host.lastIndexOf('.') + 1).toLowerCase(); - - if (!validList.includes(tld)) { - return null; - } - } - - return response; - } - - /** - * Package the matched response. - */ - handleMatches(matches: string[]): UrlMatch { - return { - url: matches[0], - urlParts: { - auth: matches[2] ? matches[2].slice(0, -1) : '', - fragment: matches[7] || '', - host: matches[3], - path: matches[5] || '', - port: matches[4] ? matches[4] : '', - query: matches[6] || '', - scheme: matches[1] ? matches[1].replace('://', '') : 'http', - }, - }; - } -} diff --git a/packages/autolink/src/emailMatcher.tsx b/packages/autolink/src/emailMatcher.tsx new file mode 100644 index 00000000..0f89e573 --- /dev/null +++ b/packages/autolink/src/emailMatcher.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { createMatcher } from 'interweave'; +import Email from './Email'; +import { EMAIL_PATTERN } from './constants'; +import { EmailMatch } from './types'; + +export default createMatcher( + EMAIL_PATTERN, + ({ email }, props, children) => {children}, + { + onMatch: ({ matches }) => ({ + email: matches[0], + parts: { + host: matches[2], + username: matches[1], + }, + }), + tagName: 'a', + }, +); diff --git a/packages/autolink/src/hashtagMatcher.tsx b/packages/autolink/src/hashtagMatcher.tsx new file mode 100644 index 00000000..0fe5e002 --- /dev/null +++ b/packages/autolink/src/hashtagMatcher.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { createMatcher } from 'interweave'; +import Hashtag from './Hashtag'; +import { HASHTAG_PATTERN } from './constants'; +import { HashtagMatch } from './types'; + +export default createMatcher( + HASHTAG_PATTERN, + ({ hashtag }, props, children) => {children}, + { + onMatch: ({ matches }) => ({ + hashtag: matches[0], + }), + tagName: 'a', + }, +); diff --git a/packages/autolink/src/index.ts b/packages/autolink/src/index.ts index d00ba01f..ac72243b 100644 --- a/packages/autolink/src/index.ts +++ b/packages/autolink/src/index.ts @@ -3,16 +3,16 @@ * @license https://opensource.org/licenses/MIT */ +import emailMatcher from './emailMatcher'; +import hashtagMatcher from './hashtagMatcher'; +import ipMatcher from './ipMatcher'; +import urlMatcher from './urlMatcher'; import Email from './Email'; -import EmailMatcher from './EmailMatcher'; import Hashtag from './Hashtag'; -import HashtagMatcher from './HashtagMatcher'; -import IpMatcher from './IpMatcher'; import Link from './Link'; import Url from './Url'; -import UrlMatcher from './UrlMatcher'; -export { Email, EmailMatcher, Hashtag, HashtagMatcher, IpMatcher, Link, Url, UrlMatcher }; +export { emailMatcher, hashtagMatcher, ipMatcher, urlMatcher, Email, Hashtag, Link, Url }; export * from './constants'; export * from './types'; diff --git a/packages/autolink/src/ipMatcher.tsx b/packages/autolink/src/ipMatcher.tsx new file mode 100644 index 00000000..f162f111 --- /dev/null +++ b/packages/autolink/src/ipMatcher.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { createMatcher } from 'interweave'; +import Url from './Url'; +import { onMatch } from './urlMatcher'; +import { IP_PATTERN } from './constants'; +import { UrlMatch } from './types'; + +export default createMatcher( + IP_PATTERN, + ({ url }, props, children) => {children}, + { onMatch, tagName: 'a' }, +); diff --git a/packages/autolink/src/types.ts b/packages/autolink/src/types.ts index 3c2e90b7..76645756 100644 --- a/packages/autolink/src/types.ts +++ b/packages/autolink/src/types.ts @@ -1,35 +1,42 @@ import React from 'react'; -import { ChildrenNode } from 'interweave'; export interface LinkProps { - children: React.ReactNode; - href: string; - key?: string | number; + children: NonNullable; + href?: string; newWindow?: boolean; - onClick?: () => void | null; + onClick?: React.MouseEventHandler; } -export interface EmailProps extends Partial { - children: ChildrenNode; +export interface EmailProps extends LinkProps { email: string; - emailParts: { +} + +export interface EmailMatch { + email: string; + parts: { host: string; username: string; }; } -export interface HashtagProps extends Partial { - children: ChildrenNode; - encodeHashtag?: boolean; +export interface HashtagProps extends LinkProps { + encoded?: boolean; + hashtag: string; + url?: string | ((hashtag: string) => string); + preserved?: boolean; +} + +export interface HashtagMatch { hashtag: string; - hashtagUrl?: string | ((hashtag: string) => string); - preserveHash?: boolean; } -export interface UrlProps extends Partial { - children: ChildrenNode; +export interface UrlProps extends LinkProps { + href: string; +} + +export interface UrlMatch { url: string; - urlParts: { + parts: { auth: string; fragment: string; host: string; diff --git a/packages/autolink/src/urlMatcher.tsx b/packages/autolink/src/urlMatcher.tsx new file mode 100644 index 00000000..ebfb19f2 --- /dev/null +++ b/packages/autolink/src/urlMatcher.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { createMatcher, MatchResult } from 'interweave'; +import Url from './Url'; +import { URL_PATTERN, TOP_LEVEL_TLDS, EMAIL_DISTINCT_PATTERN } from './constants'; +import { UrlMatch, UrlMatcherOptions } from './types'; + +export function onMatch( + result: MatchResult, + props: object, + { customTLDs = [], validateTLD = true }: UrlMatcherOptions, +): UrlMatch | null { + const { matches } = result; + const match = { + parts: { + auth: matches[2] ? matches[2].slice(0, -1) : '', + fragment: matches[7] || '', + host: matches[3], + path: matches[5] || '', + port: matches[4] ? matches[4] : '', + query: matches[6] || '', + scheme: matches[1] ? matches[1].replace('://', '') : 'http', + }, + url: matches[0], + }; + + // False positives with URL auth scheme + if (result.match!.match(EMAIL_DISTINCT_PATTERN)) { + result.valid = false; + } + + // Do not match if TLD is invalid + if (validateTLD) { + const { host } = match.parts; + const validList = TOP_LEVEL_TLDS.concat(customTLDs); + const tld = host.slice(host.lastIndexOf('.') + 1).toLowerCase(); + + if (!validList.includes(tld)) { + return null; + } + } + + return match; +} + +export default createMatcher( + URL_PATTERN, + ({ url }, props, children) => {children}, + { + onMatch, + tagName: 'a', + }, +); diff --git a/packages/core/src/Element.tsx b/packages/core/src/Element.tsx index 6c7c311f..1294a312 100644 --- a/packages/core/src/Element.tsx +++ b/packages/core/src/Element.tsx @@ -5,8 +5,9 @@ export default function Element({ attributes = {}, children = null, selfClose = false, - tagName: Tag, + tagName, }: ElementProps) { - // @ts-ignore BUG: https://github.com/Microsoft/TypeScript/issues/28806 + const Tag = tagName as 'div'; + return selfClose ? : {children}; } diff --git a/packages/core/src/Filter.ts b/packages/core/src/Filter.ts deleted file mode 100644 index 3e2e31b5..00000000 --- a/packages/core/src/Filter.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FilterInterface, ElementAttributes } from './types'; - -export default class Filter implements FilterInterface { - /** - * Filter and clean an HTML attribute value. - */ - attribute( - name: K, - value: ElementAttributes[K], - ): ElementAttributes[K] | undefined | null { - return value; - } - - /** - * Filter and clean an HTML node. - */ - node(name: string, node: HTMLElement): HTMLElement | null { - return node; - } -} diff --git a/packages/core/src/Interweave.tsx b/packages/core/src/Interweave.tsx index 867ca53f..413296cf 100644 --- a/packages/core/src/Interweave.tsx +++ b/packages/core/src/Interweave.tsx @@ -1,38 +1,46 @@ import React from 'react'; import Parser from './Parser'; import Markup from './Markup'; -import { InterweaveProps } from './types'; +import { InterweaveProps, CommonInternals, OnBeforeParse, OnAfterParse } from './types'; export default function Interweave(props: InterweaveProps) { const { attributes, content = '', - disableFilters = false, - disableMatchers = false, emptyContent = null, - filters = [], matchers = [], + noWrap = false, onAfterParse = null, onBeforeParse = null, - tagName = 'span', - noWrap = false, - ...parserProps + tagName, + transformers = [], } = props; - const allMatchers = disableMatchers ? [] : matchers; - const allFilters = disableFilters ? [] : filters; - const beforeCallbacks = onBeforeParse ? [onBeforeParse] : []; - const afterCallbacks = onAfterParse ? [onAfterParse] : []; + const beforeCallbacks: OnBeforeParse<{}>[] = []; + const afterCallbacks: OnAfterParse<{}>[] = []; - // Inherit callbacks from matchers - allMatchers.forEach(matcher => { - if (matcher.onBeforeParse) { - beforeCallbacks.push(matcher.onBeforeParse.bind(matcher)); - } + // Inherit all callbacks + function inheritCallbacks(internals: CommonInternals<{}>[]) { + internals.forEach(internal => { + if (internal.onBeforeParse) { + beforeCallbacks.push(internal.onBeforeParse); + } - if (matcher.onAfterParse) { - afterCallbacks.push(matcher.onAfterParse.bind(matcher)); - } - }); + if (internal.onAfterParse) { + afterCallbacks.push(internal.onAfterParse); + } + }); + } + + inheritCallbacks(matchers); + inheritCallbacks(transformers); + + if (onBeforeParse) { + beforeCallbacks.push(onBeforeParse); + } + + if (onAfterParse) { + afterCallbacks.push(onAfterParse); + } // Trigger before callbacks const markup = beforeCallbacks.reduce((string, callback) => { @@ -48,31 +56,33 @@ export default function Interweave(props: InterweaveProps) { }, content || ''); // Parse the markup - const parser = new Parser(markup, parserProps, allMatchers, allFilters); + const parser = new Parser(markup, props, matchers, transformers); + let nodes = parser.parse(); // Trigger after callbacks - const nodes = afterCallbacks.reduce((parserNodes, callback) => { - const nextNodes = callback(parserNodes, props); + if (nodes) { + nodes = afterCallbacks.reduce((parserNodes, callback) => { + const nextNodes = callback(parserNodes, props); - if (__DEV__) { - if (!Array.isArray(nextNodes)) { - throw new TypeError( - 'Interweave `onAfterParse` must return an array of strings and React elements.', - ); + if (__DEV__) { + if (!Array.isArray(nextNodes)) { + throw new TypeError( + 'Interweave `onAfterParse` must return an array of strings and React elements.', + ); + } } - } - return nextNodes; - }, parser.parse()); + return nextNodes; + }, nodes); + } return ( ); } diff --git a/packages/core/src/Markup.tsx b/packages/core/src/Markup.tsx index ac6c197f..552ec1dd 100644 --- a/packages/core/src/Markup.tsx +++ b/packages/core/src/Markup.tsx @@ -6,24 +6,8 @@ import Parser from './Parser'; import { MarkupProps } from './types'; export default function Markup(props: MarkupProps) { - const { attributes, containerTagName, content, emptyContent, parsedContent, tagName } = props; - const tag = containerTagName || tagName || 'div'; - const noWrap = tag === 'fragment' ? true : props.noWrap; - let mainContent; - - if (parsedContent) { - mainContent = parsedContent; - } else { - const markup = new Parser(content || '', props).parse(); - - if (markup.length > 0) { - mainContent = markup; - } - } - - if (!mainContent) { - mainContent = emptyContent; - } + const { attributes, content, emptyContent, parsedContent, tagName, noWrap } = props; + const mainContent = parsedContent || new Parser(content || '', props).parse() || emptyContent; if (noWrap) { // eslint-disable-next-line react/jsx-no-useless-fragment @@ -31,7 +15,7 @@ export default function Markup(props: MarkupProps) { } return ( - + {mainContent} ); diff --git a/packages/core/src/Matcher.ts b/packages/core/src/Matcher.ts deleted file mode 100644 index 97e12f63..00000000 --- a/packages/core/src/Matcher.ts +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import match from './match'; -import { MatchCallback, MatchResponse, Node, ChildrenNode, MatcherInterface } from './types'; - -export default abstract class Matcher - implements MatcherInterface { - greedy: boolean = false; - - options: Options; - - propName: string; - - inverseName: string; - - factory: React.ComponentType | null; - - constructor(name: string, options?: Options, factory?: React.ComponentType | null) { - if (__DEV__) { - if (!name || name.toLowerCase() === 'html') { - throw new Error(`The matcher name "${name}" is not allowed.`); - } - } - - // @ts-ignore - this.options = { ...options }; - this.propName = name; - this.inverseName = `no${name.charAt(0).toUpperCase() + name.slice(1)}`; - this.factory = factory || null; - } - - /** - * Attempts to create a React element using a custom user provided factory, - * or the default matcher factory. - */ - createElement(children: ChildrenNode, props: Props): Node { - let element: Node = null; - - if (this.factory) { - element = React.createElement(this.factory, props, children); - } else { - element = this.replaceWith(children, props); - } - - if (__DEV__) { - if (typeof element !== 'string' && !React.isValidElement(element)) { - throw new Error(`Invalid React element created from ${this.constructor.name}.`); - } - } - - return element; - } - - /** - * Trigger the actual pattern match and package the matched - * response through a callback. - */ - doMatch( - string: string, - pattern: string | RegExp, - callback: MatchCallback, - isVoid: boolean = false, - ): MatchResponse | null { - return match(string, pattern, callback, isVoid); - } - - /** - * Callback triggered before parsing. - */ - onBeforeParse(content: string, props: Props): string { - return content; - } - - /** - * Callback triggered after parsing. - */ - onAfterParse(content: Node[], props: Props): Node[] { - return content; - } - - /** - * Replace the match with a React element based on the matched token and optional props. - */ - abstract replaceWith(children: ChildrenNode, props: Props): Node; - - /** - * Defines the HTML tag name that the resulting React element will be. - */ - abstract asTag(): string; - - /** - * Attempt to match against the defined string. Return `null` if no match found, - * else return the `match` and any optional props to pass along. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - abstract match(string: string): MatchResponse | null; -} diff --git a/packages/core/src/Parser.ts b/packages/core/src/Parser.ts index c2fd67ff..c9e3f1b0 100644 --- a/packages/core/src/Parser.ts +++ b/packages/core/src/Parser.ts @@ -3,32 +3,34 @@ import React from 'react'; import escapeHtml from 'escape-html'; import Element from './Element'; -import StyleFilter from './StyleFilter'; +import styleTransformer from './styleTransformer'; import { - FILTER_DENY, - FILTER_CAST_NUMBER, + ALLOWED_TAG_LIST, + ATTRIBUTES_TO_PROPS, + ATTRIBUTES, + BANNED_TAG_LIST, FILTER_CAST_BOOL, + FILTER_CAST_NUMBER, + FILTER_DENY, FILTER_NO_CAST, TAGS, - BANNED_TAG_LIST, - ALLOWED_TAG_LIST, - ATTRIBUTES, - ATTRIBUTES_TO_PROPS, } from './constants'; import { Attributes, - Node, - NodeConfig, AttributeValue, - ChildrenNode, - ParserProps, - MatcherElementsMap, ElementProps, - FilterInterface, - ElementAttributes, - MatcherInterface, + MatchedElements, + Matcher, + Node, + ParserProps, + TagConfig, + Transformer, + TagName, } from './types'; +type MatcherInterface = Matcher; +type TransformerInterface = Transformer; + const ELEMENT_NODE = 1; const TEXT_NODE = 3; const INVALID_ROOTS = /^<(!doctype|(html|head|body)(\s|>))/i; @@ -45,29 +47,29 @@ function createDocument() { } export default class Parser { - allowed: Set; + allowed: Set; - banned: Set; + banned: Set; - blocked: Set; + blocked: Set; container?: HTMLElement; - content: Node[] = []; + content: Node = ''; - props: ParserProps; + keyIndex: number = -1; - matchers: MatcherInterface[]; + props: ParserProps; - filters: FilterInterface[]; + matchers: MatcherInterface[]; - keyIndex: number; + transformers: TransformerInterface[]; constructor( markup: string, - props: ParserProps = {}, - matchers: MatcherInterface[] = [], - filters: FilterInterface[] = [], + props: ParserProps, + matchers: MatcherInterface[] = [], + transformers: TransformerInterface[] = [], ) { if (__DEV__) { if (markup && typeof markup !== 'string') { @@ -77,42 +79,11 @@ export default class Parser { this.props = props; this.matchers = matchers; - this.filters = [...filters, new StyleFilter()]; - this.keyIndex = -1; + this.transformers = [...transformers, styleTransformer]; this.container = this.createContainer(markup || ''); - this.allowed = new Set(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: K, - value: ElementAttributes[K], - ): ElementAttributes[K] { - 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: string, node: HTMLElement | null): HTMLElement | null { - // Allow null to be returned - return this.filters.reduce( - (nextNode, filter) => - nextNode !== null && typeof filter.node === 'function' - ? filter.node(name, nextNode) - : nextNode, - node, - ); + this.allowed = new Set(props.allow || (ALLOWED_TAG_LIST as TagName[])); + this.banned = new Set(BANNED_TAG_LIST as TagName[]); + this.blocked = new Set(props.block); } /** @@ -120,22 +91,18 @@ export default class Parser { * 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: string, parentConfig: NodeConfig): ChildrenNode { - const elements: MatcherElementsMap = {}; - const { props } = this; + applyMatchers(string: string, parentConfig: TagConfig): Node { + const elements: MatchedElements = {}; let matchedString = string; let elementIndex = 0; let parts = null; this.matchers.forEach(matcher => { - const tagName = matcher.asTag().toLowerCase(); + const { tagName } = matcher; const config = this.getTagConfig(tagName); // Skip matchers that have been disabled from props or are not supported - if ( - (props as { [key: string]: unknown })[matcher.inverseName] || - !this.isTagAllowed(tagName) - ) { + if (!this.isTagAllowed(tagName)) { return; } @@ -147,9 +114,9 @@ export default class Parser { // 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 + elementIndex; + while (matchedString && (parts = matcher.match(matchedString, this.props))) { + const { index, length, match, valid, void: isVoid, params } = parts; + const tokenName = matcher.tagName + elementIndex; // Piece together a new string with interpolated tokens if (index > 0) { @@ -167,13 +134,8 @@ export default class Parser { elementIndex += 1; elements[tokenName] = { - children: match, - matcher, - props: { - ...props, - ...partProps, - key: this.keyIndex, - }, + element: matcher.factory(params, this.props, match), + key: this.keyIndex, }; } else { tokenizedString += match; @@ -203,10 +165,35 @@ export default class Parser { return this.replaceTokens(matchedString, elements); } + /** + * Loop through and apply transformers that match the specific tag name + */ + applyTransformations( + tagName: TagName, + node: HTMLElement, + children: unknown[], + ): undefined | null | React.ReactElement | HTMLElement { + const transformers = this.transformers.filter( + transformer => transformer.tagName === tagName || transformer.tagName === '*', + ); + + // eslint-disable-next-line no-restricted-syntax + for (const transformer of transformers) { + const result = transformer.factory(node, this.props, children); + + // If something was returned, the node has been replaced so we cant continue + if (result !== undefined) { + return result; + } + } + + return undefined; + } + /** * Determine whether the child can be rendered within the parent. */ - canRenderChild(parentConfig: NodeConfig, childConfig: NodeConfig): boolean { + canRenderChild(parentConfig: TagConfig, childConfig: TagConfig): boolean { if (!parentConfig.tagName || !childConfig.tagName) { return false; } @@ -276,8 +263,8 @@ export default class Parser { return undefined; } - const tag = this.props.containerTagName || 'body'; - const el = tag === 'body' || tag === 'fragment' ? doc.body : doc.createElement(tag); + const tag = this.props.tagName || 'body'; + const el = tag === 'body' ? doc.body : doc.createElement(tag); if (markup.match(INVALID_ROOTS)) { if (__DEV__) { @@ -342,10 +329,7 @@ export default class Parser { newValue = String(newValue); } - attributes[ATTRIBUTES_TO_PROPS[newName] || newName] = this.applyAttributeFilters( - newName as keyof ElementAttributes, - newValue, - ) as AttributeValue; + attributes[ATTRIBUTES_TO_PROPS[newName] || newName] = newValue; count += 1; }); @@ -374,14 +358,14 @@ export default class Parser { /** * Return configuration for a specific tag. */ - getTagConfig(tagName: string): NodeConfig { - const common = { + getTagConfig(tagName: TagName): TagConfig { + const common: TagConfig = { children: [], content: 0, invalid: [], parent: [], self: true, - tagName: '', + tagName, type: 0, void: false, }; @@ -431,7 +415,7 @@ export default class Parser { /** * Verify that an HTML tag is allowed to render. */ - isTagAllowed(tagName: string): boolean { + isTagAllowed(tagName: TagName): boolean { if (this.banned.has(tagName) || this.blocked.has(tagName)) { return false; } @@ -444,28 +428,31 @@ export default class Parser { * while looping over all child nodes and generating an * array to interpolate into JSX. */ - parse(): Node[] { + parse(): React.ReactNode { if (!this.container) { - return []; + return null; } - return this.parseNode(this.container, this.getTagConfig(this.container.nodeName.toLowerCase())); + return this.parseNode( + this.container, + this.getTagConfig(this.container.tagName.toLowerCase() as TagName), + ); } /** * Loop over the nodes children and generate a * list of text nodes and React elements. */ - parseNode(parentNode: HTMLElement, parentConfig: NodeConfig): Node[] { - const { noHtml, noHtmlExceptMatchers, allowElements, transform } = this.props; + parseNode(parentNode: HTMLElement, parentConfig: TagConfig): Node[] { + const { noHtml, noHtmlExceptInternals, allowElements } = this.props; let content: Node[] = []; let mergedText = ''; Array.from(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); + let tagName = node.nodeName.toLowerCase() as TagName; + let config = this.getTagConfig(tagName); // Persist any previous text if (mergedText) { @@ -473,35 +460,31 @@ export default class Parser { mergedText = ''; } - // Apply node filters first - const nextNode = this.applyNodeFilters(tagName, node as HTMLElement); - - if (!nextNode) { - return; - } - - // Apply transformation second - let children; - - if (transform) { - this.keyIndex += 1; - const key = this.keyIndex; + // Increase key before transforming + this.keyIndex += 1; + const key = this.keyIndex; - // Must occur after key is set - children = this.parseNode(nextNode, config); + // Must occur after key is set + const children = this.parseNode(node as HTMLElement, config); - const transformed = transform(nextNode, children, config); + // Apply transformations to element + let nextNode = this.applyTransformations(tagName, node as HTMLElement, children); - if (transformed === null) { - return; - } else if (typeof transformed !== 'undefined') { - content.push(React.cloneElement(transformed as React.ReactElement, { key })); - - return; - } + // Remove the node entirely + if (nextNode === null) { + return; + // Use the node as-is + } else if (nextNode === undefined) { + nextNode = node as HTMLElement; + // React element, so apply the key and continue + } else if (React.isValidElement(nextNode)) { + content.push(React.cloneElement(nextNode, { key })); - // Reset as we're not using the transformation - this.keyIndex = key - 1; + return; + // HTML element, so update tag and config + } else if (nextNode instanceof HTMLElement) { + tagName = nextNode.tagName.toLowerCase() as TagName; + config = this.getTagConfig(tagName); } // Never allow these tags (except via a transformer) @@ -514,12 +497,10 @@ export default class Parser { // - Tag is allowed // - Child is valid within the parent if ( - !(noHtml || (noHtmlExceptMatchers && tagName !== 'br')) && + !(noHtml || (noHtmlExceptInternals && tagName !== 'br')) && this.isTagAllowed(tagName) && (allowElements || this.canRenderChild(parentConfig, config)) ) { - this.keyIndex += 1; - // Build the props as it makes it easier to test const attributes = this.extractAttributes(nextNode); const elementProps: ElementProps = { @@ -537,7 +518,7 @@ export default class Parser { content.push( React.createElement( Element, - { ...elementProps, key: this.keyIndex }, + { ...elementProps, key }, children || this.parseNode(nextNode, config), ), ); @@ -554,7 +535,7 @@ export default class Parser { // Apply matchers if a text node } else if (node.nodeType === TEXT_NODE) { const text = - noHtml && !noHtmlExceptMatchers + noHtml && !noHtmlExceptInternals ? node.textContent : this.applyMatchers(node.textContent || '', parentConfig); @@ -577,7 +558,7 @@ export default class Parser { * Deconstruct the string into an array, by replacing custom tokens with React elements, * so that React can render it correctly. */ - replaceTokens(tokenizedString: string, elements: MatcherElementsMap): ChildrenNode { + replaceTokens(tokenizedString: string, elements: MatchedElements): Node { if (!tokenizedString.includes('{{{')) { return tokenizedString; } @@ -606,14 +587,14 @@ export default class Parser { text = text.slice(startIndex); } - const { children, matcher, props: elementProps } = elements[tokenName]; + const { element, key } = elements[tokenName]; let endIndex: number; // Use tag as-is if void if (isVoid) { endIndex = match.length; - nodes.push(matcher.createElement(children, elementProps)); + nodes.push(React.cloneElement(element, { key })); // Find the closing tag if not void } else { @@ -628,9 +609,10 @@ export default class Parser { endIndex = close.index! + close[0].length; nodes.push( - matcher.createElement( + React.cloneElement( + element, + { key }, this.replaceTokens(text.slice(match.length, close.index!), elements), - elementProps, ), ); } diff --git a/packages/core/src/StyleFilter.ts b/packages/core/src/StyleFilter.ts deleted file mode 100644 index 59862024..00000000 --- a/packages/core/src/StyleFilter.ts +++ /dev/null @@ -1,22 +0,0 @@ -import Filter from './Filter'; -import { ElementAttributes } from './types'; - -const INVALID_STYLES = /(url|image|image-set)\(/i; - -export default class StyleFilter extends Filter { - attribute( - name: K, - value: ElementAttributes[K], - ): ElementAttributes[K] { - 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]; - } - }); - } - - return value; - } -} diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 7ebd67b3..be2007e8 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1,6 +1,6 @@ /* eslint-disable no-bitwise, no-magic-numbers, sort-keys */ -import { NodeConfig, ConfigMap, FilterMap } from './types'; +import { TagConfig, TagConfigMap } from './types'; // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories export const TYPE_FLOW = 1; @@ -12,7 +12,7 @@ export const TYPE_INTERACTIVE = 1 << 5; export const TYPE_PALPABLE = 1 << 6; // https://developer.mozilla.org/en-US/docs/Web/HTML/Element -const tagConfigs: { [tagName: string]: Partial } = { +const tagConfigs: TagConfigMap = { a: { content: TYPE_FLOW | TYPE_PHRASING, self: false, @@ -188,7 +188,7 @@ const tagConfigs: { [tagName: string]: Partial } = { }, }; -function createConfigBuilder(config: Partial): (tagName: string) => void { +function createConfigBuilder(config: Partial): (tagName: string) => void { return (tagName: string) => { tagConfigs[tagName] = { ...config, @@ -268,7 +268,7 @@ function createConfigBuilder(config: Partial): (tagName: string) => ); // Disable this map from being modified -export const TAGS: ConfigMap = Object.freeze(tagConfigs); +export const TAGS: TagConfigMap = Object.freeze(tagConfigs); // Tags that should never be allowed, even if the allow list is disabled export const BANNED_TAG_LIST = [ @@ -303,7 +303,7 @@ export const FILTER_NO_CAST = 5; // Attributes not listed here will be denied // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes -export const ATTRIBUTES: FilterMap = Object.freeze({ +export const ATTRIBUTES: { [key: string]: number } = Object.freeze({ alt: FILTER_ALLOW, cite: FILTER_ALLOW, class: FILTER_ALLOW, diff --git a/packages/core/src/createMatcher.ts b/packages/core/src/createMatcher.ts new file mode 100644 index 00000000..79017181 --- /dev/null +++ b/packages/core/src/createMatcher.ts @@ -0,0 +1,51 @@ +import { Matcher, MatcherOptions, MatcherFactory, MatchResult } from './types'; + +export default function createMatcher( + pattern: string | RegExp, + factory: MatcherFactory, + options: MatcherOptions, +): Matcher { + return { + extend(customFactory, customOptions) { + return createMatcher(pattern, customFactory || factory, { + ...options, + ...customOptions, + }); + }, + factory, + greedy: options.greedy ?? false, + match(value, props) { + const matches = value.match(pattern instanceof RegExp ? pattern : new RegExp(pattern, 'i')); + + if (!matches) { + return null; + } + + const result: MatchResult = { + index: matches.index!, + length: matches[0].length, + match: matches[0], + matches, + valid: true, + value, + void: options.void ?? false, + }; + + const params = options.onMatch(result, props, options.options || {}); + + // Allow callback to intercept the result + if (params === null) { + return null; + } + + return { + params, + ...result, + }; + }, + onAfterParse: options.onAfterParse, + onBeforeParse: options.onBeforeParse, + options: options.options || {}, + tagName: options.tagName, + }; +} diff --git a/packages/core/src/createTransformer.ts b/packages/core/src/createTransformer.ts new file mode 100644 index 00000000..b702aa0f --- /dev/null +++ b/packages/core/src/createTransformer.ts @@ -0,0 +1,27 @@ +import { + WildTagName, + InferElement, + Transformer, + TransformerFactory, + TransformerOptions, +} from './types'; + +export default function createTransformer( + tagName: K, + factory: TransformerFactory, Props>, + options: TransformerOptions = {}, +): Transformer, Props, Options> { + return { + extend(customFactory, customOptions) { + return createTransformer(tagName, customFactory || factory, { + ...options, + ...customOptions, + }); + }, + factory, + onAfterParse: options.onAfterParse, + onBeforeParse: options.onBeforeParse, + options: options.options || {}, + tagName: options.tagName || tagName, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 01eec04c..a69bb1ab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,15 +5,12 @@ import Interweave from './Interweave'; import Markup from './Markup'; -import Filter from './Filter'; -import Matcher from './Matcher'; import Element from './Element'; import Parser from './Parser'; -import match from './match'; +import createMatcher from './createMatcher'; +import createTransformer from './createTransformer'; -export { Markup, Filter, Matcher, Element, Parser, match }; +export { Interweave, Markup, Element, Parser, createMatcher, createTransformer }; export * from './constants'; export * from './types'; - -export default Interweave; diff --git a/packages/core/src/match.ts b/packages/core/src/match.ts deleted file mode 100644 index 89e22cac..00000000 --- a/packages/core/src/match.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { MatchCallback, MatchResponse } from './types'; - -/** - * Trigger the actual pattern match and package the matched - * response through a callback. - */ -export default function match( - string: string, - pattern: string | RegExp, - callback: MatchCallback, - isVoid: boolean = false, -): MatchResponse | null { - const matches = string.match(pattern instanceof RegExp ? pattern : new RegExp(pattern, 'i')); - - if (!matches) { - return null; - } - - return { - match: matches[0], - void: isVoid, - ...callback(matches), - index: matches.index!, - length: matches[0].length, - valid: true, - }; -} diff --git a/packages/core/src/styleTransformer.ts b/packages/core/src/styleTransformer.ts new file mode 100644 index 00000000..474b2571 --- /dev/null +++ b/packages/core/src/styleTransformer.ts @@ -0,0 +1,14 @@ +import createTransformer from './createTransformer'; + +const INVALID_STYLES = /(url|image|image-set)\(/i; + +export default createTransformer('*', element => { + Object.keys(element.style).forEach(k => { + const key = k as keyof typeof element.style; + + if (String(element.style[key]).match(INVALID_STYLES)) { + // eslint-disable-next-line no-param-reassign + delete element.style[key]; + } + }); +}); diff --git a/packages/core/src/testing.tsx b/packages/core/src/testing.tsx index 9e648fe5..649d33e2 100644 --- a/packages/core/src/testing.tsx +++ b/packages/core/src/testing.tsx @@ -1,16 +1,7 @@ /* eslint-disable max-classes-per-file, unicorn/import-index */ import React from 'react'; -import { - Filter, - Matcher, - Element, - Node, - NodeConfig, - MatchResponse, - ChildrenNode, - TAGS, -} from './index'; +import { Element, TagConfig, TAGS, createMatcher, createTransformer } from './index'; export const TOKEN_LOCATIONS = [ 'no tokens', @@ -56,7 +47,7 @@ export function createExpectedToken( factory: (value: T, count: number) => React.ReactNode, index: number, join: boolean = false, -): React.ReactNode | string { +): React.ReactNode { if (index === 0) { return TOKEN_LOCATIONS[0]; } @@ -95,7 +86,7 @@ export const MOCK_INVALID_MARKUP = `

More text with outdated stuff.

`; -export const parentConfig: NodeConfig = { +export const parentConfig: TagConfig = { children: [], content: 0, invalid: [], @@ -107,126 +98,79 @@ export const parentConfig: NodeConfig = { ...TAGS.div, }; -export function matchCodeTag( - string: string, - tag: string, -): MatchResponse<{ - children: string; - customProp: string; -}> | null { - const matches = string.match(new RegExp(`\\[${tag}\\]`)); - - if (!matches) { - return null; - } - - return { - children: tag, - customProp: 'foo', - index: matches.index!, - length: matches[0].length, - match: matches[0], - valid: true, - void: false, - }; -} - -export class CodeTagMatcher extends Matcher<{}> { - tag: string; - - key: string; - - constructor(tag: string, key: string = '') { - super(tag, {}); - - this.tag = tag; - this.key = key; - } - - replaceWith(match: ChildrenNode, props: { children?: string; key?: string } = {}): Node { - const { children } = props; - - if (this.key) { - // eslint-disable-next-line - props.key = this.key; - } - - return ( - - {children!.toUpperCase()} - - ); - } - - asTag() { - return 'span'; - } - - match(string: string) { - return matchCodeTag(string, this.tag); - } -} - -export class MarkdownBoldMatcher extends Matcher { - replaceWith(children: ChildrenNode, props: object): Node { - return {children}; - } - - asTag() { - return 'b'; - } - - match(value: string) { - return this.doMatch(value, /\*\*([^*]+)\*\*/u, matches => ({ match: matches[1] })); - } -} - -export class MarkdownItalicMatcher extends Matcher { - replaceWith(children: ChildrenNode, props: object): Node { - return {children}; - } - - asTag() { - return 'i'; - } - - match(value: string) { - return this.doMatch(value, /_([^_]+)_/u, matches => ({ match: matches[1] })); - } -} - -export class MockMatcher extends Matcher { - replaceWith(children: ChildrenNode, props: any): Node { - return
{children}
; - } - - asTag() { - return 'div'; - } - - match() { - return null; - } -} - -export class LinkFilter extends Filter { - attribute(name: string, value: string): string { - if (name === 'href') { - return value.replace('foo.com', 'bar.net'); - } - - return value; - } - - node(name: string, node: HTMLElement): HTMLElement | null { - if (name === 'a') { - node.setAttribute('target', '_blank'); - } else if (name === 'link') { - return null; - } - - return node; - } -} - -export class MockFilter extends Filter {} +export const codeFooMatcher = createMatcher( + /\[foo]/, + (match, props, children) => {String(children).toUpperCase()}, + { + onMatch: () => ({ + codeTag: 'foo', + customProp: 'foo', + }), + tagName: 'span', + }, +); + +export const codeBarMatcher = createMatcher( + /\[bar]/, + (match, props, children) => {String(children).toUpperCase()}, + { + onMatch: () => ({ + codeTag: 'bar', + customProp: 'bar', + }), + tagName: 'span', + }, +); + +export const codeBazMatcher = createMatcher( + /\[baz]/, + (match, props, children) => {String(children).toUpperCase()}, + { + onMatch: () => ({ + codeTag: 'baz', + customProp: 'baz', + }), + tagName: 'span', + }, +); + +export const mdBoldMatcher = createMatcher( + /\*\*([^*]+)\*\*/u, + (match, props, children) => {children}, + { + onMatch: ({ matches }) => ({ + match: matches[1], + }), + tagName: 'b', + }, +); + +export const mdItalicMatcher = createMatcher( + /_([^_]+)_/u, + (match, props, children) => {children}, + { + onMatch: ({ matches }) => ({ + match: matches[1], + }), + tagName: 'i', + }, +); + +export const mockMatcher = createMatcher( + /div/, + (match, props, children) =>
{children}
, + { + onMatch: () => null, + tagName: 'div', + }, +); + +export const linkTransformer = createTransformer('a', element => { + element.setAttribute('target', '_blank'); + + if (element.href) { + element.setAttribute('href', element.href.replace('foo.com', 'bar.net') || ''); + } +}); + +export const mockTransformer = createTransformer('*', () => {}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b18935db..12d5a858 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -11,119 +11,170 @@ declare global { } } -export type Node = null | string | React.ReactElement; +export type Node = NonNullable; -export type ChildrenNode = string | Node[]; +export type AttributeValue = string | number | boolean | object; -export interface NodeConfig { - // Only children - children: string[]; - // Children content type - content: number; - // Invalid children - invalid: string[]; - // Only parent - parent: string[]; - // Can render self as a child - self: boolean; - // HTML tag name - tagName: string; - // Self content type - type: number; - // Self-closing tag - void: boolean; +export interface Attributes { + [attr: string]: AttributeValue; } -export interface ConfigMap { - [key: string]: Partial; +export interface ElementProps { + attributes?: Attributes; + children?: React.ReactNode; + selfClose?: boolean; + tagName: string; } -export type AttributeValue = string | number | boolean | object; +// CALLBACKS -export interface Attributes { - [attr: string]: AttributeValue; -} +export type OnAfterParse = (content: Node, props: Props) => Node; -export type AfterParseCallback = (content: Node[], props: T) => Node[]; +export type OnBeforeParse = (content: string, props: Props) => string; -export type BeforeParseCallback = (content: string, props: T) => string; +export type OnMatch = ( + result: MatchResult, + props: Props, + options: Partial, +) => Match | null; -export type TransformCallback = ( - node: HTMLElement, - children: Node[], - config: NodeConfig, -) => React.ReactNode; +export interface CommonInternals { + onAfterParse?: OnAfterParse; + onBeforeParse?: OnBeforeParse; + options: Partial; +} // MATCHERS -export type MatchCallback = (matches: string[]) => T; - -export type MatchResponse = T & { +export interface MatchResult { index: number; length: number; match: string; + matches: string[]; valid: boolean; - void?: boolean; -}; + value: string; + void: boolean; +} + +export type MatchHandler = ( + value: string, + props: Props, +) => (MatchResult & { params: Match }) | null; -export interface MatcherInterface { +export interface MatcherOptions { greedy?: boolean; - inverseName: string; - propName: string; - asTag(): string; - createElement(children: ChildrenNode, props: T): Node; - match(value: string): MatchResponse> | null; - onBeforeParse?(content: string, props: T): string; - onAfterParse?(content: Node[], props: T): Node[]; + tagName: TagName; + void?: boolean; + options?: Options; + onAfterParse?: OnAfterParse; + onBeforeParse?: OnBeforeParse; + onMatch: OnMatch; } -// FILTERS - -export type ElementAttributes = React.AllHTMLAttributes; +export type MatcherFactory = ( + match: Match, + props: Props, + content: Node, +) => React.ReactElement; + +export interface Matcher extends CommonInternals { + extend: ( + factory?: MatcherFactory | null, + options?: Partial>, + ) => Matcher; + factory: MatcherFactory; + greedy: boolean; + match: MatchHandler; + tagName: TagName; +} -export interface FilterInterface { - attribute?( - name: K, - value: ElementAttributes[K], - ): ElementAttributes[K] | undefined | null; - node?(name: string, node: HTMLElement): HTMLElement | null; +// TRANSFORMERS + +export type InferElement = K extends '*' + ? HTMLElement + : K extends keyof HTMLElementTagNameMap + ? HTMLElementTagNameMap[K] + : HTMLElement; + +export type TransformerFactory = ( + element: Element, + props: Props, + content: Node, +) => void | undefined | null | Element | React.ReactElement; + +export interface TransformerOptions { + tagName?: TagName; + onAfterParse?: OnAfterParse; + onBeforeParse?: OnBeforeParse; + options?: Options; } -export interface FilterMap { - [key: string]: number; +export interface Transformer extends CommonInternals { + extend: ( + factory?: TransformerFactory | null, + options?: Partial>, + ) => Transformer; + factory: TransformerFactory; + tagName: WildTagName; } // PARSER -export interface MatcherElementsMap { - [key: string]: { - children: string; - matcher: MatcherInterface<{}>; - props: object; - }; -} - export interface ParserProps { - /** Disable filtering and allow all non-banned HTML attributes. */ + /** Allow all non-banned HTML attributes. */ allowAttributes?: boolean; - /** Disable filtering and allow all non-banned/blocked HTML elements to be rendered. */ + /** Allow all non-banned and non-blocked HTML elements. */ allowElements?: boolean; /** List of HTML tag names to allow and render. Defaults to the `ALLOWED_TAG_LIST` constant. */ - allowList?: string[]; + allow?: TagName[]; /** List of HTML tag names to disallow and not render. Overrides allow list. */ - blockList?: string[]; + block?: TagName[]; /** Disable the conversion of new lines to `
` elements. */ disableLineBreaks?: boolean; - /** The container element to parse content in. Applies browser semantic rules and overrides `tagName`. */ - containerTagName?: string; /** Escape all HTML before parsing. */ escapeHtml?: boolean; /** Strip all HTML while rendering. */ noHtml?: boolean; - /** Strip all HTML, except HTML generated by matchers, while rendering. */ - noHtmlExceptMatchers?: boolean; - /** Transformer ran on each HTML element. Return a new element, null to remove current element, or undefined to do nothing. */ - transform?: TransformCallback | null; + /** Strip all HTML, except HTML generated by matchers or transformers, while rendering. */ + noHtmlExceptInternals?: boolean; + /** The element to parse content in. Applies browser semantic rules. */ + tagName: TagName; +} + +export interface MatchedElements { + [token: string]: { + element: React.ReactElement; + key: number; + }; +} + +// ELEMENTS + +export type TagName = keyof React.ReactHTML | 'rb' | 'rtc'; + +export type WildTagName = TagName | '*'; + +export interface TagConfig { + // Only children + children: TagName[]; + // Children content type + content: number; + // Invalid children + invalid: TagName[]; + // Only parent + parent: TagName[]; + // Can render self as a child + self: boolean; + // HTML tag name + tagName: TagName; + // Self content type + type: number; + // Self-closing tag + void: boolean; +} + +export interface TagConfigMap { + [tagName: string]: Partial; } // INTERWEAVE @@ -137,33 +188,17 @@ export interface MarkupProps extends ParserProps { emptyContent?: React.ReactNode; /** @ignore Pre-parsed content to render. */ parsedContent?: React.ReactNode; - /** HTML element to wrap the content. Also accepts 'fragment' (superseded by `noWrap`). */ - tagName?: string; /** Don't wrap the content in a new element specified by `tagName`. */ noWrap?: boolean; } export interface InterweaveProps extends MarkupProps { - /** Support all the props used by matchers. */ - [prop: string]: any; - /** Disable all filters from running. */ - disableFilters?: boolean; - /** Disable all matches from running. */ - disableMatchers?: boolean; - /** List of filters to apply to the content. */ - filters?: FilterInterface[]; + /** List of transformers to apply to elements. */ + transformers?: Transformer[]; /** List of matchers to apply to the content. */ - matchers?: MatcherInterface[]; + matchers?: Matcher<{}, {}, {}>[]; /** Callback fired after parsing ends. Must return an array of React nodes. */ - onAfterParse?: AfterParseCallback | null; + onAfterParse?: OnAfterParse<{}> | null; /** Callback fired beore parsing begins. Must return a string. */ - onBeforeParse?: BeforeParseCallback | null; -} - -export interface ElementProps { - [prop: string]: any; - attributes?: Attributes; - children?: React.ReactNode; - selfClose?: boolean; - tagName: string; + onBeforeParse?: OnBeforeParse<{}> | null; } diff --git a/packages/core/tests/Interweave.test.tsx b/packages/core/tests/Interweave.test.tsx index ce13faf0..f92ade4b 100644 --- a/packages/core/tests/Interweave.test.tsx +++ b/packages/core/tests/Interweave.test.tsx @@ -9,8 +9,6 @@ import { MOCK_MARKUP, MOCK_INVALID_MARKUP, LinkFilter, - CodeTagMatcher, - matchCodeTag, MarkdownBoldMatcher, MarkdownItalicMatcher, } from '../src/testing'; diff --git a/packages/core/tests/Matcher.test.tsx b/packages/core/tests/Matcher.test.tsx index 3778fa32..6dd71be5 100644 --- a/packages/core/tests/Matcher.test.tsx +++ b/packages/core/tests/Matcher.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; import Element from '../src/Element'; -import { CodeTagMatcher, MockMatcher } from '../src/testing'; +import { MockMatcher, codeFooMatcher } from '../src/testing'; describe('Matcher', () => { - const matcher = new CodeTagMatcher('foo', '1'); + const matcher = codeFooMatcher; it('errors for html name', () => { expect(() => new MockMatcher('html', {})).toThrow('The matcher name "html" is not allowed.'); diff --git a/packages/core/tests/Parser.test.tsx b/packages/core/tests/Parser.test.tsx index a1a41688..311b2fb0 100644 --- a/packages/core/tests/Parser.test.tsx +++ b/packages/core/tests/Parser.test.tsx @@ -9,12 +9,14 @@ import { FILTER_CAST_NUMBER, } from '../src/constants'; import { - CodeTagMatcher, LinkFilter, createExpectedToken, parentConfig, TOKEN_LOCATIONS, MOCK_MARKUP, + codeFooMatcher, + codeBarMatcher, + codeBazMatcher, } from '../src/testing'; function createChild(tag: string, text: string | number): HTMLElement { @@ -31,8 +33,8 @@ describe('Parser', () => { beforeEach(() => { instance = new Parser( '', - {}, - [new CodeTagMatcher('foo'), new CodeTagMatcher('bar'), new CodeTagMatcher('baz')], + { tagName: 'div' }, + [codeFooMatcher, codeBarMatcher, codeBazMatcher], [new LinkFilter()], ); }); diff --git a/packages/emoji-picker/src/Emoji.tsx b/packages/emoji-picker/src/Emoji.tsx index 6a691d6c..80658c2d 100644 --- a/packages/emoji-picker/src/Emoji.tsx +++ b/packages/emoji-picker/src/Emoji.tsx @@ -51,9 +51,9 @@ export default function Emoji({ active, emoji, onEnter, onLeave, onSelect }: Emo onMouseLeave={handleLeave} > diff --git a/packages/emoji-picker/src/PreviewBar.tsx b/packages/emoji-picker/src/PreviewBar.tsx index dcdb5a58..419fbcf4 100644 --- a/packages/emoji-picker/src/PreviewBar.tsx +++ b/packages/emoji-picker/src/PreviewBar.tsx @@ -50,11 +50,11 @@ export default function PreviewBar({
diff --git a/packages/emoji/src/Emoji.tsx b/packages/emoji/src/Emoji.tsx index 86e04e55..f76363b9 100644 --- a/packages/emoji/src/Emoji.tsx +++ b/packages/emoji/src/Emoji.tsx @@ -5,18 +5,18 @@ import EmojiDataManager from './EmojiDataManager'; import { EmojiProps, Size } from './types'; export default function Emoji({ - emojiLargeSize = '3em', - emojiPath = '{{hexcode}}', - emojiSize = '1em', - emojiSource, emoticon, - enlargeEmoji = false, + enlarged = false, hexcode, + largeSize = '3em', + path = '{{hexcode}}', renderUnicode = false, shortcode, + size = '1em', + source, unicode, }: EmojiProps) { - const data = EmojiDataManager.getInstance(emojiSource.locale); + const data = EmojiDataManager.getInstance(source.locale); if (__DEV__) { if (!emoticon && !shortcode && !unicode && !hexcode) { @@ -58,28 +58,28 @@ export default function Emoji({ }; // Handle large styles - if (enlargeEmoji && emojiLargeSize) { - styles.width = emojiLargeSize; - styles.height = emojiLargeSize; + if (enlarged && largeSize) { + styles.width = largeSize; + styles.height = largeSize; // Only apply styles if a size is defined - } else if (emojiSize) { - styles.width = emojiSize; - styles.height = emojiSize; + } else if (size) { + styles.width = size; + styles.height = size; } // Determine the path - let path = emojiPath || '{{hexcode}}'; - - if (typeof path === 'function') { - path = path(emoji.hexcode, { - enlarged: enlargeEmoji, - largeSize: emojiLargeSize, - size: enlargeEmoji ? emojiLargeSize : emojiSize, - smallSize: emojiSize, + let src = path || '{{hexcode}}'; + + if (typeof src === 'function') { + src = src(emoji.hexcode, { + enlarged, + largeSize, + size: enlarged ? largeSize : size, + smallSize: size, }); } else { - path = path.replace('{{hexcode}}', emoji.hexcode); + src = src.replace('{{hexcode}}', emoji.hexcode); } // http://git.emojione.com/demos/latest/sprites-png.html @@ -87,7 +87,7 @@ export default function Emoji({ // https://css-tricks.com/using-svg/ return ( {emoji.unicode} { - data: EmojiDataManager | null = null; - - greedy: boolean = true; - - constructor( - name: string, - options?: EmojiMatcherOptions, - factory?: React.ComponentType | null, - ) { - super( - name, - { - convertEmoticon: false, - convertShortcode: false, - convertUnicode: false, - enlargeThreshold: 1, - renderUnicode: false, - ...options, - }, - factory, - ); - } - - replaceWith(children: ChildrenNode, props: EmojiProps): Node { - return React.createElement(Emoji, { - ...props, - renderUnicode: this.options.renderUnicode, - }); - } - - asTag(): string { - return 'img'; - } - - match(string: string) { - let response = null; - - // Should we convert emoticons to unicode? - if (this.options.convertEmoticon) { - response = this.matchEmoticon(string); - - if (response) { - return response; - } - } - - // Should we convert shortcodes to unicode? - if (this.options.convertShortcode) { - response = this.matchShortcode(string); - - if (response) { - return response; - } - } - - // Should we convert unicode to SVG/PNG? - if (this.options.convertUnicode) { - response = this.matchUnicode(string); - - if (response) { - return response; - } - } - - return null; - } - - matchEmoticon(string: string): MatchResponse | null { - const response = this.doMatch( - string, - EMOTICON_BOUNDARY_REGEX, - matches => ({ - emoticon: matches[0].trim(), - }), - true, - ); - - if ( - response && - response.emoticon && - this.data && - this.data.EMOTICON_TO_HEXCODE[response.emoticon] - ) { - response.hexcode = this.data.EMOTICON_TO_HEXCODE[response.emoticon]; - response.match = String(response.emoticon); // Remove padding - - return response; - } - - return null; - } - - matchShortcode(string: string): MatchResponse | null { - const response = this.doMatch( - string, - SHORTCODE_REGEX, - matches => ({ - shortcode: matches[0].toLowerCase(), - }), - true, - ); - - if ( - response && - response.shortcode && - this.data && - this.data.SHORTCODE_TO_HEXCODE[response.shortcode] - ) { - response.hexcode = this.data.SHORTCODE_TO_HEXCODE[response.shortcode]; - - return response; - } - - return null; - } - - matchUnicode(string: string): MatchResponse | null { - const response = this.doMatch( - string, - EMOJI_REGEX, - matches => ({ - unicode: matches[0], - }), - true, - ); - - if ( - response && - response.unicode && - this.data && - this.data.UNICODE_TO_HEXCODE[response.unicode] - ) { - response.hexcode = this.data.UNICODE_TO_HEXCODE[response.unicode]; - - return response; - } - - return null; - } - - /** - * Load emoji data before matching. - */ - onBeforeParse(content: string, props: EmojiProps): string { - if (props.emojiSource) { - this.data = EmojiDataManager.getInstance(props.emojiSource.locale); - } else if (__DEV__) { - throw new Error( - 'Missing emoji source data. Have you loaded with the `useEmojiData` hook and passed the `emojiSource` prop?', - ); - } - - return content; - } - - /** - * When a single `Emoji` is the only content, enlarge it! - */ - onAfterParse(content: Node[], props: EmojiProps): Node[] { - if (content.length === 0) { - return content; - } - - const { enlargeThreshold = 1 } = this.options; - let valid = false; - let count = 0; - - // Use a for-loop, as it's much cleaner than some() - for (let i = 0, item = null; i < content.length; i += 1) { - item = content[i]; - - if (typeof item === 'string') { - // Allow whitespace but disallow strings - if (!item.match(/^\s+$/)) { - valid = false; - break; - } - } else if (React.isValidElement(item)) { - // Only count towards emojis - if (item && item.type === Emoji) { - count += 1; - valid = true; - - if (count > enlargeThreshold) { - valid = false; - break; - } - - // Abort early for non-emoji components - } else { - valid = false; - break; - } - } else { - valid = false; - break; - } - } - - if (!valid) { - return content; - } - - return content.map(item => { - if (!item || typeof item === 'string') { - return item; - } - - const element = item as React.ReactElement; - - return React.cloneElement(element, { - ...element.props, - enlargeEmoji: true, - }); - }); - } -} diff --git a/packages/emoji/src/createEmojiMatcher.tsx b/packages/emoji/src/createEmojiMatcher.tsx new file mode 100644 index 00000000..90e25ec5 --- /dev/null +++ b/packages/emoji/src/createEmojiMatcher.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { createMatcher, OnMatch, MatcherFactory, Node } from 'interweave'; +import Emoji from './Emoji'; +import { EmojiMatch, InterweaveEmojiProps, EmojiProps } from './types'; + +function factory(match: EmojiMatch, { emojiSource }: InterweaveEmojiProps) { + return ; +} + +function onBeforeParse(content: string, { emojiSource }: InterweaveEmojiProps): string { + if (__DEV__) { + if (!emojiSource) { + throw new Error( + 'Missing emoji source data. Have you loaded with the `useEmojiData` hook and passed the `emojiSource` prop?', + ); + } + } + + return content; +} + +function onAfterParse(node: Node, { emojiEnlargeThreshold = 1 }: InterweaveEmojiProps): Node { + const content = React.Children.toArray(node); + + if (content.length === 0) { + return content; + } + + let valid = false; + let count = 0; + + // Use a for-loop, as it's much cleaner than some() + // eslint-disable-next-line unicorn/no-for-loop + for (let i = 0; i < content.length; i += 1) { + const item = content[i]; + + if (typeof item === 'string') { + // Allow whitespace but disallow strings + if (!item.match(/^\s+$/)) { + valid = false; + break; + } + } else if (React.isValidElement(item)) { + // Only count towards emojis + if (item && item.type === Emoji) { + count += 1; + valid = true; + + if (count > emojiEnlargeThreshold) { + valid = false; + break; + } + + // Abort early for non-emoji components + } else { + valid = false; + break; + } + } else { + valid = false; + break; + } + } + + if (!valid) { + return content; + } + + return content.map(item => { + if (!React.isValidElement(item)) { + return item; + } + + return React.cloneElement(item, { + ...item.props, + enlarged: true, + }); + }); +} + +export default function createEmojiMatcher( + pattern: RegExp, + onMatch: OnMatch, + customFactory: MatcherFactory = factory, +) { + return createMatcher(pattern, customFactory, { + greedy: true, + onAfterParse, + onBeforeParse, + onMatch, + tagName: 'img', + void: true, + }); +} diff --git a/packages/emoji/src/emojiEmoticonMatcher.ts b/packages/emoji/src/emojiEmoticonMatcher.ts new file mode 100644 index 00000000..483401a0 --- /dev/null +++ b/packages/emoji/src/emojiEmoticonMatcher.ts @@ -0,0 +1,26 @@ +import EMOTICON_REGEX from 'emojibase-regex/emoticon'; +import EmojiDataManager from './EmojiDataManager'; +import createEmojiMatcher from './createEmojiMatcher'; + +const EMOTICON_BOUNDARY_REGEX = new RegExp( + // eslint-disable-next-line no-useless-escape + `(^|\\\b|\\\s)(${EMOTICON_REGEX.source})(?=\\\s|\\\b|$)`, +); + +export default createEmojiMatcher(EMOTICON_BOUNDARY_REGEX, (result, { emojiSource }) => { + const data = EmojiDataManager.getInstance(emojiSource.locale); + const emoticon = result.matches[0].trim(); + + if (!emoticon || !data.EMOTICON_TO_HEXCODE[emoticon]) { + return null; + } + + // Remove padding + // TODO still needed? + result.match = String(emoticon); + + return { + emoticon, + hexcode: data.EMOTICON_TO_HEXCODE[emoticon], + }; +}); diff --git a/packages/emoji/src/emojiShortcodeMatcher.ts b/packages/emoji/src/emojiShortcodeMatcher.ts new file mode 100644 index 00000000..96d5c62f --- /dev/null +++ b/packages/emoji/src/emojiShortcodeMatcher.ts @@ -0,0 +1,17 @@ +import SHORTCODE_REGEX from 'emojibase-regex/shortcode'; +import EmojiDataManager from './EmojiDataManager'; +import createEmojiMatcher from './createEmojiMatcher'; + +export default createEmojiMatcher(SHORTCODE_REGEX, (result, { emojiSource }) => { + const data = EmojiDataManager.getInstance(emojiSource.locale); + const shortcode = result.matches[0].toLowerCase(); + + if (!shortcode || !data.SHORTCODE_TO_HEXCODE[shortcode]) { + return null; + } + + return { + hexcode: data.SHORTCODE_TO_HEXCODE[shortcode], + shortcode, + }; +}); diff --git a/packages/emoji/src/emojiUnicodeMatcher.ts b/packages/emoji/src/emojiUnicodeMatcher.ts new file mode 100644 index 00000000..cb37e6ac --- /dev/null +++ b/packages/emoji/src/emojiUnicodeMatcher.ts @@ -0,0 +1,17 @@ +import EMOJI_REGEX from 'emojibase-regex'; +import EmojiDataManager from './EmojiDataManager'; +import createEmojiMatcher from './createEmojiMatcher'; + +export default createEmojiMatcher(EMOJI_REGEX, (result, { emojiSource }) => { + const data = EmojiDataManager.getInstance(emojiSource.locale); + const unicode = result.matches[0]; + + if (!unicode || !data.UNICODE_TO_HEXCODE[unicode]) { + return null; + } + + return { + hexcode: data.UNICODE_TO_HEXCODE[unicode], + unicode, + }; +}); diff --git a/packages/emoji/src/index.ts b/packages/emoji/src/index.ts index b057989e..a4c83550 100644 --- a/packages/emoji/src/index.ts +++ b/packages/emoji/src/index.ts @@ -5,10 +5,21 @@ import Emoji from './Emoji'; import EmojiDataManager from './EmojiDataManager'; -import EmojiMatcher from './EmojiMatcher'; +import createEmojiMatcher from './createEmojiMatcher'; +import emojiEmoticonMatcher from './emojiEmoticonMatcher'; +import emojiShortcodeMatcher from './emojiShortcodeMatcher'; +import emojiUnicodeMatcher from './emojiUnicodeMatcher'; import useEmojiData from './useEmojiData'; -export { Emoji, EmojiMatcher, EmojiDataManager, useEmojiData }; +export { + Emoji, + EmojiDataManager, + createEmojiMatcher, + emojiEmoticonMatcher, + emojiShortcodeMatcher, + emojiUnicodeMatcher, + useEmojiData, +}; export * from './constants'; export * from './types'; diff --git a/packages/emoji/src/types.ts b/packages/emoji/src/types.ts index 3fe57f7f..09fb658e 100644 --- a/packages/emoji/src/types.ts +++ b/packages/emoji/src/types.ts @@ -26,24 +26,24 @@ export interface Source { } export interface EmojiProps { - /** Size of the emoji when it's enlarged. */ - emojiLargeSize?: Size; - /** Path to an SVG/PNG. Accepts a string or a callback that is passed the hexcode. */ - emojiPath?: Path; - /** Size of the emoji. Defaults to 1em. */ - emojiSize?: Size; - /** Emoji datasource metadata. */ - emojiSource: Source; /** Emoticon to reference emoji from. */ emoticon?: Emoticon; /** Enlarge emoji increasing it's size. */ - enlargeEmoji?: boolean; + enlarged?: boolean; /** Hexcode to reference emoji from. */ hexcode?: Hexcode; + /** Size of the emoji when it's enlarged. */ + largeSize?: Size; + /** Path to an SVG/PNG. Accepts a string or a callback that is passed the hexcode. */ + path?: Path; /** Render literal unicode character instead of an SVG/PNG. */ renderUnicode?: boolean; /** Shortcode to reference emoji from. */ shortcode?: Shortcode; + /** Size of the emoji. Defaults to 1em. */ + size?: Size; + /** Emoji datasource metadata. */ + source: Source; /** Unicode character to reference emoji from. */ unicode?: Unicode; } @@ -63,6 +63,11 @@ export interface EmojiMatcherOptions { renderUnicode?: boolean; } +export interface InterweaveEmojiProps { + emojiEnlargeThreshold?: number; + emojiSource: Source; +} + export interface UseEmojiDataOptions { /** Avoid fetching emoji data. Assumes data has already been fetched. */ avoidFetch?: boolean; diff --git a/packages/ssr/src/index.ts b/packages/ssr/src/index.ts index 20c9ec1b..b4ac8bce 100644 --- a/packages/ssr/src/index.ts +++ b/packages/ssr/src/index.ts @@ -148,20 +148,3 @@ function createHTMLDocument(): Document { export function polyfill() { global.INTERWEAVE_SSR_POLYFILL = createHTMLDocument; } - -export function polyfillDOMImplementation() { - if (typeof document === 'undefined') { - // @ts-ignore - global.document = {}; - } - - if (typeof document.implementation === 'undefined') { - // @ts-ignore - global.document.implementation = {}; - } - - if (typeof document.implementation.createHTMLDocument !== 'function') { - // @ts-ignore - global.document.implementation.createHTMLDocument = createHTMLDocument; - } -}