Prettier Markdown 换行行为详解:从 break 测试用例看 proseWrap 与 printWidth 的底层机制
2026/9/19 22:02:40 网站建设 项目流程

Prettier Markdown 换行行为详解:从 break 测试用例看 proseWrap 与 printWidth 的底层机制

【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier

Prettier 是一款有主见的代码格式化器(opinionated code formatter),对 Markdown 的支持是其内置能力之一,而 Markdown 格式化的核心难题在于"换行(line break)"的处理:哪些换行要保留、哪些要折叠成空格、哪些要按行宽重新打断。本文以仓库中的 break 测试目录 及其两个测试输入文件 simple.md 与 wrap.md 为骨架,结合 proseWrap 选项定义 与 Markdown 打印器的源码实现,系统讲解 Prettier 在proseWrap: "always"下对普通段落、硬换行(反斜杠续行)、列表项以及超长单词的折叠与重排规则。读完本文,你将能预测任意 Markdown 片段在给定printWidthproseWrap组合下的输出结果,并能在自己的项目配置中准确选择换行策略。

一、测试夹具与运行方式

break目录是一个标准的 Prettier 格式化测试夹具(fixture),结构如下:

  • simple.md:8 行输入,覆盖普通段落、硬换行、列表项三种基础场景;
  • wrap.md:包含超长单词与硬换行组合的极端场景;
  • format.test.js:驱动测试的入口,只有一行核心调用;
  • snapshots/format.test.js.snap:快照文件,记录输入与格式化输出。

其中 format.test.js 的内容是:

runFormatTest(import.meta, ["markdown"], { proseWrap: "always" });

它等价于在命令行执行:

prettier --parser markdown --prose-wrap always tests/format/markdown/break/simple.md

也就是说,break目录下的所有输入文件统一使用proseWrap: "always",而printWidth采用默认值80。快照文件头部也明确标出了这一点:

====================================options===================================== parsers: ["markdown"] proseWrap: "always" printWidth: 80 (default) |

这是理解后续所有格式化结果的前提:所有重排行为都发生在"行宽 80 字符 + 总是换行"这一组参数之下。

二、simple.md:三种基础换行场景

simple.md 的输入只有 8 行:

123 456 123\ 456 - 123 123

它刻意构造了三个互不相同的换行场景:

  1. 普通段落中的软换行123456之间是普通换行符\n
  2. 硬换行(hard break)123\456之间是反斜杠 + 换行符,CommonMark 规范中反斜杠结尾的换行表示强制换行;
  3. 列表项中的缩进续行- 123之后另起一行、缩进两格书写123,这属于列表项段落内部的软换行。

格式化输出:原样保留

对照 快照文件 中记录的输出,输入被完整保留:

123 456 123\ 456 - 123 123

三处换行全部原样保留,没有一处被折叠成空格或重新打断。为什么proseWrap: "always"下这些换行依然不变?因为换行决策并非"无脑重排",而是由打印器中的isBreakablelineBreakCanBeConvertedToSpace两个判定函数共同决定,具体见下文第三节的分析。

三、底层机制:换行到底由谁决定

Markdown 打印器的换行逻辑集中在 src/language-markdown/print/whitespace.js 中,它是理解break测试结果的钥匙。整个判定链如下:

1.printWhitespace:最终裁决

printWhitespace 是换行输出的唯一出口:

function printWhitespace(path, value, proseWrap, isLink, options) { if (proseWrap === "preserve" && value === "\n") { return hardline; } const canBeSpace = value === " " || (value === "\n" && lineBreakCanBeConvertedToSpace(path, isLink)); if (isBreakable(path, value, proseWrap, isLink, options)) { return canBeSpace ? line : softline; } return canBeSpace ? " " : ""; }

它的决策矩阵是:

  • proseWrap === "preserve"且原文就是\n,直接输出hardline(硬换行,原样保留);
  • 否则先判断这个位置"能否变成空格"(canBeSpace);
  • 再判断这个位置"是否允许打断"(isBreakable):
    • 允许打断且可变空格 → 输出line(可折叠为空格,也可按行宽打断);
    • 允许打断但不可变空格 → 输出softline(行宽足够时显示为空,超宽时打断);
    • 不允许打断 → 能变空格就输出" ",否则输出""(直接删除)。

