diff --git a/CHANGELOG.md b/CHANGELOG.md index b498653b..f38ec114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # markdown-to-jsx ## Changelog +### 2.0.0 (March 20, 2016) + +- Add jsnext:main field (5597aa4f745e9e57c9b84b250f8d13df1bba72cc) +- Update to remark 4.x (cfa49465508dd16e68915657fcf4626d5b758e3d) +- Add tag & props override functionality (ca7271b0139400e768a482083f6a4392c4d6be77) +- Give a proper warning if improper arguments are passed (4adcd455a6ba3d6046ccd3da1ee16b8a92291b86) +- Enable footnotes support by default (8764bb181f1f0f8d510cc74d0b417c90698e11b5) + ### 1.2.0 (January 4, 2016) - Upgrade mdast 2.x to remark 3.x (same project, rewritten internals + changed name) diff --git a/LICENSE b/LICENSE index 7dde0f8c..6efb1dc4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 Evan Jacobs +Copyright (c) 2015-present Evan Jacobs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 9ec74cb3..7270d9bb 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,40 @@ render(converter('# Hello world!'), document.body); [remark options](https://github.com/wooorm/remark#remarkprocessvalue-options-done) can be passed as the second argument: ```js -converter('# Hello world[^2]!\n\n[^2]: A beautiful place.', {footnotes: true}); +converter('* abc\n* def\n* ghi', {bullet: '*'}); ``` +_Footnotes are enabled by default as of `markdown-to-jsx@2.0.0`._ + +## Overriding tags and adding props + +As of `markdown-to-jsx@2.0.0`, it's now possible to selectively override a given HTML tag's JSX representation. This is done through a new third argument to the converter: an object made of keys, each being the lowercase html tag name (p, figure, a, etc.) to be overridden. + +Each override can be given a `component` that will be substituted for the tag name and/or `props` that will be applied as you would expect. + +```js +converter('Hello there!', {}, { + p: { + component: MyParagraph, + props: { + className: 'foo' + }, + } +}); +``` + +The code above will replace all emitted `

