Web RecordWEB Record
Next.js

NextJS製のMDXブログにTOC(目次)をサーバーサイドコンポーネントで実装する。

2024.06.17
NextJS製のMDXブログにTOC(目次)をサーバーサイドコンポーネントで実装する。

このブログはNextJS製のブログで、内部ではMDXを使用しています。

今回、TOC(目次)を表示する機能を実装したので、その方法を解説します。

TOCをサーバーサイド側で実装するか、フロントエンド側で実装するか

このブログでのTOCはサーバーサイド側で生成するようにしています。

フロントエンド側でTOCの生成をするように実装すると、レンダリング時にちらつきが発生するためです。

TOCの実装方法を調べるとtoc-botを使用しての手法がよく出てきます。

しかし、toc-botuseEffectの使用が前提になっているため、サーバーサイド側でTOCの生成ができません。

なので、今回はtoc-botを使用せず別の方法でサーバーサイド側でTOCを実装するようにしました。

TOC作成方針

TOCを実装する前に、どの項目をTOCとして表示するかを決めなければなりません。

このブログでは、h2h3の要素をTOCとして表示するようにしました。

また、TOCの要素をクリックしたときに、該当のh2h3にスクロールするようにもしたかったので、

アンカーリンク先として、h2h3の要素のidに要素のテキストを設定するようにしました。

TOC側で表示するh2h3aタグを付与し、href属性#<要素のテキスト>を設定します。

このようにすることで、TOC側の要素をクリックしたときに該当するh2またはh3までスクロールしてくれるようになります。

例えば、下記のようなMDXがあった場合、

MDXの例
1## Heading2
2
3xxxxxxxxxx
4
5### Heading3
6
7xxxxxxxxxx

レンダリング後は下記のようになるイメージです。

レンダリング後のイメージ
1
2<!-- 自動生成されるTOC -->
3<ul>
4<!-- TOCの各要素のaタグのhrefにはクリック時にスクロールできるように#<該当のid>を設定する -->
5<li class="heading-2"><a href="#Heading2">Heading2</a></li>
6<li class="heading-3"><a href="#Heading3">Heading3</a></li>
7</ul>
8
9<!-- レンダリング後の記事内容 -->
10
11<!-- h2のidには要素の中身を設定する -->
12<h2 id="Heading2">Heading2</h2>
13
14<p>xxxxxxxxxx</p>
15
16<!-- h3のidには要素の中身を設定する -->
17<h3 id="Heading3">Heading3</h3>
18
19<p>xxxxxxxxxx</p>

TOCに使用する要素の抜き出し

それでは実装に入っていきます。

まず、MDXのコンテンツから、Heading2とHeading3の要素を抜き出してきます。

自前の実装は大変そうなので、MarkdownコンテンツからHeadingを抜き出してくれるパッケージを使用します。

下記のextract-md-headingsというパッケージになります。

kaf-lamed-beyt/extract-md-headings

インストールします。

bash
1npm i extract-md-headings

このパッケージの使い方はシンプルで、extractHeadingsという関数に読み取るMDXのパスを渡してあげるだけです。

すると、下記の型でHeadingの情報が返ってきます。

ts
1{
2    id: number; ランダムなID
3    slug: string; タイトルから英字を抜き出し小文字にした文字列
4    title: string; Heading内のタイトル
5    level: number; Headnigのレベル。例えば##だとlevelは2
6}[]

今回はこの返り値のうち、titlelevelを使用します。

使用例はこんな感じです。h2h3の要素のみ欲しいので、Headingの情報を抜き出したあとに、levelの値が23のもののみフィルタリングしています。

使用例
1import { extractHeadings } from 'extract-md-headings'
2
3type Heading = {
4    level: number;
5    title: string;
6    slug: string;
7}
8
9function readHeadings(path: string): Heading[] {
10    const headings = extractHeadings(filePath);
11
12    return headings
13        .filter((heading) => {
14          return heading.level === 2 || heading.level === 3
15        })
16        .map((heading) => {
17          return { level: heading.level, title: heading.title, slug: heading.title }
18        })
19}
20
21const headings = readHeadings('MDXのパス');
22
23console.log(headings);

console.logで下記のように出力されます。

json
1[
2  {
3    level: 2,
4    title: 'TOCをサーバーサイド側で実装するか、フロントエンド側で実装するか',
5    slug: 'TOCをサーバーサイド側で実装するか、フロントエンド側で実装するか'
6  },
7  { level: 2, title: 'TOC作成方針', slug: 'TOC作成方針' },
8  {
9    level: 2,
10    title: 'extract-md-headings のインストール',
11    slug: 'extract-md-headings のインストール'
12  }
13]

TOCの生成

ここまでで、MDXのコンテンツからTOCに使用する要素を抜き出すことができました。

次はその要素を使用してTOCを生成していきます。

