Display a nested table of contents easily with react-markdown.
投稿日: 2024/03/01
更新日: 2024/07/01
This article is a translation of the following article.
I added a table of contents feature to noh
, so I will introduce the implementation at that time.
Requirements for creating a table of contents
- Use react-markdown.
- Display the table of contents separately from the article body.
- Elements nested like h1 and h2 should also be nested in the table of contents.
I will create a table of contents feature similar to the image below.
Survey
There are many pages that introduce implementations to display a table of contents when searching.
How to create with the components
parameter in react-markdown
The following link and others introduce it, but most of what can be found by searching is similar implementation to this.
In react-markdown
, customization can be done using the components
argument. Using this feature, I am displaying only heading elements (such as h1 and h2).
The problem with this method is that it is difficult to display nested lists. The website I referred to only displays h2 in the table of contents. Therefore, we will avoid implementing it this way.
Implementation found in the official repository issue of react-markdown
This is an old issue but there is someone who has implemented it.
Just like earlier, I am customizing using the components argument of react-markdown. It seems to support nesting, but the implementation feels quite hacky.
I have decided not to use this method as well.
Implementation using remark-toc
It seems like a plugin that adds a table of contents within a Markdown string.
Since I wanted to display the table of contents in a different location from the main body of the article this time, I did not use it.
Implementation using markdown-toc
The package called markdown-toc was also commented on in the previous issue.
It seems that this package converts a Markdown string
into a Markdown string containing only the table of contents
. This package looks quite good. Maintenance is ongoing, and the approach to creating a Markdown string containing only the table of contents makes implementation easy.
Since it's not a complex process, this time I'll try to make it myself without using markdown-toc on purpose (I ended up creating it before finding the package).
Implementation
Display Table of Contents
Parse the markdown text of the article content into a markdown string for the table of contents.
unified
and remark-parse
are included in react-markdown
, so there is no need to install them.
const parseTocMd = (body: string) => { const root = unified().use(remarkParse).parse(body) let tocMd = "" if (root.children) { root.children.map((node) => { if ( node.type === "heading" && node?.children[0] && "value" in node?.children[0] ) { if (node.depth === 1) { tocMd += `- [${node?.children[0].value}](#${encodeURIComponent( node.children[0].value )})\n` } else if (node.depth === 2) { tocMd += ` - [${node?.children[0].value}](#${encodeURIComponent( node.children[0].value )})\n` } } }) } return tocMd }
I have heavily abbreviated it, but root
contains the result of parsing the Markdown.
console.log(root)
{ "type": "root", "children": [ { "type": "heading", "depth": 1, "children": [ { "type": "text", "value": "前提" } ] }, { "type": "heading", "depth": 2, "children": [ { "type": "text", "value": "Canvasサイズを指定する" } ] }
The return value will look like this.
console.log(parseTocMd(props.body))
- [前提](#%E5%89%8D%E6%8F%90) - [react-three-fiber を用いて表示する](#react-three-fiber%20%E3%82%92%E7%94%A8%E3%81%84%E3%81%A6%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B) - [Canvasサイズを指定する](#Canvas%E3%82%B5%E3%82%A4%E3%82%BA%E3%82%92%E6%8C%87%E5%AE%9A%E3%81%99%E3%82%8B) - [Vrmを表示する](#Vrm%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B) - [補足:three-vrmを用いずにuseGLTFを用いる](#%E8%A3%9C%E8%B6%B3%EF%BC%9Athree-vrm%E3%82%92%E7%94%A8%E3%81%84%E3%81%9A%E3%81%ABuseGLTF%E3%82%92%E7%94%A8%E3%81%84%E3%82%8B) - [react-three-fiber を使わずに Three.js + React で表示する](#react-three-fiber%20%E3%82%92%E4%BD%BF%E3%82%8F%E3%81%9A%E3%81%AB%20Three.js%20%EF%BC%8B%20React%20%E3%81%A7%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B) - [まとめ](#%E3%81%BE%E3%81%A8%E3%82%81) - [追記 2022/10/03](#%E8%BF%BD%E8%A8%98%202022%2F10%2F03)
The code for the entire component that displays the table of contents is shown below.
The TocMarkdown
component takes the Markdown string of the article body as an argument and displays the table of contents. Adjustments such as layout using ul
and a
are performed.
TocMarkdown.tsx
import { FC } from "react" import ReactMarkdown from "react-markdown" import { unified } from "unified" import remarkParse from "remark-parse" import { Link as MuiLink, Typography } from "@mui/material" type Props = { body: string } const parseTocMd = (body: string) => { const root = unified().use(remarkParse).parse(body) let tocMd = "" if (root.children) { root.children.map((node) => { if ( node.type === "heading" && node?.children[0] && "value" in node?.children[0] ) { if (node.depth === 1) { tocMd += `- [${node?.children[0].value}](#${encodeURIComponent( node.children[0].value )})\n` } else if (node.depth === 2) { tocMd += ` - [${node?.children[0].value}](#${encodeURIComponent( node.children[0].value )})\n` } } }) } return tocMd } export const TocMarkdown: FC<Props> = (props) => { return ( <div> <Typography sx={{ fontWeight: "bold" }}>目次</Typography> <ReactMarkdown components={{ ul: ({ children }) => ( <ul style={{ paddingLeft: "1.5rem" }}>{children}</ul> ), a: ({ href, children }) => { return <MuiLink href={href}>{children}</MuiLink> }, }} > {parseTocMd(props.body)} </ReactMarkdown> </div> ) }
Add an id to the heading to scroll with links in the table of contents
Edit the component that displays the article body content. Please be aware that this is not the component for displaying the table of contents.
ArticleBodyMarkdown.tsx
import { FC } from "react" import ReactMarkdown from "react-markdown" import remarkBreaks from "remark-breaks" import remarkGfm from "remark-gfm" import { HeadingProps } from "react-markdown/lib/ast-to-react" type Props = { body: string } const getTitle = (props: HeadingProps) => { return props.node?.children[0] && "value" in props.node?.children[0] ? props.node?.children[0].value : "" } export const ArticleBodyMarkdown: FC<Props> = ({ body }) => { return ( <ReactMarkdown remarkPlugins={[remarkBreaks, remarkGfm]} components={{ h1: (props) => { const title = getTitle(props) return <h1 id={title}>{title}</h1> }, h2: (props) => { const title = getTitle(props) return <h2 id={title}>{title}</h2> }, }} > {body} </ReactMarkdown> ) }
This is complete.
Table of Contents