marked 的 pedantic 列表项文本解析:`list_item_text` 测试用例源码级剖析
2026/9/19 23:54:18 网站建设 项目流程

marked 的 pedantic 列表项文本解析:list_item_text测试用例源码级剖析

【免费下载链接】markedA markdown parser and compiler. Built for speed.项目地址: https://gitcode.com/gh_mirrors/ma/marked

导读

本文围绕 marked 仓库中test/specs/new/list_item_text.md这份回归测试用例展开,深入讲解 marked 在pedantic: true(原始 John Gruber 宽松 Markdown 规范)模式下如何解析"外层列表项内部嵌套列表 + 后续文本段落"这类边界输入。读完本文,你将掌握:该测试的输入、期望输出与 pedantic 模式的关联;列表项文本(item.text)在 Tokenizer.ts 中的提取与缩进归一化流程;pedantic选项对块级与行内语法的整体影响(见 Lexer.ts 与 rules.ts);以及如何用测试运行器复现与验证该用例。

一、测试用例全貌:输入、前置条件与期望输出

1.1 输入与前置元数据

test/specs/new/list_item_text.md全文如下:

--- pedantic: true --- * item1 * item2 text

文件由两部分组成:

  • YAML front matter:声明本用例仅在pedantic: true条件下生效。marked 的测试运行器 run-specs-tests 的解析入口 会读取该元数据,把pedantic: true合并进Marked实例的选项;除显式声明的选项外,其余选项(如gfm)保持默认值。这与original/目录下所有用例统一使用{ gfm: false, pedantic: true }(见 run-spec-tests.js)的约定一致,说明 pedantic 是"原始规范回归组"的标志性选项。
  • 正文 Markdown:一段以两个空格缩进开头的内容,结构为:
* item1 ← 外层无序列表项,项目符号缩进 2 空格 ← 空行 * item2 ← 内层嵌套列表项,缩进 4 空格 ← 空行 text ← 外层列表项内、嵌套列表之后的文本行(缩进回到 2 空格)

1.2 期望输出

同名期望文件test/specs/new/list_item_text.html

<ul><li><p>item1</p> <ul><li>item2 </li></ul> <p>text</p> </li></ul>

从中可读出三条关键断言:

  1. 整个输入被解析为单一外层<ul>,且不含缩进代码块——尽管textitem2的源码缩进恰好满足"4 空格代码块"的视觉特征,pedantic 模式下它们仍属于列表项的延续内容;
  2. 外层列表项是"松散列表项"item1text各自被<p>包裹(<p>item1</p><p>text</p>),因为两个空行使列表变 loose(对应源码list.loose判定逻辑,见下文第三节);
  3. 内层列表<li>item2 </li>被渲染在第一个<p>之后,即item2属于外层项的子块内容,而非新开一个顶层列表。

也就是说,这份用例锁定的行为是:pedantic 模式下,嵌套列表后的同缩进文本行必须继续留在外层列表项内部,作为独立的段落块输出

二、pedantic 模式:选项定义与语法集切换

2.1 选项定义与默认值

pedantic是 MarkedOptions.ts 中声明的布尔选项,默认值为false(见 defaults.ts):

// src/MarkedOptions.ts pedantic?: boolean;

启用后,marked 不再追求 CommonMark/GFM 的规范兼容,而是模拟 John Gruber 原始 Markdown 的宽松行为(rules.ts 注释 明确标注"Pedantic grammar (original John Gruber's loose markdown specification)")。

2.2 语法集的切换逻辑

pedantic对词法分析的影响集中在 Lexer.ts 构造函数:三套块级/行内语法规则按优先级切换——pedantic优先于gfm

if (this.options.pedantic) { rules.block = block.pedantic; rules.inline = inline.pedantic; } else if (this.options.gfm) { rules.block = block.gfm; rules.inline = this.options.breaks ? inline.breaks : inline.gfm; }

与列表解析直接相关的差异包括:

  • block.pedantic(rules.ts):基于blockNormal扩展,但fences被替换为noopTest(围栏代码块不支持)、def/heading/lheading/paragraph均使用宽松版本。其中paragraph规则把|list|fences|html从"可中断段落"清单中移除,意味着 pedantic 下更多行会被吞并进列表上下文;
  • block.normal(rules.ts):与block.pedantic是同源基类,本用例未开启gfm时列表、标题等主要块规则并无差异;
  • 预处理差异:在 Lexer.blockTokens 中,pedantic 模式额外执行src.replace(other.tabCharGlobal, ' ').replace(other.spaceLine, ''),即把所有制表符展开为 4 空格、并删除纯空格行,这为后续列表缩进计算提供了稳定基础。

