rexxars/commonmark-react-renderer

View on GitHub
src/commonmark-react-renderer.js

Summary

Maintainability
D
2 days
Test Coverage
'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;