Rust in Everything
基于 pulldown-cmark 0.13.5

标题没有 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()));
📝NOTE
冷知识:{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 都这样。

💬 关联讨论

发起讨论
加载中...