From dd378f0c0bab46e20074323ea078a6f30c3f5f3e Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Tue, 24 Mar 2020 23:46:47 -0700 Subject: [PATCH 1/7] First pass on new API. --- package.json | 1 + packages/autolink/src/Email.tsx | 2 +- packages/autolink/src/EmailMatcher.ts | 27 ------ packages/autolink/src/HashtagMatcher.ts | 23 ----- packages/autolink/src/IpMatcher.ts | 27 ------ packages/autolink/src/Url.tsx | 10 +- packages/autolink/src/UrlMatcher.ts | 72 -------------- packages/autolink/src/createEmailMatcher.tsx | 23 +++++ .../autolink/src/createHashtagMatcher.tsx | 19 ++++ packages/autolink/src/createIpMatcher.tsx | 14 +++ packages/autolink/src/createUrlMatcher.tsx | 56 +++++++++++ packages/autolink/src/index.ts | 19 +++- packages/autolink/src/types.ts | 29 +++--- packages/core/src/Element.tsx | 5 +- packages/core/src/Matcher.ts | 96 ------------------- packages/core/src/createMatcher.ts | 75 +++++++++++++++ packages/core/src/index.ts | 5 +- 17 files changed, 233 insertions(+), 270 deletions(-) delete mode 100644 packages/autolink/src/EmailMatcher.ts delete mode 100644 packages/autolink/src/HashtagMatcher.ts delete mode 100644 packages/autolink/src/IpMatcher.ts delete mode 100644 packages/autolink/src/UrlMatcher.ts create mode 100644 packages/autolink/src/createEmailMatcher.tsx create mode 100644 packages/autolink/src/createHashtagMatcher.tsx create mode 100644 packages/autolink/src/createIpMatcher.tsx create mode 100644 packages/autolink/src/createUrlMatcher.tsx delete mode 100644 packages/core/src/Matcher.ts create mode 100644 packages/core/src/createMatcher.ts 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/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/createEmailMatcher.tsx b/packages/autolink/src/createEmailMatcher.tsx new file mode 100644 index 00000000..69419d67 --- /dev/null +++ b/packages/autolink/src/createEmailMatcher.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import createMatcher, { ElementFactory, MatchResult } from 'interweave/src/createMatcher'; +import Email from './Email'; +import { EMAIL_PATTERN } from './constants'; +import { EmailMatch } from './types'; + +function onMatch({ matches }: MatchResult): EmailMatch { + return { + email: matches[0], + emailParts: { + host: matches[2], + username: matches[1], + }, + }; +} + +export default function createEmailMatcher(factory?: ElementFactory) { + return createMatcher( + EMAIL_PATTERN, + { onMatch, tagName: 'a' }, + factory || ((content, { email }) => {content}), + ); +} diff --git a/packages/autolink/src/createHashtagMatcher.tsx b/packages/autolink/src/createHashtagMatcher.tsx new file mode 100644 index 00000000..5f86ac3f --- /dev/null +++ b/packages/autolink/src/createHashtagMatcher.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import createMatcher, { ElementFactory, MatchResult } from 'interweave/src/createMatcher'; +import Hashtag from './Hashtag'; +import { HASHTAG_PATTERN } from './constants'; +import { HashtagMatch } from './types'; + +function onMatch({ matches }: MatchResult): HashtagMatch { + return { + hashtag: matches[0], + }; +} + +export default function createHashtagMatcher(factory?: ElementFactory) { + return createMatcher( + HASHTAG_PATTERN, + { onMatch, tagName: 'a' }, + factory || ((content, { hashtag }) => {content}), + ); +} diff --git a/packages/autolink/src/createIpMatcher.tsx b/packages/autolink/src/createIpMatcher.tsx new file mode 100644 index 00000000..650a4ea4 --- /dev/null +++ b/packages/autolink/src/createIpMatcher.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import createMatcher, { ElementFactory } from 'interweave/src/createMatcher'; +import Url from './Url'; +import { onMatch } from './createUrlMatcher'; +import { IP_PATTERN } from './constants'; +import { UrlMatch } from './types'; + +export default function createIpMatcher(factory?: ElementFactory) { + return createMatcher( + IP_PATTERN, + { onMatch, tagName: 'a' }, + factory || ((content, { url }) => {content}), + ); +} diff --git a/packages/autolink/src/createUrlMatcher.tsx b/packages/autolink/src/createUrlMatcher.tsx new file mode 100644 index 00000000..0afe5d39 --- /dev/null +++ b/packages/autolink/src/createUrlMatcher.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import createMatcher, { ElementFactory, MatchResult } from 'interweave/src/createMatcher'; +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, + { customTLDs = [], validateTLD = true }: UrlMatcherOptions = {}, +): UrlMatch | null { + const { matches } = result; + const match = { + 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', + }, + }; + + // 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.urlParts; + 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 function createUrlMatcher( + options?: UrlMatcherOptions, + factory?: ElementFactory, +) { + return createMatcher( + URL_PATTERN, + { + onMatch: result => onMatch(result, options), + tagName: 'a', + }, + factory || ((content, { url }) => {content}), + ); +} diff --git a/packages/autolink/src/index.ts b/packages/autolink/src/index.ts index d00ba01f..f09838a1 100644 --- a/packages/autolink/src/index.ts +++ b/packages/autolink/src/index.ts @@ -3,16 +3,25 @@ * @license https://opensource.org/licenses/MIT */ +import createEmailMatcher from './createEmailMatcher'; +import createHashtagMatcher from './createHashtagMatcher'; +import createIpMatcher from './createIpMatcher'; +import createUrlMatcher from './createUrlMatcher'; 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 { + createEmailMatcher, + createHashtagMatcher, + createIpMatcher, + createUrlMatcher, + Email, + Hashtag, + Link, + Url, +}; export * from './constants'; export * from './types'; diff --git a/packages/autolink/src/types.ts b/packages/autolink/src/types.ts index 3c2e90b7..6544e826 100644 --- a/packages/autolink/src/types.ts +++ b/packages/autolink/src/types.ts @@ -1,16 +1,17 @@ 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; +} + +export interface EmailMatch { email: string; emailParts: { host: string; @@ -18,16 +19,22 @@ export interface EmailProps extends Partial { }; } -export interface HashtagProps extends Partial { - children: ChildrenNode; +export interface HashtagProps extends LinkProps { encodeHashtag?: boolean; hashtag: string; hashtagUrl?: string | ((hashtag: string) => string); preserveHash?: boolean; } -export interface UrlProps extends Partial { - children: ChildrenNode; +export interface HashtagMatch { + hashtag: string; +} + +export interface UrlProps extends LinkProps { + href: string; +} + +export interface UrlMatch { url: string; urlParts: { auth: string; 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/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/createMatcher.ts b/packages/core/src/createMatcher.ts new file mode 100644 index 00000000..caad3bea --- /dev/null +++ b/packages/core/src/createMatcher.ts @@ -0,0 +1,75 @@ +import React from 'react'; + +export type ElementFactory = ( + content: NonNullable, + match: Match, + props: Props, +) => React.ReactElement; + +export interface MatchResult { + index: number; + length: number; + match: string; + matches: string[]; + valid: boolean; + value: string; + void: boolean; +} + +export type MatchHandler = (value: string) => (MatchResult & { params: Match }) | null; + +export interface Matcher { + factory: ElementFactory; + greedy: boolean; + match: MatchHandler; +} + +export type OnMatch = (result: MatchResult) => T | null; + +export interface MatcherOptions { + greedy?: boolean; + tagName: string; + void?: boolean; + // onAfterParse?: (content: Node[], props: Props) => Node[]; + // onBeforeParse?: (content: string, props: Props) => string; + onMatch: OnMatch; +} + +export default function createMatcher( + pattern: string | RegExp, + options: MatcherOptions, + factory: ElementFactory, +): Matcher { + return { + factory, + greedy: options.greedy ?? false, + match(value) { + 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); + + // Allow callback to intercept the result + if (params === null) { + return null; + } + + return { + params, + ...result, + }; + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 01eec04c..7207cbc4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,7 +6,6 @@ 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'; @@ -17,3 +16,7 @@ export * from './constants'; export * from './types'; export default Interweave; + +import createMatcher from './createMatcher'; + +export { createMatcher }; From 29b4b9ba6bb1beffcbb18587c672a105ddf31cda Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Wed, 25 Mar 2020 00:08:48 -0700 Subject: [PATCH 2/7] Add emoji emoticon matcher. --- packages/core/src/createMatcher.ts | 22 +++++---- packages/emoji/src/Emoji.tsx | 44 ++++++++--------- packages/emoji/src/EmojiDataManager.ts | 2 +- packages/emoji/src/EmojiMatcher.ts | 55 --------------------- packages/emoji/src/emojiEmoticonMatcher.tsx | 41 +++++++++++++++ packages/emoji/src/helpers.ts | 16 ++++++ packages/emoji/src/types.ts | 22 +++++---- 7 files changed, 106 insertions(+), 96 deletions(-) create mode 100644 packages/emoji/src/emojiEmoticonMatcher.tsx create mode 100644 packages/emoji/src/helpers.ts diff --git a/packages/core/src/createMatcher.ts b/packages/core/src/createMatcher.ts index caad3bea..aefc2b36 100644 --- a/packages/core/src/createMatcher.ts +++ b/packages/core/src/createMatcher.ts @@ -16,34 +16,37 @@ export interface MatchResult { void: boolean; } -export type MatchHandler = (value: string) => (MatchResult & { params: Match }) | null; +export type MatchHandler = ( + value: string, + props: Props, +) => (MatchResult & { params: Match }) | null; export interface Matcher { factory: ElementFactory; greedy: boolean; - match: MatchHandler; + match: MatchHandler; } -export type OnMatch = (result: MatchResult) => T | null; +export type OnMatch = (result: MatchResult, props: Props) => Match | null; -export interface MatcherOptions { +export interface MatcherOptions { greedy?: boolean; tagName: string; void?: boolean; // onAfterParse?: (content: Node[], props: Props) => Node[]; - // onBeforeParse?: (content: string, props: Props) => string; - onMatch: OnMatch; + onBeforeParse?: (content: string, props: Props) => string; + onMatch: OnMatch; } export default function createMatcher( pattern: string | RegExp, - options: MatcherOptions, + options: MatcherOptions, factory: ElementFactory, ): Matcher { return { factory, greedy: options.greedy ?? false, - match(value) { + match(value, props) { const matches = value.match(pattern instanceof RegExp ? pattern : new RegExp(pattern, 'i')); if (!matches) { @@ -59,7 +62,8 @@ export default function createMatcher( value, void: options.void ?? false, }; - const params = options.onMatch(result); + + const params = options.onMatch(result, props); // Allow callback to intercept the result if (params === null) { 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; @@ -50,15 +44,6 @@ export default class EmojiMatcher extends Matcher | 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, @@ -153,21 +113,6 @@ export default class EmojiMatcher extends Matcher( + EMOTICON_BOUNDARY_REGEX, + { + onBeforeParse, + onMatch, + tagName: 'img', + void: true, + }, + (content, props, { emojiSource }) => , +); diff --git a/packages/emoji/src/helpers.ts b/packages/emoji/src/helpers.ts new file mode 100644 index 00000000..df22da80 --- /dev/null +++ b/packages/emoji/src/helpers.ts @@ -0,0 +1,16 @@ +import { EmojiRequiredProps } from './types'; + +/** + * Load emoji data before matching. + */ +export function onBeforeParse(content: string, props: EmojiRequiredProps): string { + if (__DEV__) { + if (!props.emojiSource) { + throw new Error( + 'Missing emoji source data. Have you loaded with the `useEmojiData` hook and passed the `emojiSource` prop?', + ); + } + } + + return content; +} diff --git a/packages/emoji/src/types.ts b/packages/emoji/src/types.ts index 3fe57f7f..4f071998 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,10 @@ export interface EmojiMatcherOptions { renderUnicode?: boolean; } +export interface EmojiRequiredProps { + emojiSource: Source; +} + export interface UseEmojiDataOptions { /** Avoid fetching emoji data. Assumes data has already been fetched. */ avoidFetch?: boolean; From af65408ec06f2cbd2ae877a3ee26826cf69aa4ec Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Wed, 25 Mar 2020 00:19:00 -0700 Subject: [PATCH 3/7] Convert remaining emoji matchers. --- packages/core/src/createMatcher.ts | 6 +- packages/emoji/src/EmojiDataManager.ts | 1 - packages/emoji/src/EmojiMatcher.ts | 177 ------------------ ...conMatcher.tsx => emojiEmoticonMatcher.ts} | 7 +- packages/emoji/src/emojiShortcodeMatcher.ts | 31 +++ packages/emoji/src/emojiUnicodeMatcher.ts | 31 +++ packages/emoji/src/helpers.ts | 16 -- packages/emoji/src/helpers.tsx | 77 ++++++++ packages/emoji/src/index.ts | 13 +- 9 files changed, 157 insertions(+), 202 deletions(-) delete mode 100644 packages/emoji/src/EmojiMatcher.ts rename packages/emoji/src/{emojiEmoticonMatcher.tsx => emojiEmoticonMatcher.ts} (84%) create mode 100644 packages/emoji/src/emojiShortcodeMatcher.ts create mode 100644 packages/emoji/src/emojiUnicodeMatcher.ts delete mode 100644 packages/emoji/src/helpers.ts create mode 100644 packages/emoji/src/helpers.tsx diff --git a/packages/core/src/createMatcher.ts b/packages/core/src/createMatcher.ts index aefc2b36..48860e98 100644 --- a/packages/core/src/createMatcher.ts +++ b/packages/core/src/createMatcher.ts @@ -1,7 +1,9 @@ import React from 'react'; +export type Node = NonNullable; + export type ElementFactory = ( - content: NonNullable, + content: Node, match: Match, props: Props, ) => React.ReactElement; @@ -33,7 +35,7 @@ export interface MatcherOptions { greedy?: boolean; tagName: string; void?: boolean; - // onAfterParse?: (content: Node[], props: Props) => Node[]; + onAfterParse?: (content: Node[], props: Props) => Node[]; onBeforeParse?: (content: string, props: Props) => string; onMatch: OnMatch; } diff --git a/packages/emoji/src/EmojiDataManager.ts b/packages/emoji/src/EmojiDataManager.ts index a748a903..20d0722e 100644 --- a/packages/emoji/src/EmojiDataManager.ts +++ b/packages/emoji/src/EmojiDataManager.ts @@ -6,7 +6,6 @@ import { generateEmoticonPermutations, Emoji, Hexcode, - TEXT, EMOTICON_OPTIONS, } from 'emojibase'; import { CanonicalEmoji } from './types'; diff --git a/packages/emoji/src/EmojiMatcher.ts b/packages/emoji/src/EmojiMatcher.ts deleted file mode 100644 index 8468bdf0..00000000 --- a/packages/emoji/src/EmojiMatcher.ts +++ /dev/null @@ -1,177 +0,0 @@ -import React from 'react'; -import { Matcher, MatchResponse, Node, ChildrenNode } from 'interweave'; -import EMOJI_REGEX from 'emojibase-regex'; -import SHORTCODE_REGEX from 'emojibase-regex/shortcode'; -import Emoji from './Emoji'; -import EmojiDataManager from './EmojiDataManager'; -import { EmojiProps, EmojiMatcherOptions, EmojiMatch } from './types'; - -export default class EmojiMatcher extends Matcher { - 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 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; - } - - 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; - } - - /** - * 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/emojiEmoticonMatcher.tsx b/packages/emoji/src/emojiEmoticonMatcher.ts similarity index 84% rename from packages/emoji/src/emojiEmoticonMatcher.tsx rename to packages/emoji/src/emojiEmoticonMatcher.ts index 439d5db4..db380d77 100644 --- a/packages/emoji/src/emojiEmoticonMatcher.tsx +++ b/packages/emoji/src/emojiEmoticonMatcher.ts @@ -1,9 +1,7 @@ -import React from 'react'; import createMatcher, { MatchResult } from 'interweave/src/createMatcher'; import EMOTICON_REGEX from 'emojibase-regex/emoticon'; -import Emoji from './Emoji'; import EmojiDataManager from './EmojiDataManager'; -import { onBeforeParse } from './helpers'; +import { factory, onBeforeParse } from './helpers'; import { EmojiMatch, EmojiRequiredProps } from './types'; const EMOTICON_BOUNDARY_REGEX = new RegExp( @@ -32,10 +30,11 @@ function onMatch(result: MatchResult, { emojiSource }: EmojiRequiredProps): Emoj export default createMatcher( EMOTICON_BOUNDARY_REGEX, { + greedy: true, onBeforeParse, onMatch, tagName: 'img', void: true, }, - (content, props, { emojiSource }) => , + factory, ); diff --git a/packages/emoji/src/emojiShortcodeMatcher.ts b/packages/emoji/src/emojiShortcodeMatcher.ts new file mode 100644 index 00000000..f08c913c --- /dev/null +++ b/packages/emoji/src/emojiShortcodeMatcher.ts @@ -0,0 +1,31 @@ +import createMatcher, { MatchResult } from 'interweave/src/createMatcher'; +import SHORTCODE_REGEX from 'emojibase-regex/shortcode'; +import EmojiDataManager from './EmojiDataManager'; +import { factory, onBeforeParse } from './helpers'; +import { EmojiMatch, EmojiRequiredProps } from './types'; + +function onMatch(result: MatchResult, { emojiSource }: EmojiRequiredProps): EmojiMatch | null { + 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, + }; +} + +export default createMatcher( + SHORTCODE_REGEX, + { + greedy: true, + onBeforeParse, + onMatch, + tagName: 'img', + void: true, + }, + factory, +); diff --git a/packages/emoji/src/emojiUnicodeMatcher.ts b/packages/emoji/src/emojiUnicodeMatcher.ts new file mode 100644 index 00000000..567de730 --- /dev/null +++ b/packages/emoji/src/emojiUnicodeMatcher.ts @@ -0,0 +1,31 @@ +import createMatcher, { MatchResult } from 'interweave/src/createMatcher'; +import EMOJI_REGEX from 'emojibase-regex'; +import EmojiDataManager from './EmojiDataManager'; +import { factory, onBeforeParse } from './helpers'; +import { EmojiMatch, EmojiRequiredProps } from './types'; + +function onMatch(result: MatchResult, { emojiSource }: EmojiRequiredProps): EmojiMatch | null { + 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, + }; +} + +export default createMatcher( + EMOJI_REGEX, + { + greedy: true, + onBeforeParse, + onMatch, + tagName: 'img', + void: true, + }, + factory, +); diff --git a/packages/emoji/src/helpers.ts b/packages/emoji/src/helpers.ts deleted file mode 100644 index df22da80..00000000 --- a/packages/emoji/src/helpers.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { EmojiRequiredProps } from './types'; - -/** - * Load emoji data before matching. - */ -export function onBeforeParse(content: string, props: EmojiRequiredProps): string { - if (__DEV__) { - if (!props.emojiSource) { - throw new Error( - 'Missing emoji source data. Have you loaded with the `useEmojiData` hook and passed the `emojiSource` prop?', - ); - } - } - - return content; -} diff --git a/packages/emoji/src/helpers.tsx b/packages/emoji/src/helpers.tsx new file mode 100644 index 00000000..29be0da7 --- /dev/null +++ b/packages/emoji/src/helpers.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Node } from 'interweave/src/createMatcher'; +import Emoji from './Emoji'; +import { EmojiRequiredProps, EmojiMatch } from './types'; + +export function factory(content: unknown, match: EmojiMatch, { emojiSource }: EmojiRequiredProps) { + return ; +} + +export function onBeforeParse(content: string, props: EmojiRequiredProps): string { + if (__DEV__) { + if (!props.emojiSource) { + throw new Error( + 'Missing emoji source data. Have you loaded with the `useEmojiData` hook and passed the `emojiSource` prop?', + ); + } + } + + return content; +} + +export function onAfterParse(content: Node[], props: EmojiRequiredProps): 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 > 1) { + 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, + enlargeEmoji: true, + }); + }); +} diff --git a/packages/emoji/src/index.ts b/packages/emoji/src/index.ts index b057989e..1d1c19cd 100644 --- a/packages/emoji/src/index.ts +++ b/packages/emoji/src/index.ts @@ -5,10 +5,19 @@ import Emoji from './Emoji'; import EmojiDataManager from './EmojiDataManager'; -import EmojiMatcher from './EmojiMatcher'; +import emojiEmoticonMatcher from './emojiEmoticonMatcher'; +import emojiShortcodeMatcher from './emojiShortcodeMatcher'; +import emojiUnicodeMatcher from './emojiUnicodeMatcher'; import useEmojiData from './useEmojiData'; -export { Emoji, EmojiMatcher, EmojiDataManager, useEmojiData }; +export { + Emoji, + EmojiDataManager, + emojiEmoticonMatcher, + emojiShortcodeMatcher, + emojiUnicodeMatcher, + useEmojiData, +}; export * from './constants'; export * from './types'; From 1ab569f87f8a1d381b1fb35900839509fa02eeb3 Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Wed, 25 Mar 2020 22:15:08 -0700 Subject: [PATCH 4/7] Lots of matcher improvements. --- packages/autolink/src/createEmailMatcher.tsx | 23 ---- .../autolink/src/createHashtagMatcher.tsx | 19 --- packages/autolink/src/createIpMatcher.tsx | 14 -- packages/autolink/src/emailMatcher.tsx | 20 +++ packages/autolink/src/hashtagMatcher.tsx | 16 +++ packages/autolink/src/index.ts | 19 +-- packages/autolink/src/ipMatcher.tsx | 12 ++ .../{createUrlMatcher.tsx => urlMatcher.tsx} | 26 ++-- packages/core/src/Filter.ts | 20 --- packages/core/src/Parser.ts | 10 +- packages/core/src/StyleFilter.ts | 22 --- packages/core/src/constants.ts | 12 +- packages/core/src/createMatcher.ts | 60 ++------ packages/core/src/createTransformer.ts | 12 ++ packages/core/src/index.ts | 12 +- packages/core/src/styleTransformer.ts | 14 ++ packages/core/src/testing.tsx | 4 +- packages/core/src/types.ts | 129 ++++++++++++------ .../{helpers.tsx => createEmojiMatcher.tsx} | 36 +++-- packages/emoji/src/emojiEmoticonMatcher.ts | 20 +-- packages/emoji/src/emojiShortcodeMatcher.ts | 20 +-- packages/emoji/src/emojiUnicodeMatcher.ts | 20 +-- packages/emoji/src/index.ts | 2 + packages/emoji/src/types.ts | 3 +- 24 files changed, 244 insertions(+), 301 deletions(-) delete mode 100644 packages/autolink/src/createEmailMatcher.tsx delete mode 100644 packages/autolink/src/createHashtagMatcher.tsx delete mode 100644 packages/autolink/src/createIpMatcher.tsx create mode 100644 packages/autolink/src/emailMatcher.tsx create mode 100644 packages/autolink/src/hashtagMatcher.tsx create mode 100644 packages/autolink/src/ipMatcher.tsx rename packages/autolink/src/{createUrlMatcher.tsx => urlMatcher.tsx} (67%) delete mode 100644 packages/core/src/Filter.ts delete mode 100644 packages/core/src/StyleFilter.ts create mode 100644 packages/core/src/createTransformer.ts create mode 100644 packages/core/src/styleTransformer.ts rename packages/emoji/src/{helpers.tsx => createEmojiMatcher.tsx} (57%) diff --git a/packages/autolink/src/createEmailMatcher.tsx b/packages/autolink/src/createEmailMatcher.tsx deleted file mode 100644 index 69419d67..00000000 --- a/packages/autolink/src/createEmailMatcher.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import createMatcher, { ElementFactory, MatchResult } from 'interweave/src/createMatcher'; -import Email from './Email'; -import { EMAIL_PATTERN } from './constants'; -import { EmailMatch } from './types'; - -function onMatch({ matches }: MatchResult): EmailMatch { - return { - email: matches[0], - emailParts: { - host: matches[2], - username: matches[1], - }, - }; -} - -export default function createEmailMatcher(factory?: ElementFactory) { - return createMatcher( - EMAIL_PATTERN, - { onMatch, tagName: 'a' }, - factory || ((content, { email }) => {content}), - ); -} diff --git a/packages/autolink/src/createHashtagMatcher.tsx b/packages/autolink/src/createHashtagMatcher.tsx deleted file mode 100644 index 5f86ac3f..00000000 --- a/packages/autolink/src/createHashtagMatcher.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import createMatcher, { ElementFactory, MatchResult } from 'interweave/src/createMatcher'; -import Hashtag from './Hashtag'; -import { HASHTAG_PATTERN } from './constants'; -import { HashtagMatch } from './types'; - -function onMatch({ matches }: MatchResult): HashtagMatch { - return { - hashtag: matches[0], - }; -} - -export default function createHashtagMatcher(factory?: ElementFactory) { - return createMatcher( - HASHTAG_PATTERN, - { onMatch, tagName: 'a' }, - factory || ((content, { hashtag }) => {content}), - ); -} diff --git a/packages/autolink/src/createIpMatcher.tsx b/packages/autolink/src/createIpMatcher.tsx deleted file mode 100644 index 650a4ea4..00000000 --- a/packages/autolink/src/createIpMatcher.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import createMatcher, { ElementFactory } from 'interweave/src/createMatcher'; -import Url from './Url'; -import { onMatch } from './createUrlMatcher'; -import { IP_PATTERN } from './constants'; -import { UrlMatch } from './types'; - -export default function createIpMatcher(factory?: ElementFactory) { - return createMatcher( - IP_PATTERN, - { onMatch, tagName: 'a' }, - factory || ((content, { url }) => {content}), - ); -} diff --git a/packages/autolink/src/emailMatcher.tsx b/packages/autolink/src/emailMatcher.tsx new file mode 100644 index 00000000..0796bfdb --- /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], + emailParts: { + 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 f09838a1..ac72243b 100644 --- a/packages/autolink/src/index.ts +++ b/packages/autolink/src/index.ts @@ -3,25 +3,16 @@ * @license https://opensource.org/licenses/MIT */ -import createEmailMatcher from './createEmailMatcher'; -import createHashtagMatcher from './createHashtagMatcher'; -import createIpMatcher from './createIpMatcher'; -import createUrlMatcher from './createUrlMatcher'; +import emailMatcher from './emailMatcher'; +import hashtagMatcher from './hashtagMatcher'; +import ipMatcher from './ipMatcher'; +import urlMatcher from './urlMatcher'; import Email from './Email'; import Hashtag from './Hashtag'; import Link from './Link'; import Url from './Url'; -export { - createEmailMatcher, - createHashtagMatcher, - createIpMatcher, - createUrlMatcher, - Email, - Hashtag, - Link, - Url, -}; +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/createUrlMatcher.tsx b/packages/autolink/src/urlMatcher.tsx similarity index 67% rename from packages/autolink/src/createUrlMatcher.tsx rename to packages/autolink/src/urlMatcher.tsx index 0afe5d39..61420aa0 100644 --- a/packages/autolink/src/createUrlMatcher.tsx +++ b/packages/autolink/src/urlMatcher.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import createMatcher, { ElementFactory, MatchResult } from 'interweave/src/createMatcher'; +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, - { customTLDs = [], validateTLD = true }: UrlMatcherOptions = {}, + props: object, + { customTLDs = [], validateTLD = true }: UrlMatcherOptions, ): UrlMatch | null { const { matches } = result; const match = { @@ -41,16 +42,11 @@ export function onMatch( return match; } -export default function createUrlMatcher( - options?: UrlMatcherOptions, - factory?: ElementFactory, -) { - return createMatcher( - URL_PATTERN, - { - onMatch: result => onMatch(result, options), - tagName: 'a', - }, - factory || ((content, { url }) => {content}), - ); -} +export default createMatcher( + URL_PATTERN, + ({ url }, props, children) => {children}, + { + onMatch, + tagName: 'a', + }, +); 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/Parser.ts b/packages/core/src/Parser.ts index c2fd67ff..5bf2371e 100644 --- a/packages/core/src/Parser.ts +++ b/packages/core/src/Parser.ts @@ -18,7 +18,7 @@ import { import { Attributes, Node, - NodeConfig, + TagConfig, AttributeValue, ChildrenNode, ParserProps, @@ -120,7 +120,7 @@ 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 { + applyMatchers(string: string, parentConfig: TagConfig): ChildrenNode { const elements: MatcherElementsMap = {}; const { props } = this; let matchedString = string; @@ -206,7 +206,7 @@ export default class Parser { /** * 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; } @@ -374,7 +374,7 @@ export default class Parser { /** * Return configuration for a specific tag. */ - getTagConfig(tagName: string): NodeConfig { + getTagConfig(tagName: string): TagConfig { const common = { children: [], content: 0, @@ -456,7 +456,7 @@ export default class Parser { * Loop over the nodes children and generate a * list of text nodes and React elements. */ - parseNode(parentNode: HTMLElement, parentConfig: NodeConfig): Node[] { + parseNode(parentNode: HTMLElement, parentConfig: TagConfig): Node[] { const { noHtml, noHtmlExceptMatchers, allowElements, transform } = this.props; let content: Node[] = []; let mergedText = ''; 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..820ce495 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 = Object.freeze({ alt: FILTER_ALLOW, cite: FILTER_ALLOW, class: FILTER_ALLOW, @@ -341,7 +341,7 @@ export const ATTRIBUTES: FilterMap = Object.freeze({ }); // Attributes to camel case for React props -export const ATTRIBUTES_TO_PROPS: { [key: string]: string } = Object.freeze({ +export const ATTRIBUTES_TO_PROPS = Object.freeze({ class: 'className', colspan: 'colSpan', datetime: 'dateTime', diff --git a/packages/core/src/createMatcher.ts b/packages/core/src/createMatcher.ts index 48860e98..50aa0169 100644 --- a/packages/core/src/createMatcher.ts +++ b/packages/core/src/createMatcher.ts @@ -1,51 +1,17 @@ -import React from 'react'; +import { Matcher, MatcherOptions, MatcherFactory, MatchResult } from './types'; -export type Node = NonNullable; - -export type ElementFactory = ( - content: Node, - match: Match, - props: Props, -) => React.ReactElement; - -export interface MatchResult { - index: number; - length: number; - match: string; - matches: string[]; - valid: boolean; - value: string; - void: boolean; -} - -export type MatchHandler = ( - value: string, - props: Props, -) => (MatchResult & { params: Match }) | null; - -export interface Matcher { - factory: ElementFactory; - greedy: boolean; - match: MatchHandler; -} - -export type OnMatch = (result: MatchResult, props: Props) => Match | null; - -export interface MatcherOptions { - greedy?: boolean; - tagName: string; - void?: boolean; - onAfterParse?: (content: Node[], props: Props) => Node[]; - onBeforeParse?: (content: string, props: Props) => string; - onMatch: OnMatch; -} - -export default function createMatcher( +export default function createMatcher( pattern: string | RegExp, - options: MatcherOptions, - factory: ElementFactory, -): Matcher { + factory: MatcherFactory, + options: MatcherOptions, +): Matcher { return { + extend(customFactory, customOptions) { + return createMatcher(pattern, customFactory, { + ...options, + ...customOptions, + }); + }, factory, greedy: options.greedy ?? false, match(value, props) { @@ -65,7 +31,7 @@ export default function createMatcher( void: options.void ?? false, }; - const params = options.onMatch(result, props); + const params = options.onMatch(result, props, options.options || {}); // Allow callback to intercept the result if (params === null) { @@ -77,5 +43,7 @@ export default function createMatcher( ...result, }; }, + 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..5ff28e6c --- /dev/null +++ b/packages/core/src/createTransformer.ts @@ -0,0 +1,12 @@ +import { ElementName, InferElement, Transformer, TransformerFactory } from './types'; + +export default function createTransformer< + K extends ElementName, + Element = InferElement, + Props = {} +>(tagName: K, factory: TransformerFactory): Transformer { + return { + factory, + tagName, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7207cbc4..a69bb1ab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,18 +5,12 @@ import Interweave from './Interweave'; import Markup from './Markup'; -import Filter from './Filter'; 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; - -import createMatcher from './createMatcher'; - -export { createMatcher }; 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..250ff729 100644 --- a/packages/core/src/testing.tsx +++ b/packages/core/src/testing.tsx @@ -6,7 +6,7 @@ import { Matcher, Element, Node, - NodeConfig, + TagConfig, MatchResponse, ChildrenNode, TAGS, @@ -95,7 +95,7 @@ export const MOCK_INVALID_MARKUP = `

More text with outdated stuff.

`; -export const parentConfig: NodeConfig = { +export const parentConfig: TagConfig = { children: [], content: 0, invalid: [], diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b18935db..d8a88467 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -11,11 +11,87 @@ declare global { } } -export type Node = null | string | React.ReactElement; +export type Node = NonNullable; -export type ChildrenNode = string | Node[]; +export type OnAfterParse = (content: Node[], props: Props) => Node[]; -export interface NodeConfig { +export type OnBeforeParse = (content: string, props: Props) => string; + +// MATCHERS + +export type OnMatch = ( + result: MatchResult, + props: Props, + options: Partial, +) => Match | null; + +export interface MatchResult { + index: number; + length: number; + match: string; + matches: string[]; + valid: boolean; + value: string; + void: boolean; +} + +export type MatchHandler = ( + value: string, + props: Props, +) => (MatchResult & { params: Match }) | null; + +export interface MatcherOptions { + greedy?: boolean; + tagName: string; + void?: boolean; + options?: Options; + onAfterParse?: OnAfterParse; + onBeforeParse?: OnBeforeParse; + onMatch: OnMatch; +} + +export type MatcherFactory = ( + match: Match, + props: Props, + content: Node, +) => React.ReactElement; + +export interface Matcher { + extend: ( + factory: MatcherFactory, + options?: Partial>, + ) => Matcher; + factory: MatcherFactory; + greedy: boolean; + match: MatchHandler; + options: Partial; + tagName: string; +} + +// TRANSFORMERS + +export type ElementName = keyof React.ReactHTML | '*'; + +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 Transformer { + factory: TransformerFactory; + tagName: string; +} + +// Elements + +export interface TagConfig { // Only children children: string[]; // Children content type @@ -34,10 +110,12 @@ export interface NodeConfig { void: boolean; } -export interface ConfigMap { - [key: string]: Partial; +export interface TagConfigMap { + [key: string]: Partial; } +// OLD + export type AttributeValue = string | number | boolean | object; export interface Attributes { @@ -51,48 +129,9 @@ export type BeforeParseCallback = (content: string, props: T) => string; export type TransformCallback = ( node: HTMLElement, children: Node[], - config: NodeConfig, + config: TagConfig, ) => React.ReactNode; -// MATCHERS - -export type MatchCallback = (matches: string[]) => T; - -export type MatchResponse = T & { - index: number; - length: number; - match: string; - valid: boolean; - void?: boolean; -}; - -export interface MatcherInterface { - 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[]; -} - -// FILTERS - -export type ElementAttributes = React.AllHTMLAttributes; - -export interface FilterInterface { - attribute?( - name: K, - value: ElementAttributes[K], - ): ElementAttributes[K] | undefined | null; - node?(name: string, node: HTMLElement): HTMLElement | null; -} - -export interface FilterMap { - [key: string]: number; -} - // PARSER export interface MatcherElementsMap { diff --git a/packages/emoji/src/helpers.tsx b/packages/emoji/src/createEmojiMatcher.tsx similarity index 57% rename from packages/emoji/src/helpers.tsx rename to packages/emoji/src/createEmojiMatcher.tsx index 29be0da7..ef80c5a5 100644 --- a/packages/emoji/src/helpers.tsx +++ b/packages/emoji/src/createEmojiMatcher.tsx @@ -1,15 +1,15 @@ import React from 'react'; -import { Node } from 'interweave/src/createMatcher'; +import { createMatcher, OnMatch, MatcherFactory, Node } from 'interweave'; import Emoji from './Emoji'; -import { EmojiRequiredProps, EmojiMatch } from './types'; +import { EmojiMatch, InterweaveEmojiProps, EmojiProps } from './types'; -export function factory(content: unknown, match: EmojiMatch, { emojiSource }: EmojiRequiredProps) { +function factory(match: EmojiMatch, { emojiSource }: InterweaveEmojiProps) { return ; } -export function onBeforeParse(content: string, props: EmojiRequiredProps): string { +function onBeforeParse(content: string, { emojiSource }: InterweaveEmojiProps): string { if (__DEV__) { - if (!props.emojiSource) { + if (!emojiSource) { throw new Error( 'Missing emoji source data. Have you loaded with the `useEmojiData` hook and passed the `emojiSource` prop?', ); @@ -19,7 +19,10 @@ export function onBeforeParse(content: string, props: EmojiRequiredProps): strin return content; } -export function onAfterParse(content: Node[], props: EmojiRequiredProps): Node[] { +function onAfterParse( + content: Node[], + { emojiEnlargeThreshold = 1 }: InterweaveEmojiProps, +): Node[] { if (content.length === 0) { return content; } @@ -44,7 +47,7 @@ export function onAfterParse(content: Node[], props: EmojiRequiredProps): Node[] count += 1; valid = true; - if (count > 1) { + if (count > emojiEnlargeThreshold) { valid = false; break; } @@ -65,13 +68,28 @@ export function onAfterParse(content: Node[], props: EmojiRequiredProps): Node[] } return content.map(item => { - if (!React.isValidElement(item)) { + if (!React.isValidElement(item)) { return item; } return React.cloneElement(item, { ...item.props, - enlargeEmoji: true, + 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 index db380d77..483401a0 100644 --- a/packages/emoji/src/emojiEmoticonMatcher.ts +++ b/packages/emoji/src/emojiEmoticonMatcher.ts @@ -1,15 +1,13 @@ -import createMatcher, { MatchResult } from 'interweave/src/createMatcher'; import EMOTICON_REGEX from 'emojibase-regex/emoticon'; import EmojiDataManager from './EmojiDataManager'; -import { factory, onBeforeParse } from './helpers'; -import { EmojiMatch, EmojiRequiredProps } from './types'; +import createEmojiMatcher from './createEmojiMatcher'; const EMOTICON_BOUNDARY_REGEX = new RegExp( // eslint-disable-next-line no-useless-escape `(^|\\\b|\\\s)(${EMOTICON_REGEX.source})(?=\\\s|\\\b|$)`, ); -function onMatch(result: MatchResult, { emojiSource }: EmojiRequiredProps): EmojiMatch | null { +export default createEmojiMatcher(EMOTICON_BOUNDARY_REGEX, (result, { emojiSource }) => { const data = EmojiDataManager.getInstance(emojiSource.locale); const emoticon = result.matches[0].trim(); @@ -25,16 +23,4 @@ function onMatch(result: MatchResult, { emojiSource }: EmojiRequiredProps): Emoj emoticon, hexcode: data.EMOTICON_TO_HEXCODE[emoticon], }; -} - -export default createMatcher( - EMOTICON_BOUNDARY_REGEX, - { - greedy: true, - onBeforeParse, - onMatch, - tagName: 'img', - void: true, - }, - factory, -); +}); diff --git a/packages/emoji/src/emojiShortcodeMatcher.ts b/packages/emoji/src/emojiShortcodeMatcher.ts index f08c913c..96d5c62f 100644 --- a/packages/emoji/src/emojiShortcodeMatcher.ts +++ b/packages/emoji/src/emojiShortcodeMatcher.ts @@ -1,10 +1,8 @@ -import createMatcher, { MatchResult } from 'interweave/src/createMatcher'; import SHORTCODE_REGEX from 'emojibase-regex/shortcode'; import EmojiDataManager from './EmojiDataManager'; -import { factory, onBeforeParse } from './helpers'; -import { EmojiMatch, EmojiRequiredProps } from './types'; +import createEmojiMatcher from './createEmojiMatcher'; -function onMatch(result: MatchResult, { emojiSource }: EmojiRequiredProps): EmojiMatch | null { +export default createEmojiMatcher(SHORTCODE_REGEX, (result, { emojiSource }) => { const data = EmojiDataManager.getInstance(emojiSource.locale); const shortcode = result.matches[0].toLowerCase(); @@ -16,16 +14,4 @@ function onMatch(result: MatchResult, { emojiSource }: EmojiRequiredProps): Emoj hexcode: data.SHORTCODE_TO_HEXCODE[shortcode], shortcode, }; -} - -export default createMatcher( - SHORTCODE_REGEX, - { - greedy: true, - onBeforeParse, - onMatch, - tagName: 'img', - void: true, - }, - factory, -); +}); diff --git a/packages/emoji/src/emojiUnicodeMatcher.ts b/packages/emoji/src/emojiUnicodeMatcher.ts index 567de730..cb37e6ac 100644 --- a/packages/emoji/src/emojiUnicodeMatcher.ts +++ b/packages/emoji/src/emojiUnicodeMatcher.ts @@ -1,10 +1,8 @@ -import createMatcher, { MatchResult } from 'interweave/src/createMatcher'; import EMOJI_REGEX from 'emojibase-regex'; import EmojiDataManager from './EmojiDataManager'; -import { factory, onBeforeParse } from './helpers'; -import { EmojiMatch, EmojiRequiredProps } from './types'; +import createEmojiMatcher from './createEmojiMatcher'; -function onMatch(result: MatchResult, { emojiSource }: EmojiRequiredProps): EmojiMatch | null { +export default createEmojiMatcher(EMOJI_REGEX, (result, { emojiSource }) => { const data = EmojiDataManager.getInstance(emojiSource.locale); const unicode = result.matches[0]; @@ -16,16 +14,4 @@ function onMatch(result: MatchResult, { emojiSource }: EmojiRequiredProps): Emoj hexcode: data.UNICODE_TO_HEXCODE[unicode], unicode, }; -} - -export default createMatcher( - EMOJI_REGEX, - { - greedy: true, - onBeforeParse, - onMatch, - tagName: 'img', - void: true, - }, - factory, -); +}); diff --git a/packages/emoji/src/index.ts b/packages/emoji/src/index.ts index 1d1c19cd..a4c83550 100644 --- a/packages/emoji/src/index.ts +++ b/packages/emoji/src/index.ts @@ -5,6 +5,7 @@ import Emoji from './Emoji'; import EmojiDataManager from './EmojiDataManager'; +import createEmojiMatcher from './createEmojiMatcher'; import emojiEmoticonMatcher from './emojiEmoticonMatcher'; import emojiShortcodeMatcher from './emojiShortcodeMatcher'; import emojiUnicodeMatcher from './emojiUnicodeMatcher'; @@ -13,6 +14,7 @@ import useEmojiData from './useEmojiData'; export { Emoji, EmojiDataManager, + createEmojiMatcher, emojiEmoticonMatcher, emojiShortcodeMatcher, emojiUnicodeMatcher, diff --git a/packages/emoji/src/types.ts b/packages/emoji/src/types.ts index 4f071998..09fb658e 100644 --- a/packages/emoji/src/types.ts +++ b/packages/emoji/src/types.ts @@ -63,7 +63,8 @@ export interface EmojiMatcherOptions { renderUnicode?: boolean; } -export interface EmojiRequiredProps { +export interface InterweaveEmojiProps { + emojiEnlargeThreshold?: number; emojiSource: Source; } From b735f467d905e40efe63e7c307d44aa45abeaecc Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Thu, 26 Mar 2020 11:47:21 -0700 Subject: [PATCH 5/7] More polish. --- packages/autolink/src/Hashtag.tsx | 20 +++++++++--------- packages/autolink/src/emailMatcher.tsx | 2 +- packages/autolink/src/types.ts | 10 ++++----- packages/autolink/src/urlMatcher.tsx | 6 +++--- packages/core/src/createMatcher.ts | 2 +- packages/core/src/match.ts | 27 ------------------------ packages/core/src/types.ts | 2 +- packages/emoji-picker/src/Emoji.tsx | 6 +++--- packages/emoji-picker/src/PreviewBar.tsx | 8 +++---- 9 files changed, 28 insertions(+), 55 deletions(-) delete mode 100644 packages/core/src/match.ts 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/emailMatcher.tsx b/packages/autolink/src/emailMatcher.tsx index 0796bfdb..0f89e573 100644 --- a/packages/autolink/src/emailMatcher.tsx +++ b/packages/autolink/src/emailMatcher.tsx @@ -10,7 +10,7 @@ export default createMatcher( { onMatch: ({ matches }) => ({ email: matches[0], - emailParts: { + parts: { host: matches[2], username: matches[1], }, diff --git a/packages/autolink/src/types.ts b/packages/autolink/src/types.ts index 6544e826..76645756 100644 --- a/packages/autolink/src/types.ts +++ b/packages/autolink/src/types.ts @@ -13,17 +13,17 @@ export interface EmailProps extends LinkProps { export interface EmailMatch { email: string; - emailParts: { + parts: { host: string; username: string; }; } export interface HashtagProps extends LinkProps { - encodeHashtag?: boolean; + encoded?: boolean; hashtag: string; - hashtagUrl?: string | ((hashtag: string) => string); - preserveHash?: boolean; + url?: string | ((hashtag: string) => string); + preserved?: boolean; } export interface HashtagMatch { @@ -36,7 +36,7 @@ export interface UrlProps extends LinkProps { 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 index 61420aa0..ebfb19f2 100644 --- a/packages/autolink/src/urlMatcher.tsx +++ b/packages/autolink/src/urlMatcher.tsx @@ -11,8 +11,7 @@ export function onMatch( ): UrlMatch | null { const { matches } = result; const match = { - url: matches[0], - urlParts: { + parts: { auth: matches[2] ? matches[2].slice(0, -1) : '', fragment: matches[7] || '', host: matches[3], @@ -21,6 +20,7 @@ export function onMatch( query: matches[6] || '', scheme: matches[1] ? matches[1].replace('://', '') : 'http', }, + url: matches[0], }; // False positives with URL auth scheme @@ -30,7 +30,7 @@ export function onMatch( // Do not match if TLD is invalid if (validateTLD) { - const { host } = match.urlParts; + const { host } = match.parts; const validList = TOP_LEVEL_TLDS.concat(customTLDs); const tld = host.slice(host.lastIndexOf('.') + 1).toLowerCase(); diff --git a/packages/core/src/createMatcher.ts b/packages/core/src/createMatcher.ts index 50aa0169..2e384073 100644 --- a/packages/core/src/createMatcher.ts +++ b/packages/core/src/createMatcher.ts @@ -7,7 +7,7 @@ export default function createMatcher( ): Matcher { return { extend(customFactory, customOptions) { - return createMatcher(pattern, customFactory, { + return createMatcher(pattern, customFactory || factory, { ...options, ...customOptions, }); 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/types.ts b/packages/core/src/types.ts index d8a88467..0884b060 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -58,7 +58,7 @@ export type MatcherFactory = ( export interface Matcher { extend: ( - factory: MatcherFactory, + factory?: MatcherFactory | null, options?: Partial>, ) => Matcher; factory: MatcherFactory; 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({
From 3a590a6497c030bf2ff9639c9fe66bbb20e8401d Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Thu, 26 Mar 2020 12:27:22 -0700 Subject: [PATCH 6/7] Start converting parser. --- packages/core/src/Parser.ts | 161 ++++++++++--------------- packages/core/src/constants.ts | 4 +- packages/core/src/createTransformer.ts | 4 +- packages/core/src/types.ts | 150 ++++++++++------------- 4 files changed, 131 insertions(+), 188 deletions(-) diff --git a/packages/core/src/Parser.ts b/packages/core/src/Parser.ts index 5bf2371e..73102e61 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, - TagConfig, 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[] = []; - 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: TagConfig): 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; @@ -276,8 +238,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 +304,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 +333,14 @@ export default class Parser { /** * Return configuration for a specific tag. */ - getTagConfig(tagName: string): TagConfig { - const common = { + getTagConfig(tagName: TagName): TagConfig { + const common: TagConfig = { children: [], content: 0, invalid: [], parent: [], self: true, - tagName: '', + tagName, type: 0, void: false, }; @@ -431,7 +390,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,12 +403,15 @@ export default class Parser { * while looping over all child nodes and generating an * array to interpolate into JSX. */ - parse(): Node[] { + parse(): Node { if (!this.container) { return []; } - return this.parseNode(this.container, this.getTagConfig(this.container.nodeName.toLowerCase())); + return this.parseNode( + this.container, + this.getTagConfig(this.container.tagName.toLowerCase() as TagName), + ); } /** @@ -457,14 +419,14 @@ export default class Parser { * list of text nodes and React elements. */ parseNode(parentNode: HTMLElement, parentConfig: TagConfig): Node[] { - const { noHtml, noHtmlExceptMatchers, allowElements, transform } = this.props; + const { noHtml, noHtmlExceptInternals, allowElements, transform } = 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 tagName = node.nodeName.toLowerCase() as TagName; const config = this.getTagConfig(tagName); // Persist any previous text @@ -514,7 +476,7 @@ 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)) ) { @@ -554,7 +516,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 +539,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 +568,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 +590,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/constants.ts b/packages/core/src/constants.ts index 820ce495..be2007e8 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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 = Object.freeze({ +export const ATTRIBUTES: { [key: string]: number } = Object.freeze({ alt: FILTER_ALLOW, cite: FILTER_ALLOW, class: FILTER_ALLOW, @@ -341,7 +341,7 @@ export const ATTRIBUTES = Object.freeze({ }); // Attributes to camel case for React props -export const ATTRIBUTES_TO_PROPS = Object.freeze({ +export const ATTRIBUTES_TO_PROPS: { [key: string]: string } = Object.freeze({ class: 'className', colspan: 'colSpan', datetime: 'dateTime', diff --git a/packages/core/src/createTransformer.ts b/packages/core/src/createTransformer.ts index 5ff28e6c..32203294 100644 --- a/packages/core/src/createTransformer.ts +++ b/packages/core/src/createTransformer.ts @@ -1,7 +1,7 @@ -import { ElementName, InferElement, Transformer, TransformerFactory } from './types'; +import { WildTagName, InferElement, Transformer, TransformerFactory } from './types'; export default function createTransformer< - K extends ElementName, + K extends WildTagName, Element = InferElement, Props = {} >(tagName: K, factory: TransformerFactory): Transformer { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0884b060..b45456a1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -13,18 +13,33 @@ declare global { export type Node = NonNullable; +export type AttributeValue = string | number | boolean | object; + +export interface Attributes { + [attr: string]: AttributeValue; +} + +export interface ElementProps { + attributes?: Attributes; + children?: React.ReactNode; + selfClose?: boolean; + tagName: string; +} + +// CALLBACKS + export type OnAfterParse = (content: Node[], props: Props) => Node[]; export type OnBeforeParse = (content: string, props: Props) => string; -// MATCHERS - export type OnMatch = ( result: MatchResult, props: Props, options: Partial, ) => Match | null; +// MATCHERS + export interface MatchResult { index: number; length: number; @@ -42,7 +57,7 @@ export type MatchHandler = ( export interface MatcherOptions { greedy?: boolean; - tagName: string; + tagName: TagName; void?: boolean; options?: Options; onAfterParse?: OnAfterParse; @@ -65,13 +80,11 @@ export interface Matcher { greedy: boolean; match: MatchHandler; options: Partial; - tagName: string; + tagName: TagName; } // TRANSFORMERS -export type ElementName = keyof React.ReactHTML | '*'; - export type InferElement = K extends '*' ? HTMLElement : K extends keyof HTMLElementTagNameMap @@ -86,24 +99,58 @@ export type TransformerFactory = ( export interface Transformer { factory: TransformerFactory; - tagName: string; + tagName: WildTagName; +} + +// PARSER + +export interface ParserProps { + /** Allow all non-banned HTML attributes. */ + allowAttributes?: boolean; + /** 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. */ + allow?: TagName[]; + /** List of HTML tag names to disallow and not render. Overrides allow list. */ + block?: TagName[]; + /** Disable the conversion of new lines to `
` elements. */ + disableLineBreaks?: boolean; + /** Escape all HTML before parsing. */ + escapeHtml?: boolean; + /** Strip all HTML while rendering. */ + noHtml?: boolean; + /** 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 +// ELEMENTS + +export type TagName = keyof React.ReactHTML | 'rb' | 'rtc'; + +export type WildTagName = TagName | '*'; export interface TagConfig { // Only children - children: string[]; + children: TagName[]; // Children content type content: number; // Invalid children - invalid: string[]; + invalid: TagName[]; // Only parent - parent: string[]; + parent: TagName[]; // Can render self as a child self: boolean; // HTML tag name - tagName: string; + tagName: TagName; // Self content type type: number; // Self-closing tag @@ -111,58 +158,7 @@ export interface TagConfig { } export interface TagConfigMap { - [key: string]: Partial; -} - -// OLD - -export type AttributeValue = string | number | boolean | object; - -export interface Attributes { - [attr: string]: AttributeValue; -} - -export type AfterParseCallback = (content: Node[], props: T) => Node[]; - -export type BeforeParseCallback = (content: string, props: T) => string; - -export type TransformCallback = ( - node: HTMLElement, - children: Node[], - config: TagConfig, -) => React.ReactNode; - -// PARSER - -export interface MatcherElementsMap { - [key: string]: { - children: string; - matcher: MatcherInterface<{}>; - props: object; - }; -} - -export interface ParserProps { - /** Disable filtering and allow all non-banned HTML attributes. */ - allowAttributes?: boolean; - /** Disable filtering and allow all non-banned/blocked HTML elements to be rendered. */ - allowElements?: boolean; - /** List of HTML tag names to allow and render. Defaults to the `ALLOWED_TAG_LIST` constant. */ - allowList?: string[]; - /** List of HTML tag names to disallow and not render. Overrides allow list. */ - blockList?: string[]; - /** 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; + [tagName: string]: Partial; } // INTERWEAVE @@ -176,33 +172,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?: AfterParseCallback | 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?: BeforeParseCallback | null; } From 50877c74ce61e4163685a245916c221b4b0bb5af Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Thu, 26 Mar 2020 15:50:18 -0700 Subject: [PATCH 7/7] Finalize more API. --- packages/core/src/Interweave.tsx | 76 ++++---- packages/core/src/Markup.tsx | 22 +-- packages/core/src/Parser.ts | 87 +++++---- packages/core/src/createMatcher.ts | 4 +- packages/core/src/createTransformer.ts | 29 ++- packages/core/src/testing.tsx | 212 ++++++++-------------- packages/core/src/types.ts | 28 ++- packages/core/tests/Interweave.test.tsx | 2 - packages/core/tests/Matcher.test.tsx | 4 +- packages/core/tests/Parser.test.tsx | 8 +- packages/emoji/src/createEmojiMatcher.tsx | 7 +- packages/ssr/src/index.ts | 17 -- 12 files changed, 234 insertions(+), 262 deletions(-) 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/Parser.ts b/packages/core/src/Parser.ts index 73102e61..c9e3f1b0 100644 --- a/packages/core/src/Parser.ts +++ b/packages/core/src/Parser.ts @@ -55,7 +55,7 @@ export default class Parser { container?: HTMLElement; - content: Node[] = []; + content: Node = ''; keyIndex: number = -1; @@ -165,6 +165,31 @@ 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. */ @@ -403,9 +428,9 @@ 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( @@ -419,15 +444,15 @@ export default class Parser { * list of text nodes and React elements. */ parseNode(parentNode: HTMLElement, parentConfig: TagConfig): Node[] { - const { noHtml, noHtmlExceptInternals, allowElements, transform } = this.props; + 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() as TagName; - const config = this.getTagConfig(tagName); + let tagName = node.nodeName.toLowerCase() as TagName; + let config = this.getTagConfig(tagName); // Persist any previous text if (mergedText) { @@ -435,35 +460,31 @@ export default class Parser { mergedText = ''; } - // Apply node filters first - const nextNode = this.applyNodeFilters(tagName, node as HTMLElement); + // Increase key before transforming + this.keyIndex += 1; + const key = this.keyIndex; - if (!nextNode) { - return; - } - - // Apply transformation second - let children; - - if (transform) { - 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) @@ -480,8 +501,6 @@ export default class Parser { 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 = { @@ -499,7 +518,7 @@ export default class Parser { content.push( React.createElement( Element, - { ...elementProps, key: this.keyIndex }, + { ...elementProps, key }, children || this.parseNode(nextNode, config), ), ); diff --git a/packages/core/src/createMatcher.ts b/packages/core/src/createMatcher.ts index 2e384073..79017181 100644 --- a/packages/core/src/createMatcher.ts +++ b/packages/core/src/createMatcher.ts @@ -1,6 +1,6 @@ import { Matcher, MatcherOptions, MatcherFactory, MatchResult } from './types'; -export default function createMatcher( +export default function createMatcher( pattern: string | RegExp, factory: MatcherFactory, options: MatcherOptions, @@ -43,6 +43,8 @@ export default function createMatcher( ...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 index 32203294..b702aa0f 100644 --- a/packages/core/src/createTransformer.ts +++ b/packages/core/src/createTransformer.ts @@ -1,12 +1,27 @@ -import { WildTagName, InferElement, Transformer, TransformerFactory } from './types'; +import { + WildTagName, + InferElement, + Transformer, + TransformerFactory, + TransformerOptions, +} from './types'; -export default function createTransformer< - K extends WildTagName, - Element = InferElement, - Props = {} ->(tagName: K, factory: TransformerFactory): Transformer { +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, - tagName, + onAfterParse: options.onAfterParse, + onBeforeParse: options.onBeforeParse, + options: options.options || {}, + tagName: options.tagName || tagName, }; } diff --git a/packages/core/src/testing.tsx b/packages/core/src/testing.tsx index 250ff729..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, - TagConfig, - 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]; } @@ -107,126 +98,79 @@ export const parentConfig: TagConfig = { ...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 b45456a1..12d5a858 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -28,7 +28,7 @@ export interface ElementProps { // CALLBACKS -export type OnAfterParse = (content: Node[], props: Props) => Node[]; +export type OnAfterParse = (content: Node, props: Props) => Node; export type OnBeforeParse = (content: string, props: Props) => string; @@ -38,6 +38,12 @@ export type OnMatch = ( options: Partial, ) => Match | null; +export interface CommonInternals { + onAfterParse?: OnAfterParse; + onBeforeParse?: OnBeforeParse; + options: Partial; +} + // MATCHERS export interface MatchResult { @@ -71,7 +77,7 @@ export type MatcherFactory = ( content: Node, ) => React.ReactElement; -export interface Matcher { +export interface Matcher extends CommonInternals { extend: ( factory?: MatcherFactory | null, options?: Partial>, @@ -79,7 +85,6 @@ export interface Matcher { factory: MatcherFactory; greedy: boolean; match: MatchHandler; - options: Partial; tagName: TagName; } @@ -97,7 +102,18 @@ export type TransformerFactory = ( content: Node, ) => void | undefined | null | Element | React.ReactElement; -export interface Transformer { +export interface TransformerOptions { + tagName?: TagName; + onAfterParse?: OnAfterParse; + onBeforeParse?: OnBeforeParse; + options?: Options; +} + +export interface Transformer extends CommonInternals { + extend: ( + factory?: TransformerFactory | null, + options?: Partial>, + ) => Transformer; factory: TransformerFactory; tagName: WildTagName; } @@ -182,7 +198,7 @@ export interface InterweaveProps extends MarkupProps { /** List of matchers to apply to the content. */ 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; + 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/src/createEmojiMatcher.tsx b/packages/emoji/src/createEmojiMatcher.tsx index ef80c5a5..90e25ec5 100644 --- a/packages/emoji/src/createEmojiMatcher.tsx +++ b/packages/emoji/src/createEmojiMatcher.tsx @@ -19,10 +19,9 @@ function onBeforeParse(content: string, { emojiSource }: InterweaveEmojiProps): return content; } -function onAfterParse( - content: Node[], - { emojiEnlargeThreshold = 1 }: InterweaveEmojiProps, -): Node[] { +function onAfterParse(node: Node, { emojiEnlargeThreshold = 1 }: InterweaveEmojiProps): Node { + const content = React.Children.toArray(node); + if (content.length === 0) { return content; } 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; - } -}