react-markdownを利用して、Next.js (App Router)・Newt製サイトに目次を作成する

最終更新日:

Table of contents

react-markdown は、マークダウンをレンダリングするReactコンポーネントを提供するライブラリです。マークダウンの変換方法をカスタマイズしたり、特定の要素を指定して、カスタムコンポーネントを適用できます。
このチュートリアルでは、react-markdownを利用して、Next.js (App Router)・Newtで作られたサイトに、目次を追加する方法を紹介します。

記事内で使用している主なソフトウェアのバージョン

  • Next.js(next): 13.5.1
  • react-markdown(react-markdown): 9.0.0

前提条件

  • Next.jsのプロジェクトを作成済みであること

Next.jsのセットアップについて知りたい場合は、以下のドキュメントをご確認ください。

概要

Newtから返却されるマークダウンテキストの内容をもとに、見出し(h2要素)を抜き出して、目次を作成します。
以下のステップで説明します。

  • HTMLデータの見出しにidを設定する
  • 目次を作成し、見出しに遷移できるようにする

1. react-markdownの準備

1-1. react-markdownをインストールする

まずは、react-markdown をインストールします。

## npmを利用している場合
npm install --save react-markdown

## yarnを利用している場合
yarn add react-markdown

1-2. 記事本文の表示にreact-markdownを利用する

もともと以下のような投稿取得メソッドと、投稿詳細ページがあるとします。
article モデルの body フィールドにはマークダウンフィールドが定義されており、dangerouslySetInnerHTML で直接HTMLデータを扱っているものとします。

lib/newt.ts
1import 'server-only'
2import { createClient } from 'newt-client-js'
3import { cache } from 'react'
4import type { Article } from '@/types/article'
5
6const client = createClient({
7  spaceUid: process.env.NEWT_SPACE_UID + '',
8  token: process.env.NEWT_CDN_API_TOKEN + '',
9  apiType: 'cdn',
10})
11
12export const getArticleBySlug = cache(async (slug: string) => {
13  const article = await client.getFirstContent<Article>({
14    appUid: 'blog',
15    modelUid: 'article',
16    query: {
17      slug,
18      select: ['_id', 'title', 'slug', 'body'],
19    },
20  })
21  return article
22})
app/articles/[slug]/page.tsx
1import { getArticleBySlug } from '@/lib/newt'
2import styles from '@/app/page.module.css'
3
4type Props = {
5  params: {
6    slug: string
7  }
8}
9
10export default async function Article({ params }: Props) {
11  const { slug } = params
12  const article = await getArticleBySlug(slug)
13  if (!article) return
14
15  return (
16    <main className={styles.main}>
17      <h1>{article.title}</h1>
18      <div dangerouslySetInnerHTML={{ __html: article.body }} />
19    </main>
20  )
21}

ここに、まずreact-markdownを追加します。
react-markdownでは、マークダウンテキストを直接扱うため、body の返却形式をHTMLではなく、マークダウンテキストにしましょう。
fmt演算子 を利用します。

lib/newt.ts
export const getArticleBySlug = cache(async (slug: string) => {
  const article = await client.getFirstContent<Article>({
    appUid: 'blog',
    modelUid: 'article',
    query: {
      slug,
      select: ['_id', 'title', 'slug', 'body'],
      body: {
        fmt: 'text',
      },
    },
  })
  return article
})

続いて、dangerouslySetInnerHTML の部分、ReactMarkdown を利用する書き方に変更しましょう。

app/articles/[slug]/page.tsx
import ReactMarkdown from 'react-markdown'
import { getArticleBySlug } from '@/lib/newt'
import styles from '@/app/page.module.css'

(省略)

export default async function Article({ params }: Props) {
  const { slug } = params
  const article = await getArticleBySlug(slug)
  if (!article) return

  return (
    <main className={styles.main}>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.body }} />
      <div>
        <ReactMarkdown>{article.body}</ReactMarkdown>
      </div>
    </main>
  )
}

これでreact-markdownを利用できるようになりました。

