Markdown 转 HTML——每一块都叫得出名字,每一次拒绝都记得下来

CommonMark 的块结构由列的算术定,强调由三的规则判,底下摆着四张表:哪一段行变成了哪一种块、为什么,拒绝解释什么,以及你自己的浏览器用它那台 HTML 解析器来核对交出去的标记。你打进去的文字不会离开这台设备。

块结构与强调规则

两趟,都不是查找替换

两趟,哪一趟都不是查找替换。第一趟把整篇文档当作一摞敞开的容器来读,逐行决定每一段行是什么块:它按四列一个停靠位来数制表符,允许任何开块的行前面留三个空格,然后按固定的顺序挨个问——这一行是不是某个已经敞开之物的尾巴,是不是围栏,是不是七种 HTML 块里的哪一种,是不是分割线,是不是 ATX 标题,是不是列表标记,是不是引用标记,还是四个列的缩进。只有当一段行已经合上之后,第二趟才去读它的里面:在那里,一段长度恰好相等的反引号打开并关闭一个代码段,一个 & 开头的串要么落成一个字符、要么原样留着你打的样子,尖括号要么拼出一个自动链接、要么拼出一个标签、要么两个都不是,而一对中括号按三种写法去找它的地址,取第一种对得上的。

这个顺序不是装饰;它就是为什么转换器没做的某些事不算漏掉。第四列上有内容的行是代码,别的是什么都不是,所以它里面的 *emphasis* 仍然是两个星号。一个列表标记只有当它后面的东西能够成为段落时才是列表标记,这就是为什么 - 后面什么都没有是一个只有一个空条目的列表,而段落末尾的 - 是一个破折号加一个圆点。块引用在其标记之后只吃一个空格或一个制表符,再多就没有,所以引用里面的第五列是缩进代码而不是文字——决定这件事的那套算术,就是下面那几张表打印出来的算术,而且它跑在你这篇文档上,不是跑在一个示例上。

那几张表里每一个数字都出自你刚跑的这一趟。块账本一块一行,写明它盖住哪几行、是哪条规则认领了它;行内计数器数的是第二趟遇到的链接、代码段、自动链接、引用和分隔符串;拒绝账本列出看过却决定不解释的那些片段,写明它停在什么文字上、停了几次;最后一张表是转换器自己做出的标记,交回给你浏览器的 HTML 解析器再数一遍。

块账本在告诉你什么

行号范围是你这篇文档的范围,在任何深度上都是。一个在三重容器之下的列表条目,打印出来用的是它在你写下的文字里的行号,而不是相对于它所在的那个引用或条目的行号,因为账本行唯一的用处就是让你找到它描述的那一块。容器排在它包含的块之前,覆盖更多行的块永远排在它包含的块之前。

原因那一列才是值得读的部分,因为 markdown 里最常见的两个意外都是在这一列上判出来的。第一个是松紧:一个列表是松的,当且仅当一个空行坐在它自己的条目之间、在它自己的那一层上,否则是紧的;紧列表把条目渲染成裸文字,松列表把每一项包成一段段落。嵌套列表里面的空行不会把外层列表弄松,而这是一条关于"空行是在哪一层量到的"的规则,不是转换器的脾气。第二个是块引用的偷懒行:一行没有 > 也照样可以属于引用已经打开的那段段落,而且它不可能成为别的东西——它是文字,所以它永远成不了 setext 下划线,账本行里也会写明这个引用吞了几行这样的行。

为什么 *foo* bar * 不是大家以为的那样

强调是由每一段分隔符串两侧的那些字符、以及这一串被打下来时的长度决定的,这一切发生在任何东西被吃掉之前。一串如果外侧是空白、内侧是标点,它能开不能合;反过来则能合不能开。_ 还要多一条限制,因为它坐在单词中间时不肯当强调用——这正是让 snake_case_name 保持为一个变量名、而不是一场排版灾难的原因。当一个候选的闭合串和一个候选的开启串相遇,两串长度相加,如果和是三的倍数,这一对就被拒掉——除非两串自己都是三的倍数。

那条"三的规则"是照打下来的原始长度算的,这一处正是一凭记忆写的页面最常弄错的地方:在 *foo**bar* 里,内侧的 ** 在外侧那一对被考虑之前就已经被削成了 *,如果算术用的是削短之后的长度,整个表达式就会出来成嵌套强调,而不是一枚多余的星号。同样的自律也是链接不能包含链接的原因:当一对中括号变成一个链接,它之前所有开启者都算用掉了,而一个只能找到用掉了的开启者的 ],就是一个字符,不是一个闭合者。

