- 开发工具
- 代码质量
- Lint
- 静态分析
【免费下载链接】markdownlint
A Node.js style checker and lint tool for Markdown/CommonMark files.
导读
本文以 markdownlint 仓库中的场景测试文件 test/headings-without-content.md 为主线,深入讲解"只有井号、没有任何正文内容"的 ATX 空标题在 CommonMark 与 markdownlint 中的行为差异:Markdown 解析器允许空标题存在,而 markdownlint 的MD019(no-multiple-space-atx)规则会精准检查井号与标题文本之间是否使用了超过一个空格。读完本文,你将掌握空标题的合法语法边界、如何用内联配置隔离单条规则进行测试、MD019 的判定与自动修复原理,以及如何阅读本仓库的快照测试(snapshot)输出。
一、场景测试文件全景
markdownlint的test/目录下存放了大量"场景测试" Markdown 文件,每个文件本身就是一份自带预期结果的迷你测试。test/headings-without-content.md是其中专门验证"空标题"场景的用例,全文共 22 行:
# Headings Without Content <!-- markdownlint-disable single-title heading-style --> <!-- markdownlint-disable no-duplicate-heading no-trailing-spaces --> # # # # ## ## ## ## {MD019:10} {MD019:12} {MD019:18} {MD019:20}从文件结构可以拆出三个层次:
- 标题行:第 1 行
# Headings Without Content是文件自身的标题(注意它本身也是一个合法的#一级标题); - 禁用注释:第 3、4 行通过
markdownlint-disable内联指令临时关闭 4 条规则,把测试范围收敛到MD019一条规则上; - 被测内容:第 6~20 行是 8 个没有任何标题文本的 ATX 空标题(4 个
#、4 个##),最后一行是测试标记(marker)。
需要特别指出的是,"空标题"本身是完全合法的 Markdown。CommonMark 规范允许#或##之后不跟任何文本,micromark 解析器会把它们正常识别为atxHeading类型的标题 token——它们可以正常参与标题层级、锚点生成等后续处理。正因为语法合法,markdownlint才对"空标题里的空格数量"这一纯格式问题单独立项为MD019。
二、内联禁用注释:如何把测试聚焦到 MD019
文件第 3、4 行使用了 markdownlint 的内联配置指令,这两行注释是理解整个测试的关键:
<!-- markdownlint-disable single-title heading-style --> <!-- markdownlint-disable no-duplicate-heading no-trailing-spaces -->markdownlint-disable表示从当前行开始到文件末尾(或遇到markdownlint-enable之前)关闭指定规则。这里一共关闭了 4 条规则,关闭原因都可以在对应文档与源码中找到依据:
| 规则 | 别名 | 文档 | 为什么要关闭 |
|---|---|---|---|
MD025 | single-title/single-h1 | doc/md025.md | 文档中出现了 5 个空的一级标题(#),会触发"同一文档存在多个顶层标题" |
MD003 | heading-style | doc/md003.md | 混合出现#与##等不同级别、且与首标题风格比较,会触发"标题风格不一致" |
MD024 | no-duplicate-heading | doc/md024.md | 8 个空标题的文本内容全部相同(都是空字符串),会触发"标题内容重复" |
MD009 | no-trailing-spaces | doc/md009.md | 第 8、10、12、16、18、20 行在井号后存在行尾空格,会触发"行尾多余空格" |
从源码实现看,MD025在 lib/md025.mjs 中通过filterByTypesCached(["atxHeading", "setextHeading"])收集所有顶层标题并报出"除第一个之外"的重复标题;MD024在 lib/md024.mjs 中会记录每个标题的文本(这里全部是空文本),第二次出现相同文本即报错。因此如果不禁用这 4 条规则,测试输出会被这些"噪音"污染,无法单独验证 MD019 对空格数量的判定——这正是该文件两行禁用注释的用意:把一条场景测试裁剪到只覆盖一条规则,使断言结果精确可控。
三、MD019 规则:井号后多重空格的判定
3.1 规则定义
MD019的完整规则名是no-multiple-space-atx,官方文档 doc/md019.md 定义如下:
- 规则名:
MD019/no-multiple-space-atx - 标签:
atx、headings、spaces - 可修复:部分违规可由工具自动修复
触发条件:ATX 风格标题中,井号(#)与标题文本之间使用了超过一个空格。例如:
# Heading 1 ## Heading 2正确写法是井号后只保留一个空格:
# Heading 1 ## Heading 2规则设计原理在文档中写得很直白:额外的空格没有任何用途,也不会影响渲染结果,属于纯粹的多余格式。
3.2 空标题场景下的特殊表现
回到空标题场景:当#后面没有任何文本、只有若干空格时,这些空格恰好构成了atxHeadingSequence(井号 token)之后唯一的子 token。于是MD019的判定逻辑就转化为——检查空标题里井号后到底跟了几个空格:
# → 井号后 0 个空格,MD019 不触发 # → 井号后 1 个空格,MD019 不触发(但 MD009 行尾空格会管它) # → 井号后 2 个空格,MD019 触发(第 10 行) # → 井号后 3 个空格,MD019 触发(第 12 行) ## → 2 个井号后 0 个空格,不触发 ## → 2 个井号后 2 个空格,MD019 触发(第 18 行) ## → 2 个井号后 3 个空格,MD019 触发(第 20 行)这正是测试文件第 22 行标记所声明的预期:{MD019:10} {MD019:12} {MD019:18} {MD019:20},即只有第 10、12、18、20 行会报MD019。注意第 8、16 行各带 1 个空格的行不报 MD019(因为 1 个空格是合法分隔),它们的存在是为了证明"单空格边界不会误报"。
四、源码级实现:token 层面的扫描
MD019与MD021(闭式 ATX 标题的井号内侧空格)共用同一套核心逻辑,实现在 lib/md019-md021.mjs 中。
4.1 空标题如何变成 token 流
仓库使用 micromark 作为 Markdown 解析器(见 package.json 中的micromark依赖与 lib/micromark-parse.mjs)。#空标题会被解析为结构如下的 token 树:
atxHeading ├── atxHeadingSequence (#,文本 "#") └── whitespace (空格序列,文本 " ")当井号后没有任何内容时,whitespace就是标题里唯一的"正文占位";MD019扫描的正是这个whitespacetoken 的文本长度。
4.2 核心判定函数 validateHeadingSpaces
lib/md019-md021.mjs 中的validateHeadingSpaces(onError, heading, delta)是判定的核心:
function validateHeadingSpaces(onError, heading, delta) { const { children, startLine, text } = heading; let index = (delta > 0) ? 0 : (children.length - 1); while ( children[index] && (children[index].type !== "atxHeadingSequence") ) { index += delta; } const headingSequence = children[index]; const whitespace = children[index + delta]; if ( (headingSequence?.type === "atxHeadingSequence") && (whitespace?.type === "whitespace") && (whitespace.text.length > 1) ) { const column = whitespace.startColumn + 1; const length = whitespace.endColumn - column; addErrorContext( onError, startLine, text.trim(), delta > 0, delta < 0, [ column, length ], { "editColumn": column, "deleteCount": length } ); } }关键点逐条拆解:
- 定位井号 token:沿
delta(+1 从前向后、-1 从后向前)在标题的children中找到atxHeadingSequence,它紧邻的下一个子 token 就是whitespace; - 判定条件:
whitespace.text.length > 1,即空格数大于 1 才报错——1 个空格是合法分隔,0 个空格(#后直接换行)也合法; - 误差范围:
column = whitespace.startColumn + 1从第二个空格开始计算,length是剩余空格数量。例如#(井号 + 2 空格)报错范围为[3, 1],只标记第 2 个空格;#(3 空格)报错范围为[3, 2]; - 修复信息:
fixInfo中editColumn指向第一个"多余空格"所在列,deleteCount是要删除的空格个数——修复策略是删除多余空格、保留井号后那 1 个合法空格。
MD019 的规则函数(lib/md019-md021.mjs)先按filterByTypesCached(["atxHeading"])收集所有 ATX 标题,再借助 helpers/micromark-helpers.cjs 中的getHeadingStyle过滤出纯atx风格(排除atx_closed闭式标题),最后对每个标题调用validateHeadingSpaces(onError, atxHeading, 1)从前向后扫描。
五、测试驱动的验证闭环
5.1 场景测试机制
markdownlint的场景测试由 test/markdownlint-test-scenarios.mjs 驱动。该文件会扫描test/目录下所有.md文件,对每个文件执行两条并行操作(第 22-27 行):
- 读取 Markdown 原文;
- 调用
lint({ "files": [file] })执行真实 lint。
随后(第 40-64 行)用正则/\{(MD\d+)(?::([-+]?)(\d+))?\}/g解析原文中的{MD019:10}这类标记,把"期望在第几行报哪条规则"解析成expected字典;再与 lint 实际输出比较,多报或漏报都会导致断言失败。文件最后一行{MD019:10} {MD019:12} {MD019:18} {MD019:20}因此扮演了"测试预期清单"的角色。
5.2 快照:修复后的输出
测试还会调用applyFixes生成"修复后的内容"并写入快照,结果保存在 test/markdownlint-test-scenarios.mjs.snapshot。针对本文件的快照包含 4 条MD019错误,每条都携带精确的定位与修复信息:
| 行号 | errorContext | errorRange | fixInfo |
|---|---|---|---|
10(#) | # | [3, 1] | editColumn: 3, deleteCount: 1 |
12(#) | # | [3, 2] | editColumn: 3, deleteCount: 2 |
18(##) | ## | [4, 1] | editColumn: 4, deleteCount: 1 |
20(##) | ## | [4, 2] | editColumn: 4, deleteCount: 2 |
快照中的fixed字段展示了自动修复的最终结果:所有空标题被统一规整为"井号 + 1 个空格"(如#、##)。这与上一节源码中的fixInfo完全对应——只删多余空格、保留合法单空格。由于第 4 行已禁用MD009(行尾空格),修复后残留的那个单空格不会被二次报错;同时 test/markdownlint-test-scenarios.mjs 还会对修复结果再次 lint,确保所有可修复错误都被彻底修复。
5.3 对照实验:headings_good.md
test/目录下的 test/headings_good.md 是本场景的"对照组":
# Heading 1 ## Heading 2 ## Heading 3它对应的快照(test/markdownlint-test-scenarios.mjs.snapshot)中errors为空数组,fixed与原文完全一致。这个对照说明:只要标题有内容且井号后只有 1 个空格,就不会触发 MD019 或任何其它标题相关规则——从正反两面印证了 MD019 判定条件的唯一变量就是"井号后的空格数量"。
六、实战:在自己的文档中规避与利用空标题
6.1 空标题的正确姿势
根据MD019的语义,写空标题时应遵守两条约束:
- 井号后最多 1 个空格:
#或#都不会触发 MD019; - 不要使用 2 个及以上的空格:
#、##都会报MD019,并会被自动修复工具折叠为单空格形式。
如果空标题是刻意为之(例如用于占位、作为模板骨架),建议:
#即只保留一个空格,既满足 MD019,又让渲染结果保持为标题。同时需要注意,空标题会继续触发 MD025、MD024、MD003、MD009 等其它规则(这正是测试文件要用两行禁用注释隔离它们的原因),所以实践中更稳妥的做法是为标题补齐实际文本,或按需在文件头部使用markdownlint-disable明确声明意图。
6.2 亲手复现与验证
如果你希望在本仓库中复现上述全部行为,可以:
# 运行包含本场景在内的全部场景测试 npm run test # 仅运行场景测试 node --test test/markdownlint-test-scenarios.mjs # 重新生成快照(更新测试预期) npm run update-snapshots其中npm run test在 package.json 中定义,运行环境要求 Node.js>=22(见 package.json 的engines字段)。修改test/headings-without-content.md后运行场景测试,即可观察断言与快照如何随文件变化,从而深入理解 markdownlint 的"测试文件即文档"设计哲学。
结语
test/headings-without-content.md虽然只有 22 行,却完整覆盖了 markdownlint 规则开发与测试的一条典型链路:合法的空标题语法 → 用内联禁用注释隔离目标规则 → micromark token 级扫描 → 精确到列的空格计数 → 可自动修复的 fixInfo → 快照回归验证。它同时告诉我们一个容易被忽略的事实:Markdown 语法合法不等于 lint 通过,MD019这类"格式化规则"正是负责在语法与规范之间补齐最后一道约束的。理解了这个测试用例,也就理解了 markdownlint 全部 60+ 条规则背后"解析器 + 规则 + 测试 + 快照"的统一工作模式,后续阅读 lib/md019-md021.mjs 或 doc/Rules.md 中的其它标题类规则(MD001、MD003、MD024、MD025)时即可举一反三。
- 开发工具
- 代码质量
- Lint
- 静态分析
【免费下载链接】markdownlint
A Node.js style checker and lint tool for Markdown/CommonMark files.
相关推荐
markdownlint MD025「单标题」规则深度解析:从测试用例到源码实现
markdownlint MD025「单标题」规则深度解析:从测试用例到源码实现 本文以 markdownlint 仓库(版本 0.41.0)中的测试夹具 te
开发工具代码质量Lint静态分析Parsedown 的 Setext 标题空格规则:从测试用例到 blockSetextHeader 源码实现
Parsedown 的 Setext 标题空格规则:从测试用例到 blockSetextHeader 源码实现 导读 Setext 标题是 Markdown 中
后端markdownlint ATX 标题空格规则(MD018–MD021)测试用例全解析:从 `{MD}` 标记到自动修复
markdownlint ATX 标题空格规则(MD018–MD021)测试用例全解析:从 {MD } 标记到自动修复 导读 本文以 markdownlint
开发工具代码质量Lint静态分析
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考