2. 見出しにidを設定する

見出しにidを設定する処理を追加します。見出しのテキストをidとして設定します。

ReactMarkdown では components オプションを利用して、特定の要素に対してカスタムコンポーネントを割り当てることができます。ここでは h2 要素に対して、idを割り当てるため、componentsh2 に対して、定義した H2 コンポーネントを渡しています。

app/articles/[slug]/page.tsx
import ReactMarkdown from 'react-markdown'
import { getArticleBySlug } from '@/lib/newt'
import styles from '@/app/page.module.css'
import type { ClassAttributes, HTMLAttributes } from 'react'
import type { ExtraProps } from 'react-markdown'

(省略)

export default async function Article({ params }: Props) {
  const { slug } = params
  const article = await getArticleBySlug(slug)
  if (!article) return

  const H2 = ({
    node,
    children,
  }: ClassAttributes<HTMLHeadingElement> &
    HTMLAttributes<HTMLHeadingElement> &
    ExtraProps) => {
    const title =
      node?.children[0] && 'value' in node?.children[0]
        ? node?.children[0].value
        : ''
    return (
      <h2>
        <a id={title} href={`#${title}`}>
          {children}
        </a>
      </h2>
    )
  }

  return (
    <main className={styles.main}>
      <h1>{article.title}</h1>
      <div>
        <ReactMarkdown>{article.body}</ReactMarkdown>
        <ReactMarkdown
          components={{
            h2: H2,
          }}
        >
          {article.body}
        </ReactMarkdown>
      </div>
    </main>
  )
}

H2コンポーネントでは、見出しのテキストを title として取得し、以下のように変換して返します。これで、id付きのリンクが作成されます。

return (
  <h2>
    <a id={title} href={`#${title}`}>
      {children}
    </a>
  </h2>
)

以下のように、見出し部分がリンクに変わっていることがわかります。
cheerio_toc1.jpg

見出し部分のHTMLは、以下のようになっています。

<h2>
  <a id="ステップ1" href="#ステップ1">ステップ1</a>
</h2>

目次を作成し、見出しに遷移できるようにする

次に目次を作成し、見出しに遷移できるようにしましょう。

目次の表示にも ReactMarkdown を利用します。allowedElements を利用すると、指定したタグのみをフィルタして利用できます。
ここでは目次には h2 要素のみを利用するため、allowedElements['h2'] を指定します。
見出しにidを設定した時と同様、目次の表示用に componentsh2 に対して TocH2 のカスタムコンポーネントを割り当てます。

app/articles/[slug]/page.tsx
(省略)

export default async function Article({ params }: Props) {
(省略)

  const TocH2 = ({
    node,
  }: ClassAttributes<HTMLHeadingElement> &
    HTMLAttributes<HTMLHeadingElement> &
    ExtraProps) => {
    const title =
      node?.children[0] && 'value' in node?.children[0]
        ? node?.children[0].value
        : ''
    return (
      <li key={title}>
        <a href={`#${title}`}>{title}</a>
      </li>
    )
  }

(省略)

  return (
    <main className={styles.main}>
      <h1>{article.title}</h1>
      <div className={styles.TableOfContents}>
        <h2>目次</h2>
        <ul>
          <ReactMarkdown
            allowedElements={['h2']}
            components={{
              h2: TocH2,
            }}
          >
            {article.body}
          </ReactMarkdown>
        </ul>
      </div>
      <div>
        <ReactMarkdown
          components={{
            h2: H2,
          }}
        >
          {article.body}
        </ReactMarkdown>
      </div>
    </main>
  )
}

TocH2コンポーネントでは、見出しのテキストを title として取得し、以下のように変換して返します。これで、見出しに遷移できるようになりました。

return (
  <li key={title}>
    <a href={`#${title}`}>{title}</a>
  </li>
)

スタイルについて、ここでは styles.TableOfContents で定義していますが、お好みで設定してください。

以下のように、目次が作成されていれば成功です。
cheerio_toc2.png

NewtMade in Newt