- 开发工具
- 文档
【免费下载链接】mdBook
Create book from markdown files. Like Gitbook but implemented in Rust
mdBook 在将 Markdown 渲染为静态站点时会为每个标题自动生成锚点id(如header-text),而同一页面内出现重复标题(完全相同的文本或仅大小写不同)时,需要保证每个锚点仍然唯一。本文以仓库中reasonable_search_index搜索索引测试夹具里的duplicate-headers.md为切入点,结合 HTML 渲染与搜索索引的源码实现,完整剖析 mdBook 的标题 ID 规范化、数字后缀去重,以及这些 ID 如何进入搜索索引并被测试逐项验证的完整链路。
测试场景:一份专门验证重复标题行为的页面
在 mdBook 的搜索测试书目中,tests/testsuite/search/reasonable_search_index是一本专门用于验证搜索索引生成质量的书。它由 SUMMARY.md 组织,包含 Introduction、First Chapter 及其下 Includes、Unicode、No Headers、Duplicate Headers、Heading Attributes 等章节,每章各司其职:unicode.md验证多字节与 RTL 字符,no-headers.md验证无标题页面,heading-attributes.md验证手工指定的{#id}/{.class}属性,而本章 duplicate-headers.md 负责验证重复标题的行为。
该页面的 Markdown 全文如下:
# Duplicate headers This page validates behaviour of duplicate headers. # Header Text # Header Text # header-text页面顶层标题是Duplicate headers,正文说明"本页验证重复标题的行为";随后是三个标题级(#)的子标题:
Header Text(出现两次,文本完全相同);header-text(与前者仅大小写不同,且恰好等于前两者 slug 化后的形式)。
这三个标题构成了两组"重复":一组是文本层面的完全重复,另一组是 slug 化后的 ID 层面的碰撞。它们恰好覆盖了 mdBook 标题 ID 机制中最具代表性的两类冲突场景。
标题 ID 如何生成:id_from_content的规范化规则
mdBook 在解析 Markdown 完成后,会对所有标题元素(h1–h6,以及定义列表的dt)执行"补 ID + 插锚点链接"的后处理,该逻辑位于 HTML 渲染器的树遍历阶段 html/tree.rs 的 add_header_links:
- 若标题元素已带有手工写入的
id属性(例如 Markdown 中显式写的{#attrs}),则直接沿用,不参与自动规范化(tree.rs中通过el.was_raw判断是否为手工 HTML); - 否则,收集标题的纯文本内容,交给 utils.rs 的
id_from_content生成初始 ID,再交给unique_id做唯一化。
id_from_content的规范化规则(与 GitHub、Pandoc、kramdown 等工具的 header id 算法"接近但非 100% 相同",源码注释中明确说明了这一点)可归纳为:
- 先
trim()去除首尾空白,再整体to_lowercase()转为小写; - 逐字符过滤:字母数字、
_、-保留;空白字符(含空格)替换为单个-;其余字符(标点、符号、emoji 等)直接丢弃; - 若最终结果为空(例如标题只有
::、!.():或纯空格),回退为section,这与 Pandoc、kramdown 的回退行为一致。
这些规则在 utils.rs 的单元测试中有大量可复现的用例,例如:
id_from_content("`--passes`: add more rustdoc passes") == "--passes-add-more-rustdoc-passes" id_from_content("Method-call 🐙 expressions \u{1f47c}") == "method-call--expressions-" id_from_content("中文標題 CJK title") == "中文標題-cjk-title" id_from_content("Über") == "über" id_from_content("::") == "section"可以看到:emoji 被丢弃、CJK 字符按原样保留、非 ASCII 字母同样参与小写化。回到本章的两个标题:Header Text与header-text经过小写化与空白替换后,都会得到同一个 IDheader-text——这正是header-text标题被特意放在这里的用意:它在 Markdown 源文本上就是 slug 的形式,用来制造"slug 碰撞"。
重复标题如何去重:unique_id的数字后缀策略
生成初始 ID 之后,add_header_links会将其交给 utils.rs 的unique_id处理。该函数维护一个HashSet<String>(即页面内已用 ID 的集合),流程如下:
- 若请求的 ID 尚未被使用,直接插入集合并原样返回;
- 若已存在,则从
-1开始递增尝试{id}-1、{id}-2、……,直到找到一个未使用的候选为止。
对应的单元测试 it_generates_unique_ids 给出了直观的行为:
unique_id("", &mut id_counter) == "" // 首次出现,原样返回 unique_id("Über", &mut id_counter) == "Über" // 首次出现 unique_id("Über", &mut id_counter) == "Über-1" // 第二次出现,追加 -1 unique_id("Über", &mut id_counter) == "Über-2" // 第三次出现,追加 -2于是duplicate-headers.md页面中三个标题的最终 ID 分别为:
| 标题文本 | 初始 slug | 最终 HTML id |
|---|---|---|
# Duplicate headers | duplicate-headers | duplicate-headers |
# Header Text(第 1 次) | header-text | header-text |
# Header Text(第 2 次) | header-text | header-text-1 |
# header-text | header-text | header-text-2 |
这一结果并非推测,而是被仓库中的测试断言和索引快照双重锁定的:搜索索引的期望文件 expected_index.js 中的doc_urls数组明确列出了first/duplicate-headers.html#duplicate-headers、first/duplicate-headers.html#header-text、first/duplicate-headers.html#header-text-1、first/duplicate-headers.html#header-text-2四个文档 URL,数字后缀的生成顺序与上面表格完全一致。
另外值得注意的是:unique_id的输入并不总是来自id_from_content。add_header_links在调用前会检查元素是否已带手工id;同时 utils.rs 的另一个单元测试punctuation_only_headings_get_unique_section_ids验证了"纯符号标题回退为section后也能继续唯一化"的场景:连续出现::、!!!、***、纯空格四个标题时,最终 ID 依次为section、section-1、section-2、section-3,说明回退值与正常 slug 共用同一套去重计数器。
重复标题如何进入搜索索引
mdBook 的全文搜索基于页面章节粒度构建索引:页面中的每一个标题会作为独立的"文档"(doc)被收录,其title、body(标题下正文)、breadcrumbs(面包屑路径,形如First Chapter » Duplicate Headers » Header Text)共同参与倒排索引的构建。因此,duplicate-headers.html一页会贡献多个索引条目,且每个条目的 URL 锚点正是上文表格中的最终 ID。
搜索索引的集成测试位于 tests/testsuite/search.rs 的reasonable_search_index。该测试构建测试书后读取生成的book/searchindex*.js,对其中的 JSON 做定点抽查:
- 通过
get_doc_ref("first/duplicate-headers.html#header-text-1")定位到第一个重复标题(Header Text第二次出现)对应的索引文档,并断言其面包屑为First Chapter » Duplicate Headers » Header Text(与首次出现时的面包屑一致,仅 URL 锚点不同); - 同时断言了其他章节的关键行为:
no-headers.html无锚点 URL、includes.html#summary的正文是全部章节标题拼接、heading-attributes.html#both的面包屑等。
同一文件中还有另一个测试search_index_hasnt_changed_accidentally(search.rs),它直接以 expected_index.js 为黄金文件比对整个搜索索引,任何标题 ID 生成规则或去重顺序的意外变动都会导致该测试失败。这意味着header-text-1/header-text-2的后缀顺序是作为契约被固定下来的,不是实现细节层面的偶然产物。
从索引的documentStore可以看到,重复标题对应文档(id 9、10、11)的body均为空字符串,breadcrumbs均为First Chapter » Duplicate Headers » Header Text(或header-text),三者仅靠 URL 锚点(即唯一化后的 ID)彼此区分。这印证了索引层面"一个标题 = 一个可检索文档"的设计,也让重复标题的锚点唯一性成为搜索可用性的前提。
对 mdBook 使用者的实践启示
综合上述机制,可以得出几条对实际编写 mdBook 书籍有直接指导意义的结论:
不要依赖自动 ID 的"语义":自动生成的锚点 ID 仅保证唯一,不保证可读性。若你希望某个标题的锚点稳定(例如被其他页面以
#片段链接引用),请用显式属性指定 ID,如## 标题 {#my-id}——手工 ID 会被原样保留且不参与 slug 化(参见 heading-attributes.md 的{#attrs}、{.class1 .class2}、{#both .class1 .class2}用法)。重复标题在搜索中是独立条目:同一页面出现 N 次
# Header Text时,搜索索引会出现 N 个锚点不同但标题文本相同的文档。搜索结果按 URL 区分条目,读者点击后分别定位到#header-text、#header-text-1、#header-text-2三个位置,体验上没有问题,但若你在意搜索结果的"去重感",建议从源头上避免完全相同的标题。大小写不敏感是必然的:slug 化一律转小写,所以
Header Text与header-text必然产生锚点碰撞并被追加后缀。想要不同的锚点,就得让标题文本本身有实质差异。纯符号标题要警惕:
## ::、## !!!这类标题会统一回退为section、section-1……锚点高度不可读,且多个这样的标题在侧边栏与面包屑中都难以区分,属于应该避免的写法。锚点唯一性受测试保护:mdBook 仓库通过
search_index_hasnt_changed_accidentally黄金文件测试将 ID 生成规则固化为契约,升级版本时若锚点 ID 发生变化,会在测试阶段即被暴露,这也意味着你可以在自己的 CI 中引入类似的索引快照比对来监控站点行为。
这份不足十行的测试页面,实际上完整刻画了 mdBook 从"Markdown 标题文本"到"HTML 锚点 ID"再到"搜索索引条目"的整条处理流水线:id_from_content负责规范化、unique_id负责唯一化、add_header_links负责写回 DOM 并生成锚点链接、搜索构建器负责按标题切分文档并建立倒排索引——每一个环节都能在 utils.rs、html/tree.rs 与 search.rs 中找到对应的实现与测试证据。
- 开发工具
- 文档
【免费下载链接】mdBook
Create book from markdown files. Like Gitbook but implemented in Rust
相关推荐
mdBook 打印页(print.html)锚点 ID 去重与链接重写机制详解
mdBook 打印页(print.html)锚点 ID 去重与链接重写机制详解 导读 当 mdBook 把分散在多个章节文件中的内容合并渲染为单页打印文档 pr
开发工具文档Pandoc 标题自动编号与去重:LaTeX 到 HTML 转换中的标题 ID 生成机制
Pandoc 标题自动编号与去重:LaTeX 到 HTML 转换中的标题 ID 生成机制 导读 在将 LaTeX 文档转换为 HTML 时,标题的锚点(ID)如
文档开发工具CLImdBook 打印页重复标题 ID 处理机制:从 duplicate_ids 测试用例看 print.html 的唯一 ID 重写与链接修复
mdBook 打印页重复标题 ID 处理机制:从 duplicate_ids 测试用例看 print.html 的唯一 ID 重写与链接修复 导读 当 mdBo
开发工具文档
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考