Rust in Everything
基于 pulldown-cmark 0.13.5

文档中间被分隔线包住的一段不见了

主线位置:块主线的 parse_block 在每个块开头都试一次元数据块(叶子块的「元数据块」一节);输出主线的 HtmlWriter 不写元数据块里的文字(HTML 写出器)。两处单看都合理,合起来就是一段正文悄悄消失。

flowchart TB
  blk["块开头 缩进为 0"] --> try["scan_metadata_block"]
  try --> look["向后找结尾的 ---"]
  look -->|找到| meta["MetadataBlock"]
  look -->|找不到| other["分隔线或标题"]
  meta --> hw["HtmlWriter 不写 Text"]
  hw --> gone["这段正文消失"]
  meta --> pos{"范围从 0 开始"}
  pos -->|否| warn["不是 front matter"]

实验 crate:exp-pulldown-cmark/pitb-probe 的 metadata(0.13.5),pitb-probe134 配 0.13.4,输出逐字节相同。M2 Max、macOS 27、rustc 1.99.0,输出在 out/metadata.txt。

现象

为了读文件头的 YAML,开了 ENABLE_YAML_STYLE_METADATA_BLOCKS。文档正文里用 --- 当分隔线,其中一条紧贴着下一节的第一行:

---
title: 笔记
---

第一节。

---
第二节,前面那条线想当分隔线。

---

第三节。

事件流和 HTML:

0..21 Start(MetadataBlock(YamlStyle))
  4..18 Text(Borrowed("title: 笔记\n"))
0..21 End(MetadataBlock(YamlStyle))
23..36 Start(Paragraph)
  23..35 Text(Borrowed("第一节。"))
23..36 End(Paragraph)
37..91 Start(MetadataBlock(YamlStyle))
  41..88 Text(Borrowed("第二节,前面那条线想当分隔线。\n\n"))
37..91 End(MetadataBlock(YamlStyle))
93..106 Start(Paragraph)
  93..105 Text(Borrowed("第三节。"))
93..106 End(Paragraph)
--- html
<p>第一节。</p>
<p>第三节。</p>

第二节整段没了,两条分隔线也没了。第 7 行的 --- 开了第二个元数据块,一直延续到第 10 行的 ---,中间的空行也包了进去。不开这个选项时,同一份文档是这样:

<hr />
<h2>title: 笔记</h2>
<p>第一节。</p>
<hr />
<p>第二节,前面那条线想当分隔线。</p>
<hr />
<p>第三节。</p>

这时文件头又成了一条分隔线加一个 Setext 二级标题。两种结果都不是想要的。

原因

元数据块在每个块开头都会试。 FirstPass::parse_block 扫完容器标记后,只要缩进为 0 就调 scan_metadata_block,排在 HTML 块和分隔线之前:

        // metadata blocks cannot be indented
        if indent == 0 {
            if let Some((_n, metadata_block_ch)) = scan_metadata_block(
                &bytes[ix..],
                self.options
                    .contains(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS),
                self.options
                    .contains(Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS),
            ) {
                self.finish_list(start_ix);
                return self.parse_metadata_block(ix, metadata_block_ch);
            }
        }

这里没有「是不是文档第一个块」的条件。scan_metadata_block 的规则是:开头一行只有 ---(或 +++)加空白;下一行不能是空行,也不能直接是结尾;然后向后找,找到 --- 或 ... 的结尾行就成立,中间可以有空行。规则写在 specs/metadata_blocks.txt 里,其中一条例子就是段落、空行之后的元数据块,说明是「But it can be after a blank line」,输出只有 <p>My paragraph here.</p>。这条规则是 PR #641(4cef04c「feat!: metadata blocks」,首次发布在 0.10.0)加入元数据块时就写进去的,main 上 scanners.rs 的这个函数和这份 spec 都没有改。

