Rust in Everything
基于 pulldown-cmark 0.13.5

ENABLE_GFM 只开了提示块

主线位置:块主线的第一遍,引用块那一支。ENABLE_GFM 在解析代码里只被读一次,就在 > 之后决定要不要试 [!NOTE]。提示块怎么认、事件长什么样,见容器块。

flowchart TB
  gfm["ENABLE_GFM"] --> bq["引用块提示"]
  tables["ENABLE_TABLES"] --> t["表格"]
  strike["ENABLE_STRIKETHROUGH"] --> s["删除线"]
  tasks["ENABLE_TASKLISTS"] --> k["任务框"]
  foot["ENABLE_FOOTNOTES"] --> f["脚注"]
  none["没有对应的位"] --> al["裸网址自动链接"]
  none --> tf["tagfilter"]

实验在 exp-pulldown-cmark/pita-lab,bin p02_gfm(release),输出存在 out-p02.txt;对照用的 comrak 0.59 在同一个 crate 里。M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5。

现象

想要「GitHub 那样的 Markdown」,看到 Options 里有一个 ENABLE_GFM,只开了它:

> [!NOTE]
> 先读文档。

| a | b |
| - | - |
| 1 | 2 |

~~删除线~~

- [ ] 任务

文档在 www.example.com/docs 和 https://example.com/a?x=1。

<script>alert(1)</script>
<blockquote class="markdown-alert-note">
<p>先读文档。</p>
</blockquote>
<p>| a | b |
| - | - |
| 1 | 2 |</p>
<p>~~删除线~~</p>
<ul>
<li>[ ] 任务</li>
</ul>
<p>文档在 www.example.com/docs 和 https://example.com/a?x=1。</p>
<script>alert(1)</script>

只有提示块生效了。表格、删除线、任务框都是文字;两个网址没有变成链接;<script> 原样输出,GFM 规范的 tagfilter 会把它开头的 < 换成 &lt;。

原因

ENABLE_GFM 的文档注释写得很清楚,只是名字容易让人想多:

        /// Misc GitHub Flavored Markdown features not supported in CommonMark.
        /// The following features are currently behind this tag:
        /// - Blockquote tags ([!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]).
        const ENABLE_GFM = 1 << 11;

git grep ENABLE_GFM v0.13.5 -- pulldown-cmark/src 在解析代码里只有一处:parse_block 认出 > 之后(第 237 行),ENABLE_GFM 打开才调 scan_blockquote_tag。另外两处是 lib.rs 里 Tag::BlockQuote 的文档和命令行工具的选项。

GFM 规范里的其余扩展,pulldown-cmark 的情况是:

  • 表格、删除线、任务框、脚注:各有一位,和 ENABLE_GFM 无关。
  • 扩展自动链接(裸写的 www.、https://):没有实现,哪一位都不管用。CommonMark 本身只认尖括号里的 <https://...>。
  • tagfilter(转义 <script>、<iframe> 等九种标签):没有实现,原始 HTML 一律原样输出,见push_html 不净化。

git diff v0.13.5 origin/main 里 ENABLE_GFM 的文档和这一处判断都没变,main 新加的位(容器扩展、<mark>、CJK 友好强调)也都是独立的位。

📝NOTE
冷知识:提示块先是默认就有的,后来才挂到 ENABLE_GFM 下面

提示块来自 PR #869(分支名 blockquote_tags),首次发布于 0.11.0。PR 的第一个功能提交 9f94f7e「feat: tagged blockquotes, 2 consecutive ones cannot be tagged currently」里,scan_blockquote_marker 总是试提示块,没有开关。六天后同一个 PR 里的 147c1bb「feat: add a GFM options flag for misc GFM features」新建了 ENABLE_GFM = 1 << 11,把调用改成 scan_blockquote_marker(self.options.contains(Options::ENABLE_GFM)),并在测试入口 tests/lib.rs 里默认打开它。文档注释从那时起就是「Misc GitHub Flavored Markdown features」加一行「currently behind this tag」的清单,清单到 0.13.5 仍然只有提示块这一项。「默认就有」只存在于 PR 的中间提交里,没有发布过。

修法

先把 GFM 里 pulldown-cmark 有的几样拼成一个常量:

const GFM: Options = Options::ENABLE_GFM
  .union(Options::ENABLE_TABLES)
  .union(Options::ENABLE_STRIKETHROUGH)
  .union(Options::ENABLE_TASKLISTS)
  .union(Options::ENABLE_FOOTNOTES);

表格、删除线、任务框都有了,自动链接还是没有:

<p>文档在 www.example.com/docs 和 https://example.com/a?x=1。</p>

能控制源文时,最简单的是把网址写进尖括号:<https://example.com/a?x=1>。控制不了(用户输入、迁移来的文档),就在事件流上补。下面是一个简化版:只在链接、图片、代码块之外的 Text 里找,网址到空白、< 或第一个非 ASCII 字符为止,去掉末尾的英文标点;不校验域名,也不配括号。

fn split_autolinks<'a>(text: &str, out: &mut Vec<Event<'a>>) {
  let mut rest = text;
  loop {
    let found = ["https://", "http://", "www."]
      .iter()
      .filter_map(|p| rest.find(p))
      .filter(|&i| {
        i == 0 || !rest[..i].chars().last().is_some_and(|c| c.is_ascii_alphanumeric())
      })
      .min();
    let Some(start) = found else { break };
    let tail = &rest[start..];
    let mut end = tail
      .find(|c: char| c.is_whitespace() || c == '<' || !c.is_ascii())
      .unwrap_or(tail.len());
    while end > 0 && tail[..end].ends_with(['.', ',', ':', ';', '!', '?', '*', '_', '~']) {
      end -= 1;
    }
    let url = &tail[..end];
    if start > 0 {
      out.push(Event::Text(CowStr::from(rest[..start].to_string())));
    }
    let href = if url.starts_with("www.") { format!("http://{url}") } else { url.to_string() };
    out.push(Event::Start(Tag::Link {
      link_type: LinkType::Autolink,
      dest_url: href.into(),
      title: "".into(),
      id: "".into(),
    }));
    out.push(Event::Text(url.to_string().into()));
    out.push(Event::End(TagEnd::Link));
    rest = &tail[end..];
  }
  if !rest.is_empty() {
    out.push(Event::Text(rest.to_string().into()));
  }
}

