UNPKG

commonmark-react-renderer

Version:

React renderer for CommonMark (rationalized Markdown)

422 lines (354 loc) 13.6 kB
'use strict'; var React = require('react'); var assign = require('lodash.assign'); var isPlainObject = require('lodash.isplainobject'); var xssFilters = require('xss-filters'); var pascalCase = require('pascalcase'); var typeAliases = { blockquote: 'block_quote', thematicbreak: 'thematic_break', htmlblock: 'html_block', htmlinline: 'html_inline', codeblock: 'code_block', hardbreak: 'linebreak' }; var defaultRenderers = { block_quote: 'blockquote', // eslint-disable-line camelcase emph: 'em', linebreak: 'br', image: 'img', item: 'li', link: 'a', paragraph: 'p', strong: 'strong', thematic_break: 'hr', // eslint-disable-line camelcase html_block: HtmlRenderer, // eslint-disable-line camelcase html_inline: HtmlRenderer, // eslint-disable-line camelcase list: function List(props) { var tag = props.type.toLowerCase() === 'bullet' ? 'ul' : 'ol'; var attrs = getCoreProps(props); if (props.start !== null && props.start !== 1) { attrs.start = props.start.toString(); } return createElement(tag, attrs, props.children); }, code_block: function CodeBlock(props) { // eslint-disable-line camelcase var className = props.language && 'language-' + props.language; var code = createElement('code', { className: className }, props.literal); return createElement('pre', getCoreProps(props), code); }, code: function Code(props) { return createElement('code', getCoreProps(props), props.children); }, heading: function Heading(props) { return createElement('h' + props.level, getCoreProps(props), props.children); }, text: null, softbreak: null }; var coreTypes = Object.keys(defaultRenderers); function getCoreProps(props) { var propKeys = Object.keys(props); var dataPropKeys = propKeys.filter(function(propKey) { return propKey.match(/data-.*/g); }); var base = { key: props.nodeKey, className: props.className }; var dataAttributes = dataPropKeys.reduce(function(prev, dataPropKey) { var attributes = {}; attributes[dataPropKey] = props[dataPropKey]; return assign(attributes, prev); }, {}); return assign(dataAttributes, base); } function normalizeTypeName(typeName) { var norm = typeName.toLowerCase(); var type = typeAliases[norm] || norm; return typeof defaultRenderers[type] !== 'undefined' ? type : typeName; } function normalizeRenderers(renderers) { return Object.keys(renderers || {}).reduce(function(normalized, type) { var norm = normalizeTypeName(type); normalized[norm] = renderers[type]; return normalized; }, {}); } function HtmlRenderer(props) { var coreProps = getCoreProps(props); var nodeProps = props.escapeHtml ? {} : { dangerouslySetInnerHTML: { __html: props.literal } }; var children = props.escapeHtml ? [props.literal] : null; if (props.escapeHtml || !props.skipHtml) { var actualProps = assign(coreProps, nodeProps); return createElement(props.isBlock ? 'div' : 'span', actualProps, children); } } function isGrandChildOfList(node) { var grandparent = node.parent.parent; return ( grandparent && grandparent.type.toLowerCase() === 'list' && grandparent.listTight ); } function addChild(node, child) { var parent = node; do { parent = parent.parent; } while (!parent.react); parent.react.children.push(child); } function createElement(tagName, props, children) { var nodeChildren = Array.isArray(children) && children.reduce(reduceChildren, []); var args = [tagName, props].concat(nodeChildren || children); return React.createElement.apply(React, args); } function reduceChildren(children, child) { var lastIndex = children.length - 1; if (typeof child === 'string' && typeof children[lastIndex] === 'string') { children[lastIndex] += child; } else { children.push(child); } return children; } function flattenPosition(pos) { return [ pos[0][0], ':', pos[0][1], '-', pos[1][0], ':', pos[1][1] ].map(String).join(''); } // For some nodes, we want to include more props than for others function getNodeProps(node, key, opts, renderer) { var props = { key: key }, undef; // `sourcePos` is true if the user wants source information (line/column info from markdown source) if (opts.sourcePos && node.sourcepos) { props['data-sourcepos'] = flattenPosition(node.sourcepos); } var type = normalizeTypeName(node.type); switch (type) { case 'html_inline': case 'html_block': props.isBlock = type === 'html_block'; props.escapeHtml = opts.escapeHtml; props.skipHtml = opts.skipHtml; break; case 'code_block': var codeInfo = node.info ? node.info.split(/ +/) : []; if (codeInfo.length > 0 && codeInfo[0].length > 0) { props.language = codeInfo[0]; props.codeinfo = codeInfo; } break; case 'code': props.children = node.literal; props.inline = true; break; case 'heading': props.level = node.level; break; case 'softbreak': props.softBreak = opts.softBreak; break; case 'link': props.href = opts.transformLinkUri ? opts.transformLinkUri(node.destination) : node.destination; props.title = node.title || undef; if (opts.linkTarget) { props.target = opts.linkTarget; } break; case 'image': props.src = opts.transformImageUri ? opts.transformImageUri(node.destination) : node.destination; props.title = node.title || undef; // Commonmark treats image description as children. We just want the text props.alt = node.react.children.join(''); node.react.children = undef; break; case 'list': props.start = node.listStart; props.type = node.listType; props.tight = node.listTight; break; default: } if (typeof renderer !== 'string') { props.literal = node.literal; } var children = props.children || (node.react && node.react.children); if (Array.isArray(children)) { props.children = children.reduce(reduceChildren, []) || null; } return props; } function getPosition(node) { if (!node) { return null; } if (node.sourcepos) { return flattenPosition(node.sourcepos); } return getPosition(node.parent); } function renderNodes(block) { var walker = block.walker(); var propOptions = { sourcePos: this.sourcePos, escapeHtml: this.escapeHtml, skipHtml: this.skipHtml, transformLinkUri: this.transformLinkUri, transformImageUri: this.transformImageUri, softBreak: this.softBreak, linkTarget: this.linkTarget }; var e, node, entering, leaving, type, doc, key, nodeProps, prevPos, prevIndex = 0; while ((e = walker.next())) { var pos = getPosition(e.node.sourcepos ? e.node : e.node.parent); if (prevPos === pos) { key = pos + prevIndex; prevIndex++; } else { key = pos; prevIndex = 0; } prevPos = pos; entering = e.entering; leaving = !entering; node = e.node; type = normalizeTypeName(node.type); nodeProps = null; // If we have not assigned a document yet, assume the current node is just that if (!doc) { doc = node; node.react = { children: [] }; continue; } else if (node === doc) { // When we're leaving... continue; } // In HTML, we don't want paragraphs inside of list items if (type === 'paragraph' && isGrandChildOfList(node)) { continue; } // If we're skipping HTML nodes, don't keep processing if (this.skipHtml && (type === 'html_block' || type === 'html_inline')) { continue; } var isDocument = node === doc; var disallowedByConfig = this.allowedTypes.indexOf(type) === -1; var disallowedByUser = false; // Do we have a user-defined function? var isCompleteParent = node.isContainer && leaving; var renderer = this.renderers[type]; if (this.allowNode && (isCompleteParent || !node.isContainer)) { var nodeChildren = isCompleteParent ? node.react.children : []; nodeProps = getNodeProps(node, key, propOptions, renderer); disallowedByUser = !this.allowNode({ type: pascalCase(type), renderer: this.renderers[type], props: nodeProps, children: nodeChildren }); } if (!isDocument && (disallowedByUser || disallowedByConfig)) { if (!this.unwrapDisallowed && entering && node.isContainer) { walker.resumeAt(node, false); } continue; } var isSimpleNode = type === 'text' || type === 'softbreak'; if (typeof renderer !== 'function' && !isSimpleNode && typeof renderer !== 'string') { throw new Error( 'Renderer for type `' + pascalCase(node.type) + '` not defined or is not renderable' ); } if (node.isContainer && entering) { node.react = { component: renderer, props: {}, children: [] }; } else { var childProps = nodeProps || getNodeProps(node, key, propOptions, renderer); if (renderer) { childProps = typeof renderer === 'string' ? childProps : assign(childProps, {nodeKey: childProps.key}); addChild(node, React.createElement(renderer, childProps)); } else if (type === 'text') { addChild(node, node.literal); } else if (type === 'softbreak') { // Softbreaks are usually treated as newlines, but in HTML we might want explicit linebreaks var softBreak = ( this.softBreak === 'br' ? React.createElement('br', {key: key}) : this.softBreak ); addChild(node, softBreak); } } } return doc.react.children; } function defaultLinkUriFilter(uri) { var url = uri.replace(/file:\/\//g, 'x-file://'); // React does a pretty swell job of escaping attributes, // so to prevent double-escaping, we need to decode return decodeURI(xssFilters.uriInDoubleQuotedAttr(url)); } function ReactRenderer(options) { var opts = options || {}; if (opts.allowedTypes && opts.disallowedTypes) { throw new Error('Only one of `allowedTypes` and `disallowedTypes` should be defined'); } if (opts.allowedTypes && !Array.isArray(opts.allowedTypes)) { throw new Error('`allowedTypes` must be an array'); } if (opts.disallowedTypes && !Array.isArray(opts.disallowedTypes)) { throw new Error('`disallowedTypes` must be an array'); } if (opts.allowNode && typeof opts.allowNode !== 'function') { throw new Error('`allowNode` must be a function'); } var linkFilter = opts.transformLinkUri; if (typeof linkFilter === 'undefined') { linkFilter = defaultLinkUriFilter; } else if (linkFilter && typeof linkFilter !== 'function') { throw new Error('`transformLinkUri` must either be a function, or `null` to disable'); } var imageFilter = opts.transformImageUri; if (typeof imageFilter !== 'undefined' && typeof imageFilter !== 'function') { throw new Error('`transformImageUri` must be a function'); } if (opts.renderers && !isPlainObject(opts.renderers)) { throw new Error('`renderers` must be a plain object of `Type`: `Renderer` pairs'); } var allowedTypes = (opts.allowedTypes && opts.allowedTypes.map(normalizeTypeName)) || coreTypes; if (opts.disallowedTypes) { var disallowed = opts.disallowedTypes.map(normalizeTypeName); allowedTypes = allowedTypes.filter(function filterDisallowed(type) { return disallowed.indexOf(type) === -1; }); } return { sourcePos: Boolean(opts.sourcePos), softBreak: opts.softBreak || '\n', renderers: assign({}, defaultRenderers, normalizeRenderers(opts.renderers)), escapeHtml: Boolean(opts.escapeHtml), skipHtml: Boolean(opts.skipHtml), transformLinkUri: linkFilter, transformImageUri: imageFilter, allowNode: opts.allowNode, allowedTypes: allowedTypes, unwrapDisallowed: Boolean(opts.unwrapDisallowed), render: renderNodes, linkTarget: opts.linkTarget || false }; } ReactRenderer.uriTransformer = defaultLinkUriFilter; ReactRenderer.types = coreTypes.map(pascalCase); ReactRenderer.renderers = coreTypes.reduce(function(renderers, type) { renderers[pascalCase(type)] = defaultRenderers[type]; return renderers; }, {}); module.exports = ReactRenderer;