` tags with the given component `MyParagraph`, and add the `className` specified in `props`. + +Depending on the type of element, there are some props that must be preserved to ensure the markdown is converted as intended. They are: + +- `a`: `title`, `href` +- `img`: `title`, `alt`, `src` +- `ol`: `start` +- `td`: `style` +- `th`: `style` + +Any conflicts between passed `props` and the specific properties above will be resolved in favor of `markdown-to-jsx`'s code. + ## Known Issues - remark's handling of lists will sometimes add a child paragraph tag inside the diff --git a/__tests__/index.js b/__tests__/index.js index 5b7e1d54..5510e26b 100644 --- a/__tests__/index.js +++ b/__tests__/index.js @@ -8,6 +8,40 @@ describe('markdown-to-jsx', () => { afterEach(() => ReactDOM.unmountComponentAtNode(mountNode)); + it('should throw if not passed a string (first arg)', () => { + expect(() => converter('')).not.toThrow(); + + expect(() => converter()).toThrow(); + expect(() => converter(1)).toThrow(); + expect(() => converter(function(){})).toThrow(); + expect(() => converter({})).toThrow(); + expect(() => converter([])).toThrow(); + expect(() => converter(null)).toThrow(); + expect(() => converter(true)).toThrow(); + }); + + it('should throw if not passed an object or undefined (second arg)', () => { + expect(() => converter('')).not.toThrow(); + expect(() => converter('', {})).not.toThrow(); + + expect(() => converter('', 1)).toThrow(); + expect(() => converter('', function(){})).toThrow(); + expect(() => converter('', [])).toThrow(); + expect(() => converter('', null)).toThrow(); + expect(() => converter('', true)).toThrow(); + }); + + it('should throw if not passed an object or undefined (third arg)', () => { + expect(() => converter('', {})).not.toThrow(); + expect(() => converter('', {}, {})).not.toThrow(); + + expect(() => converter('', {}, 1)).toThrow(); + expect(() => converter('', {}, function(){})).toThrow(); + expect(() => converter('', {}, [])).toThrow(); + expect(() => converter('', {}, null)).toThrow(); + expect(() => converter('', {}, true)).toThrow(); + }); + it('should handle a basic string', () => { const element = render(converter('Hello.')); const elementNode = ReactDOM.findDOMNode(element); @@ -520,7 +554,7 @@ describe('markdown-to-jsx', () => { 'foo[^abc] bar', '', '[^abc]: Baz baz', - ].join('\n'), {footnotes: true})); + ].join('\n'))); const elementNode = ReactDOM.findDOMNode(element); @@ -546,7 +580,7 @@ describe('markdown-to-jsx', () => { 'foo[^abc] bar', '', '[^abc]: Baz baz', - ].join('\n'), {footnotes: true})); + ].join('\n'))); const elementNode = ReactDOM.findDOMNode(element); const definitions = elementNode.children[1]; @@ -563,7 +597,7 @@ describe('markdown-to-jsx', () => { 'foo[^abc] bar', '', '[^abc]: Baz', - ].join('\n'), {footnotes: true})); + ].join('\n'))); const elementNode = ReactDOM.findDOMNode(element); const definitions = elementNode.children[1]; @@ -575,4 +609,29 @@ describe('markdown-to-jsx', () => { expect(definitions.children[0].textContent).toBe('[abc]: Baz'); }); }); + + describe('overrides', () => { + it('should substitute the appropriate JSX tag if given a component', () => { + const FakeParagraph = (props) =>

{props.children}

; + const element = render( + converter('Hello.', {}, {p: {component: FakeParagraph}}) + ); + + const elementNode = ReactDOM.findDOMNode(element); + + expect(elementNode.children.length).toBe(1); + expect(elementNode.children[0].className).toBe('foo'); + }); + + it('should add props to the appropriate JSX tag if supplied', () => { + const element = render( + converter('Hello.', {}, {p: {props: {className: 'abc'}}}) + ); + + const elementNode = ReactDOM.findDOMNode(element); + + expect(elementNode.children.length).toBe(1); + expect(elementNode.children[0].className).toBe('abc'); + }); + }); }); diff --git a/index.js b/index.js index f37af85d..ce0db293 100644 --- a/index.js +++ b/index.js @@ -1,312 +1,356 @@ import React from 'react'; import {parse} from 'remark'; +const getType = Object.prototype.toString; const textTypes = ['text', 'textNode']; -let definitions; -let footnotes; +export default function markdownToJSX(markdown, options = {}, overrides = {}) { + let definitions; + let footnotes; -function getHTMLNodeTypeFromASTNodeType(node) { - switch (node.type) { - case 'break': - return 'br'; + function getHTMLNodeTypeFromASTNodeType(node) { + switch (node.type) { + case 'break': + return 'br'; - case 'delete': - return 'del'; + case 'delete': + return 'del'; - case 'emphasis': - return 'em'; + case 'emphasis': + return 'em'; - case 'footnoteReference': - return 'a'; + case 'footnoteReference': + return 'a'; - case 'heading': - return `h${node.depth}`; + case 'heading': + return `h${node.depth}`; - case 'horizontalRule': - return 'hr'; + case 'html': + return 'div'; - case 'html': - return 'div'; + case 'image': + case 'imageReference': + return 'img'; - case 'image': - case 'imageReference': - return 'img'; + case 'inlineCode': + return 'code'; - case 'inlineCode': - return 'code'; + case 'link': + case 'linkReference': + return 'a'; - case 'link': - case 'linkReference': - return 'a'; + case 'list': + return node.ordered ? 'ol' : 'ul'; - case 'list': - return node.ordered ? 'ol' : 'ul'; + case 'listItem': + return 'li'; - case 'listItem': - return 'li'; + case 'paragraph': + return 'p'; - case 'paragraph': - return 'p'; + case 'root': + return 'div'; - case 'root': - return 'div'; + case 'tableHeader': + return 'thead'; - case 'tableHeader': - return 'thead'; + case 'tableRow': + return 'tr'; - case 'tableRow': - return 'tr'; + case 'tableCell': + return 'td'; - case 'tableCell': - return 'td'; + case 'thematicBreak': + return 'hr'; - case 'definition': - case 'footnoteDefinition': - case 'yaml': - return null; + case 'definition': + case 'footnoteDefinition': + case 'yaml': + return null; - default: - return node.type; + default: + return node.type; + } } -} -function formExtraPropsForHTMLNodeType(props = {}, ast) { - switch (ast.type) { - case 'footnoteReference': - return { - ...props, - href: `#${ast.identifier}`, - }; - - case 'image': - return { - ...props, - title: ast.title, - alt: ast.alt, - src: ast.src, - }; - - case 'imageReference': - return { - ...props, - title: definitions[ast.identifier].title, - alt: ast.alt, - src: definitions[ast.identifier].link, - }; + function formExtraPropsForHTMLNodeType(props = {}, ast) { + switch (ast.type) { + case 'footnoteReference': + return { + ...props, + href: `#${ast.identifier}`, + }; - case 'link': - return { - ...props, - title: ast.title, - href: ast.href, - }; + case 'image': + return { + ...props, + title: ast.title, + alt: ast.alt, + src: ast.url, + }; - case 'linkReference': - return { - ...props, - title: definitions[ast.identifier].title, - href: definitions[ast.identifier].link, - }; + case 'imageReference': + return { + ...props, + title: definitions[ast.identifier].title, + alt: ast.alt, + src: definitions[ast.identifier].url, + }; - case 'list': - return { - ...props, - start: ast.start, - }; + case 'link': + return { + ...props, + title: ast.title, + href: ast.url, + }; - case 'tableCell': - case 'th': - return { - ...props, - style: {textAlign: ast.align}, - }; - } + case 'linkReference': + return { + ...props, + title: definitions[ast.identifier].title, + href: definitions[ast.identifier].url, + }; - return props; -} + case 'list': + return { + ...props, + start: ast.start, + }; -function seekCellsAndAlignThemIfNecessary(root, alignmentValues) { - const mapper = (child, index) => { - if (child.type === 'tableCell') { + case 'tableCell': + case 'th': return { - ...child, - align: alignmentValues[index], + ...props, + style: {textAlign: ast.align}, }; - } else if (Array.isArray(child.children) && child.children.length) { - return child.children.map(mapper); } - return child; - }; - - if (Array.isArray(root.children) && root.children.length) { - root.children = root.children.map(mapper); + return props; } - return root; -} - -function astToJSX(ast, index) { /* `this` is the dictionary of definitions */ - if (textTypes.indexOf(ast.type) !== -1) { - return ast.value; - } + function seekCellsAndAlignThemIfNecessary(root, alignmentValues) { + const mapper = (child, index) => { + if (child.type === 'tableCell') { + return { + ...child, + align: alignmentValues[index], + }; + } else if (Array.isArray(child.children) && child.children.length) { + return child.children.map(mapper); + } - const key = index || '0'; + return child; + }; - if (ast.type === 'code') { - return ( -
-                
-                    {ast.value}
-                
-            
- ); - } /* Refers to fenced blocks, need to create a pre:code nested structure */ + if (Array.isArray(root.children) && root.children.length) { + root.children = root.children.map(mapper); + } - if (ast.type === 'listItem') { - if (ast.checked === true || ast.checked === false) { - return ( -
  • - - {ast.children.map(astToJSX)} -
  • - ); - } /* gfm task list, need to add a checkbox */ + return root; } - if (ast.type === 'html') { - return ( -
    - ); - } /* arbitrary HTML, do the gross thing for now */ + function astToJSX(ast, index) { /* `this` is the dictionary of definitions */ + if (textTypes.indexOf(ast.type) !== -1) { + return ast.value; + } - if (ast.type === 'table') { - const tbody = {type: 'tbody', children: []}; + const key = index || '0'; - ast.children = ast.children.reduce((children, child) => { - if (child.type === 'tableHeader') { - children.unshift( - seekCellsAndAlignThemIfNecessary(child, ast.align) - ); - } else if (child.type === 'tableRow') { - tbody.children.push( - seekCellsAndAlignThemIfNecessary(child, ast.align) - ); - } else if (child.type === 'tableFooter') { - children.push( - seekCellsAndAlignThemIfNecessary(child, ast.align) + if (ast.type === 'code') { + return ( +
    +                    
    +                        {ast.value}
    +                    
    +                
    + ); + } /* Refers to fenced blocks, need to create a pre:code nested structure */ + + if (ast.type === 'listItem') { + if (ast.checked === true || ast.checked === false) { + return ( +
  • + + {ast.children.map(astToJSX)} +
  • ); - } - - return children; - - }, [tbody]); - - } /* React yells if things aren't in the proper structure, so need to - delve into the immediate children and wrap tablerow(s) in a tbody */ - - if (ast.type === 'tableFooter') { - ast.children = [{ - type: 'tr', - children: ast.children - }]; - } /* React yells if things aren't in the proper structure, so need to - delve into the immediate children and wrap the cells in a tablerow */ - - if (ast.type === 'tableHeader') { - ast.children = [{ - type: 'tr', - children: ast.children.map(child => { - if (child.type === 'tableCell') { - child.type = 'th'; - } /* et voila, a proper table header */ + } /* gfm task list, need to add a checkbox */ + } - return child; - }) - }]; - } /* React yells if things aren't in the proper structure, so need to - delve into the immediate children and wrap the cells in a tablerow */ + if (ast.type === 'html') { + return ( +
    + ); + } /* arbitrary HTML, do the gross thing for now */ + + if (ast.type === 'table') { + const tbody = {type: 'tbody', children: []}; + + ast.children = ast.children.reduce((children, child, index) => { + if (index === 0) { + /* manually marking the first row as tableHeader since that was removed in remark@4.x; it's important semantically. */ + child.type = 'tableHeader'; + children.unshift( + seekCellsAndAlignThemIfNecessary(child, ast.align) + ); + } else if (child.type === 'tableRow') { + tbody.children.push( + seekCellsAndAlignThemIfNecessary(child, ast.align) + ); + } else if (child.type === 'tableFooter') { + children.push( + seekCellsAndAlignThemIfNecessary(child, ast.align) + ); + } + + return children; + + }, [tbody]); + + } /* React yells if things aren't in the proper structure, so need to + delve into the immediate children and wrap tablerow(s) in a tbody */ + + if (ast.type === 'tableFooter') { + ast.children = [{ + type: 'tr', + children: ast.children + }]; + } /* React yells if things aren't in the proper structure, so need to + delve into the immediate children and wrap the cells in a tablerow */ + + if (ast.type === 'tableHeader') { + ast.children = [{ + type: 'tr', + children: ast.children.map(child => { + if (child.type === 'tableCell') { + child.type = 'th'; + } /* et voila, a proper table header */ + + return child; + }) + }]; + } /* React yells if things aren't in the proper structure, so need to + delve into the immediate children and wrap the cells in a tablerow */ + + if (ast.type === 'footnoteReference') { + ast.children = [{type: 'sup', value: ast.identifier}]; + } /* place the identifier inside a superscript tag for the link */ + + let htmlNodeType = getHTMLNodeTypeFromASTNodeType(ast); + if (htmlNodeType === null) { + return null; + } /* bail out, not convertable to any HTML representation */ + + let props = {key}; + + const override = overrides[htmlNodeType]; + if (override) { + if (override.component) { + htmlNodeType = override.component; + } /* sub out the normal html tag name for the JSX / ReactFactory + passed in by the caller */ + + if (override.props) { + props = {...override.props, ...props}; + } /* apply the prop overrides beneath the minimal set that are necessary + to have the markdown conversion work as expected */ + } - if (ast.type === 'footnoteReference') { - ast.children = [{type: 'sup', value: ast.identifier}]; - } /* place the identifier inside a superscript tag for the link */ + /* their props + our props, with any duplicate keys overwritten by us + (necessary evil, file an issue if something comes up that needs + extra attention, only props specified in `formExtraPropsForHTMLNodeType` + will be overwritten on a key collision) */ + const finalProps = formExtraPropsForHTMLNodeType(props, ast); - const htmlNodeType = getHTMLNodeTypeFromASTNodeType(ast); + if (ast.children && ast.children.length === 1) { + if (textTypes.indexOf(ast.children[0].type) !== -1) { + ast.children = ast.children[0].value; + } + } /* solitary text children don't need full parsing or React will add a wrapper */ - if (htmlNodeType === null) { - return null; - } /* bail out, not convertable to any HTML representation */ + const children = Array.isArray(ast.children) + ? ast.children.map(astToJSX) + : ast.children; - const props = formExtraPropsForHTMLNodeType({key}, ast); + return React.createElement(htmlNodeType, finalProps, ast.value || children); + } - if (ast.children && ast.children.length === 1) { - if (textTypes.indexOf(ast.children[0].type) !== -1) { - ast.children = ast.children[0].value; - } - } /* solitary text children don't need full parsing or React will add a wrapper */ + function extractDefinitionsFromASTTree(ast) { + const reducer = (aggregator, node) => { + if (node.type === 'definition' || node.type === 'footnoteDefinition') { + aggregator.definitions[node.identifier] = node; + + if (node.type === 'footnoteDefinition') { + if ( node.children + && node.children.length === 1 + && node.children[0].type === 'paragraph') { + node.children[0].children.unshift({ + type: 'textNode', + value: `[${node.identifier}]: `, + }); + } /* package the prefix inside the first child */ + + aggregator.footnotes.push( +
    + {node.value || node.children.map(astToJSX)} +
    + ); + } + } - let children = Array.isArray(ast.children) - ? ast.children.map(astToJSX) - : ast.children; + return Array.isArray(node.children) + ? node.children.reduce(reducer, aggregator) + : aggregator; + }; - return React.createElement(htmlNodeType, props, ast.value || children); -} + return [ast].reduce(reducer, { + definitions: {}, + footnotes: [] + }); + } -function extractDefinitionsFromASTTree(ast) { - const reducer = (aggregator, node) => { - if (node.type === 'definition' || node.type === 'footnoteDefinition') { - aggregator.definitions[node.identifier] = node; - - if (node.type === 'footnoteDefinition') { - if ( node.children - && node.children.length === 1 - && node.children[0].type === 'paragraph') { - node.children[0].children.unshift({ - type: 'textNode', - value: `[${node.identifier}]: `, - }); - } /* package the prefix inside the first child */ - - aggregator.footnotes.push( -
    - {node.value || node.children.map(astToJSX)} -
    - ); - } - } + if (typeof markdown !== 'string') { + throw new Error(`markdown-to-jsx: the first argument must be + a string`); + } - return Array.isArray(node.children) - ? node.children.reduce(reducer, aggregator) - : aggregator; - }; + if (getType.call(options) !== '[object Object]') { + throw new Error(`markdown-to-jsx: the second argument must be + undefined or an object literal ({}) containing + valid remark options`); + } - return [ast].reduce(reducer, { - definitions: {}, - footnotes: [] - }); -} + if (getType.call(overrides) !== '[object Object]') { + throw new Error(`markdown-to-jsx: the third argument must be + undefined or an object literal with shape: + { + htmltagname: { + component: string|ReactComponent(optional), + props: object(optional) + } + }`); + } -export default function markdownToJSX(markdown, remarkOptions = {}) { - let ast; + options.position = options.position || false; + options.footnotes = options.footnotes || true; - remarkOptions.position = remarkOptions.position || false; + let remarkAST; try { - ast = parse(markdown, remarkOptions); + remarkAST = parse(markdown, options); } catch (error) { return error; } - const extracted = extractDefinitionsFromASTTree(ast); + const extracted = extractDefinitionsFromASTTree(remarkAST); definitions = extracted.definitions; footnotes = extracted.footnotes; - const jsx = astToJSX(ast); + const jsx = astToJSX(remarkAST); if (footnotes.length) { jsx.props.children.push( diff --git a/package.json b/package.json index 4cbd534e..f0f7af28 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "markdown-to-jsx", "description": "Interprets markdown text and outputs a JSX equivalent.", "license": "MIT", - "version": "1.2.0", + "version": "2.0.0", "keywords": [ "markdown", "react", @@ -13,18 +13,19 @@ "repository": "yaycmyk/markdown-to-jsx", "bugs": "https://github.com/yaycmyk/markdown-to-jsx/issues", "main": "index.es5.js", + "jsnext:main": "index.js", "devDependencies": { "babel-cli": "^6.3.13", "babel-jest": "^6.0.1", "babel-preset-es2015": "^6.3.13", "babel-preset-react": "^6.3.13", "babel-preset-stage-2": "^6.3.13", - "jest-cli": "^0.8.0", + "jest-cli": "^0.9.0", "react": "^0.14.0", "react-dom": "^0.14.0" }, "dependencies": { - "remark": "^3.0.0" + "remark": "^4.0.0" }, "peerDependencies": { "react": "^0.14.0"