├── .github └── workflows │ ├── gh-page.yml │ └── main.yml ├── .gitignore ├── .husky └── pre-commit ├── .storybook └── main.ts ├── LICENSE ├── package-lock.json ├── package.json ├── readme.md ├── src └── index.ts ├── stories ├── remark-component.stories.tsx ├── remark-hook-async.stories.tsx └── remark-hook-sync.stories.tsx ├── test ├── __snapshots__ │ ├── remark-component.test.tsx.snap │ └── remark-hook.test.ts.snap ├── remark-component.test.tsx └── remark-hook.test.ts └── tsconfig.json /.github/workflows/gh-page.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build-and-deploy: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | persist-credentials: false 16 | 17 | - name: Use Node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 16 21 | 22 | - name: Use cached node_modules 23 | uses: actions/cache@v3 24 | with: 25 | path: node_modules 26 | key: nodeModules-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | nodeModules- 29 | 30 | - name: Install dependencies 31 | run: npm ci 32 | env: 33 | CI: true 34 | 35 | - name: Build storybook 36 | run: npm run build-storybook 37 | 38 | - name: Deploy to GitHub pages 39 | uses: JamesIves/github-pages-deploy-action@releases/v3 40 | with: 41 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} 42 | BRANCH: gh-pages 43 | FOLDER: storybook-static 44 | CLEAN: true 45 | SINGLE_COMMIT: true 46 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | platform: [ubuntu-latest, windows-latest, macos-latest] 14 | node-version: [12, 14, 16] 15 | 16 | name: '${{ matrix.platform }}: node.js ${{ matrix.node-version }}' 17 | 18 | runs-on: ${{ matrix.platform }} 19 | 20 | steps: 21 | - name: Begin CI... 22 | uses: actions/checkout@v3 23 | 24 | - name: Use Node 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: Use cached node_modules 30 | uses: actions/cache@v3 31 | with: 32 | path: node_modules 33 | key: nodeModules-${{ hashFiles('**/package-lock.json') }} 34 | restore-keys: | 35 | nodeModules- 36 | 37 | - name: Install dependencies 38 | run: npm ci 39 | env: 40 | CI: true 41 | 42 | - name: Lint 43 | if: ${{ matrix.platform != 'windows-latest' }} 44 | run: npm run lint 45 | env: 46 | CI: true 47 | 48 | - name: Test 49 | run: npm test -- --ci --coverage --maxWorkers=2 50 | env: 51 | CI: true 52 | 53 | - name: Build 54 | run: npm run build 55 | env: 56 | CI: true 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist/ 6 | storybook-static/ 7 | coverage/ 8 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../stories/**/*"], 3 | addons: ['@storybook/addon-essentials'], 4 | }; 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Christian Murphy 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. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.1.0", 3 | "name": "react-remark", 4 | "description": "Renders Markdown as React components", 5 | "author": "Christian Murphy ", 6 | "license": "MIT", 7 | "repository": "remarkjs/react-remark", 8 | "bugs": "https://github.com/remarkjs/react-remark/issues", 9 | "funding": { 10 | "type": "opencollective", 11 | "url": "https://opencollective.com/unified" 12 | }, 13 | "main": "dist/index.js", 14 | "module": "dist/react-remark.esm.js", 15 | "typings": "dist/index.d.ts", 16 | "sideEffects": false, 17 | "files": [ 18 | "dist", 19 | "src" 20 | ], 21 | "engines": { 22 | "node": ">=10" 23 | }, 24 | "scripts": { 25 | "start": "tsdx watch", 26 | "build": "tsdx build", 27 | "test": "tsdx test", 28 | "lint": "tsdx lint", 29 | "prepare": "tsdx build", 30 | "postinstall": "husky install", 31 | "prepublishOnly": "pinst --disable", 32 | "postpublish": "pinst --enable", 33 | "storybook": "start-storybook -p 6006", 34 | "build-storybook": "build-storybook" 35 | }, 36 | "peerDependencies": { 37 | "react": ">=16.8" 38 | }, 39 | "dependencies": { 40 | "rehype-react": "^6.0.0", 41 | "remark-parse": "^9.0.0", 42 | "remark-rehype": "^8.0.0", 43 | "unified": "^9.0.0" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.0.0", 47 | "@storybook/addon-essentials": "^6.0.0", 48 | "@storybook/react": "^6.0.0", 49 | "@testing-library/jest-dom": "^5.0.0", 50 | "@testing-library/react": "^12.0.0", 51 | "@testing-library/react-hooks": "^7.0.0", 52 | "@types/react": "^17.0.0", 53 | "@types/react-dom": "^17.0.0", 54 | "husky": "^7.0.0", 55 | "katex": "^0.13.0", 56 | "pinst": "^2.0.0", 57 | "react": "^17.0.0", 58 | "react-dom": "^17.0.0", 59 | "react-test-renderer": "^17.0.0", 60 | "rehype-katex": "^5.0.0", 61 | "rehype-raw": "^5.0.0", 62 | "rehype-sanitize": "^4.0.0", 63 | "remark-gfm": "^1.0.0", 64 | "remark-math": "^4.0.0", 65 | "tsdx": "^0.14.0", 66 | "typescript": "^3.0.0" 67 | }, 68 | "prettier": { 69 | "printWidth": 80, 70 | "semi": true, 71 | "singleQuote": true, 72 | "trailingComma": "es5" 73 | }, 74 | "renovate": { 75 | "extends": [ 76 | "config:base", 77 | ":preserveSemverRanges" 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # react-remark 2 | 3 | [![CI](https://github.com/remarkjs/react-remark/workflows/CI/badge.svg?branch=main)](https://github.com/remarkjs/react-remark/actions?query=workflow%3ACI) 4 | [![Downloads](https://img.shields.io/npm/dm/react-remark.svg)](https://www.npmjs.com/package/react-remark) 5 | [![Size](https://img.shields.io/bundlephobia/minzip/react-remark.svg)](https://bundlephobia.com/result?p=react-remark) 6 | 7 | **react-remark** offers a [React hook](https://reactjs.org/docs/hooks-intro.html) and [React component](https://reactjs.org/docs/glossary.html#components) based way of rendering [markdown](https://commonmark.org/) into [React](https://reactjs.org) using [remark](https://github.com/remarkjs/remark) 8 | 9 | ## Installation 10 | 11 | _npm_ 12 | 13 | ``` 14 | npm install --save react-remark 15 | ``` 16 | 17 | _yarn_ 18 | 19 | ``` 20 | yarn add react-remark 21 | ``` 22 | 23 | ## Usage 24 | 25 | ### As a hook 26 | 27 | #### Render static content 28 | 29 | ```tsx 30 | import React, { useEffect } from 'react'; 31 | import { useRemark } from 'react-remark'; 32 | 33 | const ExampleComponent = () => { 34 | const [reactContent, setMarkdownSource] = useRemark(); 35 | 36 | useEffect(() => { 37 | setMarkdownSource('# markdown header'); 38 | }, []); 39 | 40 | return reactContent; 41 | }; 42 | 43 | export default ExampleComponent; 44 | ``` 45 | 46 | #### Using input and events to update 47 | 48 | ```tsx 49 | import React from 'react'; 50 | import { useRemark } from 'react-remark'; 51 | 52 | const ExampleComponent = () => { 53 | const [reactContent, setMarkdownSource] = useRemark(); 54 | 55 | return ( 56 | <> 57 | setMarkdownSource(currentTarget.value)} 60 | /> 61 | {reactContent} 62 | 63 | ); 64 | }; 65 | 66 | export default ExampleComponent; 67 | ``` 68 | 69 | ### Server side rendering 70 | 71 | ```tsx 72 | import React from 'react'; 73 | import { useRemarkSync } from 'react-remark'; 74 | 75 | const ExampleComponent = () => { 76 | const reactContent = useRemarkSync('# markdown header'); 77 | 78 | return reactContent; 79 | }; 80 | 81 | export default ExampleComponent; 82 | ``` 83 | 84 | :notebook: Note that some remark plugins are async, these plugins will error if used with `useRemarkSync`. 85 | 86 | [More examples of usage as hook in storybook.](https://remarkjs.github.io/react-remark/?path=/story/remark-hook) 87 | 88 | ### As a component 89 | 90 | #### Render static content 91 | 92 | ```tsx 93 | import React, { useState } from 'react'; 94 | import { Remark } from 'react-remark'; 95 | 96 | const ExampleComponent = () => ( 97 | {` 98 | # header 99 | 100 | 1. ordered 101 | 2. list 102 | `} 103 | ); 104 | 105 | export default ExampleComponent; 106 | ``` 107 | 108 | #### Using input and events to update 109 | 110 | ```tsx 111 | import React, { useState } from 'react'; 112 | import { Remark } from 'react-remark'; 113 | 114 | const ExampleComponent = () => { 115 | const [markdownSource, setMarkdownSource] = useState(''); 116 | 117 | return ( 118 | <> 119 | setMarkdownSource(currentTarget.value)} 122 | /> 123 | {markdownSource} 124 | 125 | ); 126 | }; 127 | 128 | export default ExampleComponent; 129 | ``` 130 | 131 | [More examples of usage as component in storybook.](https://remarkjs.github.io/react-remark/?path=/story/remark-component) 132 | 133 | ## Examples 134 | 135 | A set of runnable examples are provided through storybook at . 136 | The source for the story files can be found in [_/stories_](./stories). 137 | 138 | ## Architecture 139 | 140 | ``` 141 | react-remark 142 | +---------------------------------------------------------------------------------------------------------------------------------------------+ 143 | | | 144 | | +----------+ +----------------+ +---------------+ +----------------+ +--------------+ | 145 | | | | | | | | | | | | | 146 | | -markdown->+ remark +-mdast->+ remark plugins +-mdast->+ remark-rehype +-hast->+ rehype plugins +-hast->+ rehype-react +-react elements-> | 147 | | | | | | | | | | | | | 148 | | +----------+ +----------------+ +---------------+ +----------------+ +--------------+ | 149 | | | 150 | +---------------------------------------------------------------------------------------------------------------------------------------------+ 151 | ``` 152 | 153 | relevant links: [markdown](https://commonmark.org), [remark](https://github.com/remarkjs/remark), [mdast](https://github.com/syntax-tree/mdast), [remark plugins](https://github.com/remarkjs/remark/blob/main/doc/plugins.md), [remark-rehype](https://github.com/remarkjs/remark-rehype), [hast](https://github.com/syntax-tree/hast), [rehype plugins](https://github.com/rehypejs/rehype/blob/main/doc/plugins.md), [rehype-react](https://github.com/rehypejs/rehype-react) 154 | 155 | ## Options 156 | 157 | - `remarkParseOptions` (Object) - configure how Markdown is parsed, same as [`remark-parse` options](https://github.com/remarkjs/remark/tree/main/packages/remark-parse#options) 158 | - `remarkPlugins` (Array) - [remark plugins](https://github.com/remarkjs/remark/blob/main/doc/plugins.md) or [custom plugins](https://unifiedjs.com/learn/guide/create-a-plugin) to transform markdown content before it is translated to HTML (hast) 159 | - `remarkToRehypeOptions` (Object) - configure how Markdown (mdast) is translated into HTML (hast), same as [`remark-rehype` options](https://github.com/remarkjs/remark-rehype#api) 160 | - `rehypePlugins` (Array) - [rehype plugins](https://github.com/rehypejs/rehype/blob/main/doc/plugins.md) or [custom plugins](https://unifiedjs.com/learn/guide/create-a-plugin) to transform HTML (hast) before it is translated to React elements. 161 | - `rehypeReactOptions` (Object) - configure how HTML (hast) is translated into React elements, same as [`rehype-react` options](https://github.com/rehypejs/rehype-react#options) 162 | 163 | ### Pass options to hook 164 | 165 | ```tsx 166 | import React, { Fragment } from 'react'; 167 | import { useRemark } from 'react-remark'; 168 | import remarkGemoji from 'remark-gemoji'; 169 | import rehypeSlug from 'rehype-slug'; 170 | import rehypeAutoLinkHeadings from 'rehype-autolink-headings'; 171 | 172 | // ... 173 | 174 | const [reactContent, setMarkdownSource] = useRemark({ 175 | remarkPlugins: [remarkGemoji], 176 | remarkToRehypeOptions: { allowDangerousHtml: true }, 177 | rehypePlugins: [rehypeSlug, rehypeAutoLinkHeadings], 178 | rehypeReactOptions: { 179 | components: { 180 | p: (props) =>

, 181 | }, 182 | }, 183 | }); 184 | ``` 185 | 186 | ### Pass options to component 187 | 188 | ```tsx 189 | import React, { Fragment } from 'react'; 190 | import { Remark } from 'react-remark'; 191 | import remarkGemoji from 'remark-gemoji'; 192 | import rehypeSlug from 'rehype-slug'; 193 | import rehypeAutoLinkHeadings from 'rehype-autolink-headings'; 194 | 195 | // ... 196 | 197 |

