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のセットアップについて知りたい場合は、以下のドキュメントをご確認ください。
- Next.jsの Getting Started のドキュメント
- Newtと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データを扱っているものとします。
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})
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演算子 を利用します。
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
を利用する書き方に変更しましょう。
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を割り当てるため、components
の h2
に対して、定義した H2
コンポーネントを渡しています。
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>
)
以下のように、見出し部分がリンクに変わっていることがわかります。
見出し部分のHTMLは、以下のようになっています。
<h2>
<a id="ステップ1" href="#ステップ1">ステップ1</a>
</h2>
目次を作成し、見出しに遷移できるようにする
次に目次を作成し、見出しに遷移できるようにしましょう。
目次の表示にも ReactMarkdown
を利用します。allowedElements
を利用すると、指定したタグのみをフィルタして利用できます。
ここでは目次には h2
要素のみを利用するため、allowedElements
に ['h2']
を指定します。
見出しにidを設定した時と同様、目次の表示用に components
の h2
に対して TocH2
のカスタムコンポーネントを割り当てます。
(省略)
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
で定義していますが、お好みで設定してください。
以下のように、目次が作成されていれば成功です。