はじめに
Next.jsでは@next/mdxが用意されていることもあり、アプリケーションにmdやmdxを容易に取り入れられます(以後md、mdxで記述された形式を総称してマークダウンと呼びます)。
そして、表示する内容はmdx-components.tsxによって、Reactのコンポーネントを用いたコントロールを行えます。
このようなアプリケーションの見た目や挙動をReactで管理しつつマークダウンを埋め込める@next/mdxを私はとても気に入っています。
さて、挿入したマークダウンをただ表示するわけではなく、内部の構造を元にして目次を追加したくなりました。@next/mdxはnext.config.tsでrehypeやremarkのプラグインを挿入できるので、rehype・remarkのエコシステムに存在するremarkTocを導入します。
これを用いた場合、目次の内容はremarkTocが自動でマークダウン中にContentsと記述している部分に挿入します。
mdx-components.tsxで扱うタイミングの前段で処理を行うので、目次の見た目を調整は他の要素と区別せずに行う必要があり、見分けが付きにくいのでかなり大変です。
さらに、Qiitaの記事を大きな画面でみると、目次は横に出てきます。このように他の要素と異なる位置に表示させるなど、自由な目次の表示はこの方法ではできません(できたとしても外からcssを渡す必要があるので、Reactの世界から飛び出す必要があります)。
これでは、メンテナンスに苦労しますし、何よりアプリケーションの見た目や挙動をReactで管理できなくなってしまいます。この記事では、このような困りごとを解決するための方法を紹介します。
remarkでheadingを取得する
この記事で紹介するアプローチは@next/mdxとは全く異なるタイミングで、remarkを使ってマークダウンからheadingを取り出して目次を組み立てる方法です。既存の概念に引っ張れることなく、新規に組み立てるのでかなりの自由度を持ちます。
remarkのコードは下記と同義なので、これらを別々に組み合わせても良いです。
unified().use(remarkParse).use(remarkStringify).freeze()
この方法では、@next/mdxとは異なるタイミングでファイルを読み取るのでパフォーマンスは多少落ちますが、ビルド時にしか処理を行わないのでユーザーの体験が低下することはないです(計測してませんが、ビルドの処理も気にならない程度なはずです)。
以下が抽出を行い描画するコンポーネントです。内部で利用するコンポーネントは適当なので、自分が表現したいように組み合わせてください。目次はH2、H3、H4だけを抽出するようにしました。
import { FC } from 'react';
import { remark } from 'remark';
import { readFileSync } from 'fs';
import type { Root } from 'mdast';
// 目次を組み立てるためのツリーの型
type HeadingTree = {
depth: 0;
children: {
depth: 1;
text: string;
children: {
depth: 2;
text: string;
children: {
depth: 3;
text: string;
}[];
}[];
}[];
};
export const TableOfContext: FC<{ slug: string }> = async ({
slug,
}) => {
let headingTree: HeadingTree = {
depth: 0,
children: [],
};
await remark()
.use(function () {
// remarkに続く処理、マークダウンをパースしたものを扱える
return (tree: Root) => {
// 直下の一番浅い箇所でループを回す
for (const content of tree.children) {
// heading以外は無視
if (content.type !== 'heading') {
continue;
}
if (
// 簡単のためheadingの中身はすべてtextと仮定しているので、それ以外を無視
content.children[0]?.type !== 'text' ||
// 5以上は放置
content.depth > 4
) {
continue;
}
// ## <-これ
if (content.depth === 2) {
// depth1のchildrenに詰めている
headingTree.children.push({
depth: 1,
text: content.children[0].value,
children: [],
});
continue;
}
// ### <-これ
if (content.depth === 3) {
// depth1の最後のchildrenのchildrenに詰めている
const depth1Length = headingTree.children.length;
const depth1RestTree = headingTree.children.slice(
0,
depth1Length - 1,
);
const depth1LastTree =
headingTree.children[depth1Length - 1];
// ##が存在すると仮定しているので、depth1のchildrenがない時はスキップ
if (!depth1LastTree) {
continue;
}
headingTree = {
...headingTree,
children: [
...depth1RestTree,
{
...depth1LastTree,
children: [
...depth1LastTree.children,
{
depth: 2,
text: content.children[0].value,
children: [],
},
],
},
],
};
continue;
}
// ### <-これ
if (content.depth === 4) {
// depth1の最後のchildrenにあるdepth2の最後のchildrenに詰めている
const depth1Length = headingTree.children.length;
const depth1RestTree = headingTree.children.slice(
0,
depth1Length - 1,
);
const depth1LastTree =
headingTree.children[depth1Length - 1];
if (!depth1LastTree) {
continue;
}
const depth2Length = depth1LastTree.children.length;
const depth2RestTree = depth1LastTree.children.slice(
0,
depth2Length - 1,
);
const depth2LastTree =
depth1LastTree.children[depth2Length - 1];
if (!depth2LastTree) {
continue;
}
headingTree = {
...headingTree,
children: [
...depth1RestTree,
{
...depth1LastTree,
children: [
...depth2RestTree,
{
...depth2LastTree,
children: [
...depth2LastTree.children,
{
depth: 3,
text: content.children[0].value,
},
],
},
],
},
],
};
}
}
};
})
// マークダウンと読み取る
.process(
readFileSync(
process.cwd() + `/src/app/XXX/${slug}/page.mdx`,
'utf-8',
),
);
return (
<TocContainer>
<TocTitle />
<TocOl depth={1}>
{headingTree.children.map((depth1) => {
return (
<TocLi key={depth1.text} text={depth1.text}>
{depth1.children.length > 0 && (
<TocOl depth={2}>
{depth1.children.map((depth2) => {
return (
<TocLi key={depth2.text} text={depth2.text}>
{depth1.children.length > 0 && (
<TocOl depth={3}>
{depth2.children.map((depth3) => {
return (
<TocLi key={depth3.text} text={depth3.text} />
);
})}
</TocOl>
)}
</TocLi>
);
})}
</TocOl>
)}
</TocLi>
);
})}
</TocOl>
</TocContainer>
);
};
見てわかる通りremarkによってパースされた結果から地道にheadingを取り出しています。
結果のtreeの型はmdastのRoot型です。
treeからchildrenを取り出してheadingを全て取り出し、後々目次を組み立てやすいように深さごとに入れ子となるようにしています(headingは表層にあると仮定しました)。
おわりに
いくつか決め打ちで処理している箇所がありましたが、それは自身が適用するマークダウンの記述と照らし合わせて、例外がないように強化するのが良いと思います(上は私のブログでの例なので穴だらけです、表現の幅を広げるにつれて強化していきたいです)。
このように自分で目次を取り出すことで、アプリケーションの見た目や挙動をReactで管理して自由に組み立てられるので、@next/mdxだけで組み立てることに苦しさを覚えたらこの方法を参考にしてみてはいかがでしょうか。