Prettier 如何保持 Markdown 列表内代码块与嵌套列表之间的空行:issue 17746 的测试用例与实现原理

发布时间:2026/9/20 22:41:31
Prettier 如何保持 Markdown 列表内代码块与嵌套列表之间的空行:issue 17746 的测试用例与实现原理 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 itemisLooseListItem检查node.spreadmdast 解析时若列表项内部存在空行会置位或与前一项之间的行距见 children.js。若前一个兄弟是宽松列表项则在当前节点前打印空行isPreviousNodeLooseListItem见 L165-L174。issue-17746-fenced-code-then-nested-list.md中顶层- c前被补充空行正是这个分支的体现。同类兄弟节点siblinglistItem与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还负责有序列表的前缀策略getNthListSiblingIndexutilities.js统计同类型列表兄弟的序号hasGitDiffFriendlyOrderedListutilities.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.jsMarkdown 辅助工具列表序号、git-diff 友好前缀utilities.js如果读者希望进一步修改或实验该行为可以参照上述源码路径定位shouldPrePrintDoubleHardline中的 issue #17746 特判分支并借助本目录的 fixture 快速验证改动效果——这正是 Prettier 用最小回归测试集锁定格式化语义的典型示例。【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考