- 前端
【免费下载链接】marked
A markdown parser and compiler. Built for speed.
本文以 marked 仓库中的规格测试 atx_heading_closing_sequence_tab.md 为核心,深入剖析 marked 在解析 ATX 标题(#井号标题)时如何处理行尾的闭合#序列,尤其是 Tab 字符作为分隔空白时的行为。读完本文,你将掌握 marked 标题解析的完整匹配规则、尾部#移除算法的实现细节、该行为与 CommonMark 规范及pedantic选项的关系,并能独立运行和验证对应的规格测试。
一、规格文件在验证什么
该规格文件位于 marked 的test/specs/new目录,采用「同名.md输入 +.html预期输出」的成对结构,是 marked 规格测试体系(new目录)中的一员。完整内容如下:
输入(atx_heading_closing_sequence_tab.md):
--- gfm: false --- # foo # ## bar ### ### baz # # #### qux # ##### quux#预期输出(atx_heading_closing_sequence_tab.html):
<h1>foo</h1> <h2>bar</h2> <h3>baz #</h3> <h4>qux</h4> <h5>quux#</h5>文件顶部的 YAML 前置声明gfm: false是该测试的运行选项:它要求以非 GFM(即纯 CommonMark)模式解析,从而把验证范围严格限定在 ATX 标题解析本身,排除 GFM 扩展(表格、删除线、hashtag 等)的干扰。同目录下的 nogfm_hashtag.md、list_tasks_non_gfm.md 等文件也采用了同样的声明模式。
二、逐案例解读:Tab 与尾部 # 的五种组合
这 5 个案例围绕一个核心问题设计:行尾的#序列在什么情况下会被当作「闭合序列」从标题文本中移除?按 CommonMark 规范,只有前面紧邻空格或 Tab 的尾部#序列才算闭合序列;紧贴正文的#则属于标题内容本身。每个案例针对性验证一种组合:
| 输入 | 预期输出 | 验证要点 |
|---|---|---|
# foo\t# | <h1>foo</h1> | 闭合#前是 Tab,Tab 是合法分隔空白,#被移除 |
## bar\t### | <h2>bar</h2> | 闭合序列可以包含多个#(此处 3 个),只要前置 Tab 即整体移除 |
### baz #\t# | <h3>baz #</h3> | 仅「Tab 前」的尾部#被移除,正文中间的#(空格分隔)保留 |
#### qux \t# | <h4>qux</h4> | 「空格 + Tab」的组合同样是有效分隔空白,尾部#被移除 |
##### quux# | <h5>quux#</h5> | 尾部#紧贴正文、无前置空格或 Tab,不是闭合序列,原样保留 |
第 3、5 两个案例尤其关键:它们反向验证了「必须有前置空白」这一前提——baz #中因空格分隔而保留的#,以及quux#中因紧贴正文而保留的#,共同划定了闭合序列的边界。
三、前置声明gfm: false的运行机制
在 run-spec-tests.js 中,new目录的测试通过runTests({ tests: newTests, parse })运行,与commonmark、gfm、original、redos四个目录并列(见该文件第 13–20 行)。new测试组没有全局默认选项,其运行选项完全来自每个规格文件自身的 YAML 前置声明,由@markedjs/testutils的getTests解析并逐条应用到对应测试。
gfm: false意味着本次解析走 CommonMark 语义路径。这一点对 Tab 场景很重要:在 GFM 模式下 marked 会启用表格、删除线等扩展,行首的管道符、~等字符可能触发其他解析分支,从而干扰对标题行的判断;关闭 GFM 后,测试可以精确、独立地观测 ATX 标题的闭合序列处理。若想快速复现该测试的解析结果,可在 demo 页面或在本地以new Marked({ gfm: false, pedantic: false })实例化 marked 后调用parse。
四、源码级原理:ATX 标题如何被识别
ATX 标题的匹配规则定义在 src/rules.ts:
const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/;该正则的每个组成部分都有明确职责:
^ {0,3}:允许 0 到 3 个前导空格(超过 3 个空格会被视为缩进代码块而非标题);(#{1,6}):捕获 1 到 6 个#,捕获组长度即标题级别(depth);(?=\s|$):#之后必须紧跟空白字符或行尾,这是「#foo不是标题」的根本原因;(.*):捕获标题正文内容;(?:\n+|$):消费换行。
匹配成功后,Tokenizer.heading 完成标题 Token 的构造:
heading(src: string): Tokens.Heading | undefined { const cap = this.rules.block.heading.exec(src); if (cap) { let text = cap[2].trim(); // remove trailing #s if (this.rules.other.endingHash.test(text)) { const trimmed = rtrim(text, '#'); if (this.options.pedantic) { text = trimmed.trim(); } else if (!trimmed || this.rules.other.endingSpaceTabChar.test(trimmed)) { // CommonMark requires a space or tab before trailing #s text = trimmed.trim(); } } return { type: 'heading', raw: rtrim(cap[0], '\n'), depth: cap[1].length, text, tokens: this.lexer.inline(text), }; } }其中用到两个关键的辅助正则(均定义于 src/rules.ts 的other分组):
endingHash: /#$/, // 快速判断文本是否以 # 结尾 endingSpaceTabChar: /[ \t]$/, // 判断文本是否以空格或 Tab 结尾五、尾部 # 闭合序列的移除算法
结合上述源码,闭合序列的判定算法可以归纳为三步:
- 是否以
#结尾:用endingHash(/#$/)判断,不满足则跳过; - 去除全部尾部
#:rtrim(text, '#')得到中间文本trimmed; - 决定是否真正移除,分两种情况:
pedantic: true(旧版宽松模式):无条件移除,text = trimmed.trim();pedantic: false(CommonMark 模式):仅当trimmed为空(整行都是#)或trimmed以空格/Tab 结尾时移除,即源码注释所强调的"CommonMark requires a space or tab before trailing #s"。
正是第 3 步的判定条件,让endingSpaceTabChar同时接受空格()与 Tab(\t)两种空白,这是本规格文件用 Tab 构造用例的理论依据——Tab 与空格在闭合序列判定上地位完全等价。
从pedantic分支还可以推得一个重要差异:对于##### quux#这类无前置空白的尾部#,在pedantic: true下会被强制移除输出<h5>quux</h5>,而在 CommonMark 语义下保留为<h5>quux#</h5>。本测试声明gfm: false且未开启 pedantic,因此走的是 CommonMark 分支,与预期输出完全吻合。
六、逐案例推演:算法如何得出预期输出
以下按该算法对 5 个输入逐一推演(cap[2]为正则捕获的标题文本):
| 输入 | 捕获文本(trim 后) | endingHash | rtrim 后 | 是否移除 | 最终文本 |
|---|---|---|---|---|---|
# foo\t# | foo\t# | 命中 | foo\t | 是(Tab 结尾) | foo |
## bar\t### | bar\t### | 命中 | bar\t | 是(Tab 结尾) | bar |
### baz #\t# | baz #\t# | 命中 | baz #\t | 是(Tab 结尾) | baz # |
#### qux \t# | qux \t# | 命中 | qux \t | 是(Tab 结尾) | qux |
##### quux# | quux# | 命中 | quux | 否(无前置空白) | quux# |
前 4 行的trimmed均以 Tab(或空格)结尾,命中endingSpaceTabChar,尾部#被整体移除;第 3 行的中间#位于rtrim作用范围之外,得以保留。第 5 行的trimmed为quux,既不空也不以空白结尾,判定为「非闭合序列」,#保留。推演结果与 atx_heading_closing_sequence_tab.html 的预期输出逐行一致。
七、运行与验证方式
要运行包括本文件在内的全部规格测试,可执行npm run test:specs(对应脚本定义于 package.json),该命令会通过node --test运行 run-spec-tests.js,依次校验commonmark、gfm、new、original、redos五个规格目录。new目录的测试同样支持--test-only模式单独聚焦。此外npm run test:update对应 update-specs.js,可用于按需重新生成规格输出。
若只想在 REPL 或脚本中手动验证本文件的行为,可使用与测试等价的选项组合:
import { Marked } from './lib/marked.esm.js'; const marked = new Marked({ gfm: false, pedantic: false }); console.log(marked.parse('# foo\t#\n## bar\t###\n'));八、实战启示
- 闭合序列必须留空白:按 CommonMark 语义书写 Markdown 时,标题行尾的
#序列前务必保留空格或 Tab,否则会被当作标题正文的一部分。本测试的##### quux#案例即是该规则的反向示范。 - Tab 与空格地位等价:
endingSpaceTabChar: /[ \t]$/表明 marked 将 Tab 与空格同等对待,因此混用 Tab 与空格构造闭合序列(如#### qux \t#)也能正确解析;但在真实文档中仍建议统一使用空格,避免因不同编辑器对 Tab 宽度的解释差异引发视觉混乱。 pedantic选项会改变语义:pedantic: true时尾部#无条件移除,与 CommonMark 行为不一致。追求规范兼容时应保持pedantic: false(默认值)。- 规格文件是最好的行为说明书:
test/specs/new目录下成对的.md/.html文件完整记录了 marked 对各类边界场景的预期行为,是理解解析器语义、排查兼容性差异的一手资料。
- 前端
【免费下载链接】marked
A markdown parser and compiler. Built for speed.
相关推荐
深入 marked 列表解析:列表标记后 Tab 与空格的分隔规则与源码实现
深入 marked 列表解析:列表标记后 Tab 与空格的分隔规则与源码实现 列表是 Markdown 中最常用的块级结构之一,而列表标记( 、 、 1. )与
前端marked 如何解析引用符 `>` 后的 Tab 字符:`tab_after_blockquote` 测试用例源码级剖析
marked 如何解析引用符 后的 Tab 字符: tab_after_blockquote 测试用例源码级剖析 导读 本文围绕 marked(一个以速度著称的
前端Marked 解析歧义处理:列表标记与分隔线(hr)的分界机制详解
Marked 解析歧义处理:列表标记与分隔线(hr)的分界机制详解 本测试规范位于 test/specs/new/incorrectly_formatted_l
前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考