目录失效的常见情形:README 在 GitHub 上显示正常,同一个文件放到 Bitbucket 上看,或者交给另一套 slug 规则的 Jekyll 站构建,TOC 链接就再也跳不到位置了。Markdown 一字未改,标题 id 已经是另一套。

Markdown 目录生成看起来是一行就能写完的事情:读出标题、做 slug、拼成链接,前 80% 的工作确实如此。陷阱在剩下的 20%——每个渲染器都有自己一套 slugify 规则、自己一套重复处理方式、自己对 Unicode 的态度。给错目标平台生成的 TOC 比没有 TOC 还糟糕:在编辑器里看一切正常,碰到渲染器才暴露。

在线生成 Markdown 目录 →

为什么锚点没有标准

Markdown 规范里压根没提”标题锚点”这件事。CommonMark 刻意把标题的 HTML 渲染留给具体实现,而所有支持”从标题生成 slug”的渲染器都各写各的算法。各方言之间的差异已经大到不能用同一份 TOC 通杀——必须挑一个目标。

本工具提供的四种方言:

渲染器出现在哪儿锚点风格
GitHubgithub.com 的 README、issue 和 wiki转小写,去掉除 - 和 _ 以外的标点,空格变连字符,保留 Unicode 字母
GitLabgitlab.com、自托管 GitLab同 GitHub,然后把两个及以上连续的连字符合并为一个
kramdown(input: kramdown)改用 kramdown 自带解析器的 Jekyll 站去掉开头的非字母字符,只留 ASCII 字母、数字、空格和连字符,转小写;结果为空时用 section
Bitbucket Cloudbitbucket.org每个 id 都加 markdown-header- 前缀,重复用 _N

关于这张表有两点说明。Jekyll 默认用 GFM 解析器(kramdown: input: GFM),它生成的标题 id 遵循 GitHub 规则、保留 Unicode(见 kramdown-parser-gfm);只有站点改成 input: kramdown 时才适用只留 ASCII 的那一行。另外,本工具的 Bitbucket 选项目前写的是 markdown- 前缀,而 Bitbucket Cloud 渲染出来的 id 是 markdown-header-...;工具修复之前,请在生成的链接里把 markdown- 改成 markdown-header-。Hugo(goldmark,v0.60 起的默认渲染器)和 MkDocs 有各自的 id 规则;目标不在本表里时,提交 TOC 前先看一下渲染后页面里的实际 id。

slugify 算法的四种走法

挑一个标题,看四种方言把它变成什么:

标题:## 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 字符)
Bitbucket Cloud不在此列出:它的 Python-Markdown slug 规则会去掉非 ASCII 字符,请以渲染后的 id 为准

kramdown 自带解析器在这一步翻车:所有不含 ASCII 字母的标题都会变成 section、section-1……(见 kramdown auto_ids)。本工具的 Jekyll 选项则会把这类锚点留空,两种情况下链接都跳不到位置。Jekyll 站有非 ASCII 标题时,用默认的 input: GFM,并按 GitHub 风格生成 TOC。

再看一种情况——重复标题:

## 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。

Marker 模式:让 TOC 永远新鲜,又不污染 git 记录

TOC 的常见踩坑是”过期 TOC”产生的脏 diff:你给一份 2000 行的运维手册加了一节,顶部 TOC 没跟上,结果两轮 PR 之后才有评审者注意到。两条出路:

  1. 用注释标记的生成器托管 TOC。在文档里包一对 <!-- toc --> … <!-- /toc -->,每次改完跑一次生成器。标记常驻文档;只有标记之间的内容会动。
  2. pre-commit hook。同上,但在 commit 之前自动跑。

本工具的 marker 模式实现的是第一种。把文档贴进来,打开 Marker 模式,就能拿到一份完整 Markdown,TOC 已重新生成放在标记之间。如果你的文档还没标记,工具会在第一个标题之前插入一个新标记块,提交一次之后就能持续走 marker 流程。

<!-- toc --> / <!-- /toc --> 是 npm 包 markdown-toc 使用的标记,所以在这里准备好的文件之后可以用 markdown-toc -i 维护。GitHub 和 GitLab 不会显示 HTML 注释;Bitbucket Cloud 会转义原始 HTML,标记会以文本形式显示出来。

五个迟早会撞上的陷阱

按”实际项目里出现频率”从高到低排序:

  1. 代码块里的 # 注释。Bash、Ruby、Python 的注释都用 # 开头。一个朴素的标题解析器会把围栏代码块里的 # This is a comment 当成 H1。任何能用的 TOC 生成器都得跳过围栏代码块。本工具正确处理 ``` 与 ~~~ 两种围栏;如果你自己写,别忘了这一条。

  2. Setext 标题。Markdown 支持两种标题写法:ATX(# Title)和 setext(Title\n=====)。一些老 README 还在用 setext 写 H1 和 H2。只处理 ATX 的生成器会静默漏掉它们。

  3. 标题里的 inline 格式。## **Important**: Backups 应该生成的 TOC label 含粗体、anchor 用纯文本。处理不好可能就把 \code“ 当 anchor 的一部分;正确做法是给 slug 剥掉 inline 语法,给 label 保留原 markdown。

  4. 跨 H 级别的重复标题。## Examples 和 ### Examples 都 slug 成 examples,第二个就要变 examples-1。渲染器会统计文档里的所有标题,包括你没放进 TOC 的;本工具只统计所选级别范围内的标题,所以排除了 H1 时,如果文档里有 # Examples 和 ## Examples,工具会链接到 #examples,而 GitHub 给这个 H2 分配的是 examples-1。

  5. 标记已经存在的情况。如果文档里已经有一对 <!-- toc --> … <!-- /toc -->,朴素的”插入”会再追加一份 TOC,结果一文档两 TOC。正确行为是检测到已有标记后,替换标记之间的内容,而不是 append。

代码片段

pre-commit hook:commit 时自动重生成 TOC

让 TOC 永远跟上的最干净办法是:commit 时如果 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 换成你喜欢的生成器即可;约定是它能就地编辑文件并识别 marker 块。

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 --> 块是
同一页面内四种锚点风格GitHub / GitLab / Jekyll(kramdown 解析器)/ Bitbucket(前缀需补上 header-,见上文)
Bitbucket 的 _N 去重是
去重时计入级别范围以外的标题否(见陷阱 4)
anchor 用清洗后的纯文本,label 保留原 inline 格式是
标题层级过滤 + H1 开关是
在浏览器中运行是

如果你的工作流偏服务端、想要 CLI,markdown-toc(npm,GitHub 风格)用的是同一套标记。Web 端临时贴一下复制走,本工具的四风格 + 标记感知替换能省掉”挑错风格再返工”的来回。

延伸阅读