markdownlint MD043 required-headings 规则实战:用 `*`、`+`、`?` 通配符强制 Markdown 文档标题结构
markdownlint MD043 required-headings 规则实战:用 *、+、? 通配符强制 Markdown 文档标题结构
本文以 markdownlint 仓库中的测试夹具 test/required-headings-zero-or-more.md 为线索,系统讲解 MD043(
required-headings)规则如何通过headings数组与*(zero or more)、+(one or more)、?(exactly one unspecified)三种特殊值,强制一批 Markdown 文档遵循统一的标题结构。读完本文,你将掌握该规则的完整配置语法、三种通配符的精确语义、大小写匹配策略、错误报告行为,并能读懂其背后的 lib/md043.mjs 实现原理与 test/required-headings-*.md 系列测试用例。
一、规则速览:MD043 / required-headings
MD043 是 markdownlint 内置的标题结构规则,别名为 required-headings,标签为 headings,属于 micromark 解析器体系。官方规则文档见 doc/md043.md,规则声明见 lib/md043.mjs。
当文件中的标题序列与配置的期望序列不一致时触发。它常用于为一批结构相似的文档(如项目文档、教程、产品手册)强制统一骨架:
- 固定章节必须存在、顺序固定;
- 允许的部分(如示例、附录)通过通配符放行;
- 通过
match_case决定标题文本是否严格区分大小写。
参数说明
| 参数 | 类型与默认值 | 含义 |
|---|---|---|
headings |
string[],默认 [] |
期望的标题序列;元素既可以是精确标题(如 "# One"),也可以是三个特殊值 "*"、"+"、"?" |
match_case |
boolean,默认 false |
是否要求文档标题与 headings 配置严格大小写一致;默认不区分大小写 |
规则的 Rationale(设计动机)在 doc/md043.md 中明确说明:项目希望在一组相似内容上强制执行一致的文档结构。
二、核心通配符 *(zero or more):关联测试文档精读
本文的主体是测试夹具 test/required-headings-zero-or-more.md,它完整演示了 "*" 的配置方法与匹配语义。原文如下:
# One
## Two
### THREE
## four
## Five
### SiX
#### 7
<!-- markdownlint-configure-file {
"required-headings": {
"headings": [
"# One",
"*",
"### Three",
"*",
"### Six",
"*",
"#### 7"
]
}
} -->
这个文件没有携带任何 {MD043} 错误标记,意味着在默认(大小写不敏感)配置下它完全通过检查。逐条对照文档标题 # One、## Two、### THREE、## four、## Five、### SiX、#### 7 与配置序列,匹配过程如下:
| 期望值 | 实际标题 | 匹配结果 |
|---|---|---|
# One |
# One |
精确匹配 |
* |
## Two |
通配符吞掉 1 个标题 |
### Three |
### THREE |
大小写不敏感匹配(Three ≈ THREE) |
* |
## four、## Five |
通配符连续吞掉 2 个标题 |
### Six |
### SiX |
大小写不敏感匹配(Six ≈ SiX) |
* |
(无剩余标题) | 通配符匹配零个标题 |
#### 7 |
#### 7 |
精确匹配 |
这里的 * 语义是 "零个或多个未指定的标题":它既可以像 ## Two 一样匹配一个标题,也可以像 ## four、## Five 一样连续匹配多个,还可以在文件末尾(### SiX 之后没有标题)匹配零个。这正是 zero-or-more 文件名的由来。
为什么 ### Three 能匹配 ### THREE?
注意文档中实际标题是 ### THREE 和 ### SiX,而配置写的是 ### Three 与 ### Six,大小写并不一致,却没有报错。因为 match_case 默认是 false,规则在比较前会先把双方转为小写。这一行为在 doc/md043.md 中有明确说明,也是 test/required-headings-zero-or-more.md 想验证的核心点之一。
三、通配符 +(one or more):至少一个标题
与 * 相对的另一个特殊值是 "+",表示 "一个或多个未指定的标题"。官方文档的示例(doc/md043.md)同时用到了两者:
[
"# Heading",
"## Item",
"*",
"## Foot",
"*"
]
对应允许的结构是:
# Heading
## Item
### Detail (optional)
## Foot
### Notes (optional)
* 出现在中间时表示"这之间可以有零个或多个其他标题",从而让 ### Detail、### Notes 成为可选章节。
+ 与 * 的差异可以通过一对对照测试直观看出:
- test/required-headings-all-optional.md:
headings: ["*"],文档有 6 个标题,通过——*可以匹配任意多个标题; - test/required-headings-all-optional-at-least-one.md:
headings: ["+"],文档有 6 个标题,同样通过——+要求至少一个,6 个满足; - test/required-headings-none-zero-or-more.md:文档完全没有标题,
headings: ["*"],通过——零个也可以; - test/required-headings-none-one-or-more.md:文档没有标题,
headings: ["+"],在文件末尾报{MD043:15}——+要求至少一个标题,零个不满足。
test/required-headings-one-or-more.md 还展示了一个更复杂的 + 用法:
{
"headings": [
"# One",
"+",
"### Three",
"+",
"### Six",
"+",
"#### 7"
]
}
文档标题为 # One、## Two、### THREE、## four、## Five、### SiX、#### 7:每个 + 区间内都至少有一个标题(分别是 ## Two、## four/## Five、以及 ### SiX 与 #### 7 之间没有,注意这里 + 之后紧跟 #### 7,而 #### 7 是固定标题,两者不冲突),因此不满足"中间必须有额外标题"时才报错。若把 + 换成 *(即 test/required-headings-zero-or-more.md),则中间的标题数量可以为零,语义更宽松。
四、通配符 ?(exactly one unspecified):恰好一个可变标题
第三个特殊值是 "?",表示 "恰好一个未指定的标题"。典型场景是项目名这类单点可变的标题。官方文档示例(doc/md043.md):
# Project Name
## Description
## Examples
配置为:
[
"?",
"## Description",
"## Examples"
]
这里 ? 允许 # Project Name 这部分恰好出现一个任意内容的标题。相关测试覆盖了各种位置与边界:
- test/required-headings-question-first.md:
?在序列开头,文档有# Project Name作为唯一的可变标题,通过; - test/required-headings-question-middle.md:
?位于# Project Name与## Examples之间,文档只有## Description一个标题填充,通过; - test/required-headings-question-last.md:
?在序列末尾,通过; - test/required-headings-question-extra.md:
?在末尾但文档没有可匹配的标题,报{MD043:+1}; - test/required-headings-question-missing.md:
?期望恰好一个,但## Description缺失,报{MD043:+1}。
注意错误标记写法 {MD043:+1} 表示"文件末尾之后偏移 1 行"——当问题出现在文件结尾处时,markdownlint 用相对偏移来定位,这一点在下一节结合源码展开。
五、match_case:严格大小写匹配
默认情况下比较是大小写不敏感的,这在第一节的 THREE/Six 例子中已经体现。若需要严格区分大小写,设置 match_case: true,lib/md043.mjs 读取该配置,lib/md043.mjs 根据开关决定比较前是否调用 toLowerCase()。
一对对照测试最能说明问题:
- test/required-headings-match-case.md:
match_case: true,文档标题## First Heading、## Second Heading与配置完全一致,通过; - test/required-headings-wrong-match-case.md:同样的
match_case: true,但文档中出现了## SECOND HEADING(大写),与配置## Second Heading不一致,在第 5 行报{MD043:5}。
从源码结构看,handleCase 函数(lib/md043.mjs)是大小写策略的唯一入口:matchCase 为真时原样返回,为假时统一转小写,这意味着 match_case: true 下 # One、## two 这类大小写差异都会被视为不同标题。
六、源码实现:错误报告行为与匹配状态机
理解规则行为最好的方式是读实现。lib/md043.mjs 的 MD043 函数是一个线性扫描的状态机:
- 前置校验:lib/md043.mjs 中
headings不是数组时直接返回,避免无谓开销; - 标题枚举:lib/md043.mjs 通过
filterByTypesCached(<a href="https://link.gitcode.com/i/bf5b05dc79976d9080af86fef282ec95" target="_blank">"atxHeading", "setextHeading"])(见 [lib/cache.mjs)遍历所有 ATX 与 Setext 标题 token,因此文档中标题的书写风格不受限制——配置里虽然统一用"## Text"的 ATX 写法,但文档可以混用任何受支持的标题样式(doc/md043.md); - 实际值构造:lib/md043.mjs 用
"#".repeat(headingLevel)按标题级别重建actual,标题文本取自 helpers/micromark-helpers.cjs 的getHeadingText(拼接文本子 token 并把换行替换为空格),级别取自getHeadingLevel(helpers/micromark-helpers.cjs); - 通配符处理:
expected === "*"(lib/md043.mjs):先取下一个期望值,若当前标题不等于它则把游标i回退一位,让*继续吞标题;若等于则停止通配;expected === "+"(lib/md043.mjs):标记matchAny = true,允许当前标题进入通配区间;expected === "?"(lib/md043.mjs):"允许当前,匹配下一个",即恰好消费一个任意标题;
- 精确匹配:lib/md043.mjs 按
handleCase比较,相等则清除matchAny;不相等且处于通配区间则回退游标继续尝试;否则通过addErrorDetailIf报出Expected: ...; Actual: ...的详细错误并置hasError = true(每个文件只报第一处问题)。
行号报告规则
规则只报告一次错误,位置分两种情况(doc/md043.md):
- 若在标题序列中发现问题,报告第一个有问题的标题所在行;
- 若问题是标题数量不足/多余(循环结束后
extraHeadings判定,见 lib/md043.mjs),则报告文件最后一行。
后者在测试中大量出现:如 test/required-headings-missing-last.md 期望 # One、## Two、### Three 但文档只有两级 Setext 标题,在文件末尾行报 {MD043};test/required-headings-missing-last-zero-or-more.md 和 test/required-headings-missing-middle-zero-or-more.md 都是配置 * 区间后的固定标题(#### FOO)在文档中缺失,在末尾报 {MD043:30}。而 {MD043:+1} 形式的偏移标记出现在 test/required-headings-question-extra.md、test/required-headings-question-missing.md、test/required-headings-none-one-or-more.md 中,对应问题判定位置在文件结尾之后。
边界判定细节
结尾处的 extraHeadings 判定(lib/md043.mjs)还有两个细节值得注意:
- 当剩余未消费的期望值超过 1 个,或者剩余恰好 1 个且它不是
"*"时才会报错——因此配置末尾的*(如 test/required-headings-zero-or-more-last.md、test/required-headings-optional-last.md)可以合法地"多出"标题; - 当文档完全没有标题时,只有配置不全是
*才报错:["*"]通过(test/required-headings-none-zero-or-more.md),而["+"]或[]都会报错(test/required-headings-none-one-or-more.md、test/required-headings-none.md)。
七、测试用例矩阵:required-headings 家族
仓库在 test 目录下为 MD043 维护了一组非常完整的测试夹具,文件名即语义标签。下表按场景归类(无错误标记的文件表示应通过检查):
这些夹具配合 test/markdownlint-test-scenarios.mjs 等测试脚本(其快照见 test/markdownlint-test-scenarios.mjs.snapshot)运行,确保规则行为长期稳定;后续修改实现时必须保持这些断言不变。
八、如何配置与运行验证
方式一:项目配置文件
在 markdownlint 的配置文件中按规则别名或编号启用,例如:
{
"required-headings": {
"headings": [
"# 项目名",
"## 安装",
"*",
"## 使用方法",
"*",
"## 许可证"
],
"match_case": false
}
}
这份配置要求每个文档固定包含一级标题、安装、使用方法、许可证 四个章节,其中 使用方法 前后的 * 允许插入任意数量的可选小节,同时标题大小写保持宽松。相关参数含义与规则完整说明见 doc/md043.md。
方式二:文件内联配置(本文档采用的写法)
本文主题测试文件使用的是 markdownlint-configure-file HTML 注释,该注释必须以 <!-- 开头、内容为 JSON、以 --> 结束:
<!-- markdownlint-configure-file {
"required-headings": {
"headings": [
"# One",
"*",
"### Three",
"*",
"### Six",
"*",
"#### 7"
]
}
} -->
注释可以出现在文档任意位置,只对当前文件生效(相关解析逻辑见 helpers/helpers.cjs 中 inlineCommentStartRe 对 markdownlint-configure-file 指令的识别;单行变体示例见 test/inline-configure-file-single-line.md)。在测试夹具里用内联配置可以精确控制每个用例的规则参数,无需维护独立配置文件。
方式三:运行验证
安装后可通过 markdownlint 的 Promise API 对单个文件执行检查,仓库自带的测试驱动脚本 test/harness.mjs 演示了标准用法:
node test/harness.mjs test/required-headings-zero-or-more.md
脚本内部调用 markdownlint/promise 的 lint({ "files": <a href="https://link.gitcode.com/i/13b82fb5f76c4c03edd2da06d3b97e1d" target="_blank">file] }) 并打印结果对象([test/harness.mjs)。对本文的夹具运行后应得到空结果;若把 ### Six 误写成 ### SiX 且开启 match_case: true,则会在对应标题行报出 Expected: ### Six; Actual: ### SiX 的详细错误。
九、实战建议与边界注意
综合规则文档(doc/md043.md)、实现(lib/md043.mjs)与测试族,给出如下实践建议:
- 固定骨架 + 通配放行是 MD043 最典型的使用方式:把必有的章节写成精确标题,把可选的中间章节用
*包起来。要区分"至少一节"与"可有可无",分别选+与*; ?专用于单点可变:如项目名、版本号所在的标题,?恰好消费一个任意标题,比*更严格,可防止"多出"额外标题被静默接受;- 大小写策略全局生效:
match_case是对整个headings序列生效的开关,无法针对单个标题单独配置;默认不区分大小写可以容忍THREE/Three这类差异,但也会让"必须大写"的约束失效,需要根据团队规范权衡; - 注意报错定位:标题序列中间出错时报告在具体标题行,而数量不符时报告在文件末尾(可能表现为
{MD043:+1}的偏移形式),排错时需要区分两类问题; - 配置写法与实际风格解耦:
headings统一使用 ATX 风格("## Text")书写,但文档实际可以使用 Setext 或闭合 ATX 等任意受支持样式(test/required-headings-missing-last.md 中 Setext 标题同样参与匹配),不要因此误判; - 空配置即全面禁用检查:
headings: []时任何标题都会报错(test/required-headings-none.md),如需临时关闭该规则应配置"required-headings": false而不是空数组。
MD043 是 markdownlint 中少有的"结构性"规则——它不关心单个标题写得规不规范,而是把关整篇文档的章节骨架。配合 *、+、? 三种通配符与 match_case,足以应对从"固定章节模板"到"仅约束关键章节"的各种文档治理需求,是构建大规模 Markdown 文档库一致性基线的有力工具。