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 会把它开头的 < 换成 <。
原因
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 友好强调)也都是独立的位。
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))):先合并文字,网址里的 & 之类才不会把它切断(一段文字被切成几个 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上自己切,规则可以照顾中文标点。