Next.js × MDX ブログに tocbot でいい感じの Table of Contents を作る

はじめに

tocbot で Table of Contents を作りました。Table of Contents とは記事の見出し(heading 要素)をまとめた目次を指します。例えば、デスクトップのような広い画面でこのサイトの記事を見るとサイドバーに表示されます。

tocbot はその目次を自動生成してくれるライブラリです。記事の任意の場所にジャンプしたり、スクロールに応じて動的にスタイルを変化させたりすることで UX 向上に繋がります。zenn.dev でも利用しているようです。かっこいい UI ですよね。

本記事では、Next.js × MDX での tocbot の使い方をコード例を示しながら紹介します。いい感じの Table of Contents を作りましょう。

環境

  • Next.js: v10.0.5
  • MDX: v1.6.22

tocbot を追加する

まず tocbot パッケージをプロジェクトにインストールします。

1yarn add tocbot
2// or
3// npm install tocbot

TOC コンポーネントを作る

toc.tsx
1import { useEffect } from 'react'
2import tocbot from 'tocbot'
3
4const Toc: React.VFC = () => {
5  useEffect(() => {
6    tocbot.init({
7      tocSelector: '.toc',
8      contentSelector: '.post',
9      headingSelector: 'h1, h2, h3',
10    })
11  }, [])
12
13  return () => tocbot.destroy()
14}
15
16export default Toc

useEffect() で初期化する

useEffect 内で tocbot.init() して初期化します。return () => tocbot.destroy()はクリーンナップ関数です。次のレンダー時にtocbot.init()する直前で呼ばれ、前回の tocbot の結果を削除します。また第二引数に空の配列[]を渡すことで最初のマウント時以外は useEffect() が実行されないようにします。

tocbot.init() の基本的なオプション

tocSelector

目次自身を示す DOM の CSS クラスを指定します。この場合は.tocです。最終的にreturn <div className="toc" />というコンポーネントを返しています。

1<div class="toc">
2  <ol class="toc-list">
3    ...
4  </ol>
5</div>

実際の html に変換されると構造は上記のように<div class="toc">...</div>で囲う結果になります。

contentSelector

目次を生成する対象の heading 要素を持つ DOM の CSS クラスを指定します。この場合は.postです。

post.tsx
1<Layout>
2  <article>
3    <h1 className={styles.headingXl}>{meta.title}</h1>
4    <LightText className={styles.lightText}>
5      {meta.tags.map((t) => (
6        <Tag key={t} tag={t} />
7      ))}
8      <br />
9      <Date date={meta.date} />
10    </LightText>
11    <div className="post">
12      <MDXProvider components={MDXComponents}>{children}</MDXProvider>
13    </div>
14  </article>
15  <ImageOverlay />
16</Layout>

このブログの実装を例にすると、記事コンテンツは{children}で表示されます。その直上に<div className="post">を配置することで tocbot に記事コンテンツの heading タグを認識してもらいます。

<MDXProvider> はスタイルコンポーネントです。ひとまず無視して大丈夫です。

headingSelector

どの heading タグを目次として生成するかを指定します。例えば'h2, h3, h4'とすると h1 や h5 は無視されます。

heading タグの id 属性に見出し文字列を設定する

目次にページ内リンクとしての機能を持たせるために必要です。 他のマークダウン変換ライブラリ、例えば remark を利用している場合は remark-autolink-headings などを使うと良いでしょう。 今回は MDX を利用している場合を紹介します。

mdxComponents.tsx
1const MDXComponents: Components = {
2  return (
3    h1: (props) => {
4      return (
5        <h1 id={props.children} className={styles.h1}>
6          {props.children}
7        </h1>
8      )
9    },
10    h2: (props) => {
11      return (
12        <h2 id={props.children} className={styles.h2}>
13          {props.children}
14        </h2>
15      )
16    },
17    h3: (props) => {
18      return (
19        <h3 id={props.children} className={styles.h3}>
20          {props.children}
21        </h3>
22      )
23    },
24  )
25}
post.tsx
1<div className="post">
2  <MDXProvider components={MDXComponents}>{children}</MDXProvider>
3</div>

props.childrenに見出しの文字列が渡されます。それを id に設定しています。

例えば次のようにマークダウンファイルは HTML に変換されます。

mdx
1## 見出し
2
3本文です。
4
1<h1 id="見出し" class="mdxComponents_h1__2IZ3_">見出し</h1>
2<p>本文です</p>

これを tocbot がいい感じに読み込ます。

1<a href="#見出し" class="toc-link node-name--H1 ">見出し</a>

というリンクを生成します。

手前味噌ながら、このブログの実装を紹介します。

toc.tsx
1import { useEffect } from 'react'
2import tocbot from 'tocbot'
3
4export const Toc: React.VFC = () => {
5  useEffect(() => {
6    tocbot.init({
7      tocSelector: '.toc',
8      contentSelector: '.post',
9      headingSelector: 'h1, h2, h3',
10    })
11
12    return () => tocbot.destroy()
13  }, [])
14
15  return (
16    <>
17      <div className="toc" />
18      <style jsx global>{`
19        .toc {
20          background-color: var(--content-bg-primary);
21          border: 1px solid var(--content-border);
22          border-radius: 0.25rem;
23          padding: 1rem;
24          font-size: 0.875rem;
25        }
26
27        .toc-list .toc-list {
28          padding-left: 1rem;
29          padding-top: 0.5rem;
30        }
31
32        .toc-list-item {
33          padding-bottom: 0.5rem;
34        }
35
36        .toc-list-item:last-child {
37          padding-bottom: 0;
38        }
39
40        .toc-link {
41          color: var(--text-secondary);
42        }
43
44        .is-active-link {
45          color: var(--text-primary);
46          font-weight: 700;
47        }
48      `}</style>
49    </>
50  )
51}

色に CSS 変数を使っているのでコピペでは使えません。適宜置き換えてください。

まとめ

  • tocbot.init()時に目次の DOM とコンテンツの DOM の CSS クラスを指定する
  • heading タグにちゃんと id 属性を設定する