, 204 | }, 205 | }} 206 | > 207 | {markdownSource} 208 | ; 209 | ``` 210 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FunctionComponent, 3 | Fragment, 4 | ReactElement, 5 | createElement, 6 | useState, 7 | useEffect, 8 | useCallback, 9 | } from 'react'; 10 | import unified, { PluggableList } from 'unified'; 11 | import remarkParse, { RemarkParseOptions } from 'remark-parse'; 12 | import { Options as RemarkRehypeOptions } from 'mdast-util-to-hast'; 13 | import remarkToRehype from 'remark-rehype'; 14 | import rehypeReact, { Options as RehypeReactOptions } from 'rehype-react'; 15 | 16 | type PartialBy = Omit & Partial>; 17 | 18 | export interface UseRemarkSyncOptions { 19 | remarkParseOptions?: RemarkParseOptions; 20 | remarkToRehypeOptions?: RemarkRehypeOptions; 21 | rehypeReactOptions?: PartialBy< 22 | RehypeReactOptions, 23 | 'createElement' 24 | >; 25 | remarkPlugins?: PluggableList; 26 | rehypePlugins?: PluggableList; 27 | } 28 | 29 | export const useRemarkSync = ( 30 | source: string, 31 | { 32 | remarkParseOptions, 33 | remarkToRehypeOptions, 34 | rehypeReactOptions, 35 | remarkPlugins = [], 36 | rehypePlugins = [], 37 | }: UseRemarkOptions = {} 38 | ): ReactElement => 39 | unified() 40 | .use(remarkParse, remarkParseOptions) 41 | .use(remarkPlugins) 42 | .use(remarkToRehype, remarkToRehypeOptions) 43 | .use(rehypePlugins) 44 | .use(rehypeReact, { 45 | createElement, 46 | Fragment, 47 | ...rehypeReactOptions, 48 | } as RehypeReactOptions) 49 | .processSync(source).result as ReactElement; 50 | 51 | export interface UseRemarkOptions extends UseRemarkSyncOptions { 52 | onError?: (err: Error) => void; 53 | } 54 | 55 | export const useRemark = ({ 56 | remarkParseOptions, 57 | remarkToRehypeOptions, 58 | rehypeReactOptions, 59 | remarkPlugins = [], 60 | rehypePlugins = [], 61 | onError = () => {}, 62 | }: UseRemarkOptions = {}): [ReactElement | null, (source: string) => void] => { 63 | const [reactContent, setReactContent] = useState(null); 64 | 65 | const setMarkdownSource = useCallback((source: string) => { 66 | unified() 67 | .use(remarkParse, remarkParseOptions) 68 | .use(remarkPlugins) 69 | .use(remarkToRehype, remarkToRehypeOptions) 70 | .use(rehypePlugins) 71 | .use(rehypeReact, { 72 | createElement, 73 | Fragment, 74 | ...rehypeReactOptions, 75 | } as RehypeReactOptions) 76 | .process(source) 77 | .then((vfile) => setReactContent(vfile.result as ReactElement)) 78 | .catch(onError); 79 | }, []); 80 | 81 | return [reactContent, setMarkdownSource]; 82 | }; 83 | 84 | export interface RemarkProps extends UseRemarkOptions { 85 | children: string; 86 | } 87 | 88 | export const Remark: FunctionComponent = ({ 89 | children, 90 | ...useRemarkOptions 91 | }: RemarkProps) => { 92 | const [reactContent, setMarkdownSource] = useRemark(useRemarkOptions); 93 | 94 | useEffect(() => { 95 | setMarkdownSource(children); 96 | }, [children, setMarkdownSource]); 97 | 98 | return reactContent; 99 | }; 100 | -------------------------------------------------------------------------------- /stories/remark-component.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import remarkGfm from 'remark-gfm'; 3 | import remarkMath from 'remark-math'; 4 | import rehypeKatex from 'rehype-katex'; 5 | import rehypeRaw from 'rehype-raw'; 6 | import rehypeSanitize from 'rehype-sanitize'; 7 | import 'katex/dist/katex.min.css'; 8 | 9 | import { Remark } from '../src'; 10 | 11 | export default { 12 | title: 'Remark Component', 13 | component: Remark, 14 | }; 15 | 16 | export const CommonMark = ({ content }) => {content}; 17 | CommonMark.args = { 18 | content: `# header 19 | 20 | 1. ordered 21 | 2. list 22 | 23 | * unordered 24 | * list`, 25 | }; 26 | 27 | export const GithubFlavoredMarkdown = ({ content }) => ( 28 | {content} 29 | ); 30 | GithubFlavoredMarkdown.args = { 31 | content: `# header 32 | 33 | | column 1 | column 2 | 34 | | -------- | -------- | 35 | | first | row | 36 | `, 37 | }; 38 | 39 | export const MarkdownWithMath = ({ content }) => ( 40 | 41 | {content} 42 | 43 | ); 44 | MarkdownWithMath.args = { 45 | content: `Lift($L$) can be determined by Lift Coefficient ($C_L$) like the following equation. 46 | 47 | $$ 48 | L = \\frac{1}{2} \\rho v^2 S C_L 49 | $$`, 50 | }; 51 | 52 | export const MixedHTMLSanitized = ({ content }) => ( 53 | 57 | {content} 58 | 59 | ); 60 | MixedHTMLSanitized.args = { 61 | content: `# header 62 | 63 | mixed 64 | with 65 | html 66 | `, 67 | }; 68 | -------------------------------------------------------------------------------- /stories/remark-hook-async.stories.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import remarkGfm from 'remark-gfm'; 3 | import remarkMath from 'remark-math'; 4 | import rehypeKatex from 'rehype-katex'; 5 | import rehypeRaw from 'rehype-raw'; 6 | import rehypeSanitize from 'rehype-sanitize'; 7 | import 'katex/dist/katex.min.css'; 8 | 9 | import { useRemarkSync } from '../src'; 10 | 11 | export default { 12 | title: 'Remark Hooks/sync and ssr with useRemarkSync', 13 | component: useRemarkSync, 14 | }; 15 | 16 | export const CommonMark = ({ content }) => { 17 | return useRemarkSync(content); 18 | }; 19 | CommonMark.args = { 20 | content: `# header 21 | 22 | 1. ordered 23 | 2. list 24 | 25 | * unordered 26 | * list`, 27 | }; 28 | 29 | export const GithubFlavoredMarkdown = ({ content }) => { 30 | return ( 31 | useRemarkSync(content, { 32 | remarkPlugins: [remarkGfm], 33 | }) || <> 34 | ); 35 | }; 36 | GithubFlavoredMarkdown.args = { 37 | content: `# header 38 | 39 | | column 1 | column 2 | 40 | | -------- | -------- | 41 | | first | row | 42 | `, 43 | }; 44 | 45 | export const MarkdownWithMath = ({ content }) => { 46 | return useRemarkSync(content, { 47 | remarkPlugins: [remarkMath], 48 | rehypePlugins: [rehypeKatex], 49 | }); 50 | }; 51 | MarkdownWithMath.args = { 52 | content: `Lift($L$) can be determined by Lift Coefficient ($C_L$) like the following equation. 53 | 54 | $$ 55 | L = \\frac{1}{2} \\rho v^2 S C_L 56 | $$`, 57 | }; 58 | 59 | export const MixedHTMLSanitized = ({ content }) => { 60 | return useRemarkSync(content, { 61 | remarkToRehypeOptions: { allowDangerousHtml: true }, 62 | rehypePlugins: [rehypeRaw, rehypeSanitize], 63 | }); 64 | }; 65 | MixedHTMLSanitized.args = { 66 | content: `# header 67 | 68 | mixed 69 | with 70 | html`, 71 | }; 72 | -------------------------------------------------------------------------------- /stories/remark-hook-sync.stories.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import remarkGfm from 'remark-gfm'; 3 | import remarkMath from 'remark-math'; 4 | import rehypeKatex from 'rehype-katex'; 5 | import rehypeRaw from 'rehype-raw'; 6 | import rehypeSanitize from 'rehype-sanitize'; 7 | import 'katex/dist/katex.min.css'; 8 | 9 | import { useRemark } from '../src'; 10 | 11 | export default { 12 | title: 'Remark Hooks/standard use with useRemark', 13 | component: useRemark, 14 | }; 15 | 16 | export const CommonMark = ({ content }) => { 17 | const [reactContent, setMarkdownSource] = useRemark(); 18 | 19 | useEffect(() => { 20 | setMarkdownSource(content); 21 | }, [content]); 22 | 23 | return reactContent || <>; 24 | }; 25 | CommonMark.args = { 26 | content: `# header 27 | 28 | 1. ordered 29 | 2. list 30 | 31 | * unordered 32 | * list`, 33 | }; 34 | 35 | export const GithubFlavoredMarkdown = ({ content }) => { 36 | const [reactContent, setMarkdownSource] = useRemark({ 37 | remarkPlugins: [remarkGfm], 38 | }); 39 | 40 | useEffect(() => { 41 | setMarkdownSource(content); 42 | }, [content]); 43 | 44 | return reactContent || <>; 45 | }; 46 | GithubFlavoredMarkdown.args = { 47 | content: `# header 48 | 49 | | column 1 | column 2 | 50 | | -------- | -------- | 51 | | first | row | 52 | `, 53 | }; 54 | 55 | export const MarkdownWithMath = ({ content }) => { 56 | const [reactContent, setMarkdownSource] = useRemark({ 57 | remarkPlugins: [remarkMath], 58 | rehypePlugins: [rehypeKatex], 59 | }); 60 | 61 | useEffect(() => { 62 | setMarkdownSource(content); 63 | }, [content]); 64 | 65 | return reactContent || <>; 66 | }; 67 | MarkdownWithMath.args = { 68 | content: `Lift($L$) can be determined by Lift Coefficient ($C_L$) like the following equation. 69 | 70 | $$ 71 | L = \\frac{1}{2} \\rho v^2 S C_L 72 | $$`, 73 | }; 74 | 75 | export const MixedHTMLSanitized = ({ content }) => { 76 | const [reactContent, setMarkdownSource] = useRemark({ 77 | remarkToRehypeOptions: { allowDangerousHtml: true }, 78 | rehypePlugins: [rehypeRaw, rehypeSanitize], 79 | }); 80 | 81 | useEffect(() => { 82 | setMarkdownSource(content); 83 | }, [content]); 84 | 85 | return reactContent || <>; 86 | }; 87 | MixedHTMLSanitized.args = { 88 | content: `# header 89 | 90 | mixed 91 | with 92 | html`, 93 | }; 94 | -------------------------------------------------------------------------------- /test/__snapshots__/remark-component.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Remark should render content 1`] = ` 4 |

