为什么 Markdown 列表是解析器最难处理的块结构之一

Markdown 列表最容易诱导出一种错误实现:匹配行首的 - * 1. ,生成一个 <li>,遇到不匹配的行就结束列表。

这个实现能通过最简单的示例,却回答不了下面的问题:

10. alpha

   beta

beta 属于第 10 个列表项,还是列表外的新段落?如果把三个空格改成四个,结果为什么又会变化?再把整个结构放进引用块、另一个列表项或代码围栏里,缩进应该从哪一列开始计算?

列表的难点不在于识别标记,而在于它是一个可以递归容纳其他块的容器。解析器必须逐行维护仍然打开的容器、计算相对缩进、区分普通段落延续和新块,并在整棵子树形成后决定列表是紧凑还是松散。

本文以 CommonMark 0.31.2 为基准,用 12 组输入对照 commonmark 0.31.2markdown-it 15.0.0marked 18.0.9 的实际结果,说明这些规则为何不能被一条正则表达式替代。

列表项不是一行文本,而是一组块

在语法树中,列表通常至少有三层:

document
└─ bullet_list
   ├─ item
   │  ├─ paragraph("alpha")
   │  └─ paragraph("beta")
   └─ item
      └─ paragraph("gamma")

对应的源文本可以是:

- alpha

  beta
- gamma

第一项含两个段落,第二项含一个段落。列表项内部还可以放引用块、围栏代码块、标题、另一个列表,甚至多个不同类型的块。换句话说,item 不是叶子节点,而是块级容器。

CommonMark 将块分为容器块和叶子块,并在第一阶段逐行确定块结构;行内强调、链接、代码跨度等要等块边界稳定后才解析。列表因此处在块解析最棘手的位置:它既要参与当前行的匹配,又会改变后续行的解释环境。

一个可靠的块解析器处理新行时,思路更接近下面的状态机,而不是“查找列表正则”:

读取下一行
  ↓
从外到内检查所有已打开容器能否继续
  ↓
关闭无法继续的容器
  ↓
按优先级尝试打开新的引用、列表项、代码块等容器
  ↓
把剩余内容交给当前叶子块

例如当前路径是 blockquote → bullet_list → item → paragraph,下一行首先要证明它仍在引用块中,再证明缩进足以留在列表项中,最后才能判断它是原段落的延续还是一个新块。只检查行首字符会丢掉这条祖先路径。

真正规则是 W + N,不是“固定缩进四格”

CommonMark 列表项规则把列表标记宽度记为 W,把标记后用于分隔内容的空白宽度记为 N。在基本情形中,后续块需要相对列表项缩进 W + N

对下面的项目:

- alpha

- 的宽度 W = 1,后面一个空格 N = 1,所以新块需要两个空格才能留在项目中:

- alpha

 beta

这里 beta 只有一个前导空格。三种解析器都把它放在列表外:

<ul>
<li>alpha</li>
</ul>
<p>beta</p>

增加到两个空格:

- alpha

  beta

beta 就成为同一列表项里的第二个段落。由于一个项目直接包含两个被空行分隔的块,输出中的两个段落都保留 <p> 标签。

有序标记会让“固定四格”的经验法则更明显地失效:

10. alpha

   beta

10.W = 3,再加一个分隔空格,阈值是四。三个空格不足,因此 beta 在列表外;四个空格才进入第 10 项。

这也是编辑器不能只显示绝对列号的原因。真正有意义的是相对当前列表项内容起点的缩进,而且这个起点会随着标记位数、嵌套层级、引用前缀和制表符展开方式变化。

“懒惰延续”只对段落文本开放

如果所有续行都必须补齐缩进,Markdown 会很难手写。因此 CommonMark 允许段落的懒惰延续:某些本应缩进的普通段落行,可以省略部分或全部缩进。

- alpha
lazy text

三种解析器都把 lazy text 留在列表项的同一段落中:

<ul>
<li>alpha
lazy text</li>
</ul>

但“懒惰”不是任意容错。它只适用于段落延续文本。把下一行换成新的块结构:

