- 开发工具
- 代码质量
- Lint
- 静态分析
【免费下载链接】markdownlint
A Node.js style checker and lint tool for Markdown/CommonMark files.
导读
MD023(别名heading-start-left)是 markdownlint 中用于强制ATX/Setext 标题必须从行首开始(无前导空格缩进)的规则,属于headings、spaces标签组。本文以仓库测试文件 test/headings_with_spaces_at_the_beginning.md 为切入点,逐行拆解该规则命中与豁免的边界场景(普通段落、代码块注释、列表内标题、行内配置指令),并结合规则源码 lib/md023.mjs 与相邻规则 lib/md022.mjs 的底层实现、测试快照 test/markdownlint-test-scenarios.mjs.snapshot 中的输出与自动修复结果,讲清规则触发条件、修复原理及与 MD022 的协作关系。读完本文,你将能准确预判“带缩进的标题”何时会被 MD023 拦截、如何利用fixInfo自动修复,以及在列表等嵌套结构中如何规避误报。
一、规则背景:MD023 检查什么
依据官方规则文档 doc/md023.md(同内容收录于 doc/Rules.md):
Headings must start at the beginning of the line(标题必须从行首开始)。
该规则在以下场景触发:
Some text # Indented heading修正方式是将标题顶格书写:
Some text # Heading例外场景:块引用(block quote)会使整行看起来“有缩进”,但这是合法的:
> # Heading in Block Quote规则信息一览:
| 项目 | 值 |
|---|---|
| 规则名 | MD023 |
| 别名 | heading-start-left |
| 标签 | headings、spaces |
| 是否可自动修复 | 是(部分违规可由工具修复) |
| 修复类型 | 删除标题前的缩进字符(editColumn+deleteCount) |
设计动机:标题如果带有前导空格缩进,Markdown 解析器不会把它当作标题,而会渲染为普通文本(Rationale 原文即指出此点),这会造成文档结构与视觉预期的偏差。
二、从规则源码看检测原理
规则实现在 lib/md023.mjs,其核心逻辑基于 micromark 解析器产生的 token 流:
const headings = filterByTypesCached([ "atxHeading", "linePrefix", "setextHeading" ]);检测条件由三部分组合而成(lib/md023.mjs#L14-L34):
- 前一个 token 是
linePrefix(行前缀,即行首的空格/缩进); - 后一个 token 不是
linePrefix(说明该行确实以标题开始); - 前后两个 token 位于同一行(
startLine相同)。
命中后调用addErrorContext记录违规,并携带editColumn(起始列)与deleteCount(删除字符数)作为fixInfo,供自动修复使用。linePrefixtoken 的endColumn - startColumn即为缩进长度。
关于 token 过滤与缓存:filterByTypesCached定义于 lib/cache.mjs,底层委托给 helpers/micromark-helpers.cjs 的filterByTypes,并在规则执行期间按参数做缓存,避免多条规则重复遍历 token 流。
三、测试文件逐行解读:哪些缩进会被判违规
测试文件 test/headings_with_spaces_at_the_beginning.md 全文件仅 25 行,却覆盖了 MD023 的 4 个命中点与 2 类豁免场景。
3.1 ATX 标题带 1 个空格(第 3 行)
# Heading 1 {MD023}第 1 列是空格,linePrefix长度为 1,随后同一行出现atxHeading→ 触发 MD023。快照中对应错误(test/markdownlint-test-scenarios.mjs.snapshot):
{ "lineNumber": 3, "ruleNames": [ "MD023", "heading-start-left" ], "errorContext": " # Heading 1 {MD023}", "errorRange": [ 1, 1 ], "fixInfo": { "editColumn": 1, "deleteCount": 1 } }3.2 Setext 标题:两条下划线一行缩进一行不缩进(第 5–6、8–9 行)
Setext 标题由“文本行 + 下划线行”构成,MD023 对下划线行的缩进同样敏感:
Setext style fully indented {MD023} ===================================文本行与下划线行都带 1 个空格缩进 → 触发。快照中第 5 行错误(test/markdownlint-test-scenarios.mjs.snapshot#L14842-L14861)的fixInfo为{ "editColumn": 1, "deleteCount": 1 }。
对比第 8–9 行:
Setext style title only indented {MD023} =========================================文本行缩进、下划线行顶格。快照显示第 8 行仍被判定违规(test/markdownlint-test-scenarios.mjs.snapshot#L14862-L14881),因为setextHeading的起始位置取决于文本行是否带前导缩进;而下划线行顶格并不会“抵消”违规。反过来说,如果文本行顶格而下划线行缩进,同样会触发——两条线必须都顶格才算合规。
3.3 列表项内嵌套标题(第 19–20 行):与 MD022 的联动
* This is another case where MD023 shouldn't be triggered # Test {MD022} Valid heading for CommonMark (see section 5.2) # Test {MD022} {MD023} Also valid heading for CommonMark这是本测试文件中信息量最大的部分,需要结合 CommonMark 规范 5.2 节来理解:
- 第 19 行:列表项内缩进 2 个空格后的
# Test ...。按 CommonMark,标题位于列表项内容内部,是合法嵌套,因此MD023 不触发;但因为其上方/下方缺少空行,触发MD022(标题周围应有空行)。快照显示 MD022 给出两条错误(Above/Below,test/markdownlint-test-scenarios.mjs.snapshot#L14772-L14805),fixInfo均为insertText: "\n"。 - 第 20 行:缩进 4 个空格(
# Test ...)。此时标题不仅缺少空行(MD022),而且缩进已超过列表内容所需的缩进级别,linePrefix与atxHeading出现在同一行且无其他 token 隔断,因此MD023 同时触发。快照中第 20 行的 MD023 错误为(test/markdownlint-test-scenarios.mjs.snapshot#L14882-L14901):
{ "lineNumber": 20, "errorContext": " # Test {MD022} {MD023} Als...", "errorRange": [ 3, 2 ], "fixInfo": { "editColumn": 3, "deleteCount": 2 } }注意errorRange从第 3 列开始、长度为 2——修复只删除多出的 2 个空格,保留列表项内容缩进所需的 2 个空格,避免破坏列表嵌套结构。这正是 markdownlint 自动修复“最小干预”设计的一个体现。
3.4 豁免场景:代码块中的“井号”注释(第 11–16 行)
* Test situations in which MD023 shouldn't be triggered. ```rb # This shouldn't trigger MD023 as it is a code comment. foo = "And here is some code"代码栅栏内的 `# ...` 是代码注释,不是 Markdown 标题。micromark 会将其解析为 `codeFenced` 内容而非 `atxHeading`,因此 MD023 的 token 过滤天然将其排除——快照中该区域没有任何错误。这一场景说明:**MD023 的判定完全依赖解析结果(token 类型),而不是简单的正则匹配**,这也是本项目采用 micromark 解析器(`"parser": "micromark"`,见 [lib/md023.mjs#L11](https://link.gitcode.com/i/8b8fd0e261275647a187c029d8a20313#L11))的根本原因。 ### 3.5 行内配置指令:关闭相邻规则的干扰(第 22–25 行) 测试文件末尾通过 HTML 注释形式声明了行内配置: ```markdown <!-- markdownlint-configure-file { "heading-style": false, "first-line-heading": false } -->该指令关闭了heading-style(MD003,ATX 与 Setext 标题风格一致性)与first-line-heading(MD041,首行应为一级标题),使得测试可以专注验证 MD023/MD022,而不会被文件首行Some text触发 MD041、也不会因混合 ATX/Setext 风格触发 MD003。这种markdownlint-configure-file行内配置是仓库测试用例的常用手法,也适用于实际项目按文件粒度定向开关规则。
四、自动修复:applyFixes 与 fixInfo 的完整链路
修复能力由fixInfo提供,最终通过applyFixes落地。测试框架 test/markdownlint-test-scenarios.mjs 展示了完整验证链路:
- 用
lint()检测得到errors(其中包含fixInfo); - 调用
applyFixes(content, errors)生成修复后文本; - 对修复后文本再次
lint(),断言不再残留任何带fixInfo的错误; - 同时校验:凡出现
fixInfo的规则名必须登记在 lib/constants.mjs 的fixableRuleNames中(MD022、MD023 均在列)。
快照中fixed字段展示了本测试文件的修复结果(test/markdownlint-test-scenarios.mjs.snapshot#L14903):
- 第 3、5、8 行的前导空格被删除(
deleteCount: 1); - 第 20 行删除第 3 列起的 2 个多余空格(
editColumn: 3, deleteCount: 2); - MD022 在第 19、20 行标题上方插入空行(
insertText: "\n")。
注意第 5 行的 Setext 下划线行:修复后下划线仍保留 1 个前导空格(===================================),因为该违规标记在第 5 行文本行上,fixInfo只针对文本行,下划线行并不单独计错——这也解释了快照中第 6 行没有单独错误条目的原因。修复后标题变为普通文本行,MD023 对后续下划线行的缩进不再追究,这是规则“基于 token 同源判定”的又一佐证。
五、与相邻规则的边界划分
MD023 常与以下规则在同一行同时出现,实际使用中需要区分职责:
| 规则 | 检查内容 | 本测试文件中的体现 |
|---|---|---|
MD022blanks-around-headings | 标题上/下方是否缺少空行 | 第 19、20 行,每个标题报 Above/Below 两条 |
MD023heading-start-left | 标题是否带前导空格缩进 | 第 3、5、8、20 行 |
MD003heading-style | ATX 与 Setext 风格是否混用 | 被行内配置关闭以隔离干扰 |
MD041first-line-heading | 文档首行是否为一/二级标题 | 被行内配置关闭(文件首行为Some text) |
MD022 的实现位于 lib/md022.mjs,其lines_above/lines_below参数支持按标题级别(1–6)分别配置空行数量(整数或整数数组,-1表示任意数量),并通过getBlockQuotePrefixText(helpers/micromark-helpers.cjs)在插入空行时自动带上块引用前缀,保证修复不破坏块引用结构。这与 MD023 的deleteCount修复思路同源:修复必须尊重文档原有的结构上下文。
六、实战建议与验证方式
- 顶格是硬性要求:无论 ATX(
#)还是 Setext(===/---),标题起始行与下划线行都应从第 1 列开始,除非位于块引用(>)内。 - 列表内标题先补空行再谈缩进:列表项内的标题常见问题其实是 MD022(缺空行)而非 MD023;只有当缩进超过列表内容所需缩进量时,MD023 才会介入,此时自动修复只会删除多余的缩进。
- 代码块注释不会误报:因为 MD023 基于 micromark 的 token 类型判定,栅栏代码、行内代码中的
#内容天然豁免。 - 本地复现:可直接运行
node test/harness.mjs test/headings_with_spaces_at_the_beginning.md查看lint的完整结果对象(test/harness.mjs),或用--profile观察 1000 次 lint 的耗时;运行node --test test/markdownlint-test-scenarios.mjs可执行全部场景测试并校验快照与自动修复闭环。
结语
MD023 表面上是“去掉标题前的空格”这么简单,但通过 test/headings_with_spaces_at_the_beginning.md 这个 25 行的小样本可以看到:真正的工程复杂度在于区分“结构缩进”(列表、块引用)与“非法缩进”、在自动修复时只删除多余字符,以及与 MD022、MD003、MD041 等规则的职责协同。理解 token 级别的判定方式,你就能在任何 Markdown 文档中准确预测并快速修复标题缩进问题。
- 开发工具
- 代码质量
- Lint
- 静态分析
【免费下载链接】markdownlint
A Node.js style checker and lint tool for Markdown/CommonMark files.
相关推荐
markdownlint MD023 规则详解:标题必须从行首开始(heading-start-left)
markdownlint MD023 规则详解:标题必须从行首开始(heading start left) MD023 是 markdownlint 规则集中与
开发工具代码质量Lint静态分析markdownlint MD025「单标题」规则深度解析:从测试用例到源码实现
markdownlint MD025「单标题」规则深度解析:从测试用例到源码实现 本文以 markdownlint 仓库(版本 0.41.0)中的测试夹具 te
开发工具代码质量Lint静态分析markdownlint 空标题(无内容标题)与 MD019 规则:从测试用例到源码实现
markdownlint 空标题(无内容标题)与 MD019 规则:从测试用例到源码实现 导读 本文以 markdownlint 仓库中的场景测试文件 test
开发工具代码质量Lint静态分析
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考