markedでTOCを作成

CategoryNext.js
Published

このブログでは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();

リファクタリング

前述のように取得したオブジェクトで利用するのはdepthtextのみ。更に上記でスルーしたユニークキーが必要なため、トークンの取得は以下のようなコードにした。

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に戻り別の記事を開くと、グローバル状態のため前の記事のフラグが維持された状態になっている。

display

ページ遷移時にフラグをリセットする必要がある。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内の状態フラグをリセットすることができた。