fn autolink<'a>(events: impl Iterator<Item = Event<'a>>) -> Vec<Event<'a>> {
  let mut out = Vec::new();
  let mut skip_depth = 0usize; // inside link, image or code block
  for ev in events {
    match &ev {
      Event::Start(Tag::Link { .. } | Tag::Image { .. } | Tag::CodeBlock(_)) => skip_depth += 1,
      Event::End(TagEnd::Link | TagEnd::Image | TagEnd::CodeBlock) => skip_depth -= 1,
      Event::Text(t) if skip_depth == 0 => {
        split_autolinks(t, &mut out);
        continue;
      }
      _ => {}
    }
    out.push(ev);
  }
  out
}

调用是 autolink(TextMergeStream::new(Parser::new_ext(doc, GFM))):先合并文字,网址里的 &amp; 之类才不会把它切断(一段文字被切成几个 Text)。行内代码是 Event::Code,不是 Text,不会被碰到。输出:

<p>文档在 <a href="http://www.example.com/docs">www.example.com/docs</a> 和 <a href="https://example.com/a?x=1">https://example.com/a?x=1</a>。</p>

「遇到非 ASCII 字符就停」是为中文正文定的。同一份文档交给 comrak 0.59,打开它的 autolink 等 GFM 扩展:

<p>文档在 <a href="http://www.example.com/docs">www.example.com/docs</a> 和 <a href="https://example.com/a?x=1%E3%80%82">https://example.com/a?x=1。</a></p>

comrak 按 GFM 规范把网址一直取到空白或 <,紧跟的全角句号进了地址。中文里网址后面通常直接跟标点,自己写的规则可以按内容调整,这是在事件流上做的好处。tagfilter 不要自己仿,原始 HTML 的处理统一见 push_html 不净化。

怎么检查

  • 在代码里搜 ENABLE_GFM。如果它是唯一打开的位,或者旁边有「开启 GFM」之类的注释,多半是这个坑。
  • 测试输入里放一张表格、一个 ~~x~~、一个 - [ ] x、一个裸写的网址,断言输出里有 <table>、<del>、checkbox 和 <a href。
  • 从 GitHub 迁移过来的内容,先搜一遍裸写的 http 和 www.,数一下有多少;数量少就改成尖括号,省掉一层变换。

小结

  • ENABLE_GFM 在 0.13.5(和 main)里只管引用块的五种提示,表格、删除线、任务框、脚注各开各的位。
  • GFM 的扩展自动链接和 tagfilter 没有实现,哪一位都打不开。
  • 修法:把需要的位拼成一个常量;自动链接要么写成 <https://...>,要么在合并后的 Text 上自己切,规则可以照顾中文标点。

💬 关联讨论

发起讨论
加载中...