markdownlint ATX 标题空格规则实战剖析:基于 short-headings.md 场景测试解析 MD018–MD021
markdownlint ATX 标题空格规则实战剖析:基于 short-headings.md 场景测试解析 MD018–MD021
本文以仓库中的 short-headings.md 场景测试文件为核心载体,系统拆解 markdownlint 中 4 条 ATX 标题空格规则(MD018/MD019/MD020/MD021)的触发条件、判定逻辑与自动修复行为。读完本文,你将掌握这些规则在开放式(# Heading)与闭合式(# Heading #)标题上的完整行为矩阵,理解 {MD018:5} 这类测试标记的语义,并能在自己的文档中准确排查、修复同类格式问题。
一、场景测试文件的定位:为何用它讲解规则
在 markdownlint 仓库中,test/short-headings.md 并非普通示例文档,而是一份场景测试输入:它的每一行都经过精心编排,用来在最小篇幅内穷举 ATX 标题空格规则的各种触发形态,并由 {MD###:行号} 标记声明预期报告位置。
markdownlint-test-scenarios.mjs 会把 test/ 目录下所有 .md 文件当作测试用例逐一遍历:先调用 lint() 真实执行全部规则,再解析正文中的 {MD###:行号} 标记生成期望结果,最后断言两者完全一致。因此这份文件的每一处 {MD018:5} 都是经过测试验证的规则行为事实,非常适合作为讲解四条标题空格规则的权威基线。
二、先看文件全貌:结构、分组与禁用的干扰规则
文件首部有一行关键指令:
<!-- markdownlint-disable single-title heading-style -->
这是 markdownlint 的内联配置语法。因为文件里写了多个顶层标题(# A、# MM 等),会触发 MD025/single-title(single-h1)(同一文档不允许出现多个顶级标题);同时文件混用了 # A(atx)与 # E #(atx_closed)两种风格,会触发 MD003/heading-style(标题风格需一致)。测试目标只聚焦空格规则,所以用 disable 指令先屏蔽这两个"噪音"规则,保证报告里只留下 MD018–MD021。
正文共 82 行,可以清晰分成 4 组,每组 20 行、包含 5 个标题和 5 个标记:
| 组别 | 标题示例 | 覆盖规则 | 标记行号 |
|---|---|---|---|
| 第 1 组(第 5–23 行) | #A、# B、# C、#D#、# E #、# F # |
MD018 / MD019 / MD020 / MD021 | 5 / 11 / 15 / 21 |
| 第 2 组(第 25–43 行) | ##G、## H、## I、##J##、## K ##、## L ## |
同上 | 25 / 31 / 35 / 41 |
| 第 3 组(第 45–63 行) | #MM、# NN、# OO、#PP#、# QQ #、# RR # |
同上 | 45 / 51 / 55 / 61 |
| 第 4 组(第 65–82 行) | ##SS、## TT、## UU、##VV##、## WW ##、## XX ## |
同上 | 65 / 71 / 75 / 81 |
设计意图很清晰:用短标题(1 个或 2 个字符)压缩行宽,让同一组内尽量多放用例;用 # 与 ## 验证规则与标题级别无关;用开放/闭合两种形态分别验证对应的规则对。
三、四条规则的官方定义与参数速览
先给出四条规则在仓库规则文档中的完整定义,它们都属于 headings / spaces 相关标签:
| 规则 | 别名 | 官方描述 | 触发条件 | 修复方式 |
|---|---|---|---|---|
| MD018 | no-missing-space-atx |
No space after hash on atx style heading | atx 标题的 # 后缺失空格,如 #Heading |
在 # 后插入一个空格 |
| MD019 | no-multiple-space-atx |
Multiple spaces after hash on atx style heading | atx 标题的 # 后出现多个空格,如 # Heading |
删除多余空格,保留一个 |
| MD020 | no-missing-space-closed-atx |
No space inside hashes on closed atx style heading | 闭合式标题 #Heading# 任一侧缺失空格 |
两侧补上单个空格 |
| MD021 | no-multiple-space-closed-atx |
Multiple spaces inside hashes on closed atx style heading | 闭合式标题 # Heading # 任一侧出现多个空格 |
删除两侧多余空格 |
需要特别留意两条"闭合式"规则的边界行为:
- MD020 的任一侧语义:只要
#与正文之间有任何一侧缺空格就会报告(见 doc/md020.md 的 Note)。#D#、##J##属于两侧同时缺失,同样命中。 - MD021 的任一侧语义:闭合式标题任意一侧出现多个空格即报告(见 doc/md021.md 的 Note)。由于两侧可以同时违规,一行最多产生两条 MD021 报告——这正是本文件第 21、41、61、81 行各有两条标记的原因。
四、逐组解读 20 个用例的触发矩阵
4.1 开放 atx 标题:缺空格与多空格(MD018 / MD019)
先看第一组中开放的 3 个标题:
#A → {MD018:5} 缺空格,需在 # 后补一个空格
# B → 合规 单个空格是标准写法
# C → {MD019:11} 两个空格,多余一个
对应到 ## 级别完全等价(第 25、31 行),验证了规则对标题级别不敏感。第 3 组、第 4 组的 #MM、# OO、##SS、## UU 进一步证明:标题文本长度(1 个字符还是 2 个字符)不影响判定,判定的唯一依据是 # 序列与正文之间的空格数量是否为 1。
4.2 闭合 atx 标题:两侧空格校验(MD020 / MD021)
闭合式标题需要同时校验"开头一侧"和"结尾一侧":
#D# → {MD020:15} 两侧都缺空格
# E # → 合规 两侧各一个空格
# F # → {MD021:21} 两侧各有两个空格,报告两条
##J##、## K ##、## L ##(第 35、41 行)及第 3、4 组的 #PP#、# QQ #、# RR #、##VV##、## WW ##、## XX ## 均复现同一模式。其中 MD021 在 # F #(第 21 行)、## L ##(第 41 行)、# RR #(第 61 行)、## XX ##(第 81 行)上都是一行两条报告,合计 8 条,与快照中的输出完全一致。
4.3 汇总:20 条预期报告
| 规则 | 预期报告位置 | 数量 |
|---|---|---|
| MD018 | 5、25、45、65 | 4 |
| MD019 | 11、31、51、71 | 4 |
| MD020 | 15、35、55、75 | 4 |
| MD021 | 21、41、61、81(每行 2 条) | 8 |
合计 20 条。这个矩阵同时验证了快照中所有 errors 条目的行号与 ruleNames 字段。
五、测试标记 {MD018:5} 的语法与校验机制
文件正文里的 {MD018:5} 并不是渲染给读者看的内容,而是测试骨架使用的期望声明。其语法在 markdownlint-test-scenarios.mjs 中由正则 /\{(MD\d+)(?::([-+]?)(\d+))?\}/g 定义:
MD018:期望命中的规则名(取错误ruleNames数组的第一个元素,即标准名);- 冒号后的数字:默认表示绝对行号,例如
{MD018:5}表示"第 5 行应报告 MD018"; - 可选的
+/-前缀:表示相对行号偏移,例如{MD020:+2}表示"当前标记行往下数 2 行应报告 MD020"。
测试执行时,骨架收集实际 lint 结果中每条错误的行号,与解析出的期望行号数组逐条 deepEqual 比对,任何多报、漏报或行号错位都会导致测试失败。这意味着文件里每个标记的位置都经过真实引擎验证,本文件因此可以作为四条规则行为的最小回归样本库。
六、源码级原理:四条规则如何实现
6.1 MD018:正则驱动的逐行扫描
lib/md018.mjs 的实现是四条规则中最直观的:它逐行检查,核心判定为
/^#+[^# \t]/.test(line) && !/#\s*$/.test(line)
- 第一个正则命中"
#序列后紧跟非#、非空格、非制表符的字符",即缺空格; - 第二个正则排除行尾本身就是
#的闭合式标题(那是 MD020/MD021 的管辖范围); - 此外还通过
filterByTypesCached(["codeFenced", "codeIndented", "htmlFlow"])收集代码块、HTML 块的行号,把这些行从检查范围中剔除,避免误报代码内容; - 命中后生成
fixInfo:editColumn为#序列长度 + 1,insertText为单个空格,即"在缺空格的位置插入一个空格"。
6.2 MD019 与 MD021:基于 micromark 词法结构的校验
lib/md019-md021.mjs 共用同一个内部函数 validateHeadingSpaces(onError, heading, delta),这是两条规则共享的核心逻辑:
- 先用
filterByTypesCached(["atxHeading"])拿到所有 atx 标题 token; - 用
getHeadingStyle()(定义在 helpers/micromark-helpers.cjs)判断风格:统计标题 token 子节点中atxHeadingSequence的个数,1 个判定为开放 atx,2 个判定为闭合 atx_closed; - 在 token 子节点序列中定位
atxHeadingSequence,取其相邻的whitespace节点:若其text.length > 1(即连续空格超过一个),就按该空格的起止列生成fixInfo(deleteCount为多余空格数); - MD019 只沿
delta = 1方向检查开头一侧;MD021 以delta = 1和delta = -1双向检查开头与结尾两侧——这正是闭合式标题一行最多报两条 MD021 的结构性原因。
6.3 规则元数据与可修复标记
四条规则都声明了 "parser": "micromark"(MD019/MD021 依赖词法树定位 whitespace 节点),并携带 names、description、tags 元数据。仓库在 lib/constants.mjs 中通过 fixableRuleNames 数组统一登记可自动修复的规则,MD018–MD021 全部在列,与 markdownlint-test-scenarios.mjs 中"带 fixInfo 的错误必须出现在可修复名单中"的断言相互印证。
七、自动修复:快照中的修复结果实证
场景测试在断言报告位置后,还会调用 applyFixes(content, errors) 对文件执行自动修复,并把结果写入快照 markdownlint-test-scenarios.mjs.snapshot(short-headings.md 一节位于该文件第 41529 行起)。修复逻辑的输入输出可从快照 fixed 字段完整还原:
| 原始文本 | 修复动作(fixInfo) | 修复后文本 |
|---|---|---|
#A |
第 2 列插入空格 | # A |
# C |
第 3 列删除 1 个字符 | # C |
#D# |
整行替换为 # D # |
# D # |
# F # |
第 3 列、第 6 列各删除 1 个字符 | # F # |
快照还显示:修复后立即对结果再次 lint 必须零残留可修复错误(见 markdownlint-test-scenarios.mjs)。也就是说,这份文件的 20 条违规全部可以通过 applyFixes 一键修复,无需手工处理。
对应到实际工程中,你可以在使用 markdownlint 时开启 fix 流程(如 markdownlint-cli 的 --fix 或编辑器插件自动修复),把这类空格问题批量收敛。
八、如何复现与验证
在仓库根目录安装依赖后即可复现本文件的全部断言:
npm install
node --test test/markdownlint-test-scenarios.mjs
该测试会遍历 test/*.md,其中就包含 short-headings.md;所有标记与真实报告一致、快照一致、修复后可再次 lint 通过,测试才算通过。若只想看单文件报告,可借助 test/harness.mjs 这类工具传入文件路径执行 lint 并打印结构化结果。
九、实战建议:把规则矩阵迁移到你的文档
#与正文之间永远只保留一个空格:#Heading(缺空格)与# Heading(多空格)分别命中 MD018 与 MD019,这是渲染正确性与格式一致性的双重要求(详见 doc/md018.md、doc/md019.md 的 Rationale)。- 闭合式标题必须双侧各一个空格:MD020/MD021 的"任一侧"判定意味着
#Text#、# Text#、#Text #、# Text #全部违规;若团队文档大量使用闭合式 atx 风格,建议开启这两条规则以强制双侧规范。 - 代码块与 HTML 块不受影响:MD018 实现中显式排除了
codeFenced、codeIndented、htmlFlow区域,避免误伤代码示例,因此无需为代码里的#foo写豁免。 - 冲突规则需按需禁用:若你的测试或文档同样存在多个顶级标题、混合标题风格,可仿照本文件在头部使用
<!-- markdownlint-disable single-title heading-style -->内联指令临时屏蔽 MD025 与 MD003,聚焦当前要排查的规则维度。