Prettier 如何保持 Markdown 列表内代码块与嵌套列表之间的空行:issue #17746 的测试用例与实现原理
【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier
导读
在 Prettier 的 Markdown 格式化器中,列表项内部结构(缩进代码块、段落、嵌套列表)之间的空行处理是一类容易产生语义歧义的问题。本文以 Prettier 仓库中的格式测试用例 issue-17746-code-before-list.md 为切入点,深入解读 Prettier 针对 issue #17746 确立的"保留列表项内已有空行、但不凭空插入空行"的格式化规则,结合同目录测试矩阵、Jest 快照与 children.js 源码实现,说明该规则在markdown与mdx两种解析器下的行为,并给出在仓库中运行与验证这些测试的具体方法。
一、一个只有四行的测试用例,究竟在测什么
先看关联文档的完整内容(即测试输入 fixture):
- a - b - c - d表面上看这只是一段普通的 Markdown 列表,但其中每一个字符都经过精心设计:
- a:-前缀之后紧跟5 个空格。在 CommonMark 语法中,行首缩进达到 4 个及以上空格的内容会被解析为缩进代码块(indented code block),因此这一行表示"列表项 1 内包含一段缩进代码块,代码内容为a"。这一点在 Prettier 源码中也有印证:src/language-markdown/print/list.js中getPrefix()在补空格时特意将尾部空格限制在 4 个以内、并注释// 4+ will cause indented code block(见 list.js)。- b:缩进 2 个空格,是列表项 1 内部的嵌套列表,与上方的缩进代码块之间没有空行。- c、- d:顶层列表的后续兄弟项。
该文件属于tests/format/markdown/list/blank-lines/目录下围绕 issue #17746 的一组回归测试之一,其测试驱动文件 format.test.js 只有一行:
runFormatTest(import.meta, ["markdown", "mdx"]);即同一组 fixture 会分别用markdown与mdx两种解析器跑一遍格式化对比测试,确保两个解析器下的行为一致。
二、issue #17746 的背景:空行不该被随意删除
在 Markdown 中,列表项内代码块之后紧跟嵌套列表,是一个容易产生歧义的结构。尤其是缩进代码块的边界判定依赖缩进量:代码块会持续到出现缩进不足 4 个空格的内容为止。当源码在代码块与嵌套列表之间写了空行时,这个空行实际上承担了"明确分隔代码块与列表"的作用,删除它可能造成结构解读上的风险。
Prettier 针对该问题的修复逻辑,可以从src/language-markdown/print/children.js中shouldPrePrintDoubleHardline函数的注释直接读到(见 children.js):
// Preserve blank line before nested list within listItem (issue #17746) previous.type === "code" || previous.type === "paragraph"这条判断的完整上下文是:当当前节点是列表(node.type === "list")、父节点是列表项(parent.type === "listItem")、前一个兄弟节点是代码块或段落,并且前一个兄弟节点的结束行与当前节点的开始行之间存在至少一行的间隔(previous.position.end.line + 1 < node.position.start.line)时,Prettier 会输出两个换行符(double hardline),从而把源码中存在的空行原样保留下来。
这里有一个关键细节值得强调:该条件严格要求previous.position.end.line + 1 < node.position.start.line,即只有源码中确实存在空行时才保留。如果源码本身没有空行(如本文主角issue-17746-code-before-list.md所示),Prettier 不会自作主张插入空行。这一"保留已有、不新增"的语义,正是通过精确比较相邻节点的 position 行号实现的。
该逻辑在children.js中同时存在于markdown与mdx两个分支(options.parser === "mdx"时走 L66-L77,否则走 L78-L97),与测试文件中同时注册["markdown", "mdx"]两种解析器一一对应。
三、同目录测试矩阵:四个 fixture 拼出完整行为边界
tests/format/markdown/list/blank-lines/下共 5 个 fixture,除了本文主角外,其余 4 个从不同角度覆盖 issue #17746 的边界情况:
| fixture 文件 | 输入要点 | 输出行为 |
|---|---|---|
| issue-17746-code-before-list.md | 缩进代码块后无空行直接跟嵌套列表 | 保持原样,不插入空行 |
| issue-17746-indented-code-then-nested-list.md | 缩进代码块后有空行再跟嵌套列表 | 保留该空行 |
| issue-17746-fenced-code-then-nested-list.md | 围栏代码块后有空行再跟嵌套列表 | 保留嵌套列表前空行;顶层- c前因前一项为宽松列表项而补充空行 |
| issue-17746-code-sibling-nested-list.md | 围栏代码块后无空行直接跟嵌套列表 | 保持原样 |
| issue-17746.md | 多段空行(含连续两个空行)混杂嵌套列表 | 空行统一收敛为单个空行 |
以 issue-17746.md 为例,其输入中- d前有连续两个空行:
- a - b - c - d快照输出为:
- a - b - c - d这说明空行被保留的同时,多余的空行会被收敛为单个空行。而本文主角issue-17746-code-before-list.md的快照(见 format.test.js.snap)中,输入与输出完全一致,验证了"无空行则不加空行"的另一面。
四、源码级原理:shouldPrePrintDoubleHardline 的空行决策链
children.js中printChildren负责把列表项的子节点逐个拼接为文档(doc),其核心逻辑是:
if (parts.length > 0 && shouldPrePrintHardline(path)) { parts.push(hardline); if (shouldPrePrintDoubleHardline(path, options)) { parts.push(hardline); } }也就是说,兄弟节点之间默认打印一个换行,是否升级为**两个换行(空行)**完全由shouldPrePrintDoubleHardline决定。除 issue #17746 的"代码块/段落后跟嵌套列表且源码有空行"这一特例外,该函数还综合考量了以下因素(全部为可从源码确认的实现事实):
- 宽松列表项(loose list item):
isLooseListItem检查node.spread(mdast 解析时若列表项内部存在空行会置位)或与前一项之间的行距(见 children.js)。若前一个兄弟是宽松列表项,则在当前节点前打印空行(isPreviousNodeLooseListItem,见 L165-L174)。issue-17746-fenced-code-then-nested-list.md中顶层- c前被补充空行,正是这个分支的体现。 - 同类兄弟节点(sibling):
listItem与definition这类连续兄弟之间不打印空行(SIBLING_NODE_TYPES)。 - 紧凑列表项(tight list item):位于紧凑列表项内部时同样不打印空行。
prettier-ignore:前一个节点标记了prettier-ignore时不干预。- 块级 HTML / liquid 节点:紧邻且源码无空行时保持不加空行。
由此可以看到,Prettier 对 Markdown 空行的处理是"精确到 position 行号差值的保守策略":默认按类型与紧密度决定是否加空行,而 issue #17746 的特判进一步保证"用户写下的结构性空行"不被格式化器抹掉。
五、列表前缀打印与缩进代码块的关系
issue-17746-code-before-list.md中- a之所以能保留 5 个空格而不被"规整"成- a,是因为printList在生成列表前缀时对空格数量做了刻意限制。查看 list.js 的getPrefix():
const trailingSpaces = Math.min(minIndent - prefix.length, 4); // 5+ will cause indented code block if (trailingSpaces > 0) { prefix += " ".repeat(trailingSpaces); } const leadingSpaces = Math.min(minIndent - prefix.length, 3); // 4+ will cause indented code block if (leadingSpaces > 0) { prefix = " ".repeat(leadingSpaces) + prefix; }两个方向都通过Math.min将补空格数量钳制在 3~4 个以内,防止列表前缀与内容之间总缩进达到 4 个空格而意外触发缩进代码块语义。这正是该测试用例中"缩进代码块嵌套在列表项内"这一结构能够稳定往返(round-trip)而不被破坏的底层保证。
此外,printList还负责有序列表的前缀策略:getNthListSiblingIndex(utilities.js)统计同类型列表兄弟的序号,hasGitDiffFriendlyOrderedList(utilities.js)在满足条件时让后续序号固定从 1 开始,以降低 git diff 噪音。这些机制共同构成了列表打印的完整拼图。
六、如何在仓库中运行并验证这些测试
Prettier 仓库使用 Jest 作为测试框架(package.json中"test": "jest",见 package.json)。要单独运行blank-lines这一组回归测试,可以在仓库根目录执行:
yarn jest tests/format/markdown/list/blank-lines/format.test.js运行成功后,Jest 会将格式化器的实际输出与 format.test.js.snap 中的快照逐字节比对。快照的input/output区块还标注了默认printWidth: 80,方便复现相同环境。如果想在 CI 风格下校验全部快照,可运行yarn test(即jest)。由于runFormatTest同时注册了markdown与mdx两种解析器,该命令会以两套解析路径分别验证,确保 issue #17746 的修复在两个解析器中都生效。
七、给 Markdown 作者的实践启示
综合测试矩阵与源码实现,可以总结出以下几条可直接用于日常写作的结论:
- 结构性空行是语义的一部分:在列表项内,缩进代码块、段落与嵌套列表之间的空行不应省略。Prettier 会原样保留它们(且只保留一个),这既保护了 Markdown 的结构可读性,也符合"格式化不应改变语义"的定位。
- 不要依赖格式化器为你"补"空行:
issue-17746-code-before-list.md证明,源码中没有空行时 Prettier 不会插入空行。如果你的 Markdown 在代码块与嵌套列表之间缺少空行导致阅读困难,应当先在源文档中补上。 - 注意 4 空格缩进阈值:行首缩进达到 4 个空格即触发代码块语义,Prettier 的列表前缀生成也刻意避开该阈值。需要"代码块 + 嵌套列表"并列时,注意两者缩进量的搭配。
- 多解析器场景:该行为同时覆盖
markdown与mdx,在 MDX 文档中编写列表嵌套代码块时无需担心两套解析器行为不一致。
八、延伸阅读
- 测试入口与解析器注册:format.test.js
- 完整快照(含全部 5 个 fixture 的输入输出对):format.test.js.snap
- 空行决策核心实现:children.js
- 列表前缀与缩进代码块规避:list.js
- Markdown 辅助工具(列表序号、git-diff 友好前缀):utilities.js
如果读者希望进一步修改或实验该行为,可以参照上述源码路径定位shouldPrePrintDoubleHardline中的 issue #17746 特判分支,并借助本目录的 fixture 快速验证改动效果——这正是 Prettier 用"最小回归测试集锁定格式化语义"的典型示例。
【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考