Route360

Markdownブログに目次を追加する方法

目次

Markdownで書かれたブログに、目次を追加する方法です。

こちらのブログを参考にさせて頂きました。ありがとうございます🙏

参考 Next.js + Markdown (marked) で作るブログサイト

上記記事のコードを、いくらかアレンジしています。

動作環境:

  • Next.js v12.3.1
  • marked 4.2.2

目次を作る方法の概要

markedをインストールしていない場合は、インストールしましょう。

## npmの場合
npm install marked

## yarnの場合
yarn add marked

公式ドキュメント Marked Documentation

このmarkedの機能の1つ、Markdownの本文のひとつひとつをトークンとして生成して出力するlexerを使います。

Markdownで書かれたコンテンツをmarked.lexer()に通すと、以下のようなトークンの配列が取得できます。

;[
  {
    type: "heading",
    raw: "## Headline Text",
    depth: 2,
    text: "Headline Text",
    tokens: Array(1),
  },
  {
    type: "paragraph",
    raw: "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod te",
  },
  {
    type: "heading",
    raw: "### Headline Text",
    depth: 3,
    text: "Headline Text",
    tokens: Array(1),
  },
  {
    type: "paragraph",
    raw: "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod te",
  },
]

見出しはtype: headingで取得できるので、

  1. 見出しのみを抜き出して配列にしする
  2. 1をmap()で展開する
  3. 展開しつつ、見出しの文字列を整形する

という手順で、目次として利用可能な「見出しのリスト」を作ることができます。

3で整形する理由は、見出しに半角記号や半角空白が含まれる場合、見出しに付与されるidが見出しテキストを整形した文字列となるためです(後述)。

目次用コンポーネントを作る

ここから目次用のコンポーネントを作ります。

この目次コンポーネントは、「本文を引数として渡し、見出しのみを取り出してリストにする」という働きを持たせます。

一方、先述したように、Markdownファイルから標準で自動出力されるidは、半角空白がハイフン(-)に変換される他、ハイフン以外の半角記号はすべて省略されます。

Markdown
## ハローワールド!^.+\*{}[]?-
HTML
<!-- 出力例/idからハイフン以外の半角記号は省略される -->
<h2 id="ハローワールド!">ハローワールド!^.+*{}[]?</h2>

Markdownの見出しidから省略される文字(すべて半角):

  • 括弧 () <> {} []
  • ピリオド .
  • プラス +
  • アスタリスク *
  • スラッシュ /
  • バックスラッシュ \
  • サーカムフレックス ^
  • ドル $
  • 縦線 |
  • クエスチョン ?
  • シングルクオート '
  • ダブルクオート "
  • コロン :
  • セミコロン ;
  • チルダ ~
  • カンマ ,
  • イコール =
  • アットマーク @
  • グレイヴ・アクセント `
  • シャープ #
  • エクスクラメーション !
  • パーセント %
  • アンド &

見落としがあったらご指摘ください🙇‍♀️

marked.lexer()で得られる見出しの文字列はテキストそのままのため、replace()を使って整形し、idと同じになるようにします。

Next.jsの場合

ファイル名は任意です。

/components/post-toc.js
import Link from "next/link"
import { marked } from "marked"

export default function TableOfContents({ content }) {
  const tokens = marked.lexer(content)
  const headings = tokens.filter((token, i) => token.type === "heading")

  return (
    <aside>
      <nav>
        <h2>目次</h2>
        <ul>
          {headings.map((heading, i) => (
            <li key={i} data-depth={heading.depth}>
              <Link
                href={`#${heading.text
                  .replace(/ /g, "-")
                  .replace(/[\/\\^$*+?.()|\[\]{}<>:;"'~,=@`#!%&]/g, "")
                  .toLowerCase()}`}
              >
                <a>{heading.text}</a>
              </Link>
            </li>
          ))}
        </ul>
      </nav>
    </aside>
  )
}

Gatsby.jsの場合

以下はGatsby.js公式風にアロー関数の表現にしていますが、もちろんfunctionでもかまいません。ファイル名はこちらも任意です。

/src/components/post-toc.js
import { Link } from "gatsby"
import { marked } from "marked"

const TableOfContents = ({ content }) => {
  const tokens = marked.lexer(content)
  const headings = tokens.filter((token, i) => token.type === "heading")

  return (
    <aside>
      <nav>
        <h2>目次</h2>
        <ul>
          {headings.map((heading, i) => (
            <li key={i} data-depth={heading.depth}>
              <Link
                to={`#${heading.text
                  .replace(/ /g, "-")
                  .replace(/[\/\\^$*+?.()|\[\]{}<>:;"'~,=@`#!%&]/g, "")
                  .toLowerCase()}`}
              >
                {heading.text}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
    </aside>
  )
}

export default TableOfContents

参考サイトのようにdata-depthでリストの階層をデザインに反映できるようになっていますが、できれば下位の階層の場合は別に<ul>タグで生成したほうがSEO的にはいいかもしれませんね(今後の課題)。

後は、この目次コンポーネントを記事出力テンプレートに追加し、コンテンツ本文を送るだけです。

<TableOfContent content={コンテンツ本文}>

見出しを除外したい場合

あまりに見出しが多い場合、小見出しだけを除外したい場合もあります。

その場合は、Markdownファイルの、見出しにあたる部分の直前に<!-- out of toc -->等と記入するようにします。※テキストは何でもOKです。

<!-- out of toc -->

## Heading Text

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod te

さらに、先ほど作った目次コンポーネントの見出し配列headings生成時に、「直前の要素に<!-- out of toc -->がないheadingだけを抽出」という条件を足します。

/components/post-toc.js
const headings = tokens.filter((token, i) => token.type === 'heading'
  && tokens[i-1].text !== '\x3C!-- out of toc -->\n' {/* <- これ */}
)

左カッコの文字<はjavascript内ではエスケープする必要があるため、ASCIIコードの\x3Cに変換しています。

VS Codeプラグイン Markdown All in One との比較

VS Codeのプラグインの Markdown All in One にも、Markdownファイルの見出しを抽出して自動で見出しを挿入してくれる機能があります。

今回のコンポーネントと比較した場合、「Markdown All in One」プラグインでは、目次が本文に含まれるという点で大きく異なります。

たとえば、本文の最初に目次が含まれる場合、記事一覧で見出し文を抽出する場合に、目次が文章の一部として出てきてしまいます。

また、見出しに記号が含まれる場合のリンクの生成に、やや難が感じられました。

プラグインで気軽に使えますので、興味のある方は試してみてください。私は試用してみてイマイチだったので止めました😅