TOC が壊れる典型的なパターン:README は GitHub では問題なく表示されるのに、同じファイルを Bitbucket で開いたり、slug ルールの違う Jekyll サイトでビルドしたりすると、TOC のリンクがどこにも飛ばなくなる。Markdown は一字一句同じでも、見出しの id は別物になっている。

Markdown 目次の生成は一見すると 1 行で済む話だ。見出しを読み、slug 化し、リンクに組み立てる——最初の 80% は確かにそれで終わる。罠は残りの 20% にある。レンダラーごとに slugify ルールが違い、重複処理の作法が違い、Unicode に対するスタンスが違う。違うターゲット用に作った TOC は、TOC が無いより悪い:エディタ上では正しく見えるのに、レンダリングされた瞬間に折れる。

Markdown 目次を生成 →

なぜアンカーは標準化されていないのか

Markdown 仕様には「見出しアンカー」という概念がそもそもない。CommonMark は見出しの HTML レンダリングを意図的に実装側に委ねており、「見出しから slug を生成する」機能を提供するレンダラーは各自でアルゴリズムを書いた。方言間の差は、もはや単一 TOC ジェネレーターで全方位カバーできないレベルまで広がっている——ターゲットを 1 つ選ぶしかない。

このツールが対応する 4 つの方言:

レンダラーどこで見るかアンカー形式
GitHubgithub.com の README、issue、wiki小文字化、- と _ 以外の記号を除去、空白をハイフンに、Unicode の文字は保持
GitLabgitlab.com、GitLab セルフマネージドGitHub と同じ処理のあと、2 個以上連続するハイフンを 1 個にまとめる
kramdown(input: kramdown)kramdown 独自のパーサーに切り替えた Jekyll サイト先頭の英字以外を除去、ASCII の英数字・空白・ハイフンだけを残して小文字化。空になったら section
Bitbucket Cloudbitbucket.orgすべての id に markdown-header- プレフィックス、重複は _N

表について 2 点補足する。Jekyll の既定は GFM パーサー(kramdown: input: GFM)で、見出し id は GitHub のルールに従い Unicode を保持する(kramdown-parser-gfm)。ASCII だけを残す行が当てはまるのは input: kramdown に切り替えたサイトだけだ。また、このツールの Bitbucket オプションは現在 markdown- プレフィックスを出力するが、Bitbucket Cloud が実際に付ける id は markdown-header-... なので、ツールが直るまでは生成したリンクの markdown- を markdown-header- に書き換えてほしい。Hugo(goldmark、v0.60 からの既定レンダラー)や MkDocs には独自の id ルールがある。表にないターゲットでは、TOC をコミットする前にレンダリング後のページで実際の id を確認すること。

slugify アルゴリズム、4 通りの実装

ある見出しを 4 方言に通すと、それぞれ別のアンカーが出る:

見出し: ## Quick Start: Setting up SSO (Auth 2.0)

形式アンカー
GitHubquick-start-setting-up-sso-auth-20
GitLabquick-start-setting-up-sso-auth-20
Jekyllquick-start-setting-up-sso-auth-20
Bitbucket Cloudmarkdown-header-quick-start-setting-up-sso-auth-20

ここまでは一致。次に Unicode の見出し:## クイックスタート

形式アンカー
GitHubクイックスタート
GitLabクイックスタート
kramdown(input: kramdown)section(ASCII が 1 文字も残らない)
Bitbucket Cloudここでは示さない。Python-Markdown の slug 処理で非 ASCII が除去されるため、レンダリング後の id を確認すること

kramdown 独自のパーサーはここで破綻する。ASCII の英字を含まない見出しはすべて section、section-1……になる(kramdown auto_ids)。このツールの Jekyll オプションはこうしたアンカーを空のままにするので、どちらにしてもリンクは飛ばない。Jekyll サイトに非 ASCII の見出しがあるなら、既定の input: GFM を使い、TOC は GitHub 形式で生成すること。

最後にもう 1 ケース——重複した見出し:

## Examples
### Curl
## Examples
### Python
形式アンカー
GitHub / GitLab / kramdownexamples、curl、examples-1、python
Bitbucket Cloudmarkdown-header-examples、markdown-header-curl、markdown-header-examples_1、markdown-header-python

Bitbucket だけ仲間外れ。重複カウンターをハイフンではなくアンダースコアで付ける。他の主要レンダラーは全部 -N。

マーカーモード:git ノイズ無しで TOC を最新に保つ

TOC でよくある罠が「stale TOC」差分。2,000 行のオペレーション手順書にセクションを 1 つ追加したのに、冒頭の TOC が追従しない——レビュアーが気づくまで PR を 2 周することになる。出口は 2 つ:

  1. コメントマーカーをジェネレーターに管理させる。<!-- toc --> … <!-- /toc --> で囲って、変更のたびに生成器を流す。マーカー自体はファイルに残り、間の本文だけが入れ替わる。
  2. pre-commit hook。同じことをコミット直前に自動で。

