CommonMark 规范
CommonMark规范详解:标准化Markdown的定义、解析规则与一致性测试。
Layer 2 专业层标注:本篇讲 CommonMark 规范的解析器原理,属于高级话题。零基础学习者可完全跳过,直接学 Layer 0/1 的语法文档即可。
1. CommonMark 概述
1.1 为什么需要 CommonMark
原始 Markdown 由 John Gruber 于 2004 年创建,但规范描述模糊,导致各平台实现差异巨大。CommonMark 项目始于 2014 年,目标是创建无歧义的 Markdown 标准规范;GFM 规范即在其基础上扩展而来。当前规范版本为 0.31.2(2024-01-28 发布),版本号长期处于 0.x,意味着规范仍在小步修订而非”未完成”。
| 问题 | 示例 |
|---|---|
| 嵌套列表解析不一致 | 不同解析器对缩进列表的处理不同 |
| 链接定义优先级 | 行内链接 vs 引用链接的优先级不明确 |
| HTML 块边界 | HTML 块何时结束的定义不统一 |
| 强调规则 | *foo*bar*baz* 的解析结果不一致 |
1.2 CommonMark 的目标
- 无歧义:每个输入都有唯一确定的输出
- 可测试:规范自带 600 多个带标准答案的官方示例(spec.txt 内嵌测试用例),任何实现都可逐条对照验证
- 向后兼容:尽量兼容原始 Markdown
- 可扩展:允许定义扩展规范(如 GFM)
2. 规范核心规则
2.1 块级与行内结构
CommonMark 将文档解析分为两个阶段:
输入文本
↓
第一阶段:识别块级结构
(段落、标题、列表、代码块、引用等)
↓
第二阶段:在段落文本中识别行内结构
(强调、链接、代码、图片等)
2.2 空白处理规则
| 规则 | 说明 | 示例 |
|---|---|---|
| 行尾空格 | 两个以上触发硬换行 | 空格空格↵ → <br> |
| 制表符 | 展开到下一个 4 列倍数(非简单等于 4 空格) | \t 依所在列展开 |
| 连续空行 | 多个空行等同一个 | 段落间只需一个空行 |
| 缩进 | 4 空格触发代码块 | code → <pre><code> |
2.3 列表解析规则
CommonMark 对列表的解析有严格定义:
- 项目一
- 子项目(2空格缩进)
1. 有序列表
1. 子列表(3空格缩进,与文本对齐)
关键规则:
- 无序列表项的子内容需缩进到列表标记后的第一个非空字符位置
- 有序列表项的子内容需缩进到列表标记(含点号和空格)之后的位置
- 列表连续性由空行和缩进共同决定
2.4 强调规则
CommonMark 使用左右分界(flanking)规则(依据定界符前后字符判定其能否开启/关闭强调)解析强调:
*foo* → <em>foo</em>
**foo** → <strong>foo</strong>
***foo*** → <strong><em>foo</em></strong>
foo*bar*baz → foo<em>bar</em>baz(星号支持词内强调)
snake_case → 原样输出(下划线不支持词内强调,保护标识符)
5*3*2 → 5<em>3</em>2(星号夹在数字间同样触发强调)
* not em * → 原样输出(定界符内侧有空格,不构成定界)
规则要点:
*和_都可以表示强调,唯一重要差异是_不允许词内强调(保护snake_case标识符);- 定界符必须紧贴内容,内侧有空格即失效;
- 嵌套强调时遵循”最短优先”等匹配规则,外层不能先于内层关闭。
完整的左右_flanking 判定较绕,日常写作记住两条即可:用 * 别用 _、定界符紧贴内容。
3. 与原始 Markdown 的差异
3.1 关键差异
| 方面 | 原始 Markdown(Markdown.pl) | CommonMark |
|---|---|---|
| 无空格标题 | #foo 也是标题(宽容) | # 后必须有空格才是标题 |
| 列表续行 | 规则模糊,实现各异 | 严格的内容列缩进规则 |
| HTML 块 | 仅笼统说明 | 精确定义 7 种 HTML 块类型及边界 |
| 链接引用标签 | 匹配行为未严格定义 | 标签匹配不区分大小写、空白归一化 |
| 硬换行 | 行尾两个空格 | 行尾两个空格或反斜杠 \ |
3.2 已知不兼容
一个经典例子是行首 # 无空格的写法——原始实现把它当标题,CommonMark 明确它只是普通文本:
#无空格
<!-- Markdown.pl:渲染为 <h1>无空格</h1> -->
<!-- CommonMark:渲染为普通段落文本 "#无空格" -->
类似”老实现宽容、新规范收紧”的点还有多处(如缩进列表、集合式引用定义)。迁移老文档时最常见的翻车点就在这类边缘写法上——修正为规范写法即可(# 无空格 加空格)。
4. 解析算法
4.1 两阶段解析
第一阶段:块级解析
1. 逐行读取输入
2. 将行分类(ATX 标题、Setext 标题、代码围栏、引用、列表项等)
3. 构建块级文档树
4. 将内容行附加到对应的块级元素
第二阶段:行内解析
1. 遍历段落和标题的文本内容
2. 识别行内元素(代码跨度、强调、链接等)
3. 构建行内元素树
4.3 优先级
行内解析的处理顺序(先处理者”抢走”内容,其余定界符退化为普通文本):
- 代码跨度(
`code`)— 最高优先级,内部不解析任何内容 - 自动链接(
<url>)与原始 HTML 标签 - 链接和图片(
[文本](地址))— 优先于强调,因此[**文本**](url)中的加粗属于链接文字 - 强调(
*/_) - 剩余字符按普通文本输出
这条顺序解释了一个常见现象:*text* 中被反引号包住的星号永远只是字面字符——代码跨度先把它”圈”走了。
5. 一致性测试
5.1 测试套件
CommonMark 提供了完整的测试套件,每个测试用例包含:
# 示例测试用例
---
title: 'ATX headers'
section: 'ATX headings'
example: 32
markdown: |
## foo
bar
baz
html: |
<h2>foo</h2>
<pre><code>bar
</code></pre>
<p>baz</p>
---
5.2 验证工具
# 使用 cmark 参考实现
echo '# Hello' | cmark
# <h1>Hello</h1>
# 运行规范测试
python3 spec_tests.py --program cmark
# 使用 CommonMark.js
npm install commonmark
node -e "var commonmark = require('commonmark'); \
var reader = new commonmark.Parser(); \
var writer = commonmark.HtmlRenderer(); \
var doc = reader.parse('# Hello'); \
console.log(writer.render(doc));"
6. 实现与生态
6.1 主要实现
| 实现 | 语言 | 说明 |
|---|---|---|
| cmark | C | 参考实现,性能最优 |
| commonmark.js | JavaScript | JavaScript 参考实现 |
| commonmark-hs | Haskell | 类型安全的实现 |
| comrak | Rust | 高性能 Rust 实现 |
| goldmark | Go | Go 生态主流 Markdown 解析器 |
6.2 扩展规范
CommonMark 定义了扩展机制,GFM(GitHub Flavored Markdown)是最著名的扩展:
- 表格:
| col1 | col2 | - 任务列表:
- [ ] todo - 删除线:
~~strikethrough~~ - 自动链接:
https://example.com - 代码围栏语言:
```python