The Odin Project 课程仓库的 TOP010 自定义 markdownlint 规则:有序列表惰性编号的强制与自动修复
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
在 The Odin Project 的开源课程仓库(curriculum)中,上千篇 Markdown 课程文档的质量由一套自研的 markdownlint 自定义规则体系(TOP001~TOP013)统一把关。本篇文章以其中负责有序列表编号风格的 TOP010 规则为核心,结合其规则实现、测试夹具与自动修复工具链,完整剖析"惰性编号(lazy numbering)"这一 Markdown 写作规范在真实开源项目中的落地方式。读完本文,你将理解该规则的触发条件、错误报告格式、自动修复原理,并掌握在类似文档工程中复用它或编写同类 lint 规则的方法。
一、背景:课程仓库的自定义 lint 规则体系
curriculum是一个开源的全栈 Web 开发课程仓库,其 Markdown 文档数以千计。为了在多人协作下保证文档风格统一,仓库在通用 markdownlint 之上,通过 .markdownlint-cli2.jsonc 注册了 13 条项目自研规则(customRules配置段),每条规则独立成目录,位于markdownlint/下:
- TOP001
descriptiveLinkTextLabels:链接文本可描述性 - TOP002
noCodeInHeadings:标题内禁止代码 - TOP003
defaultSectionContent:默认章节内容 - TOP004
lessonHeadings:课程标题结构 - TOP005
blanksAroundMultilineHtmlTags:多行 HTML 标签空行 - TOP006
fullFencedCodeLanguage:围栏代码块必须带语言 - TOP007
useMarkdownLinks:必须使用 Markdown 链接 - TOP008
useBackticksForFencedCodeBlocks:代码块使用反引号 - TOP009
lessonOverviewItemsSentenceStructure:概述条目句式 - TOP010
useLazyNumbering:有序列表惰性编号(本文主题) - TOP011
headingIndentation:标题缩进 - TOP012
headingLevels:标题层级 - TOP013
descriptiveHeadings:标题可描述性
每条规则的说明文档集中在 markdownlint/docs 目录,其中 markdownlint/docs/TOP010.md 专门说明了 TOP010 的规范与设计动机。本文聚焦 TOP010,其余规则可作为同类参考。
二、什么是惰性编号(Lazy Numbering)
Markdown 中的有序列表允许两种编号写法:
- 显式编号:手动维护每个列表项的数字前缀,如
1.、2.、3.; - 惰性编号(lazy numbering):所有列表项统一写作
1.,由渲染器在展示时自动递增编号。
curriculum仓库对惰性编号的约定是:有序列表的每个列表项都必须以1.作为前缀,不允许出现2.、3.等其他数字。原因很直接:显式编号在插入、删除或重排列表项时需要人工同步修改序号,极易产生编号断裂或跳号;而惰性编号在源码层面完全忽略序号差异,把编号交给渲染器,从源头消除了这一整类错误。
TOP010 规则对该规范的定义(见 markdownlint/TOP010_useLazyNumbering/TOP010_useLazyNumbering.js)如下:
module.exports = { names: ["TOP010", "lazy-numbering-for-ordered-lists"], description: "Ordered lists must always use 1. as a prefix (lazy numbering)", information: new URL( "https://github.com/TheOdinProject/curriculum/blob/main/markdownlint/docs/TOP010.md" ), tags: ["ol"], parser: "markdownit", // ... };names:规则别名数组,第一个TOP010是短名,第二个lazy-numbering-for-ordered-lists是语义化长名,二者在错误报告中均可见;description:一句话概括规则意图,会直接出现在 lint 错误消息中;tags:["ol"],即该规则仅作用于有序列表(ordered list);parser: "markdownit":声明该规则基于 markdownit 解析器提供的 token 流工作,而非基于正则的简单行匹配。
三、规则实现原理:逐段解析 TOP010 源码
TOP010 的核心逻辑非常精简(完整实现见 markdownlint/TOP010_useLazyNumbering/TOP010_useLazyNumbering.js),可以拆成三个部分理解。
3.1 匹配前缀数字的正则
// https://regexr.com/80oan to test this regex const digit = /^\s*\d+/;该正则匹配"行首的任意空白后紧跟一个或多个数字",即捕获有序列表项的数字前缀部分。注意它只关心数字本身,不关心点号(.)之后的列表内容;缩进空白也被纳入匹配,以兼容嵌套列表(如 4 空格缩进的子项)。
3.2 遍历 token 并判定违规
params.parsers.markdownit.tokens.forEach((token) => { if ( token.tag === "li" && digit.test(token.line) && token.info !== "1" ) { // ...构造错误 } });规则遍历 markdownit 解析出的全部 token,命中违规需同时满足三个条件:
token.tag === "li":该 token 是有序或无序列表项(列表项统称为li);digit.test(token.line):该项所在行的行首存在数字前缀(即排除- item这类无序列表项,它们的行首是-,无法通过数字正则);token.info !== "1":markdownit 会将列表项的数字前缀记录在token.info中,当且仅当它不是"1"时才判定为违规。
由于条件 2 已经过滤掉无序列表项,token.info !== "1"实际只针对有序列表项生效——这正是"只保留1."这一规范的精确落地。
3.3 错误报告与自动修复信息
const lineNumber = token.lineNumber; const tokenLine = token.line.split("."); const lazyNumbering = tokenLine[0].replace(/\d+/, "1"); onError({ lineNumber: lineNumber, detail: `\n Expected: "${lazyNumbering}"\n Actual: "${tokenLine[0]}"\n`, fixInfo: { lineNumber: lineNumber, deleteCount: tokenLine[0].length, insertText: lazyNumbering, }, });违规项的报错包含三类关键信息:
- 位置:
lineNumber直接指向违规行; - 详情:
Expected(期望值)与Actual(实际值)对比。lazyNumbering由token.line.split(".")[0]取数字前缀部分后,用replace(/\d+/, "1")把数字统一替换成1。因此对2. Item Two而言,Actual是2,Expected是1;对缩进的2. Child而言,Actual是2(保留缩进),Expected是1; - 修复指令(fixInfo):
deleteCount指定要删除的字符数(即原数字前缀的长度,保留缩进),insertText指定插入的替换文本。markdownlint-cli2 的--fix模式正是依据这份 fixInfo 完成自动改写,这也是该自定义规则相对 markdownlint 内置规则 MD029 的核心差异(详见第六节)。
四、测试夹具剖析:test.md 中哪些行会被标记
markdownlint/TOP010_useLazyNumbering/tests/test.md是专为 TOP010 准备的 lint 测试夹具,文件头明确写道:
This file should flag with TOP010 errors, and no other linting errors.
它被设计为"只触发 TOP010 错误、不触发其他任何 lint 错误"的隔离样本,从而保证测试断言不受其他规则干扰。整份文件是一篇仿真的课程文档,包含Introduction、Lesson overview、Assignment、Knowledge check、Additional resources等课程标准章节,其中故意混排了合规与违规的有序列表:
| 行号 | 内容 | 是否符合惰性编号 | 结果 |
|---|---|---|---|
| 21 | 1. A RESOURCE OR EXERCISE ITEM | 是 | 不报错 |
| 25 | 1. Item One | 是 | 不报错 |
| 26 | 2. Item Two | 否 | 报错:期望1,实际2 |
| 27 | 1. Child of Item Two | 是(嵌套) | 不报错 |
| 28 | 2. Child of Item Two | 否(嵌套) | 报错:期望1,实际2 |
| 29 | 3. Item Three | 否 | 报错:期望1,实际3 |
| 31–35 | 第二组列表全部为1. | 是 | 不报错 |
| 37 | 1. *foo* | 是 | 不报错 |
| 38 | 2. *Bar* | 否 | 报错:期望1,实际2 |
| 40–41 | -无序列表项 | 不适用(非有序) | 不报错 |
这份夹具刻意覆盖了多种边界情况:嵌套列表中的违规子项(第 28 行)、连续多级违规(第 28~29 行)、列表项内带行内强调(第 38 行的*Bar*)、以及已全部使用惰性编号的正确列表(第 31~35 行)——后者用于验证规则不会对1.前缀误报,也不会因为列表项包含 Markdown 语法而漏判。
对照测试夹具 markdownlint/TOP010_useLazyNumbering/tests/fixed_test.md,可以看到"修复后"的版本:第 26、28、29、38 行的编号全部被改写为1.,其余内容逐字不变。两份文件的差异恰好精确对应规则 fixInfo 的删除与插入范围。
五、测试验证:错误断言与自动修复用例
仓库的测试使用 Node.js 内置测试运行器(node --test),TOP010 的测试定义在 markdownlint/TOP010_useLazyNumbering/tests/TOP010.test.js 中。
5.1 Lint 断言:逐行精确匹配错误
assert.deepEqual(lintErrors, [ `${errorPath}:26 error ${expected.name} ${expected.description} [ Expected: "1" Actual: "2" ]`, `${errorPath}:28 error ${expected.name} ${expected.description} [ Expected: " 1" Actual: " 2" ]`, `${errorPath}:29 error ${expected.name} ${expected.description} [ Expected: "1" Actual: "3" ]`, `${errorPath}:38 error ${expected.name} ${expected.description} [ Expected: "1" Actual: "2" ]`, ]);测试用assert.deepEqual逐条比对错误输出,精确到行号、期望值与实际值。从这组断言可以反推 TOP010 的实际报错格式:
<文件路径>:<行号> error TOP010/lazy-numbering-for-ordered-lists <描述> [ Expected: "<期望前缀>" Actual: "<实际前缀>" ]注意第 28 行的断言:嵌套子项的Expected与Actual都保留了 4 个空格的缩进,印证了 3.3 节中"数字前缀含缩进"的处理方式。
5.2 修复断言:输出必须与 fixed_test.md 完全一致
const fixedFileContents = await fixLintErrors("./test.md"); const correctFile = await readFile(join(__dirname, "./fixed_test.md")); assert.equal(fixedFileContents, correctFile.toString());修复测试的做法是:对test.md执行一次带自动修复的 lint,把修复后的全文与fixed_test.md逐字节比对。由于 markdownlint/TOP010_useLazyNumbering/tests/TOP010.test.js 使用assert.equal做严格相等断言,任何多余的空格、换行差异都会导致测试失败——这保证了修复脚本是"最小改写",绝不触碰编号以外的内容。
5.3 测试工具链的配合
两条测试分别复用了仓库的两个测试工具模块:
- markdownlint/test_utils/lint.js:先校验文件存在,再执行
npm run lint -- "<文件绝对路径>";若命令失败(非零退出码),则把stderr按行拆分后返回错误数组。lint 有错时 markdownlint-cli2 恰好以非零码退出,据此即可判定"存在错误"; - markdownlint/test_utils/fix.js:通过
spawnSync执行npm run lint -- --format,把文件内容通过标准输入传入,随后剥离 markdownlint-cli2 的启动输出噪音,返回修复后的纯文本。
两者组合起来,形成"lint 断言 + fix 比对"的双层测试闭环:先证明规则能精准报错,再证明修复脚本能产出与人工修订版完全一致的文档。
六、设计动机:为什么不用内置规则 MD029
通用 markdownlint 本身就有针对有序列表编号的规则 MD029(ol-prefix)。curriculum之所以另起炉灶写 TOP010,原因记录在 markdownlint/docs/TOP010.md 的 Rationale 一节:
Markdown lint's MD029 rule already covers this check, but does not include fix information, therefore can only be used to raise errors for manual fixing. This custom rule enforces the same style but includes fix information that can be used alongside our fix scripts.
也就是说:MD029 只能"报错提示",不携带 fixInfo,无法驱动自动修复;TOP010 在强制相同风格的同时,为每个错误附带完整的deleteCount/insertText修复信息,从而无缝接入仓库的npm run fix脚本。这一点在仓库配置中也有明确体现——.markdownlint-cli2.jsonc 中MD029被显式关闭:
// ol-prefix // Enforces lazy numbering for ordered lists // MD029 Disabled and overridden by TOP010 rule "MD029": false,而customRules列表第 102 行注册了./markdownlint/TOP010_useLazyNumbering/TOP010_useLazyNumbering.js,完成规则交接。这组配置与源码共同印证了"自定义规则替换内置规则"的完整链路。
七、实际使用:在课程文档中运行与修复
TOP010 随仓库的 npm 脚本开箱即用,脚本定义在 package.json:
"scripts": { "lint": "markdownlint-cli2", "fix": "markdownlint-cli2 --fix", "test": "node --test" }使用方式(在仓库根目录执行):
# 1. 对单个文件做 lint 检查,查看 TOP010 错误 npx markdownlint-cli2 markdownlint/TOP010_useLazyNumbering/tests/test.md # 2. 自动修复所有违规项(将 2./3. 前缀改写为 1.) npm run fix -- markdownlint/TOP010_useLazyNumbering/tests/test.md # 3. 运行整个规则测试套件 npm test在.markdownlint-cli2.jsonc未做excludes调整的情况下,npm run lint/npm run fix会扫描仓库全部 Markdown 文件,其中自然包含 TOP010 对每个有序列表的检查。实践建议是:
- 编写课程文档时:所有有序列表一律写
1.,让渲染器决定显示编号; - 批量维护历史文档:直接执行
npm run fix,由 fixInfo 驱动的修复会一次性把所有非1前缀统一为1.,且保证除编号前缀外不产生任何改动; - 在 CI 中:将
npm run lint作为质量门禁,任何提交了2.、3.前缀的有序列表都会被 TOP010 拦截。
八、从 TOP010 看自定义 markdownlint 规则的通用范式
TOP010 的整个实现可以作为编写自定义 markdownlint 规则的最小完整范例,其可复用的模式包括:
- 元信息:
names(双别名)、description、information(指向规则文档 URL)、tags(便于分组过滤)、parser(声明 token 流来源); - 判定逻辑:遍历
params.parsers.markdownit.tokens,用token.tag圈定目标元素类型,用token.info/token.line做精确语义判断,而不是整行正则匹配; - 错误对象:
lineNumber+detail(Expected/Actual 对比)+fixInfo(deleteCount与insertText精确描述删除插入范围),三者齐备才可被--fix消费; - 测试闭环:一份"仅触发本规则"的夹具(如
test.md)、一份人工修订的期望产物(如fixed_test.md)、外加 lint 逐行断言与 fix 全文比对两组用例; - 配置集成:在
.markdownlint-cli2.jsonc的customRules数组中注册,必要时用"MD029": false之类的显式关闭让位。
九、总结
TOP010(lazy-numbering-for-ordered-lists)是curriculum仓库文档质量体系中的一个精小但完整的工程样本:它以"有序列表统一使用1.惰性编号"这一简单规范为切入点,通过 markdownit token 级的精确判定实现"只报该报的错",通过 fixInfo 驱动的自动修复实现"只改该改的字",并通过test.md与fixed_test.md的成对夹具把 lint 与 fix 两条路径都锁定在测试中。对于任何以 Markdown 为内容载体的开源项目,这套"规范文档 + 规则实现 + 隔离夹具 + 双断言测试 + CLI 集成"的组合,都值得直接借鉴。
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考