把生成的标记交给浏览器读一遍

只数自己做出来的块,等于转换器自己跟自己点头,所以最后一张表是去问别人。输出被交给你浏览器自己的那台 HTML 解析器——就是你把这份 HTML 粘到任何地方时都会读它的那一台——然后两棵元素树按位置一比:转换器认为自己摆下了什么,对上解析器说它找到了什么。一台由另一批人写的、照着另一份规范写的、跑了二十年的解析器,就是第二份独立的 HTML 实现,也是这里唯一被允许决定"由这些标签组成的文档到底是什么"的东西。

关于这个比对,有两件事值得在看那行结论之前知道。

它永远是拿转义那一趟来比的,不管上面那个开关指着哪一档。在那一趟里,树中每一个元素都是转换器摆下去的,因为你自己的标记是以文字的身份进去的;另一趟里你的标签也在输出当中,那里出现的分歧是在说你写的标记,不是在说这个转换器。所以结论下面那一行写得很直白:你自己写的标记被留在了两边之外。

它比的是一份元素清单和它们各坐在哪儿,仅此而已。它比标签名和嵌套,不比属性、也不比文字,所以它说不出某个链接的 href 对不对、某个词有没有活下来;那些事是块账本、计数器和你自己的眼睛负责的。两棵树不一致时,表里会打印它们停止相合的那个位置,以及两边各自在那里放了什么,其中一边写着"什么都没有",意思是那一边先数完了。如果你的浏览器不肯把脚本所需的 HTML 解析器交出来,那一行写的是"没有跑",而不是"是"——因为一次没发生的核对没有资格表态。

边界,摊开来讲

  • 照着规范自己的算例核过,652 条全对得上。 这个数字不是正确性的宣称,只是一份一致性的记录,而其中有一条值得点名,因为它是被人当 bug 报上来的那一条:HTML 块的第 148 号算例要的是一段把它吞进去的 </pre> 那行之后才合上的段落,于是印出来是 </pre></p>——一层任何浏览器都没法拆开、也没有作者想要的嵌套。转换器照样把它印出来,因为底下那条规则是规范自己写明的:一行如果是 </pre> 这一形状的关闭标签,它属于原始文本那一条规则,不属于块那一条,所以它永远不打断上面的段落。转换器对照过的两个广为使用的实现也是这么印的;一个在这里"修好"它的转换器才是那个例外。
  • 它是 CommonMark,不是 GitHub 的那套 markdown。 表格、任务清单的复选框、删除线、以及自动变链接的裸网址,全都在转换器所照的那份规范之外,所以它们会以你写的原文抵达:一行 | a | b | 是一段带竖线的段落,账本里会这么告诉你。
  • 具名字符引用由你的浏览器回答。 它们大约有 2,231 个,其中若干个跟没人想要的字符共用一个拼法,而一张抄进转换器的表会慢慢偏离最终读这份输出的那套东西。于是 &sect; 是拿去问那个会渲染这份输出的解析器"这是什么意思",它不知道的姓名就原样留着你打的样子——规范对丢了分号的 &copy 也是这么交代的。
  • 三道窗口限制着这份工作。 一段形状像标签的文字,只从它打开的那个角括号起、往后 4,096 个字符里找闭合;容器的嵌套停在 99 层,超过之后再来一个 > 或一个列表标记会被记成一次拒绝,并且保持字面原样;账本画 400 行,然后写明它留下了多少没画。文本框自己在 150,000 个字符处停下,而且它是拒绝整篇文档,不是转换一截开头——因为一份被砍短的输出看起来仍然是完整的。
  • 原始标记是你的风险,不是转换器的。 在默认那一档里,你文本中的 HTML 会作为标记直接穿过,于是你的浏览器决定那些标签变成什么。预览框把脚本关着,它们在那里跑不了;你存下去的文件没有这层保护。如果这段文字不是出自你手,先把上面那档开关换掉。
  • 一份实现,一套表格。 这里的一切都是一个转换器,照着一份规范的一个版本写的,拿它的公开算例核过,也在拿得到的地方对照过另外两份实现。它们之间的一致是一个强信号,不是证明;这里的数字描述的是转换器对你的文字做了什么,而那次读回来是唯一一处由外面的人负责核对的地方。
微信
微信
支付宝
支付宝