実際にこのブログで使用しているTOCコンポーネントになります。

TOC.tsx
1export default function TOC(props: {
2  headings: { title: string; slug: string; level: number }[]
3}) {
4  return (
5    <div className="border border-base px-7 py-5 rounded-md">
6      <div className="text-2xl font-medium mb-3 pb-1 border-b-weakPrint border-b">
7        目次
8      </div>
9      <ul>
10        {props.headings.map((heading, index) => {
11          const padding = heading.level === 2 ? 'pl-0' : 'pl-4 md:pl-8'
12
13          return (
14            <li className={`${padding} md:pb-2`} key={index}>
15              <a href={`#${heading.slug}`} className="text-lg text-weakPrint">
16                {heading.title}
17              </a>
18            </li>
19          )
20        })}
21      </ul>
22    </div>
23  )
24}

先ほど抜き出したTOCの要素をTOCコンポーネントに渡してあげて、それをもとに生成しているだけです。

TOCの各要素をaタグで囲ってあげて、hrefには#${heading.slug}でアンカーリンクを設定してクリック時に該当箇所へスクロールするようにしてます。

また、TOCに表示する際は、h2の要素とh3の要素にインデントをつけてあげたいので、levelの値を見て、paddingを振り分けています。

paddingを振り分けている箇所
1// levelを見てlevelが3なら広めのpaddingを付与する
2const padding = heading.level === 2 ? 'pl-0' : 'pl-4 md:pl-8'

Heading生成時にidを付与する

ここまででTOCの生成が完了しました。

最後に、TOCの要素をクリックしたときにスクロールできるようにします。

TOC側にはここまででhrefにアンカーリンクを設定したので、アンカーリンク先のHeading要素にidを付与していきます

このブログのTOCでは、idにHeading要素のタイトルを設定しています。

MDXを使用している場合は、MDXの内容からHTML要素へのパースをmdx-components.tsxでしているかと思いますので

Heading要素のパース時にidにタイトルを付与するようにmdx-components.tsxを変更します。

mdx-components.tsx
1export function useMDXComponents(components: MDXComponents): MDXComponents {
2  return {
3    h2: (props) => {
4      // idに要素のタイトルを設定
5      return <h2 id={props.children.toString()}>{props.children}</h2>
6    },
7    h3: (props) => {
8      // idに要素のタイトルを設定
9      return <h3 id={props.children.toString()}>{props.children}</h3>
10    },
11    ...
12  }
13}

これでアンカーリンク先の要素にidを付与できました。

仕上げ

最後にこれまでの実装を繋げて完了です。

app/[category]/[slug]/page.tsx
1export default async function ContentPage({ params }: { params: PageParams }) {
2  const filePath = makeFilePath(params.category, params.slug)
3
4  // MDXコンテンツからHeadingの情報を取得
5  const headings = readHeadings(filePath)
6
7  return (
8    <article>
9      <!-- TOC生成 -->
10      <TOC headings={headings} />
11
12      <div className="[&>ul]:my-8 [&>ol]:my-8">{parsed.content}</div>
13    </article>
14  )
15}
16
17export async function generateStaticParams() {
18  const params: { category: string; slug: string }[] = []
19
20  ...省略...
21
22  return params
23}
24
25function readHeadings(
26  path: string
27): { level: number; title: string; slug: string }[] {
28  const headings = extractHeadings(filePath)
29
30  return headings
31    .filter((heading) => {
32      return heading.level === 2 || heading.level === 3
33    })
34    .map((heading) => {
35      return { level: heading.level, title: heading.title, slug: heading.title }
36    })
37}
TOC.tsx
1export default function TOC(props: {
2  headings: { title: string; slug: string; level: number }[]
3}) {
4  return (
5    <div className="border border-base px-7 py-5 rounded-md">
6      <div className="text-2xl font-medium mb-3 pb-1 border-b-weakPrint border-b">
7        目次
8      </div>
9      <ul>
10        {props.headings.map((heading, index) => {
11          const padding = heading.level === 2 ? 'pl-0' : 'pl-4 md:pl-8'
12
13          return (
14            <li className={`${padding} md:pb-2`} key={index}>
15              <a href={`#${heading.slug}`} className="text-lg text-weakPrint">
16                {heading.title}
17              </a>
18            </li>
19          )
20        })}
21      </ul>
22    </div>
23  )
24}
mdx-components.tsx
1export function useMDXComponents(components: MDXComponents): MDXComponents {
2  return {
3    h2: (props) => {
4      // idに要素のタイトルを設定
5      return <h2 id={props.children.toString()}>{props.children}</h2>
6    },
7    h3: (props) => {
8      // idに要素のタイトルを設定
9      return <h3 id={props.children.toString()}>{props.children}</h3>
10    },
11    ...
12  }
13}

TOCが表示されました。