☰
深入解析 marked 的 tricky_list 测试:段落与列表的边界判定与实现原理
2026/10/10 2:15:58 网站建设 项目流程
  • 前端

【免费下载链接】marked

A markdown parser and compiler. Built for speed.

项目地址:https://gitcode.com/gh_mirrors/ma/marked
点击查看免费下载

导读:本文以 marked 开源仓库中的 tricky_list 测试用例 为切入点,剖析 Markdown 解析中一个极易出错的问题——紧邻段落与无序列表的边界判定。通过阅读本文,你将掌握 marked 在"强调文本段落 + 空行 + 无序列表"这一典型场景下的真实解析结果、其底层块级语法规则与列表分词实现,以及如何在项目里复现、验证并规避此类边界歧义。

一、从测试用例说起:tricky_list 到底在验证什么

tricky_list.md 位于test/specs/new/目录下,是 marked 自带的"新增行为"(new behavior)测试规格之一。该目录下的每个.md文件都对应一个同名的.html文件,二者构成一组"输入 → 期望输出"的断言。

tricky_list 的输入 Markdown 全文如下:

**hello** _world_ * hello world **hello** _world_ * hello world **hello** _world_ * Hello world **hello** _world_ * hello world

而 tricky_list.html 给出的期望输出为:

<p><strong>hello</strong> <em>world</em></p> <ul> <li>hello world</li> </ul> <p><strong>hello</strong> <em>world</em></p> <ul> <li>hello world</li> </ul> <p><strong>hello</strong> <em>world</em></p> <ul> <li>Hello world</li> </ul> <p><strong>hello</strong> <em>world</em></p> <ul> <li>hello world</li> </ul>

这份测试的核心意图非常明确:当一行内容为**hello** _world_的段落之后紧跟一个空行,再出现一行以*开头的列表项时,marked 必须把前者解析为独立的<p>段落、把后者解析为独立的<ul>列表,而不是把它们合并成一个段落,也不得把强调语法与列表混在一起。

值得注意的是,四个列表项中第三个是* Hello world(首字母大写),其余是* hello world(首字母小写),期望输出分别保留了Hello与hello的原始大小写——这说明列表项内容会被原样继承,不受前面段落中强调标记的影响。

二、为什么这个用例"tricky":段落与列表的边界是歧义高发区

Markdown 解析中最难处理的规则之一就是"哪些块级元素可以中断一个段落"。在 CommonMark 规范中,一个段落由连续的文本行组成,而列表项、标题、围栏代码等结构在某些条件下可以打断段落。

tricky_list 之所以"棘手",是因为它同时踩中了两个敏感点:

  1. 空行(blank line)的存在:每个**hello** _world_段落与后面的* hello world之间都隔着一个空行。空行是块级元素的分隔符,它明确地把前面的内容"封闭"成一个段落。
  2. 强调(emphasis)语法紧邻段落末尾:段落内容**hello** _world_本身就是行内强调语法,如果解析器对"段落能否被列表中断"的判定过于宽松或过于严格,就容易出现错误合并。

如果我们把空行去掉、让列表紧跟段落,行为就会完全不同——这一点从 marked 的块级规则中可以得到印证。

三、源码级解析:marked 如何判定列表边界

3.1 块级列表正则:list与bullet

列表的块级语法在 src/rules.ts 中定义:

const bullet = / {0,3}(?:[*+-]|\d{1,9}[.)])/; const list = edit(/^(bull)([ \t][^\n]*?)?(?:\n|$)/) .replace(/bull/g, bullet) .getRegex();

从源码结构可以推断:

  • 列表标记bullet允许最多 3 个前导空格({0,3}),支持无序列表的*、+、-,以及有序列表的1.、1)等形式(\d{1,9}[.)]);
  • 列表项首行([ \t][^\n]*?)?要求标记与内容之间至少有一个空格或制表符,随后捕获到换行前的全部内容;
  • list正则只匹配列表的起始标记行,真正的多行列表项扫描发生在 Tokenizer 中。

3.2 段落中断规则:只有"非空列表"才能打断段落

在 src/rules.ts 中定义了顶层层面的段落正则:

// only non-empty lists starting from 1 can interrupt paragraphs const paragraph = createParagraph(/ {0,3}(?:[*+-]|1[.)])[ \t]+[^ \t\n]/);

这一行注释与正则揭示了 CommonMark 的关键规则:一个无序列表(*/+/-)或从 1 开始的有序列表(1./1)),只有在其内容非空([ \t]+[^ \t\n]表示标记后必须跟有非空白字符)时,才能中断一个进行中的段落。反之,空列表项不能中断段落,会作为段落的延续文本处理。

这正是 tricky_list 中段落与列表能正确分离的规则基础:由于每个* hello world列表项都包含非空内容,它们具备中断段落的资格;而空行又先一步终结了段落,因此两者被解析为相互独立的块。

3.3 行内列表项识别:listItemRegex与nextBulletRegex

当块级正则命中后,Tokenizer 会循环收集同一列表内的所有列表项。核心匹配逻辑见 src/rules.ts:

listItemRegex: (bull: string) => new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`), nextBulletRegex: cachedIndentRegex((indent: number) => new RegExp(`^ {0,${indent}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`)),
  • listItemRegex用于匹配当前列表的第一个列表项,bull是第一次匹配时确定的标记类型(如\*);
  • nextBulletRegex用于判断后续行是否为同一缩进级别的兄弟列表项,它会依据当前列表项的缩进深度动态生成正则;
  • 二者都依赖cachedIndentRegex(src/rules.ts)做正则缓存,把缩进值映射到 0~3 的缓存槽位,避免每次扫描重复构造正则,这是 marked 追求解析速度的体现。

在 tricky_list 中,每个* hello world是孤立的单行列表(前后均有空行),因此listItemRegex命中第一项后,nextBulletRegex立即发现下一行不是列表项,列表随即结束。

3.4 Tokenizer 主循环:完整的分组流程

列表分词的主体实现在 src/Tokenizer.ts 的list(src)方法中,其关键流程为:

  1. 用this.rules.block.list.exec(src)尝试匹配列表起始标记行(src/Tokenizer.ts);
  2. 根据捕获的标记判断ordered与start(有序列表的起始序号),并在 src/Tokens.ts 定义的Tokens.List结构中初始化loose: false与空items;
  3. 进入while (src)循环,用itemRegex反复抓取每个列表项;在循环内会依次用hr、fencesBeginRegex、headingBeginRegex、htmlBeginRegex、blockquoteBeginRegex、nextBulletRegex检查后续行,一旦遇到新的块级结构就终止当前列表项(src/Tokenizer.ts);
  4. 计算每个列表项内容的缩进:indent > 4时视为 1(即超过 4 个空格的缩进代码块按 1 处理),并以此决定后续行归入哪个列表项(src/Tokenizer.ts);
  5. 最后把list.items交给this.lexer.blockTokens(item.text, [])递归分词,并在 src/Tokens.ts 定义的Tokens.ListItem中填充tokens(src/Tokenizer.ts)。

正是第 3 步的"遇到新块级结构即终止"机制,保证了 tricky_list 中每个* hello world不会吞掉后续的**hello** _world_段落——因为下一行**hello** _world_不满足任何列表项延续条件。

3.5 渲染端:<ul>/<li>的生成

分词完成后,渲染由 src/Parser.ts 的case 'list'分发到 src/Renderer.ts:

list(token: Tokens.List): RendererOutput { const ordered = token.ordered; const start = token.start; ... const type = ordered ? 'ol' : 'ul'; const startAttr = (ordered && start !== 1) ? (' start="' + start + '"') : ''; return '<' + type + startAttr + '>\n' + body + '</' + type + '>\n' as RendererOutput; }

这里可以验证 tricky_list 的输出特征:由于是*无序列表,ordered为false,因此渲染为<ul>;而listitem方法为每个列表项输出<li>${this.parser.parse(item.tokens)}</li>,列表项内嵌的文本hello world不含任何行内标记,原样输出——与期望 HTML 完全吻合。

四、在本地仓库中复现与验证

4.1 构建 marked

当前仓库使用 esbuild 进行构建,产物输出到lib/目录(构建配置见 esbuild.config.js)。可以先执行构建:

npm install npm run build

4.2 手动验证 tricky_list 的解析结果

规格测试驱动位于 test/run-spec-tests.js,它通过@markedjs/testutils的getTests加载五类规格(CommonMark、GFM、new、original、redos),再用Marked实例逐一解析比对。其中new目录的测试直接以默认选项运行(test/run-spec-tests.js)。

要单独验证 tricky_list 的解析行为,可以用Marked类直接解析输入:

import { Marked } from './lib/marked.esm.js'; const marked = new Marked(); const md = `**hello** _world_\n\n* hello world\n\n**hello** _world_\n\n* Hello world`; console.log(marked.parse(md));

预期输出为:

<p><strong>hello</strong> <em>world</em></p> <ul> <li>hello world</li> </ul> <p><strong>hello</strong> <em>world</em></p> <ul> <li>Hello world</li> </ul>

4.3 运行完整规格测试

若希望把整个new目录的规格都跑一遍,可运行:

npm test

或直接运行 test/run-spec-tests.js:

node test/run-spec-tests.js

测试框架会逐一执行 CommonMark、GFM、new、original、redos 五类断言,若任何.md的解析输出与同名.html不一致即判定失败。tricky_list 通过即代表本节描述的段落/列表边界行为得到回归保障。

五、边界对照:紧邻与分隔的行为差异

为加深理解,这里补充一组基于同一测试主题的对照实验(可在本地按上文方式验证):

实验 A:列表紧邻段落(无空行)

**hello** _world_ * hello world

由于没有空行终结段落,此时* hello world是否能中断段落取决于 3.2 节讨论的段落中断规则——非空列表可以中断段落,但解析结果会与 tricky_list 存在差异。

实验 B:空列表项紧邻段落

paragraph text *

*后没有非空白内容,属于"空列表项",不能中断段落,*会作为段落文本的一部分被继续吸收。

实验 C:带空行的独立列表(即 tricky_list 场景)

**hello** _world_ * hello world

空行先终结段落,* hello world作为全新的块级结构被解析为独立列表,段落中的**hello**与_world_只会被解析为行内强调,不会"泄漏"到列表中去。

这三个实验从正反两面印证了 tricky_list 测试所锁定的行为契约。

六、工程启示与最佳实践

  1. 测试规格即文档:test/specs/new/目录下每个成对的.md/.html文件,就是 marked 团队针对边界行为的"活文档"。阅读仓库时,遇到解析行为疑问,优先在 test/specs/new 中检索同名用例,比直接读源码更直观。
  2. 段落中断规则是理解 Markdown 解析的钥匙:无论是自己阅读解析器源码,还是排查渲染差异,都应先确认"空行是否存在""列表项是否非空""列表起始序号是否为 1"这三个维度,它们共同决定段落与列表的边界。
  3. 善用Marked类而非全局marked:如 test/run-spec-tests.js 所示,通过new Marked(options)可以隔离配置、避免全局状态污染,适合做行为对比实验。
  4. 关注缩进细节:从 src/Tokenizer.ts 可以看出,列表项的缩进计算(包括超过 4 空格的代码块按 1 处理)直接决定嵌套层级,写作 Markdown 时保持统一缩进能有效规避"列表意外中断"类问题。

七、总结

tricky_list 测试以"强调段落 + 空行 + 无序列表"的重复结构,为 marked 锁定了段落与列表之间清晰稳定的边界行为。透过这个用例,我们不仅验证了<p>与<ul>的正确分离,还顺藤摸瓜读懂了 src/rules.ts 中的块级列表正则、段落中断规则,src/Tokenizer.ts 中的列表项扫描与缩进计算,以及 src/Renderer.ts 中的<ul>/<li>渲染实现。这套"测试驱动理解源码"的方法,同样适用于继续研读 test/specs/new 目录下的其余用例,例如 list_loose、list_item_empty、list_wrong_indent 等列表边界系列测试。

  • 前端

【免费下载链接】marked

A markdown parser and compiler. Built for speed.

项目地址:https://gitcode.com/gh_mirrors/ma/marked
点击查看免费下载
上一篇:微信防撤回工具WeChatIntercept:告别错过重要信息的烦恼
下一篇:终极指南:3分钟掌握macOS微信防撤回神器WeChatIntercept

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

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

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

立即咨询