本ツールの marker mode はパターン 1 の実装。Markdown を貼り付け、Marker mode を on にすれば、マーカー間に再生成された TOC を含む完全な Markdown が返る。マーカーがまだ無いドキュメントなら、最初の見出しの直前に新しいマーカー付きブロックが挿入されるので、一度コミットすればそれ以降はマーカー運用に乗る。

<!-- toc --> / <!-- /toc --> は npm パッケージ markdown-toc が使うマーカーなので、ここで用意したファイルはあとから markdown-toc -i で保守できる。GitHub と GitLab は HTML コメントを表示しない。Bitbucket Cloud は生の HTML をエスケープするため、マーカーが文字として表示される。

いずれぶつかる 5 つの罠

実プロジェクトで遭遇する頻度の高い順に:

  1. コードブロック内の # コメント。Bash・Ruby・Python のコメントは # で始まる。素朴な見出しパーサーはフェンスドコードブロック内の # This is a comment を H1 として読んでしまう。使い物になる TOC ジェネレーターはフェンスドコードブロックをスキップする。本ツールは ``` と ~~~ の両方を正しく扱う。自前で書くならこのケースに注意。

  2. Setext 見出し。Markdown は ATX(# Title)と setext(Title\n=====)の 2 つを両方サポートする。古い README はまだ setext で H1/H2 を書いている。ATX しか扱わないジェネレーターはこれを静かに飛ばす。

  3. 見出し内のインライン書式。## **Important**: Backups の TOC ラベルは太字を含み、アンカーは平文を使うべき。これを取り違えると \code“ を anchor の一部に取り込んだりする。slug 用にはインライン syntax を剥ぎ、ラベルには元の Markdown を残すのが正解。

  4. H レベルをまたぐ重複見出し。## Examples と ### Examples は両方とも examples に slug 化され、2 つ目は examples-1 になる。レンダラーは TOC に含めない見出しも含めて文書内のすべての見出しを数えるが、このツールは選んだレベル範囲内の見出ししか数えない。H1 を除外していて文書に # Examples と ## Examples があると、ツールは #examples にリンクし、GitHub はこの H2 に examples-1 を割り当てる。

  5. マーカーが既存のケース。ドキュメントに既に <!-- toc --> … <!-- /toc --> がある状態で素朴に “挿入” すると、TOC が 2 つになる。正しい挙動は既存マーカーを検出してその中身を置換すること、append ではない。

コード例

pre-commit hook:コミット時に TOC を再生成

TOC を同期し続ける一番きれいな方法は、コミット時に古い TOC で fail させて、ワンキー修復を用意すること。markdown-toc(npm)と pre-commit hook の組み合わせ(スクリプトは GNU の md5sum を使う。macOS では md5 -q に置き換える):

#!/bin/sh
# .git/hooks/pre-commit
for f in $(git diff --cached --name-only --diff-filter=AM | grep '\.md$'); do
  before=$(md5sum "$f")
  npx markdown-toc -i "$f"
  after=$(md5sum "$f")
  if [ "$before" != "$after" ]; then
    echo "TOC out of date: $f — staged the regenerated version."
    git add "$f"
  fi
done

npx markdown-toc -i は好きな生成器に置き換えてよい。契約はファイルをインプレースで編集し、マーカーブロックを認識すること。

GitHub Actions:PR で TOC のドリフトを検出

name: TOC drift
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx markdown-toc -i README.md
      - run: |
          if ! git diff --quiet README.md; then
            echo "::error::README.md TOC is out of date. Run 'npx markdown-toc -i README.md' locally and commit."
            exit 1
          fi

Jekyll:slugger を TOC に揃える

Jekyll サイトに非 ASCII の見出しがあるなら、Jekyll の既定である GFM パーサーを使う(変えていたら戻す):

# _config.yml
kramdown:
  input: GFM

その上で GitHub 形式で TOC を生成すれば、結果アンカーと一致する。

このツールの位置付け

TOC ジェネレーターを選ぶときに体感差が出る項目と、本ツールの実装:

動作本ツール
フェンスドコードブロック(``` と ~~~)をスキップする
ATX と setext の両見出し両方
既存の <!-- toc --> ブロックをインプレース置換する
1 ページに 4 形式のアンカーGitHub / GitLab / Jekyll(kramdown パーサー)/ Bitbucket(プレフィックスに header- の追加が必要、上記参照)
Bitbucket の _N 重複処理する
レベル範囲外の見出しも重複判定に数えるしない(落とし穴 4 参照)
アンカーには inline Markdown を剥がし、ラベルには残すする
見出しレベルフィルター + H1 トグルあり
ブラウザ内で動作する

ワークフローがサーバーサイド寄りで CLI が欲しいなら、markdown-toc(npm、GitHub 形式)が同じマーカーを使う。Web 上で貼ってコピーするだけなら、ここの 4 形式対応とマーカー認識の置換で、ターゲットが GitHub でない場合の手戻りを減らせる。

関連リンク