markedでTOCを作成
目次
このブログではh5oを改良したもので目次(TOC: Table of Contents)を動的生成していたが、Next.jsのSSGと相性が悪いため他で実装することにした。markdownパーサーのmarkedはワンライナーでTOCを書き出す方法はないが、ひと工夫すると比較的簡単に作成することができた。
今回の環境
- marked v2.07
- Next.js v10.2.3
方法
issues#545で実現できそうだがもう少しシンプルにしたいため、issues#1489を参考にした。marked.lexerでトークンオブジェクトを取得し、typeがheadingのものを取り出せば見出しが抽出できる。
以下はこのページの見出しを抽出した例。
const tokens = marked.lexer(source);
const heading = tokens.filter(token => token.type === 'heading');
console.log(heading);
/*
[
{
type: 'heading',
raw: '## 準備\n\n',
depth: 2,
text: '準備',
tokens: [ [Object] ]
},
{
type: 'heading',
raw: '## 実際の例\n\n',
depth: 2,
text: '実際の例',
tokens: [ [Object] ]
}
]
tokens[0]は以下
tokens: { type: 'text', raw: '準備', text: '準備' }
*/
depth
は見出しの階層に対応し、見出しのテキストはtext
に入っている。また、markedでパースした本文の結果は、見出し要素にその見出しテキストと同じ値のid値が割り当てられるため、以下のようにページ内リンクを作成すれば一応TOCは完成する。
heading.map((item) => (
<li key={key} style={{ paddingLeft: item.depth }}>
<a href={`#${text}`}>{$text}</a>
</li>
));
しかし、上記で触れた本文にセットされるid値は、スペースは-
に変換され、-
と_
以外の特殊文字は除去された小文字でセットされる。つまりアンカーに埋め込むハッシュ値は、実際には次のような整形が必要。
const anchor = text
.replace(/\s+/g, "-")
.replace(/[!$%^&@#*()+|~=`{}[\]:";'<>?,./]/g, "")
.toLowerCase();
リファクタリング
前述のように取得したオブジェクトで利用するのはdepth
とtext
のみ。更に上記でスルーしたユニークキーが必要なため、トークンの取得は以下のようなコードにした。
const { v4: uuidv4 } = require("uuid");
const headings = tokens.reduce((result, currentValue) => {
if (currentValue.type === "heading") {
return result.concat({
depth: currentValue.depth,
text: currentValue.text,
id: uuidv4(),
});
}
return result;
}, []);
Next.jsのSSGではpropsに渡るデータがJSONリクエストになるため、オブジェクトは小さくしておくといい。
Next.jsで表示をスイッチ
TOCの表示/非表示の切り替えができるようにしたい。GitHubのREADME.mdのUIのような感じ。
自分の場合、getStaticProps
内でソースを取得したタイミングでTOCや本文をパースしているが、ページをラップしている親コンポーネントに表示の切り替え機能を組み込みたい。このためReact Contextで論理値のフラグ変数と更新関数を管理することにした。
しかし、Contextの場合はページ遷移時に問題がある。例えば、ある記事からHOMEに戻り別の記事を開くと、グローバル状態のため前の記事のフラグが維持された状態になっている。
ページ遷移時にフラグをリセットする必要がある。Next.jsでページルートが変わったときにイベントをリッスンするにはrouteChangeStart
を使用する。
// Context内の抜粋
const router = useRouter();
useEffect(() => {
const handleRouteChange = (url, { shallow }) => {
// TOCの表示フラグを初期状態に戻す
setShow(false);
};
router.events.on("routeChangeStart", handleRouteChange);
return () => { // アンマウント時
router.events.off("routeChangeStart", handleRouteChange);
};
}, []);
これでページ遷移時にContext内の状態フラグをリセットすることができた。