はじめに
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
だけで組み立てることに苦しさを覚えたらこの方法を参考にしてみてはいかがでしょうか。