- 开发工具
- 代码质量
- Lint
- 静态分析
【免费下载链接】markdownlint
A Node.js style checker and lint tool for Markdown/CommonMark files.
本文围绕 markdownlint 仓库中的 MD021 规则文档 展开,系统讲解该规则如何识别并修复「闭合式 ATX 标题(closed atx style heading)」中标题文本与井号之间的多余空格,并结合 规则实现源码、micromark 解析辅助函数 与测试快照,说明其判定逻辑、双向扫描机制与自动修复能力。读完本文,你将能准确理解 MD021 的触发边界、与 MD018/MD019/MD020 的规则家族关系,并能在实际项目中正确配置、排除或利用其自动修复能力。
规则速览
MD021 是 markdownlint 中一条针对标题书写的风格检查规则,其完整元信息定义于 doc/md021.md,同时汇总在 doc/Rules.md 与 README.md 的规则清单中:
- 规则编号:
MD021 - 别名(Alias):
no-multiple-space-closed-atx - 标签(Tags):
atx_closed、headings、spaces - 可自动修复:部分违规可通过工具自动修复
- 规则描述:闭合式 ATX 标题中,井号(
#)与标题文本之间存在多个空格
这里的「closed atx style heading」指同时具备开头井号与结尾闭合井号的 ATX 标题,例如# Heading 1 #、## Heading 2 ##。它与仅以开头井号标记的普通 ATX 标题(如# Heading 1)以及 Setext 标题(用===/---下划线标记)是三种不同的标题风格,具体区分逻辑见下文源码分析。
触发条件:什么时候会报 MD021
按照 doc/md021.md 的定义,当闭合式 ATX 标题中任一侧(开头井号之后、或结尾闭合井号之前)出现多于一个空格时,该规则即被触发:
# Heading 1 # ## Heading 2 ##上面两行中,#与Heading 1之间、Heading 1与结尾#之间均插入了两个空格,这两行都会产生 MD021 违规。
需要注意的要点:
- 两侧独立判定:规则会同时检查开头与结尾两侧,只要任意一侧有多余空格就会报告;
- 两侧同时违规时报告多次:当标题两侧同时包含多个空格时,同一行会报告两条独立错误(分别对应开头侧与结尾侧);
- 单个空格是唯一合法形式:检查基准是「恰好一个空格」,零个空格或两个及以上空格都不符合要求。
零空格与多个空格分别由不同规则覆盖:缺少空格对应 MD020 规则(no-missing-space-closed-atx),多余空格对应本文的 MD021。
正确写法与修复方式
将标题文本与井号之间统一为单个空格即可消除违规:
# Heading 1 # ## Heading 2 ##在 markdownlint 中,MD021 属于「Fixable: Some violations can be fixed by tooling」的规则。这意味着通过官方提供的applyFix/applyFixes工具函数,可以自动删除多余的空格(详见下文「自动修复机制」一节),无需手工逐个清理。
边界情况:多空格数量的差异
从规则实现看,MD021 对多余空格的数量不做上限区分——只要空白 token 长度大于 1 就会触发,2 个空格、3 个空格乃至更多均属违规。这一行为可以从测试用例 test/atx_closed_heading_spacing.md 中得到印证:
## Heading 4 {MD021} ## ← 开头侧 2 个空格 ## Heading 5 {MD021} ## ← 结尾侧 2 个空格 ## Heading 6 {MD021} ## ← 两侧各 2 个空格,报告两条错误 ## Heading 7 {MD021} ## ← 两侧各 3 个空格,报告两条错误而同一测试文件中的这些写法不会触发 MD021:
## Heading 8\# ← 结尾井号被转义,不是闭合井号 ## Heading 9 \# ## Heading 10 \#可以看到,只有真正的闭合井号(未转义、与开头井号成对)之前的多余空格才会被检查;带反斜杠转义的\#属于标题文本的一部分,不受 MD021 约束。
源码实现:双向扫描的空白校验
MD021 的实现位于 lib/md019-md021.mjs,它与 MD019(普通 ATX 标题开头的多余空格)共用同一个校验函数validateHeadingSpaces,区别仅在于扫描方向与过滤条件:
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) ) { // ... 计算列号与长度,报告错误并附加 fixInfo } }关键逻辑可以拆解为三步:
- 定位井号序列:在 heading token 的 children 中,沿
delta方向(+1表示从开头向结尾、-1表示从结尾向开头)找到类型为atxHeadingSequence的节点,即该侧的井号串; - 检查相邻空白:取井号序列紧邻的相邻 token,若其类型为
whitespace且文本长度大于 1,判定为违规; - 计算错误位置:以空白 token 的
startColumn/endColumn计算出range(列号与长度),并通过addErrorContext上报。
MD021 的规则函数对每个闭合式 ATX 标题调用两次validateHeadingSpaces,一次delta = 1检查开头侧、一次delta = -1检查结尾侧,这正对应了文档中「either side」的说明:
function MD021(params, onError) { const atxClosedHeadings = filterByTypesCached([ "atxHeading" ]) .filter((heading) => getHeadingStyle(heading) === "atx_closed"); for (const atxClosedHeading of atxClosedHeadings) { validateHeadingSpaces(onError, atxClosedHeading, 1); validateHeadingSpaces(onError, atxClosedHeading, -1); } }如何判定「closed atx」:getHeadingStyle
MD021 只作用于闭合式 ATX 标题,风格的判定复用 helpers/micromark-helpers.cjs 中的getHeadingStyle函数:
function getHeadingStyle(heading) { if (heading.type === "setextHeading") { return "setext"; } const atxHeadingSequenceLength = heading.children.filter( (child) => child.type === "atxHeadingSequence" ).length; if (atxHeadingSequenceLength === 1) { return "atx"; } return "atx_closed"; }判定规则很直观:Setext 标题返回setext;其余 ATX 类标题中,atxHeadingSequence节点(井号串)的数量为 1 的是普通 ATX 标题,数量为 2(开头 + 结尾各一组)的才是atx_closed。MD019 与 MD021 正是通过这一函数分流:前者过滤出"atx"风格、只做单侧(开头)扫描,后者过滤出"atx_closed"风格、做双侧扫描。
自动修复机制
MD021 属于可修复规则,validateHeadingSpaces在报告错误时携带了fixInfo:
addErrorContext( onError, startLine, text.trim(), delta > 0, // 标记错误位于开头侧 delta < 0, // 标记错误位于结尾侧 [ column, length ], { "editColumn": column, "deleteCount": length } );editColumn指向多余空白的起始列,deleteCount为需删除的空白长度。从 test/markdownlint-test-scenarios.mjs.snapshot 中atx_closed_heading_spacing.md的期望结果可以看到具体的修复参数:
- 第 7 行
## Heading 4 {MD021} ##:errorRange: [4, 1],fixInfo: { "editColumn": 4, "deleteCount": 1 }; - 第 9 行
## Heading 5 {MD021} ##:errorRange: [22, 1],fixInfo: { "editColumn": 22, "deleteCount": 1 }; - 第 11 行
## Heading 6 {MD021} ##:同时报告两条错误,errorRange分别为[4, 1]与[23, 1]; - 第 13 行
## Heading 7 {MD021} ##:同样两条错误,errorRange分别为[4, 2]与[24, 2](3 个空格需删除 2 个)。
在同一快照的fixed输出中,上述标题全部被规整为## Heading 4 {MD021} ##这类单空格形式。这说明自动修复是增量删除:保留第一个空格,仅删除多余部分,而不是整行重写。
实际应用时,可参照 README.md 的「Fixing」章节,用applyFixes把 lint 结果直接写回内容:
import { applyFixes } from "markdownlint"; import { lint as lintSync } from "markdownlint/sync"; const results = lintSync({ "strings": { "content": original } }); const fixed = applyFixes(original, results.content);与其他相关规则的边界
MD021 并非孤立存在,它与 MD018、MD019、MD020 共同构成「ATX 标题与井号间距」的规则家族,容易混淆,这里给出对照:
| 规则 | 别名 | 标题风格 | 检查位置 | 违规形态 |
|---|---|---|---|---|
| MD018 | no-missing-space-atx | 普通 ATX | 开头井号后 | 缺少空格(#Heading) |
| MD019 | no-multiple-space-atx | 普通 ATX | 开头井号后 | 多个空格(# Heading) |
| MD020 | no-missing-space-closed-atx | 闭合式 ATX | 两侧 | 缺少空格(#Heading 1#) |
| MD021 | no-multiple-space-closed-atx | 闭合式 ATX | 两侧 | 多个空格(# Heading 1 #) |
其中 MD019 与 MD021 共享同一实现文件 lib/md019-md021.mjs(该文件同时导出两条规则,在 lib/rules.mjs 中解构注册);MD018 与 MD020 则分别在 lib/md018.mjs、lib/md020.mjs 独立实现。从仓库的模块组织方式可以看出,作者按「间距方向」分组共享逻辑:md019-md021.mjs处理「多个空格」,md020.mjs处理「缺少空格」。
测试验证与回归保障
仓库为 MD021 准备了多层测试验证:
- 专项场景文件test/atx_closed_heading_spacing.md:集中构造了开头侧多空格、结尾侧多空格、两侧同时多空格、以及转义井号等边界用例,文件头部通过
<!-- markdownlint-configure-file { "first-line-heading": false, "heading-style": false } -->内联配置排除了无关规则的干扰; - 快照断言test/markdownlint-test-scenarios.mjs.snapshot:由
markdownlint-test-scenarios.mjs生成,精确记录了每条错误的lineNumber、errorRange与fixInfo,并对修复后的全文做逐字符比对,防止修复逻辑回归; - 综合用例test/MD011-MD021.md 与 test/atx-heading-spacing-trailing-spaces.md:将 MD021 与 MD018/MD019/MD020 混排在同一文件中,验证各规则互不误报;
- 全规则回归test/break-all-the-rules.md 配合 test/markdownlint-test.mjs 中的
styleAll测试,使用 style/all.json 配置跑全量规则,确认 MD021 在其第 31 行正常触发、无遗漏。
配置与使用建议
MD021 默认在 markdownlint 的完整配置中启用(它在 lib/rules.mjs 的rules数组中注册,运行时由库统一加载)。可按需进行以下操作:
- 按编号或别名启用/禁用:在
.markdownlint.json等配置文件中将"MD021": false或"no-multiple-space-closed-atx": false即可关闭;置为true或"error"恢复; - 行内临时控制:借助 markdownlint 的行内指令,例如
<!-- markdownlint-disable MD021 -->禁用、<!-- markdownlint-enable MD021 -->恢复,或使用<!-- markdownlint-disable-next-line MD021 -->仅豁免下一行(测试文件中的markdownlint-configure-file注释即为行内配置的实例); - 自动修复流程:在 CI 或提交钩子中接入
lintSync+applyFixes(见上文示例),让多余空格问题在保存或提交阶段自动消失,无需人工介入。
实践上的核心建议是:保持标题两侧与井号之间恰好一个空格。这一约定不仅让源码更整洁,也让标题文本在复制、搜索、以及嵌入其他工具链时行为可预期。由于多余的空白不影响渲染(这正是 doc/md021.md 中 Rationale 的表述:Extra space has no purpose and does not affect the rendering of content),MD021 本质上是一条纯风格规范,是否启用完全取决于团队约定;若你的文档风格允许对齐式的多空格标题,直接关闭该规则即可,不会产生功能影响。
- 开发工具
- 代码质量
- Lint
- 静态分析
【免费下载链接】markdownlint
A Node.js style checker and lint tool for Markdown/CommonMark files.
相关推荐
markdownlint 规则 MD021(no-multiple-space-closed-atx):闭合 ATX 标题井号内多余空格的检测与自动修复
markdownlint 规则 MD021(no multiple space closed atx):闭合 ATX 标题井号内多余空格的检测与自动修复 本篇文
开发工具代码质量Lint静态分析markdownlint MD019(no-multiple-space-atx)规则详解:ATX 标题井号后多余空格检测与修复
markdownlint MD019(no multiple space atx)规则详解:ATX 标题井号后多余空格检测与修复 MD019 是 markdow
开发工具代码质量Lint静态分析markdownlint 规则 MD020(no-missing-space-closed-atx)详解:闭合 ATX 标题井号内缺失空格检测与自动修复
markdownlint 规则 MD020(no missing space closed atx)详解:闭合 ATX 标题井号内缺失空格检测与自动修复 导读
开发工具代码质量Lint静态分析
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考