TOC が壊れる典型的なパターン:README は GitHub では問題なく表示されるのに、同じファイルを Bitbucket で開いたり、slug ルールの違う Jekyll サイトでビルドしたりすると、TOC のリンクがどこにも飛ばなくなる。Markdown は一字一句同じでも、見出しの id は別物になっている。
Markdown 目次の生成は一見すると 1 行で済む話だ。見出しを読み、slug 化し、リンクに組み立てる——最初の 80% は確かにそれで終わる。罠は残りの 20% にある。レンダラーごとに slugify ルールが違い、重複処理の作法が違い、Unicode に対するスタンスが違う。違うターゲット用に作った TOC は、TOC が無いより悪い:エディタ上では正しく見えるのに、レンダリングされた瞬間に折れる。
なぜアンカーは標準化されていないのか
Markdown 仕様には「見出しアンカー」という概念がそもそもない。CommonMark は見出しの HTML レンダリングを意図的に実装側に委ねており、「見出しから slug を生成する」機能を提供するレンダラーは各自でアルゴリズムを書いた。方言間の差は、もはや単一 TOC ジェネレーターで全方位カバーできないレベルまで広がっている——ターゲットを 1 つ選ぶしかない。
このツールが対応する 4 つの方言:
| レンダラー | どこで見るか | アンカー形式 |
|---|---|---|
| GitHub | github.com の README、issue、wiki | 小文字化、- と _ 以外の記号を除去、空白をハイフンに、Unicode の文字は保持 |
| GitLab | gitlab.com、GitLab セルフマネージド | GitHub と同じ処理のあと、2 個以上連続するハイフンを 1 個にまとめる |
kramdown(input: kramdown) | kramdown 独自のパーサーに切り替えた Jekyll サイト | 先頭の英字以外を除去、ASCII の英数字・空白・ハイフンだけを残して小文字化。空になったら section |
| Bitbucket Cloud | bitbucket.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)
| 形式 | アンカー |
|---|---|
| GitHub | quick-start-setting-up-sso-auth-20 |
| GitLab | quick-start-setting-up-sso-auth-20 |
| Jekyll | quick-start-setting-up-sso-auth-20 |
| Bitbucket Cloud | markdown-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 / kramdown | examples、curl、examples-1、python |
| Bitbucket Cloud | markdown-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 つ:
- コメントマーカーをジェネレーターに管理させる。
<!-- toc -->…<!-- /toc -->で囲って、変更のたびに生成器を流す。マーカー自体はファイルに残り、間の本文だけが入れ替わる。 - 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 つの罠
実プロジェクトで遭遇する頻度の高い順に:
-
コードブロック内の
#コメント。Bash・Ruby・Python のコメントは#で始まる。素朴な見出しパーサーはフェンスドコードブロック内の# This is a commentを H1 として読んでしまう。使い物になる TOC ジェネレーターはフェンスドコードブロックをスキップする。本ツールは```と~~~の両方を正しく扱う。自前で書くならこのケースに注意。 -
Setext 見出し。Markdown は ATX(
# Title)と setext(Title\n=====)の 2 つを両方サポートする。古い README はまだ setext で H1/H2 を書いている。ATX しか扱わないジェネレーターはこれを静かに飛ばす。 -
見出し内のインライン書式。
## **Important**: Backupsの TOC ラベルは太字を含み、アンカーは平文を使うべき。これを取り違えると\code“ を anchor の一部に取り込んだりする。slug 用にはインライン syntax を剥ぎ、ラベルには元の Markdown を残すのが正解。 -
H レベルをまたぐ重複見出し。
## Examplesと### Examplesは両方ともexamplesに slug 化され、2 つ目はexamples-1になる。レンダラーは TOC に含めない見出しも含めて文書内のすべての見出しを数えるが、このツールは選んだレベル範囲内の見出ししか数えない。H1 を除外していて文書に# Examplesと## Examplesがあると、ツールは#examplesにリンクし、GitHub はこの H2 にexamples-1を割り当てる。 -
マーカーが既存のケース。ドキュメントに既に
<!-- 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 でない場合の手戻りを減らせる。
関連リンク
- GitHub Flavored Markdown 仕様 — 仕様自体はアンカー slug を定義しない(それは GitHub レンダラーの挙動)が、slug ルールが依拠する見出しとコードブロックの解析を規定している
- kramdown auto_ids — Jekyll の slug ルールの権威ドキュメント
- GitLab Flavored Markdown リファレンス — CommonMark 上での GitLab 拡張
- Python-Markdown toc 拡張 — Bitbucket Cloud の id が従う slug 処理と
_Nの重複処理 - kramdown-parser-gfm — Jekyll が既定で使う GFM パーサーと、その GitHub 形式の見出し id
- ZeroTool Slugify、Markdown Linter、Markdown 表作成ツール — Markdown ワークフローの姉妹ツール