marked 中表格后紧跟列表的解析行为:list_following_table 测试用例源码级解读
【免费下载链接】markedA markdown parser and compiler. Built for speed.项目地址: https://gitcode.com/gh_mirrors/ma/marked
导读
在 GitHub Flavored Markdown(GFM)中,管道表格(pipe table)与无序列表相邻出现是常见排版场景,例如"表头 + 说明列表"。由于表格行与列表项都以|、-、*等字符开头,两者边界极易被误判——表格的单元格收集可能"吞掉"后续列表,列表也可能反向打断表格。marked 通过test/specs/new/list_following_table.md这一回归测试用例,把"表格后紧跟列表"的边界行为固化为可验证的契约。本文以该用例为骨架,结合 marked 的规则定义、Tokenizer、Lexer 与 Renderer 源码,逐层拆解这一场景的解析原理,并给出如何运行与扩展测试的实战方法。
一、测试用例全貌:输入与期望输出
该用例由一对文件组成,位于 test/specs/new/ 目录下:
- Markdown 输入:list_following_table.md
- 期望 HTML 输出:list_following_table.html
输入内容为一张 2 列 × 2 行的 GFM 表格,紧接一个 3 项的无序列表:
| abc | def | | --- | --- | | bar | foo | | baz | boo | - foo - bar - bazmarked(以默认gfm: true配置解析)期望的输出是两个互不干扰的块级元素:先是一个完整的<table>,再是一个<ul>:
<table> <thead> <tr> <th>abc</th> <th>def</th> </tr> </thead> <tbody> <tr> <td>bar</td> <td>foo</td> </tr> <tr> <td>baz</td> <td>boo</td> </tr> </tbody> </table> <ul> <li>foo</li> <li>bar</li> <li>baz</li> </ul>这个期望输出揭示了两条核心语义:表格在数据行之后必须恰好终止(- foo不会被当作表格的一行),同时列表必须从表格结束后重新开始(列表项不能拼进表格的单元格)。
二、边界规则从何而来:GFM 表格正则中的list替换
表格与列表的边界由块级语法规则决定。在 src/rules.ts 中,marked 通过edit()模板构建gfmTable正则,其结构分三段:
const gfmTable = edit( '^ *([^\\n ].*)\\n' // Header + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells .replace('list', ' {0,3}(?:[*+-]|1[.)])[ \\t]') // any bullet ends the table rows .getRegex();关键在第三段(Cells):它用负向前瞻(?! *\n|hr|heading|blockquote|code|fences|list|html)逐个扫描表格数据行,一旦遇到列表标记就停止收集单元格。源码注释明确写着any bullet ends the table rows——即- foo、* bar、+ baz或1. xxx这类行会直接终结表格。
因此,输入中表格最后一行的| baz | boo |之后遇到- foo,Cells 段随即终止,表格 token 的raw止步于baz | boo |;剩余的- foo、- bar、- baz三行交由后续的list规则解析为无序列表。这就是本用例输出结构的规则级成因。
需要留意gfmTable是仅 GFM 启用的规则:在 src/rules.ts 中,CommonMark 模式(blockNormal)下table被置为noopTest(恒不匹配),只有blockGfm才覆盖为gfmTable(见 src/rules.ts)。这与 src/defaults.ts 中gfm: true的默认值一致——不显式关闭 GFM 时,上述表格解析行为默认生效。
三、Tokenizer 如何决定"这是表格而不是 setext 标题"
正则命中后,是否真把文本识别为表格,还要经过 Tokenizer.table 的语义校验:
table(src: string): Tokens.Table | undefined { const cap = this.rules.block.table.exec(src); if (!cap) return; if (!this.rules.other.tableDelimiter.test(cap[2])) { // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading return; } ... }分隔行(本例的| --- | --- |)必须含|或:(对应 src/rules.ts 的tableDelimiter: /[:|]/),否则按 setext 标题处理。随后:
- 用
splitCells()切分表头与数据行单元格(src/helpers.ts,会正确处理转义管道\|与首尾管道); - 从分隔行解析对齐方式:
:-:为 center、-:为 right、:-为 left,否则为null(src/rules.ts); - 校验表头与分隔行列数相等——
if (headers.length !== aligns.length) return;,列数不一致时整块回退为普通段落; - 逐行把单元格文本交给
this.lexer.inline()做行内解析,产出Tokens.Table。
由于- foo已被正则挡在 Cells 段之外,cap[3]只包含两行数据行,rows数组恰好为['| bar | foo |', '| baz | boo |'],表格 token 结构完整。
四、Lexer 的调用顺序:table 先于 paragraph 与 list
token 的产生顺序决定了后续列表能否"接得住"。在 Lexer.lex 的块级循环中,table的检查位置在lheading、paragraph、text之前:
// table (gfm) if (token = this.tokenizer.table(src)) { src = src.substring(token.raw.length); tokens.push(token); continue; }其意义是:以管道开头的内容优先尝试表格解析,成功后src前移到表格 raw 的末尾,下一轮循环继续处理- foo。此时- foo已是行首,匹配list规则(src/rules.ts 的blockNormal.list或 GFM 变体)并被切分为Tokens.List,最终与前一个tabletoken 并列存放在 tokens 数组中。若 table 检查被后移,- foo就可能在段落收集阶段被误并,这正是该用例存在的原因。
此外 GFM 段落规则blockGfm.paragraph也做了配套处理:.replace('table', gfmTable)允许表格打断段落,而.replace('list', ' {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]')只允许"非空"列表打断段落(src/rules.ts),进一步收紧块级边界。
五、Renderer 的输出契约
解析得到的Tokens.Table由 Renderer.table 渲染:表头包进<thead>(每个单元格为<th>),数据行包进<tbody>(每个单元格为<td>),有对齐时在标签上输出align属性;tablerow生成<tr>行,tablecell内部通过parser.parseInline处理行内 token。列表则由list渲染分支输出<ul>/<li>。这与本用例期望 HTML 的逐字符结构完全吻合,也解释了为什么输出中表格与列表之间存在换行分隔。
六、如何运行与验证该测试
该用例属于test/specs/new规格集。运行规格测试的统一入口是 test/run-spec-tests.js,它通过getTests()加载commonmark、gfm、new、original、redos五组规格(test/run-spec-tests.js),其中newTests使用默认的 marked 选项直接跑runTests({ tests: newTests, parse })(test/run-spec-tests.js)。
在仓库根目录执行规格测试:
npm test # 运行完整测试套件(含单元测试与规格测试)new规格集中的每个.md/.html文件对即一个断言:marked 解析.md的产物必须与.html完全一致(忽略行尾空白差异)。list_following_table一旦被破坏(例如修改gfmTable的 Cells 前瞻、调整 Lexer 中 table 的检查位置或改变splitCells的切分逻辑),测试即失败,从而为"表格后接列表"这一边界提供持续回归保护。
七、同类边界的横向印证
test/specs/new/下存在一整组"X following table"用例,与本用例互为参照,共同锁定表格的终止边界:
- fences_following_table.md:表格后接围栏代码块;
- heading_following_table.md 与 lheading_following_table.md:表格后接 ATX / setext 标题;
- code_following_table.md:表格后接缩进代码块;
- html_following_table.md:表格后接 HTML 块;
- strong_following_tables.md、inlinecode_following_tables.md、text_following_tables.md:关注行内元素与文本在表格后的表现。
对比可见,gfmTable的 Cells 负向前瞻把hr、heading、blockquote、code、fences、list、html全部列为表格终止信号(src/rules.ts),list只是其中之一。理解了list_following_table,即可举一反三掌握整组用例的规则基础。
八、小结
list_following_table虽然只有 7 行 Markdown,却完整刻画了 marked 在 GFM 模式下的一条关键边界契约:表格单元格收集被列表行精确终止,后续列表作为独立块级元素输出。其实现证据链清晰——规则层由gfmTable的 Cells 前瞻定义(src/rules.ts),token 层由Tokenizer.table校验(src/Tokenizer.ts),调度层由Lexer中 table 优先于 paragraph/list 的顺序保证(src/Lexer.ts),输出层由Renderer.table落实(src/Renderer.ts)。对使用者而言,这意味着"表格 + 列表"组合无需任何额外分隔或转义即可安全书写;对维护者而言,任何对表格正则或调度顺序的改动都受该用例持续监督。
【免费下载链接】markedA markdown parser and compiler. Built for speed.项目地址: https://gitcode.com/gh_mirrors/ma/marked
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考