storyblok-rich-text-react-renderer
Version:
A React renderer for Storyblok rich text content
231 lines (205 loc) • 8.13 kB
JavaScript
import React from 'react';
export const NODE_HEADING = 'heading';
export const NODE_CODEBLOCK = 'code_block';
export const NODE_PARAGRAPH = 'paragraph';
export const NODE_QUOTE = 'blockquote';
export const NODE_OL = 'ordered_list';
export const NODE_UL = 'bullet_list';
export const NODE_LI = 'list_item';
export const NODE_HR = 'horizontal_rule';
export const NODE_BR = 'hard_break';
export const NODE_IMAGE = 'image';
export const NODE_EMOJI = 'emoji';
export const NODE_TABLE = 'table';
export const NODE_TABLE_HEADER = 'tableHeader';
export const NODE_TABLE_ROW = 'tableRow';
export const NODE_TABLE_CELL = 'tableCell';
export const MARK_BOLD = 'bold';
export const MARK_ITALIC = 'italic';
export const MARK_STRIKE = 'strike';
export const MARK_UNDERLINE = 'underline';
export const MARK_CODE = 'code';
export const MARK_LINK = 'link';
export const MARK_STYLED = 'styled';
export const MARK_SUBSCRIPT = 'subscript';
export const MARK_SUPERSCRIPT = 'superscript';
export const MARK_HIGHLIGHT = 'highlight';
export const MARK_TEXT_STYLE = 'textStyle';
export const MARK_ANCHOR = 'anchor';
export function render(document, options = {}) {
if (
typeof document === 'object' &&
document.type === 'doc' &&
Array.isArray(document.content)
) {
const {
blokResolvers = {},
defaultBlokResolver = () => null,
nodeResolvers: customNodeResolvers = {},
markResolvers: customMarkResolvers = {},
textResolver = str => str,
} = options;
const nodeResolvers = {
...defaultNodeResolvers,
...customNodeResolvers,
};
const markResolvers = {
...defaultMarkResolvers,
...customMarkResolvers,
};
let currentKey = 0;
const addKey = element =>
React.isValidElement(element)
? React.cloneElement(element, { key: currentKey++ })
: element;
const renderNodes = nodes => {
const elements = nodes ? nodes.map(renderNode).filter(node => node != null) : null;
return Array.isArray(elements) && elements.length === 0 ? null : elements;
};
const renderNode = node => {
if (node.type === 'blok') {
const { body } = node.attrs;
return body.map(({ component, ...props }) => {
const resolver = blokResolvers[component];
const element = resolver
? resolver(props)
: defaultBlokResolver(component, props);
return addKey(element);
});
} else {
let childNode;
if (node.type === 'text') {
childNode = textResolver(node.text);
} else {
const resolver = nodeResolvers[node.type];
childNode = resolver
? addKey(resolver(renderNodes(node.content), node.attrs))
: null;
}
const marks = node.marks ?? [];
return marks.reduceRight((children, mark) => {
const resolver = markResolvers[mark.type];
return resolver ? addKey(resolver(children, mark.attrs)) : children;
}, childNode);
}
};
return renderNodes(document.content);
} else if (typeof document === 'string') {
const { defaultStringResolver = str => str, textResolver = str => str } = options;
return defaultStringResolver(textResolver(document));
}
return null;
}
const simpleNodeResolver = element => children =>
children != null ? React.createElement(element, null, children) : null;
const emptyNodeResolver = element => () => React.createElement(element);
const headingNodeResolver = (children, props) =>
React.createElement(`h${props.level}`, null, children);
const imageNodeResolver = (children, props) => React.createElement('img', props, children);
const codeblockNodeResolver = (children, props) => {
const codeProps = { className: props.class };
const code = React.createElement('code', codeProps, children);
return React.createElement('pre', null, code);
};
const emojiNodeResolver = (_, attrs) => {
if (!attrs) return null;
const props = {
'data-type': 'emoji',
'data-name': attrs.name,
emoji: attrs.emoji,
};
if (attrs.emoji || !attrs.fallbackImage) {
return React.createElement('span', props, attrs.emoji);
} else {
const fallbackProps = {
src: attrs.fallbackImage,
draggable: 'false',
loading: 'lazy',
align: 'absmiddle',
alt: attrs.name,
};
const fallback = React.createElement('img', fallbackProps);
return React.createElement('span', props, fallback);
}
};
const tableCellNodeResolver = el => (children, props) => {
const tableCellProps = {};
const tableCellStyle = {};
if (props.colspan !== 1) {
tableCellProps.colspan = props.colspan;
}
if (props.rowspan !== 1) {
tableCellProps.rowspan = props.rowspan;
}
if (props.backgroundColor) {
tableCellStyle.backgroundColor = props.backgroundColor;
}
if (Array.isArray(props.colwidth)) {
if (props.colwidth.length === 1) {
tableCellStyle['width'] = props.colwidth[0] + 'px';
} else {
tableCellProps['data-colwidth'] = props.colwidth.join(',');
}
}
if (Object.keys(tableCellStyle).length > 0) {
tableCellProps.style = tableCellStyle;
}
return React.createElement(el, tableCellProps, children);
};
const simpleMarkResolver = element => children => React.createElement(element, null, children);
const linkMarkResolver = (children, attrs) => {
const props = attrs
? {
href: attrs.linktype === 'email' ? `mailto:${attrs.href}` : attrs.href,
target: attrs.target,
}
: {};
return React.createElement('a', props, children);
};
const styledMarkResolver = (children, attrs) => {
const props = attrs ? { className: attrs.class } : {};
return React.createElement('span', props, children);
};
const highlightMarkResolver = (children, attrs) => {
const props = attrs ? { style: { backgroundColor: attrs.color } } : {};
return React.createElement('span', props, children);
};
const textStyleMarkResolver = (children, attrs) => {
const props = attrs?.color ? { style: { color: attrs.color } } : {};
return React.createElement('span', props, children);
};
const anchorMarkResolver = (children, attrs) => {
const props = attrs ? { id: attrs.id } : {};
return React.createElement('span', props, children);
};
const defaultNodeResolvers = {
[NODE_HEADING]: headingNodeResolver,
[NODE_CODEBLOCK]: codeblockNodeResolver,
[NODE_IMAGE]: imageNodeResolver,
[NODE_PARAGRAPH]: simpleNodeResolver('p'),
[NODE_QUOTE]: simpleNodeResolver('blockquote'),
[NODE_OL]: simpleNodeResolver('ol'),
[NODE_UL]: simpleNodeResolver('ul'),
[NODE_LI]: simpleNodeResolver('li'),
[NODE_HR]: emptyNodeResolver('hr'),
[NODE_BR]: emptyNodeResolver('br'),
[NODE_EMOJI]: emojiNodeResolver,
[NODE_TABLE]: simpleNodeResolver('table'),
[NODE_TABLE_ROW]: simpleNodeResolver('tr'),
[NODE_TABLE_HEADER]: tableCellNodeResolver('th'),
[NODE_TABLE_CELL]: tableCellNodeResolver('td'),
};
const defaultMarkResolvers = {
[MARK_LINK]: linkMarkResolver,
[MARK_STYLED]: styledMarkResolver,
[MARK_BOLD]: simpleMarkResolver('b'),
[MARK_ITALIC]: simpleMarkResolver('i'),
[MARK_STRIKE]: simpleMarkResolver('s'),
[MARK_UNDERLINE]: simpleMarkResolver('u'),
[MARK_CODE]: simpleMarkResolver('code'),
[MARK_SUBSCRIPT]: simpleMarkResolver('sub'),
[MARK_SUPERSCRIPT]: simpleMarkResolver('sup'),
[MARK_HIGHLIGHT]: highlightMarkResolver,
[MARK_TEXT_STYLE]: textStyleMarkResolver,
[MARK_ANCHOR]: anchorMarkResolver,
};