元数据块不能打断段落,所以 --- 前面要么是空行,要么是另一个块的结尾。上面第 7 行前面是空行,下一行是文字,就满足了全部条件。

写出器丢掉里面的文字。 start_tag 遇到 Tag::MetadataBlock 把 in_non_writing_block 置真,End 时清掉;中间的 Text 不写(HTML 写出器)。写出器不区分文件头和文档中间。

scan_metadata_block 向后找结尾时看的是原始字节,所以引用块里的 > --- 不会被当成元数据块:结尾那行是 > ---,不是 ---。pitb-probe 的 in-quote 一例里,> --- 之间的文字照常成了分隔线和标题。

修法

只要文件头:自己切出来,正文不开这个选项。 开着选项解析一次,只看第一个事件:是从字节 0 开始的元数据块,就记下它的内容和结束位置;然后用不带元数据选项的解析器解析剩下的部分。

fn split_front_matter(src: &str) -> (Option<&str>, &str) {
  let opts = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
  let mut iter = Parser::new_ext(src, opts).into_offset_iter();
  match iter.next() {
    Some((Event::Start(Tag::MetadataBlock(_)), r)) if r.start == 0 => {
      let text = match iter.next() {
        Some((Event::Text(_), t)) => Some(&src[t]),
        _ => None,
      };
      (text, &src[r.end..])
    }
    _ => (None, src),
  }
}

Parser 是惰性的,next() 两次就停,后面的行内解析不会发生;但第一遍仍然会把整份文档的块结构建完(为什么是 pull parser)。对同一份文档:

--- split_front_matter: meta Some("title: 笔记\n")
<p>第一节。</p>
<hr />
<p>第二节,前面那条线想当分隔线。</p>
<hr />
<p>第三节。</p>

文件头拿到了,正文里的分隔线和第二节都回来了。正文的偏移量从 r.end 起算,要映射回原文件就加上它。

不想多解析一次,也可以按自己的规则(比如「第一行正好是 ---,到下一个单独的 --- 为止」)手工切;那样就要自己处理 ... 结尾、空的文件头这些边界,和 pulldown-cmark 的判断可能不一致。

正文里的分隔线:前后都留空行。 第 7 行的 --- 后面空一行,下一行就不是「非空的第一行」,scan_metadata_block 立刻放弃。只靠写作习惯防不住外来的文档,所以上面的切法仍然更稳。

怎么检查

遍历事件,凡是 Start(MetadataBlock(_)) 的范围不从 0 开始,就是文档中间被吞掉的一段:

for (ev, r) in Parser::new_ext(src, opts).into_offset_iter() {
  if let Event::Start(Tag::MetadataBlock(kind)) = ev {
    let line = src[..r.start].matches('\n').count() + 1;
    let note = if r.start == 0 { "" } else { "  <- not at the start" };
    println!("check: {kind:?} metadata block at line {line}, bytes {r:?}{note}");
  }
}
check: YamlStyle metadata block at line 1, bytes 0..21
check: YamlStyle metadata block at line 7, bytes 37..91  <- not at the start

+++ 风格(ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS)同理。对一批已有的文档跑一遍这个检查,就知道开这个选项会让哪些页面丢内容。

小结

  • ENABLE_YAML_STYLE_METADATA_BLOCKS 不限于文件开头:任何缩进为 0 的块开头、前面不是段落、下一行非空、后面能找到结尾 --- 或 ... 的 ---,都开一个元数据块,中间的空行也包进去。
  • push_html 不写元数据块里的文字,于是正文里被 --- 包住的一段连同分隔线一起消失,没有提示。这条规则从 0.10.0(PR #641)起就是这样,main 上没变。
  • 修法:只解析第一个事件、确认元数据块从字节 0 开始,切出文件头后用不带元数据选项的解析器解析正文。
  • 检查:找范围不从 0 开始的 Start(MetadataBlock)。

💬 关联讨论

发起讨论
加载中...