├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ └── github-actions.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── demo ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.js │ ├── App.test.js │ ├── components │ │ ├── Bottom.jsx │ │ ├── Content.jsx │ │ └── Top.jsx │ ├── consts.js │ ├── index.css │ ├── index.js │ ├── reportWebVitals.js │ ├── setupTests.js │ └── styles │ │ ├── Bottom.css │ │ ├── Content.css │ │ └── Top.css └── yarn.lock ├── jest.config.js ├── package.json ├── release └── README.md ├── rollup.config.js ├── src ├── Heading.tsx ├── __tests__ │ ├── Heading.test.tsx │ ├── __snapshots__ │ │ ├── Heading.test.tsx.snap │ │ └── index.test.tsx.snap │ ├── index.test.tsx │ └── utils.test.ts ├── index.tsx ├── styles.module.css ├── typings.d.ts └── utils.ts ├── tsconfig.json ├── tsconfig.test.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "parser": "@typescript-eslint/parser", 3 | "ignorePatterns": [ 4 | "typings.d.ts" 5 | ], 6 | "settings": { 7 | "react": { 8 | "version": "detect" 9 | } 10 | }, 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:jest/recommended", 15 | "plugin:react/recommended", 16 | ], 17 | "rules": { 18 | "react/prop-types": "off", 19 | "no-control-regex": 0, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic dependabot.yml file with 2 | # minimum configuration for two package managers 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for npm 7 | - package-ecosystem: "npm" 8 | # Look for `package.json` and `lock` files in the `root` directory 9 | directory: "/" 10 | # Check the npm registry for updates every day (weekdays) 11 | schedule: 12 | interval: "daily" 13 | -------------------------------------------------------------------------------- /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | jest: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Install modules 9 | run: yarn 10 | - name: Run tests 11 | run: yarn run test 12 | eslint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Install modules 17 | run: yarn 18 | - name: Run ESLint 19 | run: yarn run lint 20 | create-release-on-github: 21 | name: Create Release 22 | runs-on: ubuntu-latest 23 | needs: [jest, eslint] 24 | if: startsWith(github.ref, 'refs/tags/v') 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v2 28 | - name: Create Release 29 | id: create_release 30 | uses: actions/create-release@v1 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | with: 34 | tag_name: ${{ github.ref }} 35 | release_name: Release ${{ github.ref }} 36 | body: '' 37 | draft: false 38 | prerelease: false 39 | publish: 40 | runs-on: ubuntu-latest 41 | needs: [jest, eslint, create-release-on-github] 42 | if: startsWith(github.ref, 'refs/tags/v') 43 | steps: 44 | - uses: actions/checkout@v2 45 | - uses: actions/setup-node@v2 46 | with: 47 | node-version: 14 48 | registry-url: 'https://registry.npmjs.org' 49 | - run: npm install 50 | - run: npm publish 51 | env: 52 | NODE_AUTH_TOKEN: ${{ secrets.NPM__TOKEN }} 53 | 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # jest coverage 20 | coverage -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.autoSave": "onFocusChange", 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact", 8 | ], 9 | "eslint.workingDirectories": [ 10 | "./client" 11 | ], 12 | "editor.codeActionsOnSave": { 13 | "source.fixAll.eslint": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 K-Sato 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![react-toc](https://user-images.githubusercontent.com/32632542/172038318-d2ff8b26-27f0-4694-9b55-17cfccb9fc7e.png) 3 | 4 |
5 | 6 | [![NPM](https://img.shields.io/npm/v/react-toc.svg)](https://www.npmjs.com/package/react-toc) 7 | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/react-toc?color=%2355C503) 8 | ![npm](https://img.shields.io/npm/dw/react-toc?color=%23C43737) 9 | ![MIT](https://img.shields.io/github/license/K-Sato1995/react-toc?color=%23F6F623) 10 |
11 | 12 | ## Overview 13 | 14 | - The idea is that you can automatically create a customizable table of contents from your markdown text. 15 | - It's regex based. Thus, managed to keep the bundle size pretty tiny.(Check it out on [BUNDLEPHOBIA](https://bundlephobia.com/package/react-toc)) 16 | 17 | 18 | [![Image from Gyazo](https://i.gyazo.com/3e63575305ea5c12e1d52b73a96cdfaa.gif)](https://gyazo.com/3e63575305ea5c12e1d52b73a96cdfaa) 19 | 20 | ### Demo 21 | 22 | [Check out the demo page.](https://react-toc-k-sato1995.vercel.app/) 23 | 24 | ## Installation 25 | 26 | ```bash 27 | npm install --save react-toc 28 | ``` 29 | 30 | or 31 | 32 | ```bash 33 | yarn add react-toc 34 | ``` 35 | 36 | ## Usage 37 | 38 | Import Toc from the package and pass props to it. As for now, `markdownText` is the only required prop. 39 | 40 | ```jsx 41 | import React from "react"; 42 | import Toc from "react-toc"; 43 | 44 | const Example = () => { 45 | const yourMarkdownText = "# test \n your markdown Content # test2\n"; 46 | return ; 47 | }; 48 | 49 | export default Example; 50 | ``` 51 | 52 | ## Props 53 | 54 | | Name | Type | Description | 55 | | -------------------- | ----------------- | ----------------------------------------------------------------------------- | 56 | | `markdownText` | string | **Required** The markdown text you want to creat a TOC from. | 57 | | `titleLimit` | number | The maximum length of each title in the TOC. | 58 | | `highestHeadingLevel` | number | The highest level of headings you want to extract from the given markdownText. | 59 | | `lowestHeadingLevel` | number | The lowest level of headings you want to extract from the given markdownText. | 60 | | `className` | strig | Your custom className. | 61 | | `type` | "deafult" or"raw" | The type of a TOC you want to use. | 62 | | `customMatchers` | { [key: string]: string } | The matchers you want to use to replace the letters with. | 63 | 64 | ## CustomDesign 65 | 66 | ### Add a custom className 67 | 68 | Pass `className` like the code below. 69 | 70 | ```jsx 71 | import React from "react"; 72 | import Toc from "react-toc"; 73 | 74 | const Example = () => { 75 | const yourMarkdownText = "# test \n your markdown Content # test2\n"; 76 | return ; 77 | }; 78 | 79 | export default Example; 80 | ``` 81 | 82 | ### Style the custom class 83 | 84 | Now you can style your custom class just like the code below. 85 | 86 | ```css 87 | .customClassName { 88 | border: solid 1px; 89 | } 90 | .customClassName > li { 91 | padding-bottom: 10px; 92 | } 93 | ``` 94 | 95 | 96 | ## Custom Matchers 97 | 98 | You can use the `customMatchers` prop to replace letters in your toc. 99 | For instance, if you want to replace `?` or `!` with `-` in your list, you can simply do this. 100 | 101 | ```jsx 102 | import React from "react"; 103 | import Toc from "react-toc"; 104 | 105 | const Example = () => { 106 | const yourMarkdownText = "# test \n your markdown Content # test2\n"; 107 | const matchers = { "[?!]": "-" } 108 | 109 | return ; 110 | }; 111 | 112 | export default Example; 113 | ``` 114 | 115 | You can also give more options to the `customMatchers` prop like the code below. 116 | 117 | ```jsx 118 | import React from "react"; 119 | import Toc from "react-toc"; 120 | 121 | const Example = () => { 122 | const yourMarkdownText = "# test \n your markdown Content # test2\n"; 123 | const matchers = { "[?!]": "-", "\\*": "" } 124 | 125 | return ; 126 | }; 127 | 128 | export default Example; 129 | ``` 130 | 131 | 132 | ## Development 133 | 134 | - Install dev dependencies. 135 | 136 | ``` 137 | $ yarn install 138 | ``` 139 | 140 | - Test 141 | 142 | ``` 143 | $ yarn test 144 | ``` 145 | 146 | - Lint 147 | 148 | ``` 149 | $ yarn lint 150 | ``` 151 | 152 | - Run demo locally 153 | 154 | ``` 155 | $ cd demo && yarn && yarn start 156 | ``` 157 | 158 | ## License 159 | 160 | [MIT](https://github.com/K-Sato1995/react-toc/blob/master/LICENSE) 161 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 13 | 14 | The page will reload when you make changes.\ 15 | You may also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!** 35 | 36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. 39 | 40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^14.4.3", 9 | "react": "^18.2.0", 10 | "react-dom": "^18.2.0", 11 | "react-markdown": "^8.0.3", 12 | "react-syntax-highlighter": "^15.5.0", 13 | "react-toc": "^3.0.0", 14 | "remark-gfm": "^3.0.1", 15 | "web-vitals": "^3.0.3" 16 | }, 17 | "devDependencies": { 18 | "react-scripts": "^5.0.1" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Sato1995/react-toc/af8569d6dff8cbfb685b5614f7952eb19dceab73/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /demo/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Sato1995/react-toc/af8569d6dff8cbfb685b5614f7952eb19dceab73/demo/public/logo192.png -------------------------------------------------------------------------------- /demo/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K-Sato1995/react-toc/af8569d6dff8cbfb685b5614f7952eb19dceab73/demo/public/logo512.png -------------------------------------------------------------------------------- /demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /demo/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /demo/src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Top from './components/Top' 3 | import Content from './components/Content'; 4 | import Bottom from "./components/Bottom"; 5 | 6 | function App() { 7 | return ( 8 |
9 | 10 | 11 | 12 |
13 | ); 14 | } 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /demo/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /demo/src/components/Bottom.jsx: -------------------------------------------------------------------------------- 1 | import '../styles/Bottom.css'; 2 | 3 | const Bottom = () => { 4 | 5 | return ( 6 | 9 | ) 10 | }; 11 | 12 | export default Bottom; 13 | -------------------------------------------------------------------------------- /demo/src/components/Content.jsx: -------------------------------------------------------------------------------- 1 | import '../styles/Content.css'; 2 | import React from "react"; 3 | import { CONTENT as mdContent } from '../consts' 4 | import Toc from "react-toc"; 5 | import remarkGfm from 'remark-gfm' 6 | import ReactMarkdown from 'react-markdown' 7 | 8 | import {vscDarkPlus} from 'react-syntax-highlighter/dist/esm/styles/prism' 9 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 10 | 11 | const flatten = (text, child) => { 12 | return typeof child === "string" 13 | ? text + child 14 | : React.Children.toArray(child.props.children).reduce(flatten, text); 15 | }; 16 | 17 | export const HeadingRenderer = (props) => { 18 | var children = React.Children.toArray(props.children); 19 | var text = children.reduce(flatten, ""); 20 | var slug = text.toLowerCase().replace(/[!?\s]/g, "-"); 21 | return React.createElement( 22 | "h" + props.level, 23 | { id: slug, className: "anchor" }, 24 | props.children 25 | ); 26 | }; 27 | 28 | const Content = () => { 29 | return ( 30 |
31 |

Table of contents

32 | 33 | 34 | 49 | ) : ( 50 | 51 | {children} 52 | 53 | ) 54 | }, 55 | h1: HeadingRenderer, 56 | h2: HeadingRenderer, 57 | }} 58 | /> 59 |
60 | ) 61 | }; 62 | 63 | export default Content; 64 | -------------------------------------------------------------------------------- /demo/src/components/Top.jsx: -------------------------------------------------------------------------------- 1 | import '../styles/Top.css'; 2 | 3 | const Top = () => { 4 | 5 | return ( 6 |
7 |

react-toc

8 |
9 | ) 10 | }; 11 | 12 | export default Top; 13 | -------------------------------------------------------------------------------- /demo/src/consts.js: -------------------------------------------------------------------------------- 1 | const CONTENT = ` 2 | # Overview 3 | 4 | The idea is that you can automatically create a customizable table of contents from your markdown text. 5 | 6 | PRs/Issues are always welcome. 7 | 8 | # Installation 9 | 10 | ## Install the package with npm 11 | 12 | Run the command below. 13 | 14 | \`\`\`bash 15 | npm install --save react-toc 16 | \`\`\` 17 | 18 | ## Install the package with yarn 19 | 20 | Run the command below. 21 | 22 | \`\`\`bash 23 | yarn add react-toc 24 | \`\`\` 25 | 26 | # Usage 27 | 28 | Import Toc from the package and pass props to it. As for now, \`markdownText\` is the only required prop. 29 | 30 | \`\`\`jsx 31 | import React from "react"; 32 | import Toc from "react-toc"; 33 | 34 | const Example = () => { 35 | const yourMarkdownText = '# test your markdown Content # test2' 36 | return 37 | }; 38 | 39 | export default Example; 40 | \`\`\` 41 | 42 | 43 | # Props 44 | 45 | | Name | Type | Description | 46 | | ------------------ | ----------------- | ----------------------------------------------------------------------------- | 47 | | \`markdownText\` | string | **Required** The markdown text you want to creat a TOC from. | 48 | | \`titleLimit\` | number | The maximum length of each title in the TOC. | 49 | | \`lowestHeadingLevel\` | number | The lowest level of headings you want to extract from the given markdownText. | 50 | | \`className\` | string | Your custom className. | 51 | | \`type\` | "deafult" or"raw" | The type of a TOC you want to use. | 52 | | \`customMatchers\` | { [key: string]: string } | The matchers you want to use to replace the letters with. | 53 | 54 | # CustomDesign 55 | 56 | ## Add a custom className 57 | 58 | Pass \`className\` like the code below. 59 | 60 | \`\`\`jsx 61 | import React from "react"; 62 | import Toc from "react-toc"; 63 | 64 | const Example = () => { 65 | const yourMarkdownText = '# test your markdown Content # test2' 66 | return 67 | }; 68 | 69 | export default Example; 70 | \`\`\` 71 | 72 | ## Style the custom class 73 | 74 | Now you can style your custom class just like the code below. 75 | 76 | \`\`\`css 77 | .customClassName { 78 | border: solid 1px; 79 | } 80 | .customClassName > li { 81 | padding-bottom: 10px; 82 | } 83 | \`\`\` 84 | 85 | # Custom Matchers 86 | 87 | You can use the \`customMatchers\` prop to replace letters in your toc. 88 | For instance, if you want to replace \`?\` or \`!\` with \`-\` in your list, you can simply do this. 89 | 90 | \`\`\`jsx 91 | import React from "react"; 92 | import Toc from "react-toc"; 93 | 94 | const Example = () => { 95 | const yourMarkdownText = "# test your markdown Content # test2"; 96 | const matchers = { "[?!]": "-" } 97 | 98 | return ; 99 | }; 100 | 101 | export default Example; 102 | \`\`\` 103 | 104 | You can also give more options to the \`customMatchers\` prop like the code below. 105 | 106 | \`\`\`jsx 107 | import React from "react"; 108 | import Toc from "react-toc"; 109 | 110 | const Example = () => { 111 | const yourMarkdownText = "# test your markdown Content # test2"; 112 | const matchers = { "[?!]": "-", "\\*": "" } 113 | 114 | return ; 115 | }; 116 | 117 | export default Example; 118 | \`\`\` 119 | ` 120 | 121 | export { CONTENT } 122 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: Helvetica,Arial,sans-serif; 4 | -webkit-font-smoothing: antialiased; 5 | background-color: #F6F9FC; 6 | -moz-osx-font-smoothing: grayscale; 7 | line-height: 1.25; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /demo/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /demo/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /demo/src/styles/Bottom.css: -------------------------------------------------------------------------------- 1 | .bottom-container { 2 | height: 300px; 3 | /* border-top: solid 1px #E5E7EB; */ 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | background-color: #fff; 8 | } 9 | 10 | .link { 11 | color: #565656; 12 | text-decoration: none; 13 | font-size: 1.5rem; 14 | } 15 | 16 | .link:hover { 17 | color: #7B74FF; 18 | } -------------------------------------------------------------------------------- /demo/src/styles/Content.css: -------------------------------------------------------------------------------- 1 | .content-container { 2 | max-width: 860px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | margin-top: 2rem; 6 | margin-bottom: 2rem; 7 | background-color: #fff; 8 | /* border: solid 1px #E5E7EB; */ 9 | border-radius: 5px; 10 | } 11 | 12 | .content-container h1 { 13 | border-bottom: solid 1px #E5E7EB; 14 | padding-bottom: 0.3rem; 15 | } 16 | 17 | .content-container h2 { 18 | color: #3C4257; 19 | } 20 | 21 | table { 22 | border: 1px solid #ccc; 23 | border-collapse: collapse; 24 | margin:0; 25 | padding:0; 26 | width: 100%; 27 | } 28 | 29 | table tr { 30 | border: 1px solid #ddd; 31 | padding: 5px; 32 | } 33 | 34 | table th, table td { 35 | padding: 10px; 36 | text-align: center; 37 | } 38 | 39 | table th { 40 | font-size: 14px; 41 | letter-spacing: 1px; 42 | text-transform: uppercase; 43 | } 44 | 45 | 46 | .toc { 47 | padding-left: 1.5rem; 48 | } 49 | 50 | .toc > li, 51 | .toc > ul > li, 52 | .toc > ul > ul > li, 53 | .toc > ul > ul > ul > li, 54 | .toc > ul > ul > ul > ul > li, 55 | .toc > ul > ul > ul > ul > ul > li { 56 | padding-bottom: 10px; 57 | } 58 | 59 | .toc > li > a, 60 | .toc > ul > li > a, 61 | .toc > ul > ul > li > a, 62 | .toc > ul > ul > ul > li > a, 63 | .toc > ul > ul > ul > ul > li > a, 64 | .toc > ul > ul > ul > ul > ul > li > a { 65 | color: #565656; 66 | text-decoration: none; 67 | } 68 | 69 | .toc > li > a:hover, 70 | .toc > ul > li > a:hover, 71 | .toc > ul > ul > li > a:hover, 72 | .toc > ul > ul > ul > li > a:hover, 73 | .toc > ul > ul > ul > ul > li > a:hover, 74 | .toc > ul > ul > ul > ul > ul > li > a:hover { 75 | color: #7B74FF; 76 | } 77 | 78 | @media screen and (max-width: 600px) { 79 | table { 80 | border: 0; 81 | } 82 | table thead { 83 | display: none; 84 | } 85 | table tr { 86 | border-bottom: 2px solid #ddd; 87 | display: block; 88 | margin-bottom: 10px; 89 | } 90 | table td { 91 | border-bottom: 1px dotted #ccc; 92 | display: block; 93 | font-size: 13px; 94 | text-align: left; 95 | } 96 | table td:last-child { 97 | border-bottom: 0; 98 | } 99 | table td:before { 100 | content: attr(data-label); 101 | float: left; 102 | font-weight: bold; 103 | text-transform: uppercase; 104 | } 105 | } 106 | 107 | code { 108 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 109 | monospace; 110 | background-color: #1E1E1E; 111 | color: #f8f8f2; 112 | border-radius: 0.3em; 113 | padding: 0 4px; 114 | white-space: nowrap; 115 | } -------------------------------------------------------------------------------- /demo/src/styles/Top.css: -------------------------------------------------------------------------------- 1 | .top-container { 2 | height: 500px; 3 | background: linear-gradient(to bottom right,rgba(123,116,255,1) 00%, rgba(127,233,255,1) 50%, #fff 50%); 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | /* border-bottom: solid 1px #E5E7EB; */ 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.tsx?$": "ts-jest", 4 | ".+\\.(css|styl|less|sass|scss)$": "jest-css-modules-transform", 5 | }, 6 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 7 | testPathIgnorePatterns: ["/lib/", "/node_modules/", "/dist/", "/demo/"], 8 | moduleFileExtensions: ["ts", "tsx", "js", "jsx"], 9 | testEnvironmentOptions: { 10 | url: "http://localhost/" 11 | }, 12 | coverageThreshold: { 13 | global: { 14 | branches: 50, 15 | functions: 80, 16 | lines: 80, 17 | statements: 80, 18 | }, 19 | }, 20 | coverageReporters: ["json", "lcovonly", "text", "clover"], 21 | bail: true, 22 | collectCoverage: true, 23 | }; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-toc", 3 | "version": "3.1.0", 4 | "description": "Create a table of contents from the given markdown text.", 5 | "author": "K-Sato1995", 6 | "license": "MIT", 7 | "repository": "K-Sato1995/react-toc", 8 | "main": "dist/index.js", 9 | "module": "dist/index.es.js", 10 | "jsnext:main": "dist/index.es.js", 11 | "engines": { 12 | "node": ">=8", 13 | "npm": ">=5" 14 | }, 15 | "scripts": { 16 | "test": "./node_modules/.bin/jest --config ./jest.config.js", 17 | "lint": "eslint --ext=ts,tsx src", 18 | "lint:fix": "eslint --fix --ext .ts,.tsx src", 19 | "build": "rollup -c", 20 | "start": "rollup -c -w", 21 | "prepare": "yarn run build", 22 | "predeploy": "cd example && yarn install && yarn run build", 23 | "deploy": "gh-pages -d example/build" 24 | }, 25 | "peerDependencies": { 26 | "react": ">=16", 27 | "react-dom": ">=16" 28 | }, 29 | "devDependencies": { 30 | "@svgr/rollup": "^6.2.1", 31 | "@types/jest": "^29.1.2", 32 | "@types/react": "^18.0.11", 33 | "@types/react-dom": "^18.0.5", 34 | "@types/react-test-renderer": "^18.0.0", 35 | "@typescript-eslint/eslint-plugin": "^5.27.0", 36 | "@typescript-eslint/parser": "^5.27.0", 37 | "babel-runtime": "^6.26.0", 38 | "eslint": "^8.17.0", 39 | "eslint-plugin-jest": "^27.1.1", 40 | "eslint-plugin-react": "^7.30.0", 41 | "gh-pages": "^4.0.0", 42 | "jest": "^29.1.2", 43 | "jest-css-modules-transform": "^4.4.2", 44 | "react": "^18.1.0", 45 | "react-dom": "^18.1.0", 46 | "react-test-renderer": "^18.1.0", 47 | "rollup": "^2.75.5", 48 | "rollup-plugin-babel": "^4.4.0", 49 | "rollup-plugin-commonjs": "^10.1.0", 50 | "rollup-plugin-node-resolve": "^5.2.0", 51 | "rollup-plugin-peer-deps-external": "^2.2.0", 52 | "rollup-plugin-postcss-modules": "^2.0.2", 53 | "rollup-plugin-typescript2": "^0.32.0", 54 | "rollup-plugin-url": "^3.0.1", 55 | "ts-jest": "^29.0.3", 56 | "typescript": "4.8.4" 57 | }, 58 | "files": [ 59 | "dist" 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /release/README.md: -------------------------------------------------------------------------------- 1 | ## Release flow 2 | 3 | 1. Merget the changes to master 4 | 2. Bump version in package.json 5 | 3. Create a new tag locally and push it to Github Repo -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import external from 'rollup-plugin-peer-deps-external' 4 | import postcss from 'rollup-plugin-postcss-modules' 5 | import resolve from 'rollup-plugin-node-resolve' 6 | import url from 'rollup-plugin-url' 7 | import svgr from '@svgr/rollup' 8 | 9 | import pkg from './package.json' 10 | 11 | export default { 12 | input: 'src/index.tsx', 13 | output: [ 14 | { 15 | file: pkg.main, 16 | format: 'cjs', 17 | exports: 'named', 18 | sourcemap: true 19 | }, 20 | { 21 | file: pkg.module, 22 | format: 'es', 23 | exports: 'named', 24 | sourcemap: true 25 | } 26 | ], 27 | plugins: [ 28 | external(), 29 | postcss({ 30 | modules: true 31 | }), 32 | url(), 33 | svgr(), 34 | resolve(), 35 | typescript({ 36 | rollupCommonJSResolveHack: true, 37 | clean: true 38 | }), 39 | commonjs() 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/Heading.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { replaceAll, createLink, createTitle } from './utils' 3 | 4 | export default class Heading { 5 | title: string 6 | level: number 7 | titleLimit: number 8 | customMatchers: CustomMatchers 9 | 10 | constructor( 11 | title: string, 12 | level: number, 13 | titleLimit: number, 14 | customMatchers?: CustomMatchers, 15 | ) { 16 | this.title = title 17 | this.level = level 18 | this.titleLimit = titleLimit 19 | this.customMatchers = customMatchers ? customMatchers : {} 20 | } 21 | 22 | generateList(): JSX.Element { 23 | const link = createLink(this.title) 24 | const listItem = ( 25 |
  • 26 | 27 | {createTitle(this.title, this.titleLimit)} 28 | 29 |
  • 30 | ) 31 | 32 | return <>{nestUl(this.level, listItem)} 33 | } 34 | } 35 | 36 | /* 37 | Create a new heading object from the given string 38 | */ 39 | const newHeading = ( 40 | headingText: string, 41 | titleLimit: number, 42 | customMatchers?: CustomMatchers, 43 | ): Heading | null => { 44 | const matchedHashes = headingText.match(/^#+/) 45 | if (!matchedHashes) return null 46 | const headingLevel: number = matchedHashes[0].split('').length 47 | const matchers = customMatchers ? customMatchers : {} 48 | 49 | return new Heading(headingText, headingLevel, titleLimit, matchers) 50 | } 51 | 52 | /* 53 | Return a nested Unordered list based on the given heading level. 54 | */ 55 | const nestUl = (level: number, listItem: React.ReactNode) => { 56 | switch (level) { 57 | case 1: 58 | return listItem 59 | case 2: 60 | return
      {listItem}
    61 | case 3: 62 | return ( 63 |
      64 |
        {listItem}
      65 |
    66 | ) 67 | case 4: 68 | return ( 69 |
      70 |
        71 |
          {listItem}
        72 |
      73 |
    74 | ) 75 | case 5: 76 | return ( 77 |
      78 |
        79 |
          80 |
            {listItem}
          81 |
        82 |
      83 |
    84 | ) 85 | case 6: 86 | return ( 87 |
      88 |
        89 |
          90 |
            91 |
              {listItem}
            92 |
          93 |
        94 |
      95 |
    96 | ) 97 | default: 98 | return listItem 99 | } 100 | } 101 | 102 | export { newHeading } 103 | -------------------------------------------------------------------------------- /src/__tests__/Heading.test.tsx: -------------------------------------------------------------------------------- 1 | import Heading, { newHeading } from '../Heading' 2 | import renderer from 'react-test-renderer' 3 | 4 | describe('Instantiate Heading', () => { 5 | const createdObject = new Heading('## Test Heading?! ', 5, 50, { 6 | '[?!]': '-', 7 | }) 8 | it('Create a new heading object from the given string', () => { 9 | expect(createdObject).toEqual({ 10 | level: 5, 11 | title: '## Test Heading?! ', 12 | titleLimit: 50, 13 | customMatchers: { '[?!]': '-' }, 14 | }) 15 | }) 16 | }) 17 | 18 | describe('newHeading', () => { 19 | const createdObject = newHeading('## Test Heading?! ', 50, { '[?!]': '-' }) 20 | it('Create a new heading object.', () => { 21 | expect(createdObject instanceof Heading).toBeTruthy() 22 | }) 23 | 24 | it('Create a new heading object from the given string', () => { 25 | expect(createdObject).toEqual({ 26 | level: 2, 27 | title: '## Test Heading?! ', 28 | titleLimit: 50, 29 | customMatchers: { '[?!]': '-' }, 30 | }) 31 | }) 32 | 33 | it('returns null when there is no # in the given string', () => { 34 | expect(newHeading('Test Heading ', 50)).toBeNull() 35 | }) 36 | }) 37 | 38 | describe('generateList', () => { 39 | it('renders in with a heading1', () => { 40 | const headingObj = new Heading('# this is the title', 1, 100) 41 | const component = renderer.create(headingObj.generateList()) 42 | const tree = component.toJSON() 43 | 44 | expect(tree).toMatchSnapshot() 45 | }) 46 | 47 | it('renders in with a heading2', () => { 48 | const headingObj = new Heading('## this is the title', 2, 100) 49 | const component = renderer.create(headingObj.generateList()) 50 | const tree = component.toJSON() 51 | 52 | expect(tree).toMatchSnapshot() 53 | }) 54 | 55 | it('renders in with a heading3', () => { 56 | const headingObj = new Heading('### this is the title', 3, 100) 57 | const component = renderer.create(headingObj.generateList()) 58 | const tree = component.toJSON() 59 | 60 | expect(tree).toMatchSnapshot() 61 | }) 62 | 63 | it('renders in with a heading4', () => { 64 | const headingObj = new Heading('#### this is the title', 4, 100) 65 | const component = renderer.create(headingObj.generateList()) 66 | const tree = component.toJSON() 67 | 68 | expect(tree).toMatchSnapshot() 69 | }) 70 | 71 | it('renders in with a heading5', () => { 72 | const headingObj = new Heading('##### this is the title', 5, 100) 73 | const component = renderer.create(headingObj.generateList()) 74 | const tree = component.toJSON() 75 | 76 | expect(tree).toMatchSnapshot() 77 | }) 78 | 79 | it('renders in with a heading6', () => { 80 | const headingObj = new Heading('###### this is the title', 6, 100) 81 | const component = renderer.create(headingObj.generateList()) 82 | const tree = component.toJSON() 83 | 84 | expect(tree).toMatchSnapshot() 85 | }) 86 | 87 | it("renders the listItem when the given heading's level is over 6", () => { 88 | const headingObj = new Heading('####### this is the title', 7, 100) 89 | const component = renderer.create(headingObj.generateList()) 90 | const tree = component.toJSON() 91 | 92 | expect(tree).toMatchSnapshot() 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/Heading.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generateList renders in with a heading1 1`] = ` 4 |
  • 5 | 8 | this is the title 9 | 10 |
  • 11 | `; 12 | 13 | exports[`generateList renders in with a heading2 1`] = ` 14 | 23 | `; 24 | 25 | exports[`generateList renders in with a heading3 1`] = ` 26 | 37 | `; 38 | 39 | exports[`generateList renders in with a heading4 1`] = ` 40 | 53 | `; 54 | 55 | exports[`generateList renders in with a heading5 1`] = ` 56 | 71 | `; 72 | 73 | exports[`generateList renders in with a heading6 1`] = ` 74 | 91 | `; 92 | 93 | exports[`generateList renders the listItem when the given heading's level is over 6 1`] = ` 94 |
  • 95 | 98 | this is the title 99 | 100 |
  • 101 | `; 102 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders properly 1`] = ` 4 | 38 | `; 39 | 40 | exports[` renders properly with className option 1`] = ` 41 | 72 | `; 73 | 74 | exports[` renders properly with customMatchers option 1`] = ` 75 | 109 | `; 110 | 111 | exports[` renders properly with lowestHeadingLevel option 1`] = ` 112 | 143 | `; 144 | 145 | exports[` renders properly with titleLimit option 1`] = ` 146 | 177 | `; 178 | 179 | exports[` renders properly with type option 1`] = ` 180 | 211 | `; 212 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Toc from '../index' 3 | import renderer from 'react-test-renderer' 4 | 5 | const markdownText = ` 6 | # Heading1!? 7 | This is a test sentence. 8 | 9 | ## Heading2! 10 | This is a test sentence. 11 | 12 | ### Heading3? 13 | This is a test sentence. 14 | ` 15 | 16 | describe('', () => { 17 | it('returns null if the markdownText dose not exist', () => { 18 | const component = renderer.create() 19 | const tree = component.toJSON() 20 | 21 | expect(tree).toBe(null) 22 | }) 23 | 24 | it('renders properly', () => { 25 | const component = renderer.create() 26 | const tree = component.toJSON() 27 | 28 | expect(tree).toMatchSnapshot() 29 | }) 30 | 31 | it('renders properly with titleLimit option', () => { 32 | const component = renderer.create( 33 | , 34 | ) 35 | const tree = component.toJSON() 36 | 37 | expect(tree).toMatchSnapshot() 38 | }) 39 | 40 | it('renders properly with lowestHeadingLevel option', () => { 41 | const component = renderer.create( 42 | , 43 | ) 44 | const tree = component.toJSON() 45 | 46 | expect(tree).toMatchSnapshot() 47 | }) 48 | 49 | it('renders properly with className option', () => { 50 | const component = renderer.create( 51 | , 57 | ) 58 | const tree = component.toJSON() 59 | 60 | expect(tree).toMatchSnapshot() 61 | }) 62 | 63 | it('renders properly with type option', () => { 64 | const component = renderer.create( 65 | , 72 | ) 73 | const tree = component.toJSON() 74 | 75 | expect(tree).toMatchSnapshot() 76 | }) 77 | 78 | it('renders properly with customMatchers option', () => { 79 | const customMatchers = { 80 | '[?!]': '-', 81 | } 82 | const component = renderer.create( 83 | , 90 | ) 91 | const tree = component.toJSON() 92 | 93 | expect(tree).toMatchSnapshot() 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | replaceAll, 3 | createLink, 4 | createTitle, 5 | extractHeadingsFromMd, 6 | removeCodeBlockFromMd, 7 | } from '../utils' 8 | 9 | describe('replaceAll', () => { 10 | it("won't replace any string if the given macher is {}", () => { 11 | expect(replaceAll('Test String', {})).toEqual('Test String') 12 | }) 13 | it('replaces all letters based on the given customMatchers', () => { 14 | expect(replaceAll('THIS ! IS TEST STRING!?', { '[?!]': '-' })).toEqual( 15 | 'THIS - IS TEST STRING--', 16 | ) 17 | expect( 18 | replaceAll('aaaeeeuuuuooooo', { a: 'z', e: 'y', u: 'w', o: 'x' }), 19 | ).toEqual('zzzyyywwwwxxxxx') 20 | 21 | expect(replaceAll('**ui**非依存', { '\\*': '' })).toEqual('ui非依存') 22 | 23 | expect( 24 | replaceAll('(ui/web/devices/db/external-interfaces)', { 25 | '[\\(\\)]': '', 26 | '/': '', 27 | }), 28 | ).toEqual('uiwebdevicesdbexternal-interfaces') 29 | }) 30 | }) 31 | 32 | describe('createLink', () => { 33 | it("removes # and connects each word with '-'.", () => { 34 | expect(createLink('# Test Heading ')).toEqual('test-heading') 35 | expect(createLink('## This is a test heading')).toEqual( 36 | 'this-is-a-test-heading', 37 | ) 38 | }) 39 | }) 40 | 41 | describe('createTitle', () => { 42 | it('removes #.', () => { 43 | expect(createTitle('# Test Heading', 100)).toEqual('Test Heading') 44 | }) 45 | 46 | it('shortens the string', () => { 47 | expect(createTitle('# Test Heading', 6)).toEqual('Test H..') 48 | }) 49 | }) 50 | 51 | describe('extractHeadingsFromMd', () => { 52 | const markdownText = ` 53 | # Heading1 54 | This is the first paragraph. 55 | ## Heading2 56 | This is the second paragraph. 57 | ### Heading3 58 | This is the third paragraph. 59 | ` 60 | 61 | it('extracts 1-3 headings from the given markdownText.', () => { 62 | expect(extractHeadingsFromMd(markdownText, 1, 3)).toEqual([ 63 | '# Heading1\n', 64 | '## Heading2\n', 65 | '### Heading3\n', 66 | ]) 67 | }) 68 | 69 | it('extracts 1-2 headings from the given markdownText.', () => { 70 | expect(extractHeadingsFromMd(markdownText, 1, 2)).toEqual([ 71 | '# Heading1\n', 72 | '## Heading2\n', 73 | ]) 74 | }) 75 | 76 | it('extracts 2-3 headings from the given markdownText.', () => { 77 | expect(extractHeadingsFromMd(markdownText, 2, 3)).toEqual([ 78 | '## Heading2\n', 79 | '### Heading3\n', 80 | ]) 81 | }) 82 | 83 | it('extracts headings from the given markdownText.', () => { 84 | expect(extractHeadingsFromMd(markdownText, 2, 2)).toEqual(['## Heading2\n']) 85 | }) 86 | }) 87 | 88 | describe('removeCodeBlockFromMd', () => { 89 | const markdownText = ` 90 | # Heading1 91 | This is the first paragraph. 92 | ## Heading2 93 | This is the second paragraph. 94 | \`\`\` 95 | ### This is typical codeblock 96 | \`\`\` 97 | Text between codeblock 98 | \`\`\`\` 99 | \`\`\` 100 | ### Escaped codeblock 101 | \`\`\` 102 | \`\`\`\` 103 | Text between codeblock 104 | ~~~ 105 | \`\`\` 106 | ### Escaped codeblock 107 | \`\`\` 108 | ~~~` 109 | 110 | it('removes codeblock from the given markdownText.', () => { 111 | expect(removeCodeBlockFromMd(markdownText)).toEqual(` 112 | # Heading1 113 | This is the first paragraph. 114 | ## Heading2 115 | This is the second paragraph. 116 | 117 | Text between codeblock 118 | 119 | Text between codeblock 120 | `) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styles from './styles.module.css' 3 | import { extractHeadingsFromMd, removeCodeBlockFromMd } from './utils' 4 | import Heading, { newHeading } from './Heading' 5 | 6 | type TocProps = { 7 | /* 8 | The markdown text you want to creat a TOC from. 9 | */ 10 | markdownText: string 11 | /* 12 | The maximum length of each title in the TOC. 13 | */ 14 | titleLimit?: number 15 | /* 16 | The highest level of headings you want to extract from the given markdownText. 17 | */ 18 | highestHeadingLevel?: number 19 | /* 20 | The lowest level of headings you want to extract from the given markdownText. 21 | */ 22 | lowestHeadingLevel?: number 23 | /* 24 | The custom className. 25 | You can style the TOC like this. 26 | 27 | ```css 28 | .customClassName { 29 | border: solid 1px; 30 | } 31 | .customClassName > li { 32 | padding-bottom: 10px; 33 | } 34 | ``` 35 | */ 36 | className?: string 37 | /* 38 | The type of a TOC you want to use. 39 | */ 40 | type?: 'default' | 'raw' // "fixed-left" | "fixed-right" | "material" | "bootstrap" 41 | /* 42 | The custom options for the anchors 43 | */ 44 | customMatchers?: CustomMatchers 45 | } 46 | 47 | const Toc = ({ 48 | markdownText, 49 | titleLimit, 50 | highestHeadingLevel, 51 | lowestHeadingLevel, 52 | className, 53 | type, 54 | customMatchers, 55 | }: TocProps): JSX.Element | null => { 56 | if (!markdownText) return null 57 | // Set default values 58 | const limit = titleLimit ? titleLimit : 200 59 | const defaultClass = type === 'raw' ? '' : 'react-toc' 60 | const customClass = className || defaultClass 61 | const headingLevels: number[] = [ 62 | highestHeadingLevel || 1, 63 | lowestHeadingLevel || 6, 64 | ] 65 | 66 | // Style settings 67 | const style: string | undefined = styles[customClass] || className 68 | 69 | // Mutate headings 70 | const matchedHeadings: RegExpMatchArray | null = extractHeadingsFromMd( 71 | removeCodeBlockFromMd(markdownText), 72 | headingLevels[0], 73 | headingLevels[1], 74 | ) 75 | const headingObjects = matchedHeadings?.map((heading) => 76 | newHeading(heading, limit, customMatchers), 77 | ) 78 | const headingTags: 79 | | JSX.Element[] 80 | | undefined = headingObjects?.map((heading: Heading) => 81 | heading.generateList(), 82 | ) 83 | 84 | if (!headingTags) return null 85 | 86 | return ( 87 |
      88 | {headingTags.map((heading: JSX.Element, index: number) => ( 89 | {heading} 90 | ))} 91 |
    92 | ) 93 | } 94 | 95 | export default Toc 96 | -------------------------------------------------------------------------------- /src/styles.module.css: -------------------------------------------------------------------------------- 1 | /* add css styles here (optional) */ 2 | 3 | .react-toc > li, 4 | .react-toc > ul > li, 5 | .react-toc > ul > ul > li, 6 | .react-toc > ul > ul > ul > li, 7 | .react-toc > ul > ul > ul > ul > li, 8 | .react-toc > ul > ul > ul > ul > ul > li { 9 | padding-bottom: 10px; 10 | } 11 | 12 | .react-toc > li > a, 13 | .react-toc > ul > li > a, 14 | .react-toc > ul > ul > li > a, 15 | .react-toc > ul > ul > ul > li > a, 16 | .react-toc > ul > ul > ul > ul > li > a, 17 | .react-toc > ul > ul > ul > ul > ul > li > a { 18 | color: #dc014e; 19 | text-decoration: none; 20 | } 21 | 22 | .react-toc > li > a:hover, 23 | .react-toc > ul > li > a:hover, 24 | .react-toc > ul > ul > li > a:hover, 25 | .react-toc > ul > ul > ul > li > a:hover, 26 | .react-toc > ul > ul > ul > ul > li > a:hover, 27 | .react-toc > ul > ul > ul > ul > ul > li > a:hover { 28 | color: #565656; 29 | } 30 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default CSS definition for typescript, 3 | * will be overridden with file-specific definitions by rollup 4 | */ 5 | declare module "*.css" { 6 | const content: { [className: string]: string }; 7 | export default content; 8 | } 9 | 10 | interface SvgrComponent 11 | extends React.StatelessComponent> {} 12 | 13 | declare module "*.svg" { 14 | const svgUrl: string; 15 | const svgComponent: SvgrComponent; 16 | export default svgUrl; 17 | export { svgComponent as ReactComponent }; 18 | } 19 | 20 | type CustomMatchers = { [key: string]: string } 21 | 22 | interface TocProps { 23 | /* 24 | The markdown text you want to creat a TOC from. 25 | */ 26 | markdownText: string 27 | /* 28 | The maximum length of each title in the TOC. 29 | */ 30 | titleLimit?: number | undefined 31 | /* 32 | The highest level of headings you want to extract from the given markdownText. 33 | */ 34 | highestHeadingLevel?: number 35 | /* 36 | The lowest level of headings you want to extract from the given markdownText. 37 | */ 38 | lowestHeadingLevel?: number 39 | /* 40 | The custom className. 41 | You can style the TOC like this. 42 | 43 | ```css 44 | .customClassName { 45 | border: solid 1px; 46 | } 47 | .customClassName > li { 48 | padding-bottom: 10px; 49 | } 50 | ``` 51 | */ 52 | className?: string 53 | /* 54 | The type of a TOC you want to use. 55 | */ 56 | type?: 'default' | 'raw' // "fixed-left" | "fixed-right" | "material" | "bootstrap" 57 | } 58 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | // Replaces all the specified letters. 2 | const replaceAll = (retStr: string, customMatchers: CustomMatchers): string => { 3 | for (const key in customMatchers) { 4 | retStr = retStr.replace(new RegExp(key, 'g'), customMatchers[key]) 5 | } 6 | return retStr 7 | } 8 | 9 | // Removes # and connects each word with '-'. 10 | const createLink = (string: string): string => { 11 | const shapedString = string.toLowerCase().replace(/^#+\s/, '').trimRight() 12 | const anchor = shapedString.split(' ').join('-') 13 | return anchor 14 | } 15 | 16 | // It removes # from the given string. And it shortens the string if its longer than "stringLimit". 17 | const createTitle = (string: string, stringLimit: number): string => { 18 | const rawTitle = string.replace(/^#+\s/g, '') 19 | 20 | if (rawTitle.length >= stringLimit) 21 | return `${rawTitle.slice(0, stringLimit)}..` 22 | 23 | return rawTitle 24 | } 25 | 26 | // It extracts headings from the given markdownText. 27 | const extractHeadingsFromMd = ( 28 | markdownText: string, 29 | highestTargetHeadings: number, 30 | lowestTargetHeadings: number, 31 | ): RegExpMatchArray | null => { 32 | const headingRegex = new RegExp( 33 | `^#{${highestTargetHeadings},${lowestTargetHeadings}}\\s.+(\\n|\\r|\\r\\n)`, 34 | 'gm', 35 | ) 36 | return markdownText.match(headingRegex) 37 | } 38 | 39 | const removeCodeBlockFromMd = (markdownText: string): string => { 40 | const codeBlockRegex = new RegExp( 41 | '((````[a-z]*\n[\\s\\S]*?\n````)|(```[a-z]*\n[\\s\\S]*?\n```)|(~~~[a-z]*\n[\\s\\S]*?\n~~~))', 42 | 'gms', 43 | ) 44 | return markdownText.replace(codeBlockRegex, '') 45 | } 46 | 47 | export { 48 | replaceAll, 49 | createLink, 50 | createTitle, 51 | extractHeadingsFromMd, 52 | removeCodeBlockFromMd, 53 | } 54 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build", 4 | "module": "esnext", 5 | "target": "es5", 6 | "lib": ["es6", "dom", "es2016", "es2017"], 7 | "sourceMap": true, 8 | "allowJs": false, 9 | "jsx": "react", 10 | "declaration": true, 11 | "moduleResolution": "node", 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "suppressImplicitAnyIndexErrors": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "esModuleInterop": true 21 | }, 22 | "include": ["src"], 23 | "exclude": ["node_modules", "build", "dist", "example", "rollup.config.js"] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } --------------------------------------------------------------------------------