- 前端
【免费下载链接】marked
A markdown parser and compiler. Built for speed.
本文基于 marked 仓库中test/specs/new/lheading_following_nptable这一回归测试用例,深入讲解 GFM(GitHub Flavored Markdown)表格在无管道(non-pipe table)形态下如何"吞并"紧随其后的 setext 标题候选行,并给出 Lexer / Tokenizer / 正则三层源码证据与本地验证方法。读完本文,你将掌握 marked 中表格与 setext 标题的判定优先级、tableDelimiter校验规则以及表格行正则的吞噬边界,能够准确预判类似 Markdown 片段的解析结果。
测试用例原文:一个看似"标题"却变成表格行的输入
test/specs/new/目录存放 marked 的自有扩展测试用例(见 test/run-spec-tests.js),其中lheading_following_nptable.md的内容极短,只有 5 行:
abc | def --- | --- bar | foo baz | boo title =====从 Markdown 字面看,最后两行title/=====完全符合 setext 二级标题的形态(=====等价于-下方的=一级标题标记)。如果这段文本被当作普通段落处理,title会变成一级标题,=====会变成标题下划线。然而,对应的期望输出lheading_following_nptable.html显示,整段输入被解析成了同一个表格:
<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> <tr> <td>title</td> <td></td> </tr> <tr> <td>=====</td> <td></td> </tr> </tbody> </table>title和=====被作为两个额外的表格行、每行一个单元格输出(第二列为空),而不是渲染成<h1>title</h1>。这一结果揭示了 marked 在 GFM 模式下的核心行为:一旦表格匹配成功,其"数据行"部分会贪婪地吸收后续所有符合条件的文本行,包括看起来像 setext 标题的行。
规则一:分隔行必须包含管道或冒号,否则回退为 setext 标题
表格之所以能成立,关键在于第二行--- | ---被判定为合法的表格分隔行。marked 在 src/Tokenizer.ts 的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; } ... }其中tableDelimiter定义在 src/rules.ts 的other组:
tableDelimiter: /[:|]/,含义是:分隔行(cap[2],即第二个捕获组)必须包含至少一个|或:字符,否则table()直接返回undefined,该输入会流转到 setext 标题(lheading)规则继续尝试匹配。这正是测试用例table_vs_setext(见下文)所要验证的边界行为。
在我们的用例中,第二行是--- | ---,包含管道符,因此表格判定成功,setext 标题的候选资格随之让位。
规则二:Lexer 按固定顺序尝试 tokenizer,"表格先于 lheading"
有了表格 tokenizer 的成功,还要看它在块级词法分析中的优先级。在 src/Lexer.ts 的blockTokens()方法 中,每轮循环依次尝试:def→table(仅 GFM)→lheading→paragraph。关键代码:
// table (gfm) if (token = this.tokenizer.table(src)) { src = src.substring(token.raw.length); tokens.push(token); continue; } // lheading if (token = this.tokenizer.lheading(src)) { src = src.substring(token.raw.length); tokens.push(token); continue; }可以看到table的尝试顺序严格位于lheading之前。只要table()返回了 token,lheading()就永远不会被调用。这是"表格吞标题"的第一层保障——不是 setext 标题不够格,而是它根本没机会被匹配。
顺带一提,普通 CommonMark 模式(gfm: false)下table规则被替换为noopTest(见 src/rules.ts 的blockNormal),因此同样的输入在非 GFM 模式下会走lheading路径,title会被渲染为<h1>——这一点也印证了该测试用例依赖 GFM 默认开启的前提(marked 默认gfm: true,见 src/defaults.ts)。
规则三:GFM 表格正则的数据行部分"贪婪吞噬"后续行
表格判定成功只是第一步,真正让title与=====进入表格行的是 GFM 表格正则gfmTable本身。其定义在 src/rules.ts:
const gfmTable = edit( '^ *([^\\n ].*)\\n' // Header + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells ...第三个捕获组(Cells)的结构是:以换行开头,然后重复匹配"不以空行、hr、heading、blockquote、code、fences、list、html开头"的任意行,直到这些中断条件出现为止。
将输入代入分析:
| 输入行 | 是否命中中断条件(负向前瞻) | 处理结果 |
|---|---|---|
abc \| def | 表头行 | 捕获组 1 |
--- \| --- | 分隔行 | 捕获组 2 |
bar \| foo | 否(含管道,普通文本行) | 表格行 |
baz \| boo | 否 | 表格行 |
title | 否 | 表格行(单单元格,第二列为空) |
===== | 否 | 表格行(单单元格,第二列为空) |
=====之所以不会被中断,是因为:
hr正则(src/rules.ts 的hr)只接受-、_、*三种字符连续 3 个以上,=不在其中;heading正则(ATX 形式,src/rules.ts 的heading)要求以#开头,=同样不满足;- 而setext 标题(
lheading)并不在 Cells 的负向前瞻中断列表里——也就是说,表格行区域内部根本不会检查"这一行是不是 setext 标题",因为表格一旦开始,数据行就只服从表格自己的行规则。
这正是本测试用例名称lheading_following_nptable的含义:"跟随在无管道表之后的 setext 标题"应当被表格吸收,而不是独立成标题。title/=====恰好踩中了 GFM 表格数据行对文本的贪婪吞噬范围。
表格行正则中被.replace('hr', hr)等替换注入的中断项(heading、blockquote、code、fences、list、html)均来自 src/rules.ts,读者可以对照查看这些块元素是如何被"编织"进表格行边界的。
纵深对比:table_vs_setext测试用例验证分隔行边界
与lheading_following_nptable形成互补的是同目录下的 table_vs_setext.md 及其 期望输出。该用例在文件头声明gfm: true(YAML front-matter 由测试框架解析),系统性对比了分隔行的不同形态:
| setext | ---------- | setext | setext ------ setext | table | :-------- | table | table :---- table | table | |-------- | table |对应输出中的关键结论:
| setext |下方的----------分隔行不含|和:,tableDelimiter校验失败 → 回退为 setext 标题,渲染成<h2>| setext |</h2>;- 第二组
setext/------同理,成为<h2>setext</h2>; - 从
| table |/:--------开始,分隔行含:或|,表格成立,后续两行均进入表格行,输出<table>(且:---使列对齐为align="left",见 src/Tokenizer.ts 的对齐解析); - 最后一组
|--------中,行首管道使分隔行通过校验,同样按表格处理,且无冒号故对齐为null。
这组用例与lheading_following_nptable一正一反,共同锁定了"分隔行是表格与 setext 标题的分水岭"这一规则。
渲染链路:从 Table token 到 HTML 表格
表格 token 最终如何变成期望输出中的<table>结构?解析与渲染路径为:
- src/Lexer.ts 将表格 token 推入 token 列表,token 结构定义在 src/Tokens.ts 的
Tokens.Table:包含align、header(TableCell[])、rows(TableCell[][]); - src/Parser.ts 的
parse分发逻辑 遇到case 'table'调用this.renderer.table(token); - src/Renderer.ts 的
table()依次渲染<thead>(header 行)与<tbody>(rows 行),每格通过tablecell()输出,最终拼出<table>...</table>\n。
对于title和=====这两行,由于输入中没有管道,splitCells(src/helpers.ts)将整行视为单个单元格,因此期望输出中第二列为空的<td></td>正是"单列输入 + 两列表格"对齐后的结果。表头单元格的解析还会调用this.lexer.inline(cell)(src/Tokenizer.ts),即表格内单元格同样支持行内 Markdown 语法。
本地验证:如何复现该测试用例
该用例属于new规格组,测试框架默认以 marked 的默认选项(gfm: true)运行,见 test/run-spec-tests.js。你可以通过以下方式本地复现:
- 运行规格测试:在仓库根目录执行
npm test(或查看package.json中的 test 脚本),run-spec-tests.js会加载test/specs/new下的全部.md/.html配对并逐一比对输出; - 快速手工验证:使用 api/dingus.js 或
docs/demo/目录下的浏览器演示页(index.html),粘贴上文 5 行输入观察输出;或通过 Node 直接调用marked.parse():import { Marked } from './src/marked.ts'; const marked = new Marked({ gfm: true }); console.log(marked.parse('abc | def\n--- | ---\nbar | foo\nbaz | boo\ntitle\n====='));输出即为
<table>结构而非<h1>title</h1>;将选项改为{ gfm: false }再运行,title则会变成<h1>标题,两类行为一目了然。
小结
lheading_following_nptable虽然只有 5 行输入,却是理解 marked 块级解析优先级的绝佳样本,其背后有三层机制环环相扣:
- 分隔行校验:
tableDelimiter(/[:|]/)决定了输入是表格还是 setext 标题的分水岭(src/Tokenizer.ts); - tokenizer 顺序:Lexer 中
table先于lheading尝试,一旦表格命中,setext 标题规则不再有机会执行(src/Lexer.ts); - 表格行吞噬:
gfmTable正则的 Cells 捕获组以负向前瞻划定边界,中断列表里没有 setext 标题,因此title、=====乃至任意非中断文本都会成为表格行内容(src/rules.ts)。
当你使用 marked 处理"表格后面紧跟着看起来像 setext 标题的文本"这类边界输入时,请牢记:在 GFM 模式下,只要分隔行带|或:,表格就会无条件接管后续文本行。若确实需要让文本成为标题,应在表格与标题之间插入一个空行,使表格的数据行区域在空行处终止。
- 前端
【免费下载链接】marked
A markdown parser and compiler. Built for speed.
相关推荐
marked 解析 GFM 非管道表格(nptable):strong_following_nptables 用例源码级剖析
marked 解析 GFM 非管道表格(nptable):strong_following_nptables 用例源码级剖析 摘要 :本文以 marked 仓库
前端marked 源码剖析:setext 标题的哈希前缀与 ATX 标题优先级边界(lheading_hash_prefix 测试规范详解)
marked 源码剖析:setext 标题的哈希前缀与 ATX 标题优先级边界(lheading_hash_prefix 测试规范详解) 在 marked 的
前端marked 中 GFM 表格与 Setext 标题的歧义消解规则:从 `table_vs_setext` 测试看源码实现
marked 中 GFM 表格与 Setext 标题的歧义消解规则:从 table_vs_setext 测试看源码实现 | 单元格 | 与 的组合,究竟是一张表
前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考