叶子块
主线位置:块主线里
parse_block的后半段。容器匹配完、新容器开完,剩下的一行交给某个叶子块的解析函数,它把这个块的所有行读完、在树里留下一个块节点和它的内容节点,再返回下一行的位置。分派的顺序见一行一行;表格虽然也是叶子块,在表格与脚注讲。
stateDiagram-v2 [*] --> ScanLine ScanLine --> NextLine: 追加文本和占位 NextLine --> Setext: 全部容器匹配且是下划线 NextLine --> Interrupted: 能打断段落 NextLine --> Blank: 空行 NextLine --> ScanLine: 续行或惰性续行 Setext --> [*]: 改成 Heading Interrupted --> [*] Blank --> [*]
图是段落的循环 parse_paragraph,叶子块里最复杂的一个。其他叶子块的循环更简单:每读一行,先用 scan_containers 确认外层容器还在,再检查自己的结束条件。
CommonMark 0.31.2 的规范(仓库里的 third_party/CommonMark/spec.txt)第 4 章「Leaf blocks」逐个定义了叶子块,下面每节都先引规范、再看实现。实验在 exp-pulldown-cmark/blocka-run(依赖 crates.io 的 0.13.5,打印带字节范围的事件流和 HTML;标了「对照」的例子同时打印 comrak 0.59 和 markdown-rs 1.0 的 HTML)和 blocka-run134(同一个程序换成 0.13.4),M2 Max、macOS 27、rustc 1.99.0。两个版本在本页所有例子上的输出逐字相同,原始输出在 blocka-run/leaf.out.txt。
段落:parse_paragraph 的循环
规范 4.8 节「Paragraphs」:「A sequence of non-blank lines that cannot be interpreted as other kinds of blocks forms a paragraph」。段落是兜底:parse_block 把其他叶子块都试过,才调 parse_paragraph。
它先 append 一个 Paragraph 节点并 push,然后循环:parse_line 扫一行,把文本和行内占位追加成孩子(一行一行最后一节);然后看下一行。下一行先过 scan_containers,得到 current_container(是否匹配了全部容器),再按这个顺序判断:
- 缩进不到 4 列、
current_container为真,而且是 Setext 下划线:段落变成标题,结束。 - 缩进不到 4 列,而且能打断段落:结束。
- 空行:结束。
- 否则这一行是段落的续行(
current_container为假时就是惰性续行),回到循环开头。
第 2 步的 scan_paragraph_interrupt 先调 scan_paragraph_interrupt_no_table,再检查以 | 开头的表格:
let bytes = text.as_bytes();
scan_eol(bytes).is_some()
|| scan_hrule(bytes).is_ok()
|| scan_atx_heading(bytes).is_some()
|| scan_code_fence(bytes).is_some()
|| scan_blockquote_start(bytes).is_some()
|| scan_listitem(bytes).map_or(false, |(ix, delim, index, _)| {
! current_container ||
tree.is_in_table() ||
// we don't allow interruption by either empty lists or
// numbered lists starting at an index other than 1
(delim == b'*' || delim == b'-' || delim == b'+' || index == 1)
&& (scan_blank_line(&bytes[ix..]).is_none())
})
|| bytes.starts_with(b"<")
&& (get_html_end_tag(&bytes[1..]).is_some() || starts_html_block_type_6(&bytes[1..]))
// ... 定义列表的 `:`、脚注定义的 `[^label]:`
这张表就是规范里散落在各节的「能不能打断段落」:分隔线、ATX 标题、围栏代码块、引用块可以;列表项要非空,有序列表要从 1 开始(5.2 节);HTML 块只有第 1 到 6 类可以(「All types of HTML blocks except type 7 may interrupt a paragraph」)。不在表里的就不能打断:缩进代码块(「An indented code block cannot interrupt a paragraph」)、第 7 类 HTML 块、链接引用定义、元数据块。
=== indented-no-interrupt: "foo\n bar\n"
0..12 Start(Paragraph)
0..3 Text(Borrowed("foo"))
3..4 SoftBreak
8..11 Text(Borrowed("bar"))
0..12 End(Paragraph)
html: "<p>foo\nbar</p>\n"
续行开头的空白不进文本:bar 的范围从第 8 字节开始,规范说段落的原始内容要「removing initial and final spaces or tabs」。
Setext 标题:下一行才知道
规范 4.3 节「Setext headings」:一行或多行文字,后面跟一行全是 =(一级)或全是 -(二级)的下划线;那几行文字必须「were they not followed by the setext heading underline, they would be interpreted as a paragraph」。
一行文字是段落还是标题,取决于它后面的行。逐行往前读的解析器读到 Foo 时没法决定,只能先当段落处理。pulldown-cmark 正是这样做的:Foo 进树时挂在一个 Paragraph 节点下面,读到下一行,parse_setext_heading 才改主意:
if !line_start.scan_space(4) {
let ix_new = ix + line_start.bytes_scanned();
if current_container {
if let Some(ix_setext) =
self.parse_setext_heading(ix_new, node_ix, trailing_backslash_pos.is_some())
{
// ...
self.pop(ix_setext);
// ...
return ix_setext;
}
}
parse_setext_heading 先用 scan_setext_heading 认下划线(第一个字节是 = 或 -,后面同一个字符重复,再接空白和行尾),去掉内容末尾的空白(开了标题属性时还要切掉 {#id},见扁平数组上的树的动脑筋),最后一句把段落节点就地改成标题:
self.tree[node_ix].item.body = ItemBody::Heading(
level,
attrs.map(|attrs| self.allocs.allocate_heading(attrs)),
);
改一个字段就够了,因为两件事:
- 树是先建完再出事件的。 第一遍结束之前
Parser一个事件都没产出,Paragraph节点从来没被谁看见过,改成Heading不需要撤回任何东西。这是两遍解析给的自由:如果块解析也是「边读边发事件」,Start(Paragraph)早就发出去了,就只能先把这一段缓存起来。 - 标题和段落的孩子一样。 两者的内容都是行内文本,
parse_line已经按段落的规则把Text、SoftBreak、MaybeEmphasis追加好了,换成标题照用。所以多行的 Setext 标题自然成立:
=== setext-2lines: "Foo *bar*\nbaz\n---\n"
0..18 Start(Heading { level: H2, id: None, classes: [], attrs: [] })
0..4 Text(Borrowed("Foo "))
4..9 Start(Emphasis)
5..8 Text(Borrowed("bar"))
4..9 End(Emphasis)
9..10 SoftBreak
10..13 Text(Borrowed("baz"))
0..18 End(Heading(H2))
html: "<h2>Foo <em>bar</em>\nbaz</h2>\n"
代码里判断的顺序就是规范的优先级。--- 既能是分隔线也能是 Setext 下划线,4.1 节「Thematic breaks」规定「the interpretation as a setext heading takes precedence」:Setext 的检查排在 scan_paragraph_interrupt(里面有 scan_hrule)之前。*** 不是下划线,就落到第 2 步,作为分隔线打断段落:
=== setext-vs-hr: "Foo\n***\n"
0..4 Start(Paragraph)
0..3 Text(Borrowed("Foo"))
0..4 End(Paragraph)
4..8 Rule
html: "<p>Foo</p>\n<hr />\n"
中间隔一个空行,段落在第 3 步已经结束,--- 在下一次 parse_block 里被当成分隔线(setext-blank)。列表项里的 - foo 后面跟 --- 也一样:--- 没有缩进,不属于列表项,current_container 为假,结果是列表加分隔线(setext-list)。
> foo\n---\n 渲染成什么?是引用块里的一个二级标题,还是别的?Options::empty(),答案在文末。
ATX 标题与分隔线
规范 4.2 节「ATX headings」:1 到 6 个 #,后面跟空格、制表符或行尾;可选的结尾 # 序列前面要有空格。scan_atx_heading 数 #,要求后面是空白,再用 HeadingLevel::try_from 限制在 1 到 6;####### x 和 #5 都会落到段落。
ATX 标题只有一行,parse_atx_heading 不走段落循环:append 标题节点,push,对这一行调一次 parse_line,然后从内容末尾往回去掉空白和结尾的 #(前面紧跟空格才算结尾序列,# foo# 的 # 是内容),把最后一个文本节点的 end 改短。全部去完什么都不剩(### ###)时,用 remove_node 删掉那个空文本节点。
这里有一个漏网的情形。只有「整个标题为空」才删节点;如果内容以行内标记结尾、后面跟结尾序列,最后一个文本节点只装着 #,被截短成空串却留在树里,事件流里就多出一个空的 Text:
=== atx-em-closing: "# *a* #\n"
0..8 Start(Heading { level: H1, id: None, classes: [], attrs: [] })
2..5 Start(Emphasis)
3..4 Text(Borrowed("a"))
2..5 End(Emphasis)
5..5 Text(Borrowed(""))
0..8 End(Heading(H1))
html: "<h1><em>a</em></h1>\n"
HTML 不受影响,但按事件处理文本的代码(例如拿标题文字生成锚点)会收到一个长度为 0 的 Text。用本站的选项(含 ENABLE_HEADING_ATTRIBUTES)结果相同,0.13.4 相同;main 上的 parse_atx_heading 和 tag 上逐字相同。
规范 4.1 节「Thematic breaks」:三个或更多相同的 -、_、*,中间可以夹空格和制表符。scan_hrule 照此计数;它失败时返回 Err(位置),告诉调用方「这个位置之前不可能有分隔线」,LineStart 的 min_hrule_offset 记住它,- - - - x 这样的行在判断列表标记时就不用反复从头扫。分隔线的优先级高于列表项(- - - 是分隔线),这个判断在 scan_list_marker_with_indent 里。分隔线节点 Rule 没有孩子,parse_hrule 只 append 不 push。
缩进代码块
规范 4.4 节「Indented code blocks」:若干「indented chunk」(每行缩进 4 列以上的非空行),之间可以隔空行。
parse_indented_code_block 的循环条件是「下一行匹配全部容器,并且缩进 4 列或者是空行」。空行也被收进来,因为后面可能还有缩进行;所以循环结束后要把结尾的空行退掉:
if !self.last_line_blank {
last_nonblank_child = self.tree.cur();
last_nonblank_ix = ix;
end_ix = ix;
}
// ...
// Trim trailing blank lines.
if let Some(child) = last_nonblank_child {
self.tree[child].next = None;
self.tree[child].item.end = last_nonblank_ix;
}
退掉的方法是把最后一个非空行所在节点的 next 断开、end 改回那一行的末尾(结尾的空行如果已经并进了这个文本节点,也就截掉了),和扁平数组上的树里截断的做法一样,断开的节点留在数组里:
=== indented: " a\n\n\n b\n\n\nc\n"
4..14 Start(CodeBlock(Indented))
4..8 Text(Borrowed("a\n\n\n"))
12..14 Text(Borrowed("b\n"))
4..14 End(CodeBlock)
16..18 Start(Paragraph)
16..17 Text(Borrowed("c"))
16..18 End(Paragraph)
html: "<pre><code>a\n\n\nb\n</code></pre>\n<p>c</p>\n"
中间的两个空行在代码里,结尾的两个空行不在。a\n\n\n 是一个事件,因为 append_text 遇到首尾相接的文本节点会直接延长前一个。每行内容去掉 4 列缩进;缩进是制表符时,按 4 列制表位算(规范 2.2 节「Tabs」),\tfoo 也是缩进代码块,内容是 foo\n。
围栏代码块
规范 4.5 节「Fenced code blocks」的规则,对应到代码:
| 规范 | 代码 |
|---|---|
至少 3 个 ` 或 ~,不能混用 | scan_code_fence 数同一个字符的重复 |
| 反引号围栏的 info string 不能含反引号 | 同一个函数:这一行后面还有 ` 就返回 None |
| info string 去掉首尾空白 | parse_fenced_code_block 里 scan_whitespace_no_nl 和 scan_rev_while,再 unescape,存进 allocs |
| 结尾围栏:同一种字符、至少一样长,前面最多 3 个空格 | scan_closing_code_fence |
| 开头围栏缩进 N 列,内容每行去掉至多 N 列 | 循环里 line_start.scan_space(indent) |
| 没有结尾围栏就到容器或文档结束 | scan_containers 不全匹配时 pop 返回;文档结束时 scan_closing_code_fence 对空输入返回 Some(0) |
parse_fenced_code_block 的循环先确认容器,再试结尾围栏,最后把这一行当代码文本追加:
loop {
let mut line_start = LineStart::new(&bytes[ix..]);
let n_containers = scan_containers(&self.tree, &mut line_start, self.options, false);
if n_containers < self.tree.spine_len() {
// this line will get parsed again as not being part of the code
// if it's blank, it should be parsed as a blank line
self.pop(ix);
return ix;
}
line_start.scan_space(indent);
let mut close_line_start = line_start.clone();
if !close_line_start.scan_space(4 - indent) {
let close_ix = ix + close_line_start.bytes_scanned();
if let Some(n) = scan_closing_code_fence(&bytes[close_ix..], fence_ch, n_fence_char)
{
ix = close_ix + n;
self.pop(ix);
// try to read trailing whitespace or it will register as a completely blank line
return ix + scan_blank_line(&bytes[ix..]).unwrap_or(0);
}
}
// ...
self.append_code_text(remaining_space, ix, next_ix);
ix = next_ix;
}
围栏代码块没有「惰性续行」:容器不全匹配,代码块立刻结束。> ```\n> x\ny\n 里 y 不在引用块里,是一个新段落(fence-in-quote)。几个例子:
=== fence-info: "``` rust ignore\nx\n```\n"
0..22 Start(CodeBlock(Fenced(Borrowed("rust ignore"))))
17..19 Text(Borrowed("x\n"))
0..22 End(CodeBlock)
html: "<pre><code class=\"language-rust\">x\n</code></pre>\n"
=== fence-short-close: "````\nx\n```\n````\n"
0..15 Start(CodeBlock(Fenced(Borrowed(""))))
5..11 Text(Borrowed("x\n```\n"))
0..15 End(CodeBlock)
html: "<pre><code>x\n```\n</code></pre>\n"
=== fence-indented: " ```\n x\n y\n```\n"
2..19 Start(CodeBlock(Fenced(Borrowed(""))))
8..12 Text(Borrowed(" x\n"))
14..16 Text(Borrowed("y\n"))
2..19 End(CodeBlock)
html: "<pre><code> x\ny\n</code></pre>\n"
info string 原样保留整串(rust ignore),只取第一个词当语言是 HTML 写出器的事(HTML 写出器)。开头围栏缩进 2 列,内容行 x 去掉 2 列剩 x。块主线例子文档里的代码块是节点 46,FencedCodeBlock(CowIndex(1)):info string rust 存在 allocs 的第 1 个 CowStr 里(第 0 个是引用块里 & 解出来的 &)。
与规范的出入:结尾围栏后面的制表符。 规范说结尾围栏「may be followed only by spaces or tabs」,scan_closing_code_fence 只跳过空格。结尾围栏后面带一个制表符时,0.13.5 不认它,代码块一直延续到文档末尾(对照):
=== fence-close-tab: "```\nx\n```\t\ny\n"
0..13 Start(CodeBlock(Fenced(Borrowed(""))))
4..13 Text(Borrowed("x\n```\t\ny\n"))
0..13 End(CodeBlock)
html: "<pre><code>x\n```\t\ny\n</code></pre>\n"
comrak: "<pre><code>x\n</code></pre>\n<p>y</p>\n"
markdown-rs: "<pre><code>x\n</code></pre>\n<p>y</p>\n"
main 上的 PR #1150(提交 73b05dd「Allow tabs after a closing code fence」)已经改成同时跳过空格和制表符,但截至 tag 它没有进入任何发布版本。
HTML 块:七类
规范 4.6 节「HTML blocks」按开始条件和结束条件分七类。块里的内容原样输出,不解析 Markdown:
| 类 | 开始条件 | 结束条件 | 代码 |
|---|---|---|---|
| 1 | <pre、<script、<style、<textarea,后跟空白、> 或行尾(不分大小写) | 某行含 </pre>、</script>、</style>、</textarea> | get_html_end_tag 返回结束串 |
| 2 | <!-- | 某行含 --> | 同上 |
| 3 | <? | 某行含 ?> | 同上 |
| 4 | <! 后跟 ASCII 字母 | 某行含 > | 同上 |
| 5 | <![CDATA[ | 某行含 ]]> | 同上 |
| 6 | < 或 </ 后跟 62 个块级标签名之一,再跟空白、行尾、> 或 /> | 下一行是空行 | starts_html_block_type_6 |
| 7 | 一个完整的开标签或闭标签(标签名不限,第 1 类的四个除外),后面只有空白 | 下一行是空行 | scan_html_type_7 |
parse_block 在行首是 < 时按 1–5、6、7 的顺序试,所以 <pre> 永远是第 1 类,不会落到第 7 类,规范里「other than pre, script, style, or textarea」的排除是靠顺序做到的。解析分两个函数,按结束条件分:
parse_html_block_type_1_to_5:每读一行,先追加,再看这一行里有没有结束串(self.text[line_start_ix..ix].contains(html_end_tag))。先追加后检查,所以开始行本身含结束串时块只有一行,和规范「If the first line meets both the start condition and the end condition, the block will contain just that line」一致。中间的空行不结束块。parse_html_block_type_6_or_7:读到空行(或容器结束、文档结束)为止。
每一行变成一个 ItemBody::Html 节点,事件流里是一串 Html 事件,夹在 Start(HtmlBlock) 和 End(HtmlBlock) 之间:
=== html1: "<pre class=\"x\">\n**a**\n\nb</pre>\nc\n"
0..31 Start(HtmlBlock)
0..16 Html(Borrowed("<pre class=\"x\">\n"))
16..22 Html(Borrowed("**a**\n"))
22..23 Html(Borrowed("\n"))
23..31 Html(Borrowed("b</pre>\n"))
0..31 End(HtmlBlock)
31..33 Start(Paragraph)
31..32 Text(Borrowed("c"))
31..33 End(Paragraph)
html: "<pre class=\"x\">\n**a**\n\nb</pre>\n<p>c</p>\n"
=== html6: "<div>\n*a*\n\n*b*\n"
0..10 Start(HtmlBlock)
0..6 Html(Borrowed("<div>\n"))
6..10 Html(Borrowed("*a*\n"))
0..10 End(HtmlBlock)
11..15 Start(Paragraph)
11..14 Start(Emphasis)
12..13 Text(Borrowed("b"))
11..14 End(Emphasis)
11..15 End(Paragraph)
html: "<div>\n*a*\n<p><em>b</em></p>\n"
第 1 类里的空行和 **a** 都原样保留;第 6 类在空行处结束,之后的 *b* 回到 Markdown。第 2 到 5 类和第 7 类的例子(<!-- x\n\n-->、<?php\n\n?>、<!DOCTYPE html>、<![CDATA[\n\n]]>、<my-tag x="1">)都在 leaf.out.txt 里,行为同表。这些 Html 事件交给 push_html 时不转义、不过滤,见转义。
第 6 类的 62 个标签名在 scanners.rs 的 HTML_TAGS 里,注释写着「sorted for binary search」;用脚本从 tag 上的 spec.txt 抽出第 6 类的列表和它比,62 个逐个相同。is_html_tag 在上面做二分查找,比较时把输入字节 | 0x20:表里全是小写字母和数字,ASCII 大写字母或上 0x20 就是小写,数字 0–9(0x30–0x39)本来就带这一位,不受影响。这样不用先把标签名转成小写的副本。
第 7 类必须独占一行,<my-tag> *a* 不是 HTML 块,是段落里的行内 HTML(html7-not-alone)。它也不能打断段落:
=== para-then-html7: "foo\n<my-tag>\nbar\n"
0..17 Start(Paragraph)
0..3 Text(Borrowed("foo"))
3..4 SoftBreak
4..12 InlineHtml(Borrowed("<my-tag>"))
12..13 SoftBreak
13..16 Text(Borrowed("bar"))
0..17 End(Paragraph)
html: "<p>foo\n<my-tag>\nbar</p>\n"
把 <my-tag> 换成 <div>,段落在 foo 处结束,<div>\nbar\n 成了第 6 类 HTML 块(para-then-html6)。
<source> 在 0.10.0 换了一类PR #834 的提交 5368834「Update to CommonMark 0.31.2」(首次发布于 0.10.0)把规范从 0.30 升到 0.31.2,提交说明列了三处解析变化,其中一条是「The search element is added to the HTML block start list, and source is removed.」。对 HTML_TAGS 的改动就是加一行 "search"、删一行 "source",数组长度仍是 62。之前 <source src="a.mp4"> 是第 6 类,能打断段落;现在它是第 7 类,跟在段落后面时只是一段行内 HTML(para-then-source:<p>foo\n<source src="a.mp4">\nbar</p>)。同一个提交还把行内 HTML 注释改成按 HTML5 的规则扫描(scan_inline_html_comment 的新注释写着 <!-->、<!---> 也算注释),并在判断强调的左右侧时把 Unicode 符号算作标点。
与规范的出入:第 1 类的结束标签。 规范对第 1 类的结束条件是「line contains an end tag </pre>, </script>, </style>, or </textarea> (case-insensitive; it need not match the start tag)」。get_html_end_tag 按开始标签只返回一个结束串,contains 又区分大小写,于是两种写法都结束不了块,HTML 块一直延续到文档或容器结束(对照;comrak 默认不输出原始 HTML,显示成注释,但能看到它在 </PRE> 处结束了块):
=== html1-upper-end: "<pre>\na\n</PRE>\n*b*\n"
0..19 Start(HtmlBlock)
0..6 Html(Borrowed("<pre>\n"))
6..8 Html(Borrowed("a\n"))
8..15 Html(Borrowed("</PRE>\n"))
15..19 Html(Borrowed("*b*\n"))
0..19 End(HtmlBlock)
html: "<pre>\na\n</PRE>\n*b*\n"
comrak: "<!-- raw HTML omitted -->\n<p><em>b</em></p>\n"
markdown-rs: "<pre>\na\n</PRE>\n<p><em>b</em></p>\n"
=== html1-other-end: "<pre>\na</script>\n*b*\n"
...
html: "<pre>\na</script>\n*b*\n"
comrak: "<!-- raw HTML omitted -->\n<p><em>b</em></p>\n"
markdown-rs: "<pre>\na</script>\n<p><em>b</em></p>\n"
main 上的 PR #1163(提交 a8fd0bd「End an HTML block at any of the four type 1 end tags」)改成在每个 < 处不分大小写地试四个结束标签,同样没有进入任何发布版本。这两处出入在 0.13.4 上相同,本站的渲染也是这个行为。
元数据块
元数据块不是 CommonMark 的一部分,是 pulldown-cmark 的扩展,由两个选项打开:ENABLE_YAML_STYLE_METADATA_BLOCKS(--- 开头,--- 或 ... 结尾)和 ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS(+++ 开头和结尾)。规则写在仓库的 specs/metadata_blocks.txt 里,由 build.rs 生成测试。
它和分隔线、Setext 下划线共用 ---,所以在 parse_block 的分派里排在 HTML 和分隔线之前,并且要求缩进为 0。scan_metadata_block 要一直向后看到块尾才能决定:开头一行只能是三个分隔符加空白,然后一路往后找结尾行,找到才算;第一行内容不能是空行,也不能紧接着就是结尾(块不能为空);文档结束都没找到结尾就不是元数据块,这一行按其他规则重新判断(--- 通常成了分隔线)。这次向后扫描不是白做的:parse_metadata_block 再按行读一遍,把内容当代码文本追加。
=== meta-start: "---\ntitle: a\n---\n# H\n"
0..16 Start(MetadataBlock(YamlStyle))
4..13 Text(Borrowed("title: a\n"))
0..16 End(MetadataBlock(YamlStyle))
17..21 Start(Heading { level: H1, id: None, classes: [], attrs: [] })
19..20 Text(Borrowed("H"))
17..21 End(Heading(H1))
html: "<h1>H</h1>\n"
=== meta-off: "---\ntitle: a\n---\n"
0..4 Rule
4..17 Start(Heading { level: H2, id: None, classes: [], attrs: [] })
4..12 Text(Borrowed("title: a"))
4..17 End(Heading(H2))
html: "<hr />\n<h2>title: a</h2>\n"
事件流里内容是一个 Text,push_html 什么都不输出(HtmlWriter 遇到 Start(MetadataBlock) 置 in_non_writing_block)。不开选项时,同样的文字是一条分隔线加一个 Setext 二级标题。
元数据块不只认文档开头。parse_block 对每一个缩进为 0 的块开头都试 scan_metadata_block,specs/metadata_blocks.txt 也写明它不能打断段落,「But it can be after a blank line」:
=== meta-middle: "para\n\n---\ntitle: a\n---\n"
0..5 Start(Paragraph)
0..4 Text(Borrowed("para"))
0..5 End(Paragraph)
6..22 Start(MetadataBlock(YamlStyle))
10..19 Text(Borrowed("title: a\n"))
6..22 End(MetadataBlock(YamlStyle))
html: "<p>para</p>\n"
文档中间一段被 --- 包住的文字,开了这个选项就从 HTML 里消失了。只想要文件头的 front matter,得在事件流上自己判断它是不是第一个事件。
动脑筋答案
=== setext-lazy: "> foo\n---\n"
0..6 Start(BlockQuote(None))
2..6 Start(Paragraph)
2..5 Text(Borrowed("foo"))
2..6 End(Paragraph)
0..6 End(BlockQuote(None))
6..10 Rule
html: "<blockquote>\n<p>foo</p>\n</blockquote>\n<hr />\n"
引用块里的一个段落,后面是引用块外的一条分隔线。--- 没有 >,scan_containers 匹配 0 个容器,current_container 为假,Setext 的检查直接跳过;它能不能当惰性续行?不能,scan_hrule 成立,分隔线可以打断段落。于是段落结束,下一次 parse_block 开头弹出 BlockQuote,--- 成了顶层的 Rule。规范 4.3 节有同一个例子(「The setext heading underline cannot be a lazy continuation line in a list item or block quote」),输出相同。
小结
parse_paragraph每读一行先看下一行:Setext 下划线(要求匹配全部容器)优先于打断检查,打断检查优先于续行;scan_paragraph_interrupt_no_table是规范各节「能否打断段落」的汇总。- Setext 标题在下一行才确定。段落节点就地改成
Heading,因为第一遍结束前没有发出任何事件,而且两者的孩子一样。 - ATX 标题单行处理,去掉结尾
#时可能留下空Text事件(# *a* #,0.13.4、0.13.5、main 都如此)。 - 缩进代码块把中间的空行收进来、结尾的空行断开;围栏代码块没有惰性续行,没有结尾围栏就到容器结束。
- HTML 块七类:1–5 由
get_html_end_tag判定、以「某行含结束串」结束,6 查 62 项的HTML_TAGS,6、7 以空行结束,只有 1–6 能打断段落。 - 0.13.5 与规范有两处出入:结尾围栏后的制表符、第 1 类结束标签的大小写和种类。两处在 main 上已修(PR #1150、#1163),尚未发布。
- 元数据块是扩展,要向后找到结尾才成立;它不限于文档开头,空行之后的
---块都算。