Markdown 指南

CommonMark、GFM,以及写出「能活着到达」的 Markdown

为什么 Markdown 在各处渲染结果不同、CommonMark 钉死了哪些规则、GitHub Flavored Markdown 加了什么、原始 HTML 如何被消毒,以及怎样写出在每个目的地都长得一样的文档。

Markdown 用起来像一种格式,表现却像一族方言。同一份文件在代码仓库、文档站和聊天工具里可以渲染出三种样子,而出现分歧的恰恰是大家用得最多的那几样:换行、嵌套列表、表格和原始 HTML。这篇指南讲清楚差异从哪来、哪些差异真的会咬到你,以及怎样写出一份能完整抵达的文档。

世上没有一种 Markdown

Markdown 于 2004 年发布,形式是一个 Perl 脚本加一页说明文字。它没有文法定义,没有测试套件,也没有说明歧义情形该如何处理 —— 而 Markdown 的歧义情形多得很。此后每一个实现都各自决断,于是同一份文档在这个工具里是一种样子、在那个工具里是另一种样子,而两边都不能算错。

十年之后问世的 CommonMark 就是为了修这件事:一份真正的规范,带着几百个逐条示例和一套一致性测试,并且带版本号,实现可以声明自己遵循哪一版。它有意没有增加新特性,它的贡献在于把边角情形一一定下来,如今多数现代渲染器都以它为起点。

GitHub Flavored Markdown 被定义为 CommonMark 的严格超集,外加一小组扩展。你遇到的其余几乎所有方言 —— 文档生成器、静态站点构建器、Wiki、聊天工具和笔记应用里的那些 —— 都是 CommonMark 或 GFM 再叠一层本地扩展与选项。知道某个写法属于这三层中的哪一层,就立刻知道它能走多远。

CommonMark 钉死了哪些规则

CommonMark 定下来的那些规则都不起眼,却是文档在老渲染器和现代渲染器之间搬家时大部分意外的来源。其中最要紧的是列表项缩进:嵌套项的内容必须缩进到父项内容开始的那一列 —— 在「连字符加空格」标记下是两个空格,在「1. 加空格」标记下是三个空格。老实现普遍接受四个空格,所以按那套缩进写的文档可能会意外产出一个代码块。

强调语法是另一个长期悬案。CommonMark 专门把星号和下划线区分开,就是为了让词内下划线保持字面:snake_case_identifier 会照原样渲染,而 snake*case*identifier 中间那个词会被强调。这一条规则消灭了技术写作中一整类「莫名其妙变斜体」的问题,也正是为什么一份为前 CommonMark 渲染器写的文档会突然变了样。

它还钉死了一批边界:围栏代码块在哪里结束、HTML 块从哪里开始到哪里结束、一个列表是紧凑还是松散(因而其条目要不要包进段落标签)、以及列表在什么情况下可以不留空行就打断一个段落。这些都不是你写作时要做的选择 —— 它们本来就是渲染器替你做的选择,只不过各家做法不同。

GFM 加了什么

GFM 规范在 CommonMark 之上加了五样东西:竖线表格、任务列表项、删除线、把裸 URL 或以 www 开头的主机名自动变成链接的扩展自动链接,以及一个禁用特定原始 HTML 标签的过滤器。扩展集合就这么多,而且 GitHub 之外的许多渲染器也实现了它们,所以这些写法的可移植性还算不错。

不在那份规范里的东西值得单独记住,因为它们正是「只在 GitHub 上看着对」的文档的根源。脚注、用美元符界定的数学表达式、emoji 短代码、提示型引用块以及自动生成的标题锚点,都是叠在 GFM 之上的 GitHub 产品特性,而非已发布规范的一部分。它们在 github.com 上渲染良好,在别处经常一样都不认。

表格值得单独提醒,因为它是用得最多、也最脆弱的扩展。竖线表格的单元格只能装行内内容:不能有列表,不能有围栏代码,不能有段落。单元格里的字面竖线必须用反斜杠转义,行内代码里也一样 —— 这是「代码段里一切都是字面量」这条直觉唯一不成立的地方。而单元格内换行必须用 HTML 换行元素,因为一个换行符就意味着这一行结束了。

越往下可移植性越低。第一组在任何遵循规范的渲染器上都安全。

写法所属层可移植性
标题、列表、链接、围栏代码、引用块CommonMark处处可用
竖线表格GFM 扩展非常广,但单元格只能装行内内容
双波浪线删除线GFM 扩展广
任务列表项GFM 扩展广;只有在 GitHub 上可交互
裸 URL 自动链接GFM 扩展广
脚注GitHub 特性各家不一
美元符界定的数学公式GitHub 特性各家不一
emoji 短代码GitHub 特性各家不一
标题锚点 slug渲染器自定每个渲染器都不同

真正会出分歧的那几个写法

换行是最常见的可移植性故障。CommonMark 给了两种在段落内强制换行的方式:行尾两个及以上空格,或者行尾一个反斜杠。行尾空格在编辑器里看不见,会被许多「保存时格式化」配置删掉,在代码评审里也根本看不出来 —— 反斜杠写法存在的理由正在于此。另外,许多渲染器提供「软换行即换行」的选项,把每一个换行符都变成换行元素;GitHub 在 issue 和 PR 评论里开启了它,在仓库文件渲染时则没有 —— 这就是为什么一段在评论里看着正常的文字,粘进 README 后变成了一整个长段落。

第二个是嵌套列表缩进。连字符标记下正确的是两个空格,有序标记下是三个,但大量文档一律用四个,很多编辑器也是这样自动缩进的。在 CommonMark 下,连字符标记后的四个空格仍然落在列表项内部,所以通常还能用 —— 故障出现在更深的嵌套,以及有序列表里,那时累积的缩进会越界进入代码块的地盘。

