react-from-dom
Version:
Convert HTML/XML source code or DOM nodes to React elements
322 lines (269 loc) • 7.9 kB
text/typescript
import * as React from 'react';
import { noTextChildNodes, possibleStandardNames, randomString, styleToObject } from './helpers';
interface Attributes {
[index: string]: any;
key: string;
}
interface GetReactNodeOptions extends Options {
key: string;
level: number;
}
export type Output = React.ReactNode | Node | NodeList;
export interface Action {
// If this returns true, the two following functions are called if they are defined
condition: (node: Node, key: string, level: number) => boolean;
// Use this to inject a component or remove the node
// It must return something that can be rendered by React
post?: (node: Node, key: string, level: number) => React.ReactNode;
// Use this to update or replace the node
// e.g. for removing or adding attributes, changing the node type
pre?: (node: Node, key: string, level: number) => Node;
}
export interface Options {
/**
* An array of actions to modify the nodes before converting them to ReactNodes.
*/
actions?: Action[];
/**
* Don't remove white spaces in the output.
*/
allowWhiteSpaces?: boolean;
/**
* Parse all nodes instead of just a single parent node.
* This will return a ReactNode array (or a NodeList if `nodeOnly` is true).
*/
includeAllNodes?: boolean;
/**
* The index to start the React key identification.
* @default 0
*/
index?: number;
/**
* The level to start the React key identification.
* @default 0
*/
level?: number;
/**
* Only return the node (or NodeList) without converting it to a ReactNode.
*/
nodeOnly?: boolean;
/**
* Add a random key to the root element.
* @default false
*/
randomKey?: boolean;
/**
* The selector to use in the `document.querySelector` method.
* @default 'body > *'
*/
selector?: string;
/**
* The mimeType to use in the DOMParser's parseFromString.
* @default 'text/html'
*/
type?: DOMParserSupportedType;
}
function getReactNode(node: Node, options: GetReactNodeOptions): React.ReactNode {
const { key, level, ...rest } = options;
switch (node.nodeType) {
case 1: {
// regular dom-node
return React.createElement(
parseName(node.nodeName),
parseAttributes(node, key),
parseChildren(node.childNodes, level, rest),
);
}
case 3: {
// textnode
const nodeText = node.nodeValue?.toString() ?? '';
if (!rest.allowWhiteSpaces && /^\s+$/.test(nodeText) && !/[\u00A0\u202F]/.test(nodeText)) {
return null;
}
/* c8 ignore next 3 */
if (!node.parentNode) {
return nodeText;
}
const parentNodeName = node.parentNode.nodeName.toLowerCase();
if (noTextChildNodes.includes(parentNodeName)) {
if (/\S/.test(nodeText)) {
// eslint-disable-next-line no-console
console.warn(
`A textNode is not allowed inside '${parentNodeName}'. Your text "${nodeText}" will be ignored`,
);
}
return null;
}
return nodeText;
}
case 8: {
// html-comment
return null;
}
case 11: {
// fragment
return parseChildren(node.childNodes, level, options);
}
/* c8 ignore next 3 */
default: {
return null;
}
}
}
function parseAttributes(node: Node, reactKey: string): Attributes {
const attributes: Attributes = {
key: reactKey,
};
if (node instanceof Element) {
const nodeClassNames = node.getAttribute('class');
if (nodeClassNames) {
attributes.className = nodeClassNames;
}
[...node.attributes].forEach(d => {
switch (d.name) {
// this is manually handled above, so break;
case 'class':
break;
case 'style':
attributes[d.name] = styleToObject(d.value);
break;
case 'allowfullscreen':
case 'allowpaymentrequest':
case 'async':
case 'autofocus':
case 'autoplay':
case 'checked':
case 'controls':
case 'default':
case 'defer':
case 'disabled':
case 'formnovalidate':
case 'hidden':
case 'ismap':
case 'itemscope':
case 'loop':
case 'multiple':
case 'muted':
case 'nomodule':
case 'novalidate':
case 'open':
case 'readonly':
case 'required':
case 'reversed':
case 'selected':
case 'typemustmatch':
attributes[possibleStandardNames[d.name] || d.name] = true;
break;
default:
attributes[possibleStandardNames[d.name] || d.name] = d.value;
}
});
}
return attributes;
}
function parseChildren(childNodeList: NodeList, level: number, options: Options) {
const children: React.ReactNode[] = [...childNodeList]
.map((node, index) =>
convertFromNode(node, {
...options,
index,
level: level + 1,
}),
)
.filter(Boolean);
if (!children.length) {
return null;
}
return children;
}
function parseName(nodeName: string) {
if (/[a-z]+[A-Z]+[a-z]+/.test(nodeName)) {
return nodeName;
}
return nodeName.toLowerCase();
}
export default function convert(input: Node | string, options: Options = {}): Output {
if (typeof input === 'string') {
return convertFromString(input, options);
}
if (input instanceof Node) {
return convertFromNode(input, options);
}
return null;
}
export function convertFromNode(input: Node, options: Options = {}): React.ReactNode {
if (!input || !(input instanceof Node)) {
return null;
}
const { actions = [], index = 0, level = 0, randomKey } = options;
let node = input;
let key = `${level}-${index}`;
const result: React.ReactNode[] = [];
if (randomKey && level === 0) {
key = `${randomString()}-${key}`;
}
if (Array.isArray(actions)) {
actions.forEach((action: Action) => {
if (action.condition(node, key, level)) {
if (typeof action.pre === 'function') {
node = action.pre(node, key, level);
if (!(node instanceof Node)) {
node = input;
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.warn(
'The `pre` method always must return a valid DomNode (instanceof Node) - your modification will be ignored (Hint: if you want to render a React-component, use the `post` method instead)',
);
}
}
}
if (typeof action.post === 'function') {
result.push(action.post(node, key, level));
}
}
});
}
if (result.length) {
return result;
}
return getReactNode(node, { key, level, ...options });
}
export function convertFromString(input: string, options: Options = {}): Output {
if (!input || typeof input !== 'string') {
return null;
}
const {
includeAllNodes = false,
nodeOnly = false,
selector = 'body > *',
type = 'text/html',
} = options;
try {
const parser = new DOMParser();
const document = parser.parseFromString(input, type);
if (includeAllNodes) {
const { childNodes } = document.body;
if (nodeOnly) {
return childNodes;
}
return [...childNodes].map(node => convertFromNode(node, options));
}
const node = document.querySelector(selector) || document.body.childNodes[0];
/* c8 ignore next 3 */
if (!(node instanceof Node)) {
throw new TypeError('Error parsing input');
}
if (nodeOnly) {
return node;
}
return convertFromNode(node, options);
/* c8 ignore start */
} catch (error) {
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.error(error);
}
}
return null;
/* c8 ignore stop */
}