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 片段在给定printWidth与proseWrap组合下的输出结果,并能在自己的项目配置中准确选择换行策略。
一、测试夹具与运行方式
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它刻意构造了三个互不相同的换行场景:
- 普通段落中的软换行:
123与456之间是普通换行符\n; - 硬换行(hard break):
123\与456之间是反斜杠 + 换行符,CommonMark 规范中反斜杠结尾的换行表示强制换行; - 列表项中的缩进续行:
- 123之后另起一行、缩进两格书写123,这属于列表项段落内部的软换行。
格式化输出:原样保留
对照 快照文件 中记录的输出,输入被完整保留:
123 456 123\ 456 - 123 123三处换行全部原样保留,没有一处被折叠成空格或重新打断。为什么proseWrap: "always"下这些换行依然不变?因为换行决策并非"无脑重排",而是由打印器中的isBreakable与lineBreakCanBeConvertedToSpace两个判定函数共同决定,具体见下文第三节的分析。
三、底层机制:换行到底由谁决定
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(行宽足够时显示为空,超宽时打断); - 不允许打断 → 能变空格就输出
" ",否则输出""(直接删除)。
- 允许打断且可变空格 → 输出
line与hardline等文档构建器定义在 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; - 位于
tableCell、link、wikiLink这类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返回true(always下普通段落允许打断),于是输出line。line在printWidth: 80下整行只有 7 个字符,远未超宽,因此保持原始折行位置,输出123\n456; - 场景 2(
123\硬换行):反斜杠 + 换行在解析后仍是"空白"节点,同样输出line,在未超宽时保留原文折行——反斜杠本身是 CommonMark 的硬换行语法,Prettier 不会删除它,因为它承载语义; - 场景 3(列表缩进续行):列表项的段落由 printList 处理,续行使用
align对齐到列表标记之后,换行逻辑与普通段落一致,未超宽时原样保留。
5. 段落与句子的文档组装
换行决策得出的line/softline/" "最终被组装成文档。流程是:paragraph节点由 printParagraph 打印,其中的sentence节点由 printSentence 处理——后者把所有子节点(word与whitespace交替出现)收集进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可以观察到三个明确行为:
- 前导空格被剥除:第二行的首空格被删除,段落统一从行首对齐;
- 超长单词在空白处折行:每个
very-very-...单词约 63 字符,一行放下一个单词加空格刚好贴近 80 列,放不下第二个时就在单词之间的空白处打断(fill行为),而不是在单词内部切开; - 孤立的
\被保留:反斜杠 + 换行是硬换行语法,即使单独成行也原样输出,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_TYPES(link、wikiLink)内部的空白禁止打断,防止把链接语法拦腰截断; - MDX:走
printWordLegacy(word.js)与printListLegacy(list.js)两条旧路径,行为与其他 Markdown 方言有细微差异。
这些分支都集中在 mdast.js 的 switch 分发 中,感兴趣可以顺着这条调用链继续深入。
七、小结:如何预测 Markdown 的换行输出
把break测试与源码逻辑合并,可以得到一套可操作的预测规则:
- 看
proseWrap:"preserve"原样保留全部\n;"never"把所有软换行折叠为空格并压成单行(反斜杠硬换行除外);"always"进入第 2 步; - 在
"always"下,逐个空白位置调用isBreakable:标题内部、链接/表格单元内部、CJK 字符之间均不可打断; - 可打断位置输出
line,由fill在printWidth附近自动折行;不可打断且原文为\n时,若两侧为可空格化字符则折叠为空格,否则删除或保留; - 超长单词绝不从内部切开,只在单词间的空白处折行,允许个别行略超
printWidth; - 反斜杠硬换行(
\+ 换行)是语法语义,任何模式下都不被删除。
这套规则正是 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),仅供参考