D 的个人博客

但行好事莫问前程

  menu
417 文章
3446695 浏览
13 当前访客
ღゝ◡╹)ノ❤️

CommonMark 规范要点解读

为什么需要 Markdown 规范

CommonMark 规范开篇就提到了这个问题,并列举了十多个例子来说明制定一个 Markdown 规范的必要性。

由于没有明确的规范,Markdown 解析渲染会有很大差异。因此用户经常会发现在一个系统(例如 GitHub)上渲染正常的文档在另一个系统上渲染不正常。更糟糕的是由于 Markdown 中不存在“语法错误”,所以无法立即发现这类问题。

在 Markdown 处理上“模糊的正确”是不可取的。所以 CommonMark 规范的目的就是消除二义性,制定统一明确的 Markdown 解析渲染规则。

作者阵容

CommonMark 规范的主创 John MacFarlanejgm)是加州大学伯克利分校的哲学教授,他在文本标记语言领域有一个很出名的项目 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):

  1. 是一个分隔符序列
  2. 后面不能跟空白
  3. 后面不能跟标点;或者后面跟标点并且前面是空白或者标点

右侧分隔符序列(right-flanking delimiter run):

  1. 是一个分隔符序列
  2. 前面不能是空白
  3. 前面不能是标点;或者前面是标点并且后面是空白或者标点

解析策略参考

规范附录部分介绍了一种解析策略,总的来说分为两个阶段:

  1. 将输入文本断行,顺序解析每一行并生成块级节点。文本作为块级节点的内容,暂时不进行解析。链接引用定义在这个阶段也会被解析构造放到一个 Map 中
  2. 解析每个块级节点的内容生成行级节点。如果有引用定义的话使用阶段 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 之上的所有分隔符。