Rust in Everything
基于 pulldown-cmark 0.13.5

叶子块

主线位置:块主线里 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(是否匹配了全部容器),再按这个顺序判断:

  1. 缩进不到 4 列、current_container 为真,而且是 Setext 下划线:段落变成标题,结束。
  2. 缩进不到 4 列,而且能打断段落:结束。
  3. 空行:结束。
  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)。

❗IMPORTANT
动脑筋:> 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 个是引用块里 &amp; 解出来的 &)。

与规范的出入:结尾围栏后面的制表符。 规范说结尾围栏「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)。

📝NOTE
冷知识:<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),尚未发布。
  • 元数据块是扩展,要向后找到结尾才成立;它不限于文档开头,空行之后的 --- 块都算。

💬 关联讨论

发起讨论
加载中...