├── 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 | 
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 | '';
24 | const html = renderInnerHTML({ markdown });
25 | expect(html).toEqual(
26 | '
'
27 | );
28 | });
29 |
30 | it('renders React elements', () => {
31 | const markdown =
32 | '';
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 |
--------------------------------------------------------------------------------