NextJS製のMDXブログにTOC(目次)をサーバーサイドコンポーネントで実装する。
このブログはNextJS製のブログで、内部ではMDXを使用しています。
今回、TOC(目次)を表示する機能を実装したので、その方法を解説します。
TOCをサーバーサイド側で実装するか、フロントエンド側で実装するか
このブログでのTOCはサーバーサイド側で生成するようにしています。
フロントエンド側でTOCの生成をするように実装すると、レンダリング時にちらつきが発生するためです。
TOCの実装方法を調べるとtoc-botを使用しての手法がよく出てきます。
しかし、toc-botはuseEffectの使用が前提になっているため、サーバーサイド側でTOCの生成ができません。
なので、今回はtoc-botを使用せず別の方法でサーバーサイド側でTOCを実装するようにしました。
TOC作成方針
TOCを実装する前に、どの項目をTOCとして表示するかを決めなければなりません。
このブログでは、h2とh3の要素をTOCとして表示するようにしました。
また、TOCの要素をクリックしたときに、該当のh2かh3にスクロールするようにもしたかったので、
アンカーリンク先として、h2とh3の要素のidに要素のテキストを設定するようにしました。
TOC側で表示するh2とh3はaタグを付与し、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
インストールします。
bash1npm i extract-md-headings
このパッケージの使い方はシンプルで、extractHeadingsという関数に読み取るMDXのパスを渡してあげるだけです。
すると、下記の型でHeadingの情報が返ってきます。
ts1{ 2 id: number; ランダムなID 3 slug: string; タイトルから英字を抜き出し小文字にした文字列 4 title: string; Heading内のタイトル 5 level: number; Headnigのレベル。例えば##だとlevelは2 6}[]
今回はこの返り値のうち、titleとlevelを使用します。
使用例はこんな感じです。h2とh3の要素のみ欲しいので、Headingの情報を抜き出したあとに、levelの値が2と3のもののみフィルタリングしています。
使用例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で下記のように出力されます。
json1[ 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.tsx1export 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.tsx1export 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.tsx1export 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.tsx1export 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.tsx1export 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が表示されました。