目录失效的常见情形:README 在 GitHub 上显示正常,同一个文件放到 Bitbucket 上看,或者交给另一套 slug 规则的 Jekyll 站构建,TOC 链接就再也跳不到位置了。Markdown 一字未改,标题 id 已经是另一套。
Markdown 目录生成看起来是一行就能写完的事情:读出标题、做 slug、拼成链接,前 80% 的工作确实如此。陷阱在剩下的 20%——每个渲染器都有自己一套 slugify 规则、自己一套重复处理方式、自己对 Unicode 的态度。给错目标平台生成的 TOC 比没有 TOC 还糟糕:在编辑器里看一切正常,碰到渲染器才暴露。
为什么锚点没有标准
Markdown 规范里压根没提”标题锚点”这件事。CommonMark 刻意把标题的 HTML 渲染留给具体实现,而所有支持”从标题生成 slug”的渲染器都各写各的算法。各方言之间的差异已经大到不能用同一份 TOC 通杀——必须挑一个目标。
本工具提供的四种方言:
| 渲染器 | 出现在哪儿 | 锚点风格 |
|---|---|---|
| GitHub | github.com 的 README、issue 和 wiki | 转小写,去掉除 - 和 _ 以外的标点,空格变连字符,保留 Unicode 字母 |
| GitLab | gitlab.com、自托管 GitLab | 同 GitHub,然后把两个及以上连续的连字符合并为一个 |
kramdown(input: kramdown) | 改用 kramdown 自带解析器的 Jekyll 站 | 去掉开头的非字母字符,只留 ASCII 字母、数字、空格和连字符,转小写;结果为空时用 section |
| Bitbucket Cloud | bitbucket.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)
| 风格 | 锚点 |
|---|---|
| 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 字符) |
| 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 / kramdown | examples、curl、examples-1、python |
| Bitbucket Cloud | markdown-header-examples、markdown-header-curl、markdown-header-examples_1、markdown-header-python |
Bitbucket 是个异类:重复计数器用下划线 _ 而不是连字符 -,其他主流渲染器都是 -N。
Marker 模式:让 TOC 永远新鲜,又不污染 git 记录
TOC 的常见踩坑是”过期 TOC”产生的脏 diff:你给一份 2000 行的运维手册加了一节,顶部 TOC 没跟上,结果两轮 PR 之后才有评审者注意到。两条出路:
- 用注释标记的生成器托管 TOC。在文档里包一对
<!-- toc -->…<!-- /toc -->,每次改完跑一次生成器。标记常驻文档;只有标记之间的内容会动。 - pre-commit hook。同上,但在 commit 之前自动跑。
本工具的 marker 模式实现的是第一种。把文档贴进来,打开 Marker 模式,就能拿到一份完整 Markdown,TOC 已重新生成放在标记之间。如果你的文档还没标记,工具会在第一个标题之前插入一个新标记块,提交一次之后就能持续走 marker 流程。
<!-- toc --> / <!-- /toc --> 是 npm 包 markdown-toc 使用的标记,所以在这里准备好的文件之后可以用 markdown-toc -i 维护。GitHub 和 GitLab 不会显示 HTML 注释;Bitbucket Cloud 会转义原始 HTML,标记会以文本形式显示出来。
五个迟早会撞上的陷阱
按”实际项目里出现频率”从高到低排序:
-
代码块里的
#注释。Bash、Ruby、Python 的注释都用#开头。一个朴素的标题解析器会把围栏代码块里的# This is a comment当成 H1。任何能用的 TOC 生成器都得跳过围栏代码块。本工具正确处理```与~~~两种围栏;如果你自己写,别忘了这一条。 -
Setext 标题。Markdown 支持两种标题写法:ATX(
# Title)和 setext(Title\n=====)。一些老 README 还在用 setext 写 H1 和 H2。只处理 ATX 的生成器会静默漏掉它们。 -
标题里的 inline 格式。
## **Important**: Backups应该生成的 TOC label 含粗体、anchor 用纯文本。处理不好可能就把\code“ 当 anchor 的一部分;正确做法是给 slug 剥掉 inline 语法,给 label 保留原 markdown。 -
跨 H 级别的重复标题。
## Examples和### Examples都 slug 成examples,第二个就要变examples-1。渲染器会统计文档里的所有标题,包括你没放进 TOC 的;本工具只统计所选级别范围内的标题,所以排除了 H1 时,如果文档里有# Examples和## Examples,工具会链接到#examples,而 GitHub 给这个 H2 分配的是examples-1。 -
标记已经存在的情况。如果文档里已经有一对
<!-- 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 端临时贴一下复制走,本工具的四风格 + 标记感知替换能省掉”挑错风格再返工”的来回。
延伸阅读
- GitHub Flavored Markdown 规范 — 规范本身不定义 anchor slug(那是 GitHub 渲染器自身行为),但它框定了 slug 规则赖以工作的标题与代码块解析
- kramdown auto_ids — Jekyll slug 规则的权威定义
- GitLab Flavored Markdown 参考 — GitLab 在 CommonMark 上的扩展
- Python-Markdown toc 扩展 — Bitbucket Cloud 的 id 所遵循的 slug 规则和
_N去重 - kramdown-parser-gfm — Jekyll 默认使用的 GFM 解析器及其 GitHub 风格的标题 id
- ZeroTool Slugify、Markdown Linter、Markdown 表格生成器 — Markdown 工作流的姊妹工具