三、源码级解析流程:list_item_text在 Tokenizer 中的行走路径

外层listtoken 的产生位于 Tokenizer.list(),我们按执行顺序还原本用例的处理。

3.1 匹配首个项目符号并计算缩进

list()首先用this.rules.block.list匹配到* item1bull = '*'(非有序,isordered = false)。pedantic 分支(L273-L275)把后续项目符号正则放宽为[*+-](任意项目符号都算同列表),而非默认模式下仅接受相同的*。这是 pedantic 列表语义的第一个差异点。

随后进入关键的第一项缩进计算(L296-L311):

let line = expandTabs(cap[2].split('\n', 1)[0], cap[1].length); ... if (this.options.pedantic) { indent = 2; // ← 固定缩进基准为 2 itemContents = line.trimStart(); // ← 去掉前导空白取内容 }

在 pedantic 分支中,外层项的缩进基准被硬编码为 2indent = 2),item1的内容直接trimStart()得到item1。而在非 pedantic 分支,缩进由"首个非空格字符位置"动态计算(L307-L310),因此本用例只有开启pedantic: true才能得到缩进基准 2,从而让后续text(2 空格缩进)恰好等于外层项的延续缩进。

3.2 子行归属判定:text为何留在列表项内

解析完* item1后,循环检查后续行(L327-L401),逐行做六大"提前终止"测试:

if (fencesBeginRegex.test(nextLine)) break; // 围栏代码块 if (headingBeginRegex.test(nextLine)) break; // ATX 标题 if (htmlBeginRegex.test(nextLine)) break; // HTML 块 if (blockquoteBeginRegex.test(nextLine)) break; // 引用块 if (nextBulletRegex.test(nextLine)) break; // 新项目符号 if (hrRegex.test(nextLine)) break; // 水平分割线

这些正则均由indent(=2)动态生成,例如nextBulletRegex(rules.ts)为^ {0,2}(?:[*+-]|\d{1,9}[.)])...,即只有缩进不超过 2 空格的项目符号才被视为"新项"。

本用例的三行后续内容逐行分析:

  • * item2(4 空格缩进):不匹配nextBulletRegex(缩进 4 > 2),因此它不是新列表项;但它的缩进 ≥indent(2),走"继续并入列表项"分支(L371-L372),把nextLineWithoutTabs.slice(indent)追加进itemContents。这里 pedantic 有一个独特处理:先执行listReplaceNesting替换(L334-L336),即用正则^ {1,4}(?=( {4})*[^ ])(rules.ts)把"1~4 空格起始、后面紧跟 4 空格倍数缩进"的行重新对齐为 2 空格,再按indent切片。这正是 pedantic 模式下嵌套列表得以在itemContents中形成、并被下一轮递归blockTokens识别为子<ul>的关键;
  • 空行:blankLine置位,但itemContents仍追加空行,作为段落分隔与 loose 判定依据;
  • text(2 空格缩进):indent(2)≥nextLineWithoutTabs.search(nonSpaceChar)(2),同样满足并入条件,追加为第二段文本text

3.3 最终itemContents与递归子解析

三项内容拼接后,第一项的外层item.text(即 Tokenizer.ts L418 的itemContents)为:

item1 * item2 text

随后在 L438-L448 的第一轮子 tokenize 中,this.lexer.blockTokens(item.text, [])递归解析这份内容,得到三个子块:段落item1→ 嵌套list* item2)→ 段落text。同时,由于子 token 序列中存在space类型的空行 token,且spacers.some(t => this.rules.other.anyLine.test(t.raw))判定其含完整换行(L444-L447),list.loose被置为true,随后 L496-L505 将所有子项loose置位、并把text类型子 token 升级为paragraph——这正是期望输出中<p>item1</p><p>text</p>的来源。

3.4 渲染输出

Parser 对listtoken 调用 Renderer.list(),遍历token.items调用 Renderer.listitem()(<li>${this.parser.parse(item.tokens)}</li>),最终把上述三个子块逐一渲染,拼出与期望文件一致的输出:

<ul><li><p>item1</p> <ul><li>item2 </li></ul> <p>text</p> </li></ul>

四、对照实验:非 pedantic 模式下的差异