5 | header 6 |

7 | `; 8 | -------------------------------------------------------------------------------- /test/__snapshots__/remark-hook.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`useRemark should render content 1`] = ` 4 | 5 |

6 | header 7 |

8 |
9 | `; 10 | 11 | exports[`useRemark should support custom element renderer 1`] = ` 12 | 13 |

14 | heading 15 |

16 |
17 | `; 18 | 19 | exports[`useRemark should support gfm through remark plugins 1`] = ` 20 | 21 |

22 | 25 | https://example.com 26 | 27 |

28 |
29 | `; 30 | 31 | exports[`useRemark should support html through rehype plugins 1`] = ` 32 | 33 |

34 | 35 | example 36 | 37 |

38 |
39 | `; 40 | 41 | exports[`useRemark should support math through remark and rehype plugins 1`] = ` 42 | 43 |

44 | Lift( 45 | 48 | 51 | 54 | 57 | 58 | 59 | 60 | L 61 | 62 | 63 | 66 | L 67 | 68 | 69 | 70 | 71 | 95 | 96 | ) can be determined by Lift Coefficient ( 97 | 100 | 103 | 106 | 109 | 110 | 111 | 112 | 113 | C 114 | 115 | 116 | L 117 | 118 | 119 | 120 | 123 | C_L 124 | 125 | 126 | 127 | 128 |

231 |
232 | `; 233 | 234 | exports[`useRemarkSync should render content 1`] = ` 235 | 236 |

237 | header 238 |

239 |
240 | `; 241 | 242 | exports[`useRemarkSync should support custom element renderer 1`] = ` 243 | 244 |

245 | heading 246 |

247 |
248 | `; 249 | 250 | exports[`useRemarkSync should support gfm through remark plugins 1`] = ` 251 | 252 |

253 | 256 | https://example.com 257 | 258 |

259 |
260 | `; 261 | 262 | exports[`useRemarkSync should support html through rehype plugins 1`] = ` 263 | 264 |

265 | 266 | example 267 | 268 |

269 |
270 | `; 271 | 272 | exports[`useRemarkSync should support math through remark and rehype plugins 1`] = ` 273 | 274 |

275 | Lift( 276 | 279 | 282 | 285 | 288 | 289 | 290 | 291 | L 292 | 293 | 294 | 297 | L 298 | 299 | 300 | 301 | 302 | 326 | 327 | ) can be determined by Lift Coefficient ( 328 | 331 | 334 | 337 | 340 | 341 | 342 | 343 | 344 | C 345 | 346 | 347 | L 348 | 349 | 350 | 351 | 354 | C_L 355 | 356 | 357 | 358 | 359 |

462 |
463 | `; 464 | -------------------------------------------------------------------------------- /test/remark-component.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, waitFor } from '@testing-library/react'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | import { Remark } from '../src'; 5 | 6 | describe('Remark', () => { 7 | it('should render content', async () => { 8 | const { container, getByText } = render(# header); 9 | await waitFor(() => { 10 | expect(getByText('header')).toBeInTheDocument(); 11 | }); 12 | expect(container.firstChild).toMatchSnapshot(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/remark-hook.test.ts: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import type { ComponentPropsWithoutNode } from 'rehype-react'; 3 | import { renderHook, act } from '@testing-library/react-hooks'; 4 | import '@testing-library/jest-dom/extend-expect'; 5 | import remarkGfm from 'remark-gfm'; 6 | import rehypeRaw from 'rehype-raw'; 7 | import rehypeSanitize from 'rehype-sanitize'; 8 | import remarkMath from 'remark-math'; 9 | import rehypeKatex from 'rehype-katex'; 10 | import { useRemark, useRemarkSync } from '../src'; 11 | 12 | describe('useRemark', () => { 13 | it('should render content', async () => { 14 | const { result, waitForNextUpdate } = renderHook(() => useRemark()); 15 | act(() => { 16 | result.current[1]('# header'); 17 | }); 18 | await waitForNextUpdate(); 19 | expect(result.current[0]).toMatchSnapshot(); 20 | }); 21 | 22 | it('should support gfm through remark plugins', async () => { 23 | const { result, waitForNextUpdate } = renderHook(() => 24 | useRemark({ remarkPlugins: [remarkGfm] }) 25 | ); 26 | act(() => { 27 | result.current[1]('https://example.com'); 28 | }); 29 | await waitForNextUpdate(); 30 | expect(result.current[0]).toMatchSnapshot(); 31 | }); 32 | 33 | it('should support html through rehype plugins', async () => { 34 | const { result, waitForNextUpdate } = renderHook(() => 35 | useRemark({ 36 | remarkToRehypeOptions: { allowDangerousHtml: true }, 37 | rehypePlugins: [rehypeRaw, rehypeSanitize], 38 | }) 39 | ); 40 | act(() => { 41 | result.current[1]('example'); 42 | }); 43 | await waitForNextUpdate(); 44 | expect(result.current[0]).toMatchSnapshot(); 45 | }); 46 | 47 | it('should support math through remark and rehype plugins', async () => { 48 | const { result, waitForNextUpdate } = renderHook(() => 49 | useRemark({ 50 | remarkPlugins: [remarkMath], 51 | rehypePlugins: [rehypeKatex], 52 | }) 53 | ); 54 | act(() => { 55 | result.current[1]( 56 | 'Lift($L$) can be determined by Lift Coefficient ($C_L$) like the following equation.' 57 | ); 58 | }); 59 | await waitForNextUpdate(); 60 | expect(result.current[0]).toMatchSnapshot(); 61 | }); 62 | 63 | it('should support custom element renderer', async () => { 64 | const { result, waitForNextUpdate } = renderHook(() => 65 | useRemark({ 66 | rehypeReactOptions: { 67 | components: { 68 | h1: (props: ComponentPropsWithoutNode) => 69 | createElement('h2', props), 70 | }, 71 | }, 72 | }) 73 | ); 74 | act(() => { 75 | result.current[1]('# heading'); 76 | }); 77 | await waitForNextUpdate(); 78 | expect(result.current[0]).toMatchSnapshot(); 79 | }); 80 | }); 81 | 82 | describe('useRemarkSync', () => { 83 | it('should render content', async () => { 84 | const { result } = renderHook(() => useRemarkSync('# header')); 85 | expect(result.current).toMatchSnapshot(); 86 | }); 87 | 88 | it('should support gfm through remark plugins', async () => { 89 | const { result } = renderHook(() => 90 | useRemarkSync('https://example.com', { remarkPlugins: [remarkGfm] }) 91 | ); 92 | expect(result.current).toMatchSnapshot(); 93 | }); 94 | 95 | it('should support html through rehype plugins', async () => { 96 | const { result } = renderHook(() => 97 | useRemarkSync('example', { 98 | remarkToRehypeOptions: { allowDangerousHtml: true }, 99 | rehypePlugins: [rehypeRaw, rehypeSanitize], 100 | }) 101 | ); 102 | expect(result.current).toMatchSnapshot(); 103 | }); 104 | 105 | it('should support math through remark and rehype plugins', async () => { 106 | const { result } = renderHook(() => 107 | useRemarkSync( 108 | 'Lift($L$) can be determined by Lift Coefficient ($C_L$) like the following equation.', 109 | { 110 | remarkPlugins: [remarkMath], 111 | rehypePlugins: [rehypeKatex], 112 | } 113 | ) 114 | ); 115 | expect(result.current).toMatchSnapshot(); 116 | }); 117 | 118 | it('should support custom element renderer', async () => { 119 | const { result } = renderHook(() => 120 | useRemarkSync('# heading', { 121 | rehypeReactOptions: { 122 | components: { 123 | h1: (props: ComponentPropsWithoutNode) => 124 | createElement('h2', props), 125 | }, 126 | }, 127 | }) 128 | ); 129 | expect(result.current).toMatchSnapshot(); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 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 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "paths": { 18 | "@": ["./"], 19 | "*": ["src/*", "node_modules/*"] 20 | }, 21 | "jsx": "react", 22 | "esModuleInterop": true 23 | } 24 | } 25 | --------------------------------------------------------------------------------