- alpha
lazy text
> quote

引用块不会自动钻进列表项。实测结果是列表先结束,然后产生一个与列表同级的 <blockquote>。如果作者希望引用属于列表项,就必须提供足够的相对缩进。

这一条规则迫使解析器同时知道两件事:当前打开的是不是段落,以及新行在当前上下文中能否成为段落延续。单独判断“缩进不足”或“行首是 >”都不够。

列表标记不一定能打断段落

下面两段只差一个数字:

intro
2. item
intro
1. item

三种解析器的结果一致:2. item 留在原段落中,而 1. item 可以打断段落并打开有序列表。

这是 CommonMark 为减少误判设置的限制。正文中以 2.2026. 等形式开头的续行很常见;若任意数字都能在没有空行时打开列表,普通文本会被意外拆开。能打断段落的有序列表标记必须从 1 开始。

空列表项也不能打断段落:

intro
*

outro

这里的 * 仍属于 intro 段落,而不是一个空列表项。解析器不能看到合法标记就立即提交新节点,还必须结合“当前是否在段落中”“项目是否为空”等上下文验证启动条件。

紧凑与松散是渲染属性,但来自结构

这两段 Markdown 的 AST 都包含列表、列表项和段落;HTML 却不同。

紧凑列表:

- alpha
- beta
<ul>
<li>alpha</li>
<li>beta</li>
</ul>

松散列表:

- alpha

- beta
<ul>
<li><p>alpha</p></li>
<li><p>beta</p></li>
</ul>

空行不只是视觉空白。它可能让整个列表变为松散列表,从而让每个项目中的段落都显式渲染为 <p>。更隐蔽的是,只要某一项直接包含两个被空行分隔的块,同级项目的段落渲染也会受到影响。

我检查了 markdown-it 对“双段落列表项”生成的 token。它保留 paragraph_openparagraph_close,并把这些 token 的 hidden 设为 false。在紧凑列表中,相应段落 token 仍可存在,但渲染器会隐藏包装标签。也就是说,紧凑与松散不等于“有没有段落语义”,而是列表结构形成后决定段落包装是否显示。

嵌套又增加了一层作用域:内层列表可以是松散的,外层列表仍然紧凑。实现若只在文档级维护一个 loose 布尔值,就会把状态错误传播到无关的祖先或兄弟列表。

12 组边界实验说明了什么

我在 2026 年 8 月 9 日固定版本、默认配置运行了以下矩阵。这里比较的是结构结果;Marked 在部分 HTML 的换行和标签排列上不同,不算结构差异。

编号 输入变量 三个解析器的结构结论
C01 两个连续项目 紧凑列表
C02 项目之间有空行 松散列表,项目段落带 <p>
C03 一项内有两个段落 整个列表松散
C04 - 项目后的新块缩进 1 格 新段落在列表外
C05 同一位置缩进 2 格 新段落进入列表项
C06 10. 项目后的新块缩进 3 格 新段落在列表外
C07 同一位置缩进 4 格 新段落进入列表项
C08 2. 紧跟普通段落 不打断段落
C09 1. 紧跟普通段落 打断段落并打开列表
C10 子列表相对缩进 2 格 形成嵌套列表
C11 * 紧跟普通段落 不打断段落
C12 懒惰文本后接未缩进引用 文本留在项目中,引用在列表外

三种解析器在这 12 组输入的块结构判断全部一致。C02—C07、C10 的原始 HTML 字符串并非逐字相同,差异来自换行、缩进和 <li><p> 的排列。这也说明回归测试不应只做整段 HTML 快照;结构相同的输出可能被误报为失败。

若要复现实验,可安装固定版本后运行:

npm install commonmark@0.31.2 markdown-it@15.0.0 marked@18.0.9

最小对照脚本如下:

import * as commonmark from "commonmark";
import markdownit from "markdown-it";
import { marked } from "marked";

const source = `10. alpha

   beta`;

const reader = new commonmark.Parser();
const writer = new commonmark.HtmlRenderer();