为印证pedantic在该用例中的决定性作用,可把同样的输入放到默认(pedantic: false)模式下对比。依据 Tokenizer.ts L307-L310 的默认分支,缩进改为由首个非空格字符位置计算,* item1的实际缩进为 2,但indent计算变为indent = line.search(nonSpaceChar)再加项目符号长度;更关键的是 L334-L339 不再执行listReplaceNesting重对齐,* item2text的归属判定会遵循 CommonMark 缩进语义发生变化(item2可能因 4 空格缩进被当作外层项的缩进代码块或产生不同嵌套),输出结构将与list_item_text.html不一致。这解释了为什么该用例必须在 front matter 中强制pedantic: true——它本身就是一份锁定 pedantic 列表缩进语义的回归测试。

五、测试体系中的定位与复现方法

5.1 目录定位

该用例位于test/specs/new/目录。按 run-spec-tests.js 的加载逻辑,new/目录的用例使用无额外默认选项parse函数(仅由每个用例自身的 front matter 决定选项),与original/(固定pedantic: true)、gfm/commonmark/组成五组规范测试。

5.2 运行命令

仓库 package.json(test 脚本)提供了完整的规范测试入口:

# 仅运行规范测试(含 new/ 目录) npm run test:specs # 或仅运行规范测试的 only 标记用例 npm run test:specs:only

test:specs实际执行node --test --test-reporter=spec test/run-spec-tests.js。若要为 new/ 组新增或修改用例,可运行npm run test:updatenode test/update-specs.js)批量更新期望 HTML。运行前需先执行npm run build生成lib/下的 ESM 产物(run-spec-tests.js../lib/marked.esm.js导入)。

5.3 手写最小验证脚本

也可绕过测试框架,直接构造Marked实例复现:

import { Marked } from './lib/marked.esm.js'; const src = ' * item1\n\n * item2\n\n text\n'; // pedantic 模式:与 list_item_text.html 期望一致 console.log(new Marked({ pedantic: true, gfm: false }).parse(src)); // 默认模式:结构不同(用于对照) console.log(new Marked({ gfm: false }).parse(src));

注意 pedantic 模式下gfm会被rules.block = block.pedantic分支覆盖(见 Lexer.ts L48-L58),因此显式设置gfm: false仅用于与original/组配置保持一致、避免歧义。

六、扩展讨论:pedantic 对行内语法的连带影响

pedantic不只作用于块级列表,还通过inline.pedantic影响行内解析(Lexer.ts L49-L50)。相关实现点包括:

  • 行内链接解析Tokenizer中 L716-L731 使用pedanticHrefTitle(rules.ts L79)以宽松方式拆分 href 与 title,并允许"只有左尖括号、没有右尖括号"的链接形式(if (this.options.pedantic && !endAngleBracket.test(trimmedUrl)));
  • 强调定界符:rules.ts L297 的注释指出,pedantic 的 LDelim 在nextChar判断中排除开引号/闭引号,使*text*更宽松地触发强调。

这说明list_item_text所验证的固定indent = 2listReplaceNesting重对齐只是 pedantic 语义在块级列表上的一个切片;同一选项还牵动链接、强调等行内规则,使用时应整体把握(详见 MarkedOptions.ts 选项说明 与 rules.ts 的 pedantic 分组)。

七、小结

list_item_text是 marked 中一份小而关键的 pedantic 回归用例,它验证了三件事:

  1. 固定缩进基准:pedantic 模式下列表项缩进硬编码为 2(Tokenizer.ts L301-L303),使 2 空格缩进的text被判定为列表项延续内容;
  2. 嵌套重对齐listReplaceNesting正则把 4 空格缩进的嵌套列表重新对齐为 2 空格(Tokenizer.ts L334-L336),保证* item2被递归解析为子<ul>
  3. 松散判定:空行使list.loose = true(Tokenizer.ts L442-L448),最终渲染出<p>item1</p><p>text</p>的段落包裹结构。

理解这份用例,既能帮助你在使用pedantic: true处理遗留 Markdown 文档时预判列表结构,也能为阅读 marked 词法分析器(Lexer.ts、Tokenizer.ts)与规范测试体系(test/run-spec-tests.js)提供一条清晰的入口路径。

【免费下载链接】markedA markdown parser and compiler. Built for speed.项目地址: https://gitcode.com/gh_mirrors/ma/marked

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

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

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

立即咨询