为什么需要 Markdown 规范
CommonMark 规范开篇就提到了这个问题,并列举了十多个例子来说明制定一个 Markdown 规范的必要性。
由于没有明确的规范,Markdown 解析渲染会有很大差异。因此用户经常会发现在一个系统(例如 GitHub)上渲染正常的文档在另一个系统上渲染不正常。更糟糕的是由于 Markdown 中不存在“语法错误”,所以无法立即发现这类问题。
在 Markdown 处理上“模糊的正确”是不可取的。所以 CommonMark 规范的目的就是消除二义性,制定统一明确的 Markdown 解析渲染规则。
作者阵容
CommonMark 规范的主创 John MacFarlane(jgm)是加州大学伯克利分校的哲学教授,他在文本标记语言领域有一个很出名的项目 Pandoc(用于在各种文本标记语言之间互转格式)。他用多种编程语言实现过 Markdown 引擎,在 Markdown 处理方面他可以称得上行家中的行家。
该规范的其他参与者包括:
- David Greenspan, 来自 Meteor
- Vicent Marti, 来自 GitHub
- Neil Williams, 来自 Reddit
- Benjamin Dumke-von der Ehe, 来自 Stack Exchange
- Jeff Atwood, 前 Stack Exchange 联合创始人,Discourse 创始人
从作者阵容我们可以看出,该规范算是众望所归了,因为这几大社区都需要一个标准化的 Markdown。
除了强大的作者阵容外,最重要的是规范的严谨度我相信不会有任何问题,600+ 测试用例也尽量将各种情况都列举验证了,整体的权威性毋庸置疑。
介绍完大致背景,下面我们进入技术细节,对规范中定义的要点进行解读。
元素分类
为了方便解析,将内容元素分为块级元素和行级元素,其中块级元素又分为两类:
- 容器块:可包含其他块级元素,只有 3 种容器块:块引用(
>
)、列表项和列表(列表只能包含列表项) - 叶子块:不能包含其他块级元素,只能包含行级元素。比如分隔线、标题、代码块、某些 HTML 块、段落等
行级元素包括:内联代码(`code`
)、强调、加粗、链接、图片、某些 HTML 标签、文本等。
优先级
- 块级元素解析优先级永远高于行级元素(先生成块级元素后生成行级元素)
- 行级元素中 HTML 标签和自动链接的优先级最高,Code Span 次之
- Setext 标题优先级高于分隔线。比如如下示例
将生成1Foo 2--- 3bar
1<h2>Foo</h2> 2<p>bar</p>
- 分隔线优先级高于列表项
- 缩进如果出现在列表项中,以列表项对齐优先(不解析为缩进代码块)。比如如下示例
将生成1 - foo(f 之前是 2 个空格、1 个 - 然后再加 1 个空格) 2 bar(b 之前是 4 个空格)
1<ul> 2<li>foo 3bar</li> 4</ul>
- 强调分隔(
*
或_
)出现嵌套情况时,优先第一个。比如*foo _bar* baz_
将生成<em>foo _bar</em> baz_
而不是生成*foo <em>bar* baz</em>
- 出现多个可结束的强调分隔符时,以后打开的分隔符优先。比如
**foo **bar baz**
将生成**foo <strong>bar baz</strong>
而不是生成<strong>foo **bar baz</strong>
- 链接文本(link-text)优先级高于强调:
*[foo*](/uri)
将生成<p>*<a href="/uri">foo*</a></p>
具体场景细节可在规范中搜索关键字 precedence。
段落分段规则
某些情况下不需要空行即可“打断”当前内容,形成新的段落或者其他块级元素。
- 专题分隔线
***
打断段落 - ATX 标题
# h
打断段落,Setext 标题不打断,需要用空行分隔之前的内容 - 围栏代码块
```
打断段落 - 大部分 HTML 标签可打断段落,除了带属性的,比如
<a
、<img
- 块引用
>
打断段落 - 第一个非空列表项打断段落(即新列表打断段落)
每种情况的细节可以到规范中搜索关键字 interrupt。
段落延续文本
段落延续文本(Paragraph continuation text)即段落开始后不被分段规则打断的部分,该部分也算作当前段落。
最简单的例子是以 \n
分隔的两行文本:
1foo
2bar
渲染的 HTML 结果应该是:
1<p>foo
2bar</p>
在其他元素中的例子:
1> foo
2bar
渲染的 HTML 结果应该是:
1<blockquote>
2<p>foo
3bar</p>
4</blockquote>
强调和加粗
分隔符序列(delimiter run):
- 由一个或多个非
\
转义的*
构成或者 - 由一个或多个非
\
转义的_
构成
左侧分隔符序列(left-flanking delimiter run):
- 是一个分隔符序列
- 后面不能跟空白
- 后面不能跟标点;或者后面跟标点并且前面是空白或者标点
右侧分隔符序列(right-flanking delimiter run):
- 是一个分隔符序列
- 前面不能是空白
- 前面不能是标点;或者前面是标点并且后面是空白或者标点
解析策略参考
规范附录部分介绍了一种解析策略,总的来说分为两个阶段:
- 将输入文本断行,顺序解析每一行并生成块级节点。文本作为块级节点的内容,暂时不进行解析。链接引用定义在这个阶段也会被解析构造放到一个 Map 中
- 解析每个块级节点的内容生成行级节点。如果有引用定义的话使用阶段 1 中的 Map 进行解析
举个例子,对于给定的 Markdown 文本:
1## 简介
2
3一款 *Markdown* 引擎。
4
5## 特性
6
7* 实现 _GFM_
8* 非常快
生成 Markdown 语法树为:
{
"title": {
"text": "Markdown 语法树示例"
},
"tooltip": {
"trigger": "item",
"triggerOn": "mousemove"
},
"toolbox": {
"show": true,
"feature": {
"mark": {
"show": true
},
"restore": {
"show": true
},
"saveAsImage": {
"show": true
}
}
},
"calculable": false,
"series": [
{
"name": "树图",
"type": "tree",
"symbolSize": 10,
"initialTreeDepth": -1,
"roam": true,
"left": 0,
"right": 0,
"orient": "vertical",
"label": {
"position": "top",
"verticalAlign": "middle",
"align": "left",
"fontSize": 12,
"offset": [9, 12]
},
"lineStyle": {
"color": "#4285f4",
"shadowBlur": 8,
"shadowOffsetX": 3,
"shadowOffsetY": 5,
"type": "curve"
},
"data": [
{
"name": "Document",
"children": [
{
"name": "Heading\nh2",
"children": [
{
"name": "Text\n'简介'"
}
]
},
{
"name": "Paragraph\np",
"children": [
{
"name": "Text\n'一款'"
},
{
"name": "Emph\nem",
"children": [
{
"name": "Text\n'Markdown'"
}
]
},
{
"name": "Text\n'引擎'"
}
]
},
{
"name": "Heading\nh2",
"children": [
{
"name": "Text\n'特性'"
}
]
},
{
"name": "List\nul",
"children": [
{
"name": "Item\nli",
"children": [{"name": "Paragrap\np", "children": [{"name": "Text\n'实现'"},{"name": "Emph\nem", "children": [{"name": "Text\n'GFM'"}]}]}]
},
{
"name": "Item\nli",
"children": [{"name": "Paragrap\np", "children": [{"name": "Text\n'非常快'"}]}]
}
]
}
]
}
]
}
]
}
下面介绍强调和链接解析处理,这部分比较有技巧。
嵌套强调和链接的解析算法
强调、加粗、链接、图片这四种节点都是行级元素,但这四种元素的解析生成稍微有点麻烦,因为它们有可能存在嵌套。建议结合 CommonM 官方参考实现 JavaScript 版的文件 inlines.js 来看就容易理解了(注意该实现进行了一定优化,比如去掉了 current_position
变量,但总体逻辑没有变)。
解析行级元素时,如果遇到:
- 一系列
*
或_
字符,或者 - 一个
[
或![
时,则以这些符号作为文本内容生成一个文本节点,并在分隔符栈(delimiter stack)中压入一个指向该文本节点的元素。
分隔符栈是一个双向链表,其中每个元素都指向一个文本节点,并附加如下信息:
- 分隔符类型(
[
,![
,*
,_
) - 分隔符数量,比如强调是 1 个
*
,加粗则为 2 - 分隔符是否处于“激活”状态(开始解析时都是激活状态)
- 分隔符是否是一个开始分隔符、结束分隔符或者两者都可能(这取决于分隔符前后的字符序列)
当我们解析时遇到 ]
,则进入下面介绍的链接和图片处理过程。
当我们解析到输入结束时,则将 stack_bottom
置为 NULL 并进入下面介绍的强调处理过程。
链接和图片处理过程
从分隔符栈顶部开始回看寻找开始的 [
或者 ![
分隔符元素。
- 如果没有找到,则返回一个文本节点
]
- 如果找到了,但这个元素处于非激活状态,则从栈中移除该元素,然后返回一个文本节点
]
- 如果找到了,并且这个元素是激活的,则我们继续解析看是否能构成一个内联链接/图片、引用链接/图片、紧凑链接/图片或者快捷链接/图片
- 如果不能,则从栈中移除这个开始分隔符,然后返回一个文本节点
]
- 如果能,则执行如下步骤
- 生成一个链接或图片节点,其子元素为开始分隔符指向的文本节点之后的行级元素
- 在这些行级元素上以开始分隔符
[
作为stack_bottom
执行强调处理过程 - 从栈中移除该开始分隔符
- 如果是链接(不是图片),则设置所有位于该开始分隔符之前的
[
为非激活状态(防止链接嵌套链接)
- 如果不能,则从栈中移除这个开始分隔符,然后返回一个文本节点
强调处理过程
参数 stack_bottom
设置了分隔符栈的栈底下限。如果其值为 NULL 则我们可以一直遍历到栈底。否则我们应该在访问到 stack_bottom
之前停止。
current_position
指向分隔符栈中高于 stack_bottom
的元素(当 stack_bottom
为 NULL 时指向第一个元素)。
使用 openers_bottom
来跟踪每种分隔符(按类型 *
、_
和结束分隔符长度模 3)。初始化值为 stack_bottom
。
然后我们重复以下步骤,直到用完了潜在的结束分隔符:
- 在分隔符栈中向前移动
current_position
直到找到第一个潜在的结束分隔符*
或_
。(这是离开始最近的结束分隔符 —— 也是按解析顺序的第一个) - 现在我们向回查找(查找位置高于
stack_bottom
以及相应的openers_bottom
)第一个匹配的开始分隔符(“匹配”的意思是和结束分隔符一样的分隔符)。 - 如果找到了:
- 需要弄清楚是强调还是加粗:如果开始符和结束符的长度都 >=2,则是加粗,否则是普通强调
- 在开始分隔符指向的文本节点后面插入一个 em 或者 strong 节点
- 从分隔符栈中移除所有位于开始符和结束符之间的分隔符
- 从开始和结束文本节点中移除 1 个(对于普通强调)或者 2 个(对于加粗)分隔符。如果它们为空了,则移除它们,并从分隔符栈中也进行移除。如果结束符节点被移除,则设置
current_position
为栈中的下一个元素
- 如果没有找到:
- 设置
openers_bottom
指向current_position
前的元素。(此时我们知道该结束符没有对应的开始符,所以需要更新下限以便用于将来的搜索) - 如果
current_position
指向的结束符不是一个潜在的开始符,则将它从分隔符栈中移除(因为它既不是开始符也不是结束符) - 将
current_position
移动到栈中的下一个元素
- 设置
处理完后,我们从分隔符栈中移除了位于 stack_bottom
之上的所有分隔符。