├── example ├── .npmignore ├── index.tsx ├── index.html ├── tsconfig.json ├── package.json └── src │ └── App.tsx ├── .gitignore ├── .travis.yml ├── catalog-info.yaml ├── .github └── workflows │ └── main.yml ├── tsconfig.json ├── CHANGELOG.md ├── LICENSE ├── src ├── index.tsx ├── MarkdownView_images.test.tsx ├── MarkdownView.tsx ├── MarkdownView_innerHTML.test.tsx └── MarkdownView.test.tsx ├── package.json └── README.md /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | coverage 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | coverage 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "12" 4 | 5 | script: 6 | - yarn lint 7 | - yarn test --coverage 8 | 9 | after_script: 10 | - cat ./coverage/lcov.info | node_modules/.bin/coveralls 11 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | 5 | import App from './src/App'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: react-showdown 5 | annotations: 6 | github.com/project-slug: jerolimov/react-showdown 7 | spec: 8 | type: other 9 | lifecycle: unknown 10 | owner: jerolimov 11 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | 27 | - name: Lint 28 | run: yarn lint 29 | 30 | - name: Test 31 | run: yarn test --coverage 32 | 33 | - name: Build 34 | run: yarn build 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictPropertyInitialization": true, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "node", 22 | "baseUrl": "./", 23 | "paths": { 24 | "*": ["src/*", "node_modules/*"] 25 | }, 26 | "jsx": "react", 27 | "esModuleInterop": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.6.0 (2017-04-18) 2 | 3 | * Fix handling of class in embedded html (#11 by @modosc) 4 | 5 | ## 1.5.0 (2017-04-13) 6 | 7 | * Pass options into htmlparser to keep capitalization of tags and attributes (#8 by @modosc) 8 | 9 | ## 1.4.0 (2017-04-13) 10 | 11 | * Fix handling of className prop (#7 by @modosc) 12 | * Strip markdown prop from react elements (#6 by @modosc) 13 | 14 | ## 1.3.0 (2017-04-12) 15 | 16 | * Update react to 15.5 with new create-react-class and prop-types dependency (#5 by @modosc) 17 | * Fix some warnings (#5 by @modosc) 18 | 19 | ## 1.2.0 (2016-11-21) 20 | 21 | * Add table tag support (#4 by @ryohey) 22 | 23 | ## 1.1.0 (2016-11-20) 24 | 25 | * Improve PropTypes because components is not required (#2 by @justin-lau) 26 | * Update showdown to 1.5.0 (#3 by @ryohey) 27 | * Update react to 15.4 28 | 29 | ## 1.0.1 (2016-03-16) 30 | 31 | * Quiets a React warning about how void elements can't have children (#1 by @ryancbarry) 32 | 33 | ## 1.0.0 (2015-12-02) 34 | 35 | * First stable release. 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-2020 Christoph Jerolimov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | // Flavors 3 | setFlavor, 4 | getFlavor, 5 | 6 | // Options 7 | setOption, 8 | getOption, 9 | getOptions, 10 | resetOptions, 11 | 12 | // Extensions 13 | extension, 14 | getAllExtensions, 15 | removeExtension, 16 | resetExtensions, 17 | ShowdownExtension, 18 | } from 'showdown'; 19 | 20 | import MarkdownView from './MarkdownView'; 21 | 22 | export default MarkdownView; 23 | export const Markdown = MarkdownView; 24 | 25 | export { MarkdownViewProps } from './MarkdownView'; 26 | 27 | export { Flavor, ShowdownExtension } from 'showdown'; 28 | 29 | const setExtension: ( 30 | name: string, 31 | ext: 32 | | (() => ShowdownExtension[] | ShowdownExtension) 33 | | ShowdownExtension[] 34 | | ShowdownExtension 35 | ) => void = extension; 36 | const getExtension: (name: string) => ShowdownExtension[] = extension; 37 | 38 | export const GlobalConfiguration = { 39 | // Flavors, 40 | setFlavor, 41 | getFlavor, 42 | 43 | // Options 44 | setOption, 45 | getOption, 46 | getOptions, 47 | resetOptions, 48 | 49 | // Extensions 50 | setExtension, 51 | getExtension, 52 | getAllExtensions, 53 | removeExtension, 54 | resetExtensions, 55 | }; 56 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import MarkdownView from '../..'; 4 | 5 | export default function App() { 6 | const markdown = ` 7 | 10 | # Welcome to React Showdown! 11 | 12 | ![A forest trail in autumn](https://cdn.pixabay.com/photo/2015/12/01/20/28/road-1072823_1280.jpg) 13 | 14 | --- 15 | 16 | 17 | 18 | To get started, edit the markdown in \`example/src/App.tsx\`. 19 | 20 | | Column 1 | Column 2 | 21 | |----------|----------| 22 | | A1 | B1 | 23 | | A2 | B2 | 24 | 25 |

Supports HTML in markdown

26 | 27 |

Headline with class

28 | 29 |

Headline with className

30 | 31 |

Headline with style

32 | 33 | ## Supports Emojis as well :+1: 34 | 35 | 36 | 37 | Hello Ülaute! ;) 38 | 39 | **Hello Ülaute! ;)** 40 | 41 | Hello Ülaute! ;) 42 | 43 | |h1|h2|h3| 44 | |:--|:--:|--:| 45 | |*foo*|**bar**|baz| 46 | `; 47 | 48 | return ( 49 | 54 | ); 55 | }; 56 | 57 | function InlineComponent() { 58 | return ( 59 | Inline rendered Component! 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/MarkdownView_images.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TestRenderer from 'react-test-renderer'; 3 | 4 | import MarkdownView, { MarkdownViewProps, GlobalConfiguration } from './'; 5 | 6 | afterEach(() => { 7 | GlobalConfiguration.resetExtensions(); 8 | GlobalConfiguration.resetOptions(); 9 | }); 10 | 11 | describe('MarkdownView images test', () => { 12 | const renderInnerHTML = (props: MarkdownViewProps) => { 13 | const testRenderer = TestRenderer.create( 14 | 15 | ); 16 | const renderedProps = (testRenderer.toJSON() as TestRenderer.ReactTestRendererJSON) 17 | .props; 18 | return renderedProps.dangerouslySetInnerHTML.__html; 19 | }; 20 | 21 | it('renders HTML correctly', () => { 22 | const markdown = 23 | '![A forest trail in autumn](https://cdn.pixabay.com/photo/2015/12/01/20/28/road-1072823_1280.jpg)'; 24 | const html = renderInnerHTML({ markdown }); 25 | expect(html).toEqual( 26 | '

A forest trail in autumn

' 27 | ); 28 | }); 29 | 30 | it('renders React elements', () => { 31 | const markdown = 32 | '![A forest trail in autumn](https://cdn.pixabay.com/photo/2015/12/01/20/28/road-1072823_1280.jpg)'; 33 | const testRenderer = TestRenderer.create( 34 | 35 | ); 36 | const testInstance = testRenderer.root; 37 | const img = testInstance.findByType('img'); 38 | expect(img.props.alt).toEqual('A forest trail in autumn'); 39 | expect(img.props.src).toEqual( 40 | 'https://cdn.pixabay.com/photo/2015/12/01/20/28/road-1072823_1280.jpg' 41 | ); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-showdown", 3 | "version": "2.3.1", 4 | "description": "Render React components within markdown and markdown as React components!", 5 | "license": "MIT", 6 | "main": "dist/index.js", 7 | "module": "dist/react-showdown.esm.js", 8 | "typings": "dist/index.d.ts", 9 | "files": [ 10 | "src", 11 | "dist" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/jerolimov/react-showdown.git" 16 | }, 17 | "engines": { 18 | "node": ">=10" 19 | }, 20 | "scripts": { 21 | "start": "tsdx watch", 22 | "build": "tsdx build", 23 | "test": "tsdx test", 24 | "lint": "tsdx lint", 25 | "prepare": "tsdx build" 26 | }, 27 | "author": "Christoph Jerolimov", 28 | "bugs": { 29 | "url": "https://github.com/jerolimov/react-showdown/issues" 30 | }, 31 | "homepage": "https://github.com/jerolimov/react-showdown", 32 | "keywords": [ 33 | "react", 34 | "reactjs", 35 | "react-component", 36 | "markdown", 37 | "showdown" 38 | ], 39 | "husky": { 40 | "hooks": { 41 | "pre-commit": "tsdx lint" 42 | } 43 | }, 44 | "prettier": { 45 | "printWidth": 80, 46 | "semi": true, 47 | "singleQuote": true, 48 | "trailingComma": "es5" 49 | }, 50 | "devDependencies": { 51 | "@types/jest": "^25.1.4", 52 | "@types/react": "^16.9.25", 53 | "@types/react-dom": "^16.9.5", 54 | "@types/react-test-renderer": "^16.9.2", 55 | "@types/showdown": "^1.9.3", 56 | "husky": "^4.2.3", 57 | "react": "^16.13.1", 58 | "react-dom": "^16.13.1", 59 | "react-test-renderer": "^16.9.25", 60 | "tsdx": "^0.13.0", 61 | "tslib": "^1.11.1", 62 | "typescript": "^3.8.3" 63 | }, 64 | "peerDependencies": { 65 | "react": ">=16" 66 | }, 67 | "dependencies": { 68 | "htmlparser2": "^6.0.1", 69 | "domhandler": "^4.0.0", 70 | "showdown": "^1.9.1" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-showdown [![Build status][travis-image]][travis-url] [![Test coverage][coveralls-image]][coveralls-url] [![Dependency Status][dependency-image]][dependency-url] 2 | 3 | > Render [React](http://facebook.github.io/react/index.html) 4 | > [components](http://facebook.github.io/react/docs/component-specs.html) 5 | > within markdown and markdown as React components! 6 | 7 | ## Features 8 | 9 | * **Render markdown as React components.** 10 | * **Render React components within the markdown!** 11 | * Full TypeScript Support. 12 | * Fully tested. 13 | * Supports all [Showdown extensions](https://github.com/showdownjs/showdown/wiki/extensions), like the 14 | [Twitter Extension](https://github.com/showdownjs/twitter-extension) and the 15 | [Youtube Extension](https://github.com/showdownjs/youtube-extension). 16 | * New in 2.0: Supports Showdown Flavors! 17 | * New in 2.1: 18 | * Fixes [#54](https://github.com/jerolimov/react-showdown/issues/54): Missing content after a self-closing component. This was fixed by setting the default value of showdown config `recognizeSelfClosing` to `true`. Thanks [@n1ru4l](https://github.com/n1ru4l) 19 | * New feature: add new optional `sanitizeHtml` prop for sanitizing html before it was rendered. Thanks [@n1ru4l](https://github.com/n1ru4l) aswell. 20 | 21 | ## Installation 22 | 23 | ```bash 24 | npm install --save react-showdown 25 | ``` 26 | 27 | or 28 | 29 | ```bash 30 | yarn add react-showdown 31 | ``` 32 | 33 | ## Use as React component 34 | 35 | Example with ES6/JSX: 36 | 37 | ```jsx 38 | import React from 'react'; 39 | import MarkdownView from 'react-showdown'; 40 | 41 | export default function App() { 42 | const markdown = ` 43 | # Welcome to React Showdown :+1: 44 | 45 | To get started, edit the markdown in \`example/src/App.tsx\`. 46 | 47 | | Column 1 | Column 2 | 48 | |----------|----------| 49 | | A1 | B1 | 50 | | A2 | B2 | 51 | `; 52 | 53 | return ( 54 | 58 | ); 59 | }; 60 | ``` 61 | 62 | Use a React component and use it within the markdown with ES6 / TypeScript: 63 | 64 | ```jsx 65 | import MarkdownView from 'react-showdown'; 66 | 67 | function CustomComponent({ name }: { name: string }) { 68 | return Hello {name}!; 69 | } 70 | 71 | const markdown = ` 72 | # A custom component: 73 | 74 | `; 75 | 76 | 77 | ``` 78 | 79 | ## Available props 80 | 81 | * markdown, string, required 82 | * flavor, Flavor, optional 83 | * options, ConverterOptions, optional 84 | * extensions, showdown extensions, optional 85 | * components, components, optional 86 | 87 | Converter options will be pushed forward to the showdown converter, please 88 | checkout the [valid options section](https://github.com/showdownjs/showdown#valid-options). 89 | 90 | ## Credits 91 | 92 | Project is based on the markdown parser [Showdown](https://github.com/showdownjs/showdown) and 93 | the html parser [htmlparser2](https://github.com/fb55/htmlparser2/). 94 | 95 | ## Alternatives 96 | 97 | * [reactdown](https://github.com/andreypopp/reactdown) 98 | * [react-markdown](https://github.com/rexxars/react-markdown), based on 99 | [commonmark.js](https://github.com/jgm/commonmark.js) 100 | * [commonmark-react-renderer](https://github.com/rexxars/commonmark-react-renderer), based on 101 | [commonmark.js](https://github.com/jgm/commonmark.js) 102 | 103 | [travis-image]: https://img.shields.io/travis/jerolimov/react-showdown/master.svg?style=flat-square 104 | [travis-url]: https://travis-ci.org/jerolimov/react-showdown 105 | [coveralls-image]: https://img.shields.io/coveralls/jerolimov/react-showdown/master.svg?style=flat-square 106 | [coveralls-url]: https://coveralls.io/r/jerolimov/react-showdown 107 | [dependency-image]: http://img.shields.io/david/jerolimov/react-showdown.svg?style=flat-square 108 | [dependency-url]: https://david-dm.org/jerolimov/react-showdown 109 | -------------------------------------------------------------------------------- /src/MarkdownView.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createElement, 3 | useMemo, 4 | ClassType, 5 | FunctionComponent, 6 | ReactNode, 7 | ReactElement, 8 | HTMLAttributes, 9 | } from 'react'; 10 | import { 11 | Converter, 12 | ConverterOptions, 13 | Flavor, 14 | ShowdownExtension, 15 | } from 'showdown'; 16 | import * as htmlparser from 'htmlparser2'; 17 | import { Node, Element, DataNode } from 'domhandler'; 18 | 19 | export interface MarkdownViewProps 20 | extends Omit, 'dangerouslySetInnerHTML'> { 21 | dangerouslySetInnerHTML?: boolean; 22 | flavor?: Flavor; 23 | markdown: string; 24 | sanitizeHtml?: (html: string) => string; 25 | markup?: string; 26 | options?: ConverterOptions; 27 | extensions?: ShowdownExtension[]; 28 | components?: Record< 29 | string, 30 | ClassType | FunctionComponent 31 | >; 32 | } 33 | 34 | export default function MarkdownView(props: MarkdownViewProps): ReactElement { 35 | const { 36 | dangerouslySetInnerHTML, 37 | flavor, 38 | markdown, 39 | markup, 40 | options, 41 | extensions, 42 | components, 43 | sanitizeHtml, 44 | ...otherProps 45 | } = props; 46 | 47 | const mapElement = useMemo( 48 | () => 49 | function mapElement(node: Node, index: number): ReactNode { 50 | if (node.type === 'tag' && node instanceof Element) { 51 | const elementType = components?.[node.name] || node.name; 52 | const props: Record = { key: index, ...node.attribs }; 53 | 54 | // Rename class to className to hide react warning 55 | if (props.class && !props.className) { 56 | props.className = props.class; 57 | delete props.class; 58 | } 59 | 60 | // Map style strings to style objects 61 | if (typeof props.style === 'string') { 62 | const styles: Record = {}; 63 | props.style.split(';').forEach(style => { 64 | if (style.indexOf(':') !== -1) { 65 | let [key, value] = style.split(':'); 66 | key = key 67 | .trim() 68 | .replace(/-([a-z])/g, match => match[1].toUpperCase()); 69 | value = value.trim(); 70 | styles[key] = value; 71 | } 72 | }); 73 | props.style = styles; 74 | } 75 | 76 | const children = skipAnyChildrenFor.includes(node.name) 77 | ? null 78 | : skipWhitespaceElementsFor.includes(node.name) 79 | ? node.children.filter(filterWhitespaceElements).map(mapElement) 80 | : node.children.map(mapElement); 81 | return createElement(elementType, props, children); 82 | } else if (node.type === 'text' && node instanceof DataNode) { 83 | return node.data; 84 | } else if (node.type === 'comment') { 85 | return null; // noop 86 | } else if (node.type === 'style' && node instanceof Element) { 87 | const props: Record = { key: index, ...node.attribs }; 88 | const children = node.children.map(mapElement); 89 | return createElement('style', props, children); 90 | } else { 91 | console.warn( 92 | `Warning: Could not map element with type "${node.type}".`, 93 | node 94 | ); 95 | return null; 96 | } 97 | }, 98 | [components] 99 | ); 100 | 101 | if (dangerouslySetInnerHTML && components) { 102 | console.warn( 103 | 'MarkdownView could not render custom components when dangerouslySetInnerHTML is enabled.' 104 | ); 105 | } 106 | 107 | const converter = new Converter(); 108 | if (flavor) { 109 | converter.setFlavor(flavor); 110 | } 111 | if (options) { 112 | for (const key in options) { 113 | if (key === 'extensions' && options.extensions) { 114 | for (const extension of options.extensions) { 115 | if (typeof extension === 'string') { 116 | converter.useExtension(extension); 117 | } else { 118 | converter.addExtension(extension); 119 | } 120 | } 121 | } 122 | converter.setOption(key, options[key]); 123 | } 124 | } 125 | if (extensions) { 126 | converter.addExtension(extensions); 127 | } 128 | 129 | let html = converter.makeHtml(markdown ?? markup); 130 | if (sanitizeHtml) { 131 | html = sanitizeHtml(html); 132 | } 133 | 134 | if (dangerouslySetInnerHTML) { 135 | return
; 136 | } 137 | 138 | const root = htmlparser.parseDOM(html, { 139 | // Don't change the case of parsed html tags to match inline components. 140 | lowerCaseTags: false, 141 | // Don't change the attribute names so that stuff like `className` works correctly. 142 | lowerCaseAttributeNames: false, 143 | // Encode entities automatically, so that © and ü works correctly. 144 | decodeEntities: true, 145 | // Fix issue with content after a self closing tag. 146 | recognizeSelfClosing: true, 147 | }); 148 | 149 | return createElement('div', otherProps, root.map(mapElement)); 150 | } 151 | 152 | // Match react-dom omittedCloseTags. See also: 153 | // https://github.com/facebook/react/blob/master/packages/react-dom/src/shared/omittedCloseTags.js 154 | const skipAnyChildrenFor = [ 155 | 'area', 156 | 'br', 157 | 'col', 158 | 'embed', 159 | 'hr', 160 | 'img', 161 | 'input', 162 | 'keygen', 163 | 'param', 164 | 'source', 165 | 'track', 166 | 'wbr', 167 | ]; 168 | 169 | const skipWhitespaceElementsFor = ['table', 'thead', 'tbody', 'tr']; 170 | 171 | function filterWhitespaceElements(node: Node) { 172 | if (node.type === 'text' && node instanceof DataNode) { 173 | return node.data.trim().length > 0; 174 | } else { 175 | return true; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/MarkdownView_innerHTML.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TestRenderer from 'react-test-renderer'; 3 | 4 | import MarkdownView, { 5 | MarkdownViewProps, 6 | GlobalConfiguration, 7 | ShowdownExtension, 8 | } from './'; 9 | 10 | afterEach(() => { 11 | GlobalConfiguration.resetExtensions(); 12 | GlobalConfiguration.resetOptions(); 13 | }); 14 | 15 | describe('MarkdownView', () => { 16 | const renderInnerHTML = (props: MarkdownViewProps) => { 17 | const testRenderer = TestRenderer.create( 18 | 19 | ); 20 | const html = (testRenderer.toJSON()! as TestRenderer.ReactTestRendererJSON) 21 | .props.dangerouslySetInnerHTML.__html; 22 | return html; 23 | }; 24 | 25 | it('render markdown', () => { 26 | const markdown = '# Headline level 1!\n\nAnother paragraph.\n\n'; 27 | const html = renderInnerHTML({ markdown }); 28 | expect(html).toMatch('

Headline level 1!

'); 29 | expect(html).toMatch('

Another paragraph.

'); 30 | }); 31 | 32 | it('render markdown table', () => { 33 | const markdown = ` 34 | | Column 1 | Column 2 | 35 | |----------|----------| 36 | | A1 | B1 | 37 | | A2 | B2 | 38 | `; 39 | const html = renderInnerHTML({ markdown, options: { tables: true } }); 40 | expect(html).toMatch('Column 1'); 41 | expect(html).toMatch('Column 2'); 42 | expect(html).toMatch('A1'); 43 | expect(html).toMatch('B1'); 44 | expect(html).toMatch('A2'); 45 | expect(html).toMatch('B2'); 46 | }); 47 | 48 | it('render markdown table with flavor github', () => { 49 | const markdown = ` 50 | | Column 1 | Column 2 | 51 | |----------|----------| 52 | | A1 | B1 | 53 | | A2 | B2 | 54 | `; 55 | const html = renderInnerHTML({ flavor: 'github', markdown }); 56 | expect(html).toMatch('Column 1'); 57 | expect(html).toMatch('Column 2'); 58 | expect(html).toMatch('A1'); 59 | expect(html).toMatch('B1'); 60 | expect(html).toMatch('A2'); 61 | expect(html).toMatch('B2'); 62 | }); 63 | 64 | it('render markdown table with flavor original', () => { 65 | const markdown = ` 66 | | Column 1 | Column 2 | 67 | |----------|----------| 68 | | A1 | B1 | 69 | | A2 | B2 | 70 | `; 71 | const html = renderInnerHTML({ flavor: 'original', markdown }); 72 | expect(html).toMatch('| Column 1 | Column 2 |'); 73 | expect(html).toMatch('|----------|----------|'); 74 | expect(html).toMatch('| A1 | B1 |'); 75 | expect(html).toMatch('| A2 | B2 |'); 76 | }); 77 | 78 | it('render html as it was parsed', () => { 79 | const markdown = '

Title

'; 80 | const html = renderInnerHTML({ markdown }); 81 | expect(html).toMatch('

Title

'); 82 | }); 83 | 84 | it('render html class name', () => { 85 | const markdown = '

A red title

'; 86 | const html = renderInnerHTML({ markdown }); 87 | expect(html).toMatch('

A red title

'); 88 | }); 89 | 90 | it('render html style', () => { 91 | const markdown = '

A red title

'; 92 | const html = renderInnerHTML({ markdown }); 93 | expect(html).toMatch('

A red title

'); 94 | }); 95 | 96 | it('render text emojies', () => { 97 | const markdown = ':showdown: :+1:'; 98 | const html = renderInnerHTML({ markdown, options: { emoji: true } }); 99 | expect(html).not.toMatch(':showdown:'); 100 | expect(html).not.toMatch(':+1:'); 101 | expect(html).toMatch(/S<\/span>/); 102 | expect(html).toMatch('👍'); 103 | }); 104 | 105 | it('render custom output extension (via global name)', () => { 106 | // Register global extension 107 | GlobalConfiguration.setExtension('big', { 108 | type: 'output', 109 | regex: new RegExp(``, 'g'), 110 | replace: `

`, 111 | }); 112 | 113 | const markdown = '# Example headline'; 114 | const html = renderInnerHTML({ 115 | markdown, 116 | options: { extensions: ['big'] }, 117 | }); 118 | expect(html).toMatch( 119 | '

Example headline

' 120 | ); 121 | }); 122 | 123 | it('render custom output extension (via options)', () => { 124 | const extension: ShowdownExtension = { 125 | type: 'output', 126 | regex: new RegExp(``, 'g'), 127 | replace: `

`, 128 | }; 129 | 130 | const markdown = '# Example headline'; 131 | const html = renderInnerHTML({ 132 | markdown, 133 | options: { extensions: [extension] }, 134 | }); 135 | expect(html).toMatch( 136 | '

Example headline

' 137 | ); 138 | }); 139 | 140 | it('render custom output extension (via prop)', () => { 141 | const extension: ShowdownExtension = { 142 | type: 'output', 143 | regex: new RegExp(``, 'g'), 144 | replace: `

`, 145 | }; 146 | 147 | const markdown = '# Example headline'; 148 | const html = renderInnerHTML({ markdown, extensions: [extension] }); 149 | expect(html).toMatch( 150 | '

Example headline

' 151 | ); 152 | }); 153 | 154 | it('render custom language extension (via global name)', () => { 155 | // Register global extension 156 | GlobalConfiguration.setExtension('autocorrect', { 157 | type: 'lang', 158 | regex: /Markdown/, 159 | replace: 'Showdown', 160 | }); 161 | 162 | const markdown = 'Markdown ftw!'; 163 | const html = renderInnerHTML({ 164 | markdown, 165 | options: { extensions: ['autocorrect'] }, 166 | }); 167 | expect(html).toMatch('Showdown ftw!'); 168 | }); 169 | 170 | it('render custom language extension (via options)', () => { 171 | const extension: ShowdownExtension = { 172 | type: 'lang', 173 | regex: /Markdown/, 174 | replace: 'Showdown', 175 | }; 176 | 177 | const markdown = 'Markdown ftw!'; 178 | const html = renderInnerHTML({ 179 | markdown, 180 | options: { extensions: [extension] }, 181 | }); 182 | expect(html).toMatch('Showdown ftw!'); 183 | }); 184 | 185 | it('render custom language extension (via prop)', () => { 186 | const extension: ShowdownExtension = { 187 | type: 'lang', 188 | regex: /Markdown/, 189 | replace: 'Showdown', 190 | }; 191 | 192 | const markdown = 'Markdown ftw!'; 193 | const html = renderInnerHTML({ markdown, extensions: [extension] }); 194 | expect(html).toMatch('Showdown ftw!'); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /src/MarkdownView.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TestRenderer from 'react-test-renderer'; 3 | 4 | import MarkdownView, { GlobalConfiguration } from './'; 5 | 6 | afterEach(() => { 7 | GlobalConfiguration.resetExtensions(); 8 | GlobalConfiguration.resetOptions(); 9 | }); 10 | 11 | describe('MarkdownView', () => { 12 | it('render markdown with React elements', () => { 13 | const markdown = '# Title!\n\nA paragraph.\n\n'; 14 | const testRenderer = TestRenderer.create( 15 | 16 | ); 17 | const testInstance = testRenderer.root; 18 | expect(testInstance.findByType('h1').props.id).toEqual('title'); 19 | expect(testInstance.findByType('h1').children).toEqual(['Title!']); 20 | expect(testInstance.findByType('p').children).toEqual(['A paragraph.']); 21 | }); 22 | 23 | it('render markdown table', () => { 24 | const markdown = ` 25 | | Column 1 | Column 2 | 26 | |----------|----------| 27 | | A1 | B1 | 28 | | A2 | B2 | 29 | `; 30 | const testRenderer = TestRenderer.create( 31 | 32 | ); 33 | const testInstance = testRenderer.root; 34 | expect(testInstance.findAllByType('table')).toHaveLength(1); 35 | expect(testInstance.findAllByType('tr')).toHaveLength(3); 36 | expect(testInstance.findAllByType('th')).toHaveLength(2); 37 | expect(testInstance.findAllByType('td')).toHaveLength(4); 38 | expect(testInstance.findAllByType('th').map(th => th.children)).toEqual([ 39 | ['Column 1'], 40 | ['Column 2'], 41 | ]); 42 | expect(testInstance.findAllByType('td').map(th => th.children)).toEqual([ 43 | ['A1'], 44 | ['B1'], 45 | ['A2'], 46 | ['B2'], 47 | ]); 48 | }); 49 | 50 | it('render markdown table with flavor github', () => { 51 | const markdown = ` 52 | | Column 1 | Column 2 | 53 | |----------|----------| 54 | | A1 | B1 | 55 | | A2 | B2 | 56 | `; 57 | const testRenderer = TestRenderer.create( 58 | 59 | ); 60 | const testInstance = testRenderer.root; 61 | expect(testInstance.findAllByType('table')).toHaveLength(1); 62 | expect(testInstance.findAllByType('tr')).toHaveLength(3); 63 | expect(testInstance.findAllByType('th')).toHaveLength(2); 64 | expect(testInstance.findAllByType('td')).toHaveLength(4); 65 | expect(testInstance.findAllByType('th').map(th => th.children)).toEqual([ 66 | ['Column 1'], 67 | ['Column 2'], 68 | ]); 69 | expect(testInstance.findAllByType('td').map(th => th.children)).toEqual([ 70 | ['A1'], 71 | ['B1'], 72 | ['A2'], 73 | ['B2'], 74 | ]); 75 | }); 76 | 77 | it('render markdown table with flavor original', () => { 78 | const markdown = ` 79 | | Column 1 | Column 2 | 80 | |----------|----------| 81 | | A1 | B1 | 82 | | A2 | B2 | 83 | `; 84 | const testRenderer = TestRenderer.create( 85 | 86 | ); 87 | const testInstance = testRenderer.root; 88 | expect(testInstance.findAllByType('table')).toHaveLength(0); 89 | expect(testInstance.findAllByType('p')).toHaveLength(1); 90 | expect(testInstance.findByType('p').children).toEqual([markdown.trim()]); 91 | }); 92 | 93 | it('render html as it was parsed', () => { 94 | const markdown = '

Title

'; 95 | const testRenderer = TestRenderer.create( 96 | 97 | ); 98 | const testInstance = testRenderer.root; 99 | expect(testInstance.findByType('h1').children).toEqual(['Title']); 100 | }); 101 | 102 | it('render html class name', () => { 103 | const markdown = '

A red title

'; 104 | const testRenderer = TestRenderer.create( 105 | 106 | ); 107 | const testInstance = testRenderer.root; 108 | expect(testInstance.findByType('h1').props).toEqual({ 109 | className: 'red', 110 | children: ['A red title'], 111 | }); 112 | }); 113 | 114 | it('render html className name', () => { 115 | const markdown = '

A red title

'; 116 | const testRenderer = TestRenderer.create( 117 | 118 | ); 119 | const testInstance = testRenderer.root; 120 | expect(testInstance.findByType('h1').props).toEqual({ 121 | className: 'red', 122 | children: ['A red title'], 123 | }); 124 | }); 125 | 126 | it('render html style', () => { 127 | const markdown = '

A red title

'; 128 | const testRenderer = TestRenderer.create( 129 | 130 | ); 131 | const testInstance = testRenderer.root; 132 | expect(testInstance.findByType('h1').props).toEqual({ 133 | style: { color: 'red' }, 134 | children: ['A red title'], 135 | }); 136 | }); 137 | 138 | it('render text emojies', () => { 139 | const markdown = ':showdown: :+1:'; 140 | const testRenderer = TestRenderer.create( 141 | 142 | ); 143 | const testInstance = testRenderer.root; 144 | // TODO: expect(testInstance.findAllByProps({ children: 'S' })).toHaveLength(1); 145 | expect(testInstance.findByType('p').children).toContain(' 👍'); 146 | }); 147 | 148 | /* 149 | it('render custom output extension (via global name)', () => { 150 | // Register global extension 151 | GlobalConfiguration.setExtension('big', { 152 | type: 'output', 153 | regex: new RegExp(``, 'g'), 154 | replace: `

`, 155 | }); 156 | 157 | const markdown = '# Example headline'; 158 | const html = renderHTML({ markdown, options: { extensions: ['big'] } }); 159 | expect(html).toMatch( 160 | '

Example headline

' 161 | ); 162 | }); 163 | 164 | it('render custom output extension (via options)', () => { 165 | const extension: ShowdownExtension = { 166 | type: 'output', 167 | regex: new RegExp(``, 'g'), 168 | replace: `

`, 169 | }; 170 | 171 | const markdown = '# Example headline'; 172 | const html = renderHTML({ markdown, options: { extensions: [extension] } }); 173 | expect(html).toMatch( 174 | '

Example headline

' 175 | ); 176 | }); 177 | 178 | it('render custom output extension (via prop)', () => { 179 | const extension: ShowdownExtension = { 180 | type: 'output', 181 | regex: new RegExp(``, 'g'), 182 | replace: `

`, 183 | }; 184 | 185 | const markdown = '# Example headline'; 186 | const html = renderHTML({ markdown, extensions: [extension] }); 187 | expect(html).toMatch( 188 | '

Example headline

' 189 | ); 190 | }); 191 | 192 | it('render custom language extension (via global name)', () => { 193 | // Register global extension 194 | GlobalConfiguration.setExtension('autocorrect', { 195 | type: 'lang', 196 | regex: /Markdown/, 197 | replace: 'Showdown', 198 | }); 199 | 200 | const markdown = 'Markdown ftw!'; 201 | const html = renderHTML({ 202 | markdown, 203 | options: { extensions: ['autocorrect'] }, 204 | }); 205 | expect(html).toMatch('Showdown ftw!'); 206 | }); 207 | 208 | it('render custom language extension (via options)', () => { 209 | const extension: ShowdownExtension = { 210 | type: 'lang', 211 | regex: /Markdown/, 212 | replace: 'Showdown', 213 | }; 214 | 215 | const markdown = 'Markdown ftw!'; 216 | const html = renderHTML({ markdown, options: { extensions: [extension] } }); 217 | expect(html).toMatch('Showdown ftw!'); 218 | }); 219 | 220 | it('render custom language extension (via prop)', () => { 221 | const extension: ShowdownExtension = { 222 | type: 'lang', 223 | regex: /Markdown/, 224 | replace: 'Showdown', 225 | }; 226 | 227 | const markdown = 'Markdown ftw!'; 228 | const html = renderHTML({ markdown, extensions: [extension] }); 229 | expect(html).toMatch('Showdown ftw!'); 230 | }); 231 | */ 232 | 233 | it('render custom component without prop', () => { 234 | function CustomComponent() { 235 | return Hello world!; 236 | } 237 | const markdown = ` 238 | # Title 239 | 240 | 241 | `; 242 | const testRenderer = TestRenderer.create( 243 | 244 | ); 245 | const testInstance = testRenderer.root; 246 | expect(testInstance.findByType('h1').children).toEqual(['Title']); 247 | expect(testInstance.findByType('span').children).toEqual(['Hello world!']); 248 | }); 249 | 250 | it('render custom component', () => { 251 | function CustomComponent({ name }: { name: string }) { 252 | return Hello {name}!; 253 | } 254 | const markdown = ` 255 | # Title 256 | 257 | 258 | `; 259 | const testRenderer = TestRenderer.create( 260 | 261 | ); 262 | const testInstance = testRenderer.root; 263 | expect(testInstance.findByType('h1').children).toEqual(['Title']); 264 | expect(testInstance.findByType('span').children).toEqual([ 265 | 'Hello ', 266 | 'world', 267 | '!', 268 | ]); 269 | }); 270 | 271 | it('does render multiple components on the same line', () => { 272 | function CustomComponent({ name }: { name: string }) { 273 | return Hello {name}!; 274 | } 275 | const markdown = ` More content`; 276 | const testRenderer = TestRenderer.create( 277 | 278 | ); 279 | const testInstance = testRenderer.root; 280 | expect(testInstance.findAllByType('span')[0].children).toEqual([ 281 | 'Hello ', 282 | 'world', 283 | '!', 284 | ]); 285 | expect(testInstance.findAllByType('span')[1].children).toEqual([ 286 | 'More content', 287 | ]); 288 | }); 289 | }); 290 | --------------------------------------------------------------------------------