标题没有 id,{#id} 原样显示
主线位置:块主线建出
Heading节点,输出主线把它写成<hN>。id只有一个来源:开了ENABLE_HEADING_ATTRIBUTES时,第一遍从标题末尾的{...}里解析出来。写出器不会自己算。
flowchart TB
h["## 安装 {#install}"] --> opt{"开了属性选项"}
opt -->|没开| txt["花括号进文字"]
opt -->|开了| id["id 是 install"]
plain["## 用法"] --> none["id 是 None"]
none --> w["h2 不带 id"]
w --> buf["缓冲标题 自己算"]
实验在 exp-pulldown-cmark/pita-lab,bin p05_heading、p05_attrs(release),输出存在 out-p05.txt、out-p05-attrs.txt。M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5。
现象
想做页内目录,标题要有 id:
## 安装 {#install}
## 用法
## 用法
## `Parser` 与 *事件* ##
Options::empty():
<h2>安装 {#install}</h2>
<h2>用法</h2>
<h2>用法</h2>
<h2><code>Parser</code> 与 <em>事件</em></h2>
打开 ENABLE_HEADING_ATTRIBUTES:
<h2 id="install">安装</h2>
<h2>用法</h2>
<h2>用法</h2>
<h2><code>Parser</code> 与 <em>事件</em></h2>
两种情况下都没有自动生成的 id;不开选项时,{#install} 还成了标题文字的一部分。
最后一个标题的事件流里多了一样东西:
0..28 Start(Heading { level: H2, id: None, classes: [], attrs: [] })
3..11 Code(Borrowed("Parser"))
11..16 Text(Borrowed(" 与 "))
16..24 Start(Emphasis)
17..23 Text(Borrowed("事件"))
16..24 End(Emphasis)
24..24 Text(Borrowed(""))
0..28 End(Heading(H2))
24..24 是一个长度为 0 的 Text。
原因
写出器只写给定的 id。 start_tag 的 Tag::Heading 分支:
write!(&mut self.writer, "{}", level)?;
if let Some(id) = id {
self.write(" id=\"")?;
escape_html(&mut self.writer, &id)?;
self.write("\"")?;
}
id 是 Tag::Heading 的字段,None 就不写。HtmlWriter 没有任何选项,不生成锚点(HTML 写出器)。
{#id} 是一个扩展。 parse_atx_heading 只在 ENABLE_HEADING_ATTRIBUTES 打开时调 extract_and_parse_heading_attribute_block,把末尾的 {...} 从内容里截掉、解析成 id、classes、attrs。没开时整行都交给 parse_line,花括号是普通文字。
空 Text 来自去掉结尾 # 的方式。 parse_atx_heading 先解析整行,再把最后一个节点的 end 往回挪,去掉空白和结尾的 #。只有整个标题都空了才删节点;最后一个节点前面是行内元素时,它只装着 ##,被截成空串却留在树里。机制见叶子块,## foo ## 这种结尾是纯文字的不会出现。git diff v0.13.5 origin/main 里 parse_atx_heading 没有变化。
修法
显式 id。 打开 ENABLE_HEADING_ATTRIBUTES。代价是标题末尾的花括号都会被当成属性块:
<h2 name="">模板语法</h2>
<h2 1,="">集合</h2>
<h2>空</h2>
三行输入是 ## 模板语法 {name}、## 集合 {1, 2}、## 空 {}。花括号从标题文字里消失,里面的词成了属性;{1, 2} 写出了一个名叫 1, 的属性,2 不见了。标题里本来就要写花括号的,要转义的是右花括号:## 集合 {1, 2\} 输出 <h2>集合 {1, 2}</h2>;只转义左花括号的 ## 集合 \{1, 2} 仍是属性块,输出 <h2 1,="">集合 \</h2>,多了一个反斜杠。specs/heading_attrs.txt 写明了这一点:「escaping only opening braces does not prevent the braces from starting attribute blocks」,属性块在行内转义之前就被截掉了。渲染用户输入时,这一位还能写出 onmouseover 这样的属性,见 push_html 原样输出 <script>。
自动 id。 Start(Heading) 在文字之前到达,只能把整个标题缓冲到 End(Heading),拼出文字再填 id。缓冲的写法在改写事件流里有完整的一段,这里只补它没做的两件事:同名标题去重,显式写过的 id 也要登记,免得自动生成的撞上它。
fn slugify(s: &str) -> String {
s.chars()
.flat_map(char::to_lowercase)
.filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | ' '))
.map(|c| if c == ' ' { '-' } else { c })
.collect()
}
// inside the End(Heading) branch, after collecting `text` from Text and Code
if let Event::Start(Tag::Heading { id, .. }) = &mut h[0] {
if let Some(given) = id {
seen.insert(given.to_string(), 1);
} else {
let base = slugify(text.trim());
let n = seen.entry(base.clone()).or_insert(0);
let slug = if *n == 0 { base } else { format!("{base}-{n}") };
*n += 1;
*id = Some(CowStr::from(slug));
}
}
seen: HashMap<String, usize> 贯穿整份文档。打开 ENABLE_HEADING_ATTRIBUTES 再过一遍:
<h2 id="install">安装</h2>
<h2 id="用法">用法</h2>
<h2 id="用法-1">用法</h2>
<h2 id="parser-与-事件"><code>Parser</code> 与 <em>事件</em></h2>
汉字算字母,原样进 id,escape_html 只转义 &、<、>、"、'。拼文字时把所有 Text 和 Code 连起来,空 Text 贡献一个空串,结果不受影响。本站用的是同一类规则(github-slugger),见本站的 Markdown 渲染。
空 Text。 拼接不受影响,但「每个 Text 生成一个节点」的渲染器会多出一个空节点,假定 Text 非空的代码(取首字符、判断结尾标点)会出错。在管线前面滤掉:
let events = parser.filter(|ev| !matches!(ev, Event::Text(t) if t.is_empty()));
{key=value} 以前是被忽略的最早的标题属性只认 #id 和 .class,Tag::Heading(level, id, classes) 是一个三元组;specs/heading_attrs.txt 有一节「Unknown attributes」,写着「Unknown attributes are simply ignored.」b63c81f「feat!: allow custom headings attributes with optional value」(「Fixes: #634」)把这一节改成「Custom attributes」:不带前缀的词收进新字段 attrs,Tag::Heading 改成今天这样的具名字段,html.rs 把它们逐个写成属性。提交说明解释了感叹号:「This feature is a breaking change because it modifies the Heading variant of the Tag enum with named fields and the new attrs field.」它随 PR #638 首次发布于 0.10.0。从那以后,{name} 不再被丢掉,而是写出一个 name="";上面 {1, 2} 的输出就是这么来的。
怎么检查
- 测试输入放两个同名标题和一个
{#id},断言输出里id互不相同,且不含{#。 - 在测试里对交给渲染器的事件流断言没有空
Text:输入用## `x` ##或# *a* #这种结尾。 - 页内目录和正文锚点用同一个函数生成,否则点击目录跳不过去。
小结
push_html不生成标题id;{#id}只在开了ENABLE_HEADING_ATTRIBUTES时才被解析,否则留在标题文字里。- 打开它以后,标题末尾的任何花括号都会被当成属性块,里面的词成了属性。
- 自动锚点要缓冲到
End(Heading)再填id,并在全文范围内去重、登记显式id。 - ATX 标题以行内元素结尾、后面跟结尾的
#时,事件流里有一个空的Text,0.13.5 和 main 都这样。