Noh | エンジニア向け情報共有コミュニティ
Signup / Login

react-markdownで入れ子に対応した目次を簡単に表示する

javascript
typescript
react
reactmarkdown

投稿日: 2024/03/01

nohに目次機能を追加したので、その時の実装を紹介します。

作りたい目次の要件

  • react-markdownを使用
  • 記事本文中に目次を挿入しない。記事本文とは別に目次を表示する。
  • h1とh2のように入れ子になってる要素は、目次においても入れ子にする

以下の画像のような目次機能を最終的に作成します。

調査

調べると目次を表示する実装を紹介するページはいくつも見つかります。

react-markdownのcomponents引数で生成する方法

以下のリンクなどで紹介されてますが、検索して見つかるのはこれと同様の実装がほとんどでした。

https://www.newt.so/docs/tutorials/generate-anchor-links-using-react-markdown

react-markdownはcomponentsという引数でカスタマイズができるのですが、この機能を使ってheading要素(h1やh2などのこと)だけ表示するようにしています。

この方法の問題点は入れ子的なリストを表示するのが難しいことです。参考にしたサイトもh2だけを目次に表示しています。
したがって、この方法での実装は避けることにします。

react-markdown公式リポジトリのissueにあった実装

古いissueですが実装している人がいます。
先ほどと同じように、react-markdownのcomponents引数でカスタマイズしています。入れ子に対応してそうに見えますが、かなりハックな実装になっているように感じます。
この方法も使わないことにします。

remark-tocを使った実装

マークダウンの文字列内に目次を追加してくれるプラグインのようです。
今回は記事の本文とは別の場所に目次を表示したいので、使いませんでした。

markdown-tocを使った実装

markdown-tocというpackageについても先ほどのissueにコメントされていました。

マークダウンの文字列から目次だけを含むマークダウンの文字列に変換してくれるようです。このpackageはかなりよさそうです。
メンテナンスも続いてますし、目次だけを含むマークダウン文字列を作成するアプローチは実装を簡単にしてくれます。

複雑な処理ではないので、今回はあえてmarkdown-tocを使わずに自作しようと思います(package見つける前に作っちゃった)。

実装

目次を表示

記事本文のマークダウン文字列をパースして、目次のマークダウン文字列にします。
unifiedremark-parsereact-markdownに含まれてるので、インストールする必要はありません。

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 }

すごく省略してますが、rootにはマークダウンをパースした結果が入っています。

console.log(root)

{ "type": "root", "children": [ { "type": "heading", "depth": 1, "children": [ { "type": "text", "value": "前提" } ] }, { "type": "heading", "depth": 2, "children": [ { "type": "text", "value": "Canvasサイズを指定する" } ] }

返り値はこんな感じになります。

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)

目次を表示するコンポーネント全体のコードを以下に示します。
TocMarkdownコンポーネントは記事本文のマークダウン文字列を引数に受け取り、目次を表示します。ulaでレイアウトなどの調整を行っています。

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> ) }

目次のリンクでスクロールするために見出しにidをつける

記事本文を表示するコンポーネントを編集します。目次を表示するコンポーネントではないので注意してください。

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> ) }

これで完成です。

余談

記事書きながらデプロイ作業始めたら、デプロイ終わるより先に記事を投稿しちゃった。

yosi

Noh を作ってるエンジニア。