其余的差异小一些,但也值得认出来:标题锚点的生成规则因渲染器而异,所以一个指向标题的链接可能只在它被写出来的那个地方有效;有些渲染器会做排版替换,把直引号变成弯引号、把三个点变成省略号 —— 当文字紧挨着代码时这很要命;而「段落后面不留空行就接列表」的处理方式差异之大,足以让「就是留个空行」成为唯一正确的习惯。

需要小心书写的几个写法
强制换行的三种写法:
  line one··                行尾两个空格,看不见且脆弱
  line one\                  行尾反斜杠,CommonMark,评审时可见
  line one<br>              HTML,几乎处处可用

词内强调,CommonMark 规则:
  snake_case_name           保持字面
  snake*case*name           中间那个词被强调

嵌套列表,对齐到父项内容列:
  - parent
    - child                 "- " 之下两个空格
  1. parent
     1. child               "1. " 之下三个空格

表格单元格只能装行内内容:
  | column | note              |
  | ------ | ----------------- |
  | a \| b   | 转义过的竖线       |
  | one<br>two | 换行必须用 HTML |

原始 HTML 与消毒

CommonMark 允许原始 HTML 块和行内标签,并原封不动地把它们输出。这是一个语言层面的决定,不是安全层面的决定 —— 这意味着 Markdown 渲染器本身就是一台「把不可信文本变成任意 HTML」的机器。凡是渲染用户提供的 Markdown 的地方,转换之后都必须做消毒,用基于解析器的消毒器配上元素与属性白名单。指望 Markdown 解析器自带安全性,是对它职责的误解。

GFM 的「禁用原始 HTML」扩展会过滤一组特定标签 —— script、style、iframe、title、textarea、xmp、plaintext、noembed 和 noframes —— 做法是把它们转义掉而不是输出。这是一项有用的加固,但它不是消毒器:它对事件处理属性无能为力,对链接里的 javascript: URL 无能为力,对标记携带行为的其他众多方式同样无能为力。GitHub 自己还在上面另跑了一套基于白名单的消毒器。

对你自己的文档来说,实际结论是:原始 HTML 是你能写出的最不可移植的东西。它在 GitHub 上对一小组标签有效,会被某些文档流水线整个剥掉,在配置为转义 HTML 的渲染器里则直接坏掉。只在没有替代方案时才用它 —— 表格单元格内的换行、可折叠的 details 块、需要显式宽度的图片 —— 其余一切情形都优先用 Markdown 自己的写法。

写出能活着到达目的地的 Markdown

可靠的策略是:先确定这份文档会在哪里被阅读,然后按覆盖这些地方的最小特性集来写。渲染在代码托管平台上的 README、由静态站点生成器构建的文档页、粘进聊天工具的一段片段,是三个不同的目标;一份必须在三处都成立的文档,就应该只用 CommonMark 加表格,别的都不用。

几个习惯就能扛下大部分问题。列表、标题、表格和围栏代码块之前一律留空行,因为「打断段落」的规则正是各家渲染器分歧最大的地方。围栏代码块一律标注语言,既是为了高亮,也是因为有些流水线对不带标注的围栏另有处理。长 URL 用引用式链接,让源码形态下的段落保持可读。优先用反斜杠或显式换行元素,而不是行尾空格。除非你能控制生成锚点的那个渲染器,否则不要依赖标题锚点。

还有一个习惯值得采纳,理由已经超出渲染本身:一句话一行。它对输出没有任何影响,因为单个换行只是软换行;但它彻底改变了 diff。改写一句话会变成一行的改动,而不是整段重排,于是文档可以像代码一样被评审。

  • 列表、标题、表格和代码围栏之前一律留空行。
  • 每个围栏代码块都标注语言标识。
  • 凡是长到会折行的链接都改用引用式写法。
  • 用反斜杠或换行元素替代行尾空格。
  • 一句话一行,让 diff 显示的是这次编辑,而不是重新折行。

值得花时间的预览流程

预览不等于检查。预览展示的是某一个渲染器的解读,而整个问题恰恰在于渲染器各不相同。真正能抓住问题的流程是:先在一个遵循规范的渲染器里预览,确认这份文档是合法的 CommonMark、并看清各个写法实际落在了哪里;然后再把你拿不准的那几个写法拿到真正的目的地去验一遍。

要看结构,而不是看文字。每一层列表都嵌套到你想要的深度了吗,还是有一层被压平了?表格渲染成了表格,还是变成了一段全是竖线的文字?代码围栏在你预期的地方闭合了,还是把下面整节都吞了进去?标识符里的下划线是不是变成了斜体?这些故障扫一眼容易漏掉,读者却一眼就看见。

把循环保持得短。把文档粘进预览、把明显不对的地方改掉,然后再提交到它该去的地方。当一份文档必须在多个目的地都渲染正确时,每个目的地至少验一次 —— 第二次检查几乎总能找出点什么,而且几乎总是表格、嵌套列表或换行。

要点回顾

  • 把你用到的每个写法归入三层之一 —— CommonMark、GFM 扩展、渲染器自有特性 —— 这直接告诉你它能走多远。
  • 强制换行用行尾反斜杠或显式换行元素,别用行尾空格:它看不见,而且经常在保存时被删掉。
  • 嵌套列表内容要缩进到父项内容列(连字符下两个空格、有序标记下三个空格),并在每个列表、表格、标题和代码围栏之前留空行。
  • 把渲染出的 Markdown 当作不可信 HTML:解析器不是消毒器,用户提供的文档必须在转换之后再过一遍白名单消毒器。
  • 先用遵循规范的渲染器预览确认结构,再到真正要发布这份文档的目的地把表格、嵌套列表和换行复查一遍。

继续了解相关检查与工具