console.log("commonmark", writer.render(reader.parse(source)));
console.log("markdown-it", markdownit().render(source));
console.log("marked", marked.parse(source));

如果要手工观察复杂输入在不同输出工具中的表现,可以从 MDFold 的 Markdown 工具目录进入对应页面;但跨解析器结论仍应以固定版本、固定配置和保存下来的测试语料为准。

从源码看:实现维护的是状态,不是匹配结果

CommonMark 参考实现的块解析代码会维护打开块的链条,逐行尝试继续现有容器,再识别新的块开始。列表项需要保存自己的缩进信息,后续行据此决定是否匹配该容器。commonmark.js 的块解析源码直接体现了这种状态式处理。

markdown-it 的公开 token 也保留了这种结构信息:

  • type 区分 bullet_list_openlist_item_openparagraph_open 等节点;
  • nesting 使用 10-1 表示打开、独立和关闭;
  • level 记录嵌套深度;
  • map 保存节点对应的源代码行范围;
  • hidden 让紧凑列表隐藏段落包装,而不必删除段落 token。

这些字段不是为了把 API 设计得复杂,而是对应真实需求:渲染器、格式化器、诊断器和编辑器都需要知道节点属于谁、从哪里开始、在哪里结束,以及哪些结构只在渲染阶段折叠。

工程上应该怎样测试列表

只测试 - a- b 几乎没有价值。一个面向生产的列表测试集至少应覆盖以下维度。

1. 检查树,不只检查截图

断言列表数量、每个列表的项目数、项目的直接子块类型以及嵌套层级。HTML 字符串可以作为输出检查,但不应是唯一判断依据。

2. 对缩进做成对测试

每个阈值都应同时包含“少一格”和“刚好足够”两个用例,例如 C04/C05、C06/C07。只有成功用例无法证明边界真的被实现。

3. 分开测试段落延续和新块

同样的缩进不足,对普通文本可能触发懒惰延续,对引用、代码块或子列表却不成立。测试名称应明确写出预期节点归属,而不是笼统写“支持列表缩进”。

4. 验证松紧传播范围

检查一个项目中的空行是否正确影响同级列表,同时确认内层松散列表不会无条件污染外层列表。最终 HTML 中 <p> 是否显示,是很直观的观察点。

5. 固定方言、版本和配置

核心列表规则在三个解析器中表现一致,不代表所有扩展都一致。任务列表、定义列表、自定义容器和编辑器自动缩进都可能改变用户看到的行为。测试报告必须写明解析器版本与选项。

编辑器可以帮助作者,但不能重定义语法

编辑器适合减少错误:按回车时延续标记、按 Tab 时对齐当前内容列、显示不可见空格、把松散列表的空行高亮出来。这些功能能让作者更容易表达意图。

但解析器仍必须独立正确。Markdown 文件可能来自 Git、接口、旧文档、其他编辑器或自动生成程序,不能假设所有输入都经过同一个前端约束。反过来,编辑器也不应凭视觉缩进猜测并偷偷改写源码;当自动修复可能改变节点归属时,应该展示差异并让作者确认。

更合理的职责边界是:规范定义结构,解析器忠实识别结构,编辑器提前暴露歧义,格式化器只在可证明语义不变时自动重排。

结论

Markdown 列表难解析,是因为一个短标记同时启动了递归容器、相对缩进规则、段落中断限制、懒惰延续和松紧传播。解析器必须记住祖先状态,并把当前行放进整棵未完成的树里判断;正则表达式只看局部字符,无法承担这种结构决策。

如果你在实现解析器或文档流水线,最有价值的测试不是增加更多正常列表,而是围绕每个临界缩进、空行位置和容器组合建立成对用例,并断言 AST 不变量。

最后留下一个工程问题:列表语法的容错,应该由解析器承担,还是应该由编辑器在输入阶段阻止?当两者判断不同,哪一方才有权改写作者的源文本?

参考资料

posted @ 2026-08-09 18:08  frank-666  阅读(1)  评论(0)    收藏  举报