linehardline等文档构建器定义在 src/document/builders 目录下,它们是 Prettier 文档模型(Doc IR)的基本积木:line在组内可折叠/可打断,hardline永远换行。

2.lineBreakCanBeConvertedToSpace:换行能否变成空格

lineBreakCanBeConvertedToSpace 处理"原文中的\n是否可以安全地视为一个普通空格"。核心规则:

  • 链接内部(isLink为真):总是可以;
  • 非 CJK/韩文与相邻非 CJK/韩文字符之间:可以(韩文按拉丁词处理,见源码注释引用的 issue #6516);
  • 中文字符与中文字符之间:不可以(中日文不用空格分词,\n必须保留);
  • 相邻字符是CJK 标点KIND_CJK_PUNCTUATION):不可以;
  • CJK 与 ASCII 标点之间:可以,但若一侧带前导/尾随标点(hasLeadingPunctuation/hasTrailingPunctuation)则不可以。

3.isBreakable:这个位置允许打断吗

isBreakable 决定一个空格位置是否可以作为折行点,它是break测试"原样保留"结果的关键:

function isBreakable(path, value, proseWrap, isLink, options) { if ( proseWrap !== "always" || path.hasAncestor( (node) => SINGLE_LINE_NODE_TYPES.has(node.type) || (node.type === "heading" && (options.parser === "mdx" || !isSetextHeading(node))), ) ) { return false; } // ... }

规则要点:

  • 只有proseWrap === "always"时才可能打断"never""preserve"直接返回false
  • 位于tableCelllinkwikiLink这类SINGLE_LINE_NODE_TYPES节点内部:禁止打断(避免破坏链接语法);
  • 位于标题(heading)内部:默认禁止打断(MDX 的 heading 或非 Setext 标题),因为标题不参与正文折行;
  • 相邻任一侧为 CJK 字符(previous.isCJ || next.isCJ):禁止打断,避免在中文中间强行折行;
  • 其余位置允许打断。

4. 回到 simple.md:为什么全部保留

对三个场景逐一定位:

  • 场景 1(123\n456\n两侧都是普通 ASCII 字符,lineBreakCanBeConvertedToSpace返回true(非 CJK 之间可以变空格),isBreakable返回truealways下普通段落允许打断),于是输出linelineprintWidth: 80下整行只有 7 个字符,远未超宽,因此保持原始折行位置,输出123\n456
  • 场景 2(123\硬换行):反斜杠 + 换行在解析后仍是"空白"节点,同样输出line,在未超宽时保留原文折行——反斜杠本身是 CommonMark 的硬换行语法,Prettier 不会删除它,因为它承载语义;
  • 场景 3(列表缩进续行):列表项的段落由 printList 处理,续行使用align对齐到列表标记之后,换行逻辑与普通段落一致,未超宽时原样保留。

5. 段落与句子的文档组装

换行决策得出的line/softline/" "最终被组装成文档。流程是:paragraph节点由 printParagraph 打印,其中的sentence节点由 printSentence 处理——后者把所有子节点(wordwhitespace交替出现)收集进fill()构建器:

return fill(parts);

fill 构建器 的作用是:当整行超过printWidth时,在line/softline标记处自动折行,并尽量填满每一行;未超宽时则保持紧凑。这正是"printWidth不是硬上限,而是期望宽度"这一设计(见 docs/options.md)的实现基础。

四、wrap.md:超长单词的强制打断

wrap.md 检验的是极端场景——由连字符拼接的超长单词:

a very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word \ word very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word

注意第一段以a开头、第二行带前导空格,第三行是一个孤立的\(硬换行标记),之后是word加两个超长单词。

对照 快照,输出为:

a very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word \ word very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word

可以观察到三个明确行为:

  1. 前导空格被剥除:第二行的首空格被删除,段落统一从行首对齐;
  2. 超长单词在空白处折行:每个very-very-...单词约 63 字符,一行放下一个单词加空格刚好贴近 80 列,放不下第二个时就在单词之间的空白处打断(fill行为),而不是在单词内部切开;
  3. 孤立的\被保留:反斜杠 + 换行是硬换行语法,即使单独成行也原样输出,word后的折行同样发生在两个超长单词之间。

这里印证了printWidth是"期望宽度"而非"硬上限":单个very-very-...单词本身长 63 字符,任何单行都必然超长,Prettier 不会强行在单词中间插入换行,而是在允许的空白点(line标记)处打断,宁可让某一行略超printWidth

五、proseWrap 的三种模式与配置方式

break测试固定使用proseWrap: "always",但换行行为的全貌需要放在三种模式下理解。选项定义位于 src/common/common-options.evaluate.js,并由 src/language-markdown/options.js 注册给 Markdown 语言:

默认行为对应 CLI/API
"preserve"按原文保留换行(v1.9.0 起可用)--prose-wrap preserve/proseWrap: "preserve"
"always"超宽时按printWidth折行--prose-wrap always/proseWrap: "always"
"never"强制把段落压成单行--prose-wrap never/proseWrap: "never"

官方文档 docs/options.md 给出了三模式对比示例:给定段落The quick brown\nfox jumps over the lazy dog.printWidth: 20

  • "always"→ 重新折行至约 20 列;
  • "never"→ 合并为单行The quick brown fox jumps over the lazy dog.
  • "preserve"→ 原样保留The quick brown\nfox jumps over the lazy dog.

默认值是"preserve",其设计原因在 docs/options.md 中说明:部分服务(如 GitHub 评论、BitBucket)使用对换行敏感的渲染器,擅自重排会改变显示效果。这与 printWhitespace 中preserve + "\n" → hardline的硬编码路径完全对应——"preserve"模式下原文换行一律硬保留,不做任何折叠。

配置方式与所有 Prettier 选项一致:

{ "proseWrap": "always", "printWidth": 80 }

或命令行:

prettier --prose-wrap always --print-width 80 "**/*.md"

printWidth的默认值同样为 80,其含义在 docs/options.md 中有明确说明:它不是 ESLintmax-len那样的硬上限,而是"期望行宽",Prettier 会尽量贴近它但允许个别行更长。

六、扩展到其他语法:heading、inlineCode 与 MDX

break测试只覆盖了纯文本场景,但换行机制在打印器中是全局统一的,同一套isBreakable规则还延伸到了其他节点类型:

  • 标题(heading):默认不参与折行(见isBreakable中的heading分支),避免破坏标题的单行性;Setext 风格标题(下划线===/---形式)是例外;
  • 行内代码(inlineCode):在 mdast.js 中,proseWrap === "preserve"时保留代码内的换行,否则把代码内的\n替换为空格;
  • 强调/粗体(emphasis/strong):由 printWord 处理,涉及*_的转义与1*2*3这类边界情况的判定;
  • 链接与脚注:位于SINGLE_LINE_NODE_TYPESlinkwikiLink)内部的空白禁止打断,防止把链接语法拦腰截断;
  • MDX:走printWordLegacy(word.js)与printListLegacy(list.js)两条旧路径,行为与其他 Markdown 方言有细微差异。

这些分支都集中在 mdast.js 的 switch 分发 中,感兴趣可以顺着这条调用链继续深入。

七、小结:如何预测 Markdown 的换行输出

break测试与源码逻辑合并,可以得到一套可操作的预测规则:

  1. proseWrap"preserve"原样保留全部\n"never"把所有软换行折叠为空格并压成单行(反斜杠硬换行除外);"always"进入第 2 步;
  2. "always"下,逐个空白位置调用isBreakable:标题内部、链接/表格单元内部、CJK 字符之间均不可打断;
  3. 可打断位置输出line,由fillprintWidth附近自动折行;不可打断且原文为\n时,若两侧为可空格化字符则折叠为空格,否则删除或保留;
  4. 超长单词绝不从内部切开,只在单词间的空白处折行,允许个别行略超printWidth
  5. 反斜杠硬换行(\+ 换行)是语法语义,任何模式下都不被删除。

这套规则正是 simple.md 与 wrap.md 两份快照背后的一行行代码。如果你想验证更多场景,可以直接修改输入文件后运行yarn jest tests/format/markdown/break(或对任意 Markdown 文件执行prettier --prose-wrap always)观察输出,再对照本文给出的源码路径,即可完整掌握 Prettier Markdown 的换行行为。

【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询