Skip to main content

yggdrasil/api/markdown/
heading.rs

1//! 标题:层级映射、锚点 slug 与目录(TOC)HTML。
2
3/// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `<a>` 正文。
4///
5/// 复用 `utils::html::escape_html`(转义 `& < > " '`),避免在仓库内
6/// 维护第二份转义实现。原先用 `clean_html` 处理属性上下文会漏掉 `"`,标题形如
7/// `" onmouseover="alert(1)` 会越出属性边界。
8fn escape_heading_text(s: &str) -> String {
9    crate::utils::html::escape_html(s)
10}
11
12/// 根据 Markdown 标题层级返回对应的 HTML 标签名。
13pub(super) fn heading_tag(level: pulldown_cmark::HeadingLevel) -> &'static str {
14    match level {
15        pulldown_cmark::HeadingLevel::H1 => "h1",
16        pulldown_cmark::HeadingLevel::H2 => "h2",
17        pulldown_cmark::HeadingLevel::H3 => "h3",
18        pulldown_cmark::HeadingLevel::H4 => "h4",
19        pulldown_cmark::HeadingLevel::H5 => "h5",
20        pulldown_cmark::HeadingLevel::H6 => "h6",
21    }
22}
23
24/// Markdown 标题层级转数字(H1 → 1 … H6 → 6),供 TOC 层级比较。
25pub(super) fn heading_level(level: pulldown_cmark::HeadingLevel) -> u8 {
26    match level {
27        pulldown_cmark::HeadingLevel::H1 => 1,
28        pulldown_cmark::HeadingLevel::H2 => 2,
29        pulldown_cmark::HeadingLevel::H3 => 3,
30        pulldown_cmark::HeadingLevel::H4 => 4,
31        pulldown_cmark::HeadingLevel::H5 => 5,
32        pulldown_cmark::HeadingLevel::H6 => 6,
33    }
34}
35
36/// 根据标题层级生成嵌套目录 HTML。
37pub(super) fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
38    use std::fmt::Write as _;
39
40    if headings.is_empty() {
41        return String::new();
42    }
43
44    // TOC 大小按标题数估算(每个 li+a 约 64 字节起),避免 String::new 的多次 realloc。
45    let mut html = String::with_capacity(headings.len() * 64 + 16);
46    html.push_str("<ul>");
47    let mut stack: Vec<u8> = vec![headings[0].0];
48
49    for (i, (level, text, id)) in headings.iter().enumerate() {
50        let level = *level;
51
52        if i > 0 {
53            let prev_level = headings[i - 1].0;
54            if level > prev_level {
55                // 标题层级升高:打开新的嵌套列表。
56                for _ in prev_level..level {
57                    html.push_str("<ul>");
58                    stack.push(level);
59                }
60            } else if level < prev_level {
61                // 标题层级降低:关闭多余的嵌套列表。
62                while let Some(top) = stack.last() {
63                    if *top > level {
64                        html.push_str("</li></ul>");
65                        stack.pop();
66                    } else {
67                        break;
68                    }
69                }
70                html.push_str("</li>");
71            } else {
72                html.push_str("</li>");
73            }
74        }
75
76        // 标题 text 是 pulldown-cmark 收集的纯文本(Text/Code 字面字符),不是 HTML 片段,
77        // 因此正文与属性两处都走 escape_heading_text(转义 & < > " ')。原先用 clean_html
78        // 处理属性上下文会漏掉 `"`,标题中的双引号会越出 aria-label 边界。
79        let escaped_text = escape_heading_text(text);
80        let _ = write!(
81            html,
82            "<li><a href=\"#{id}\" aria-label=\"{escaped_text}\">{escaped_text}</a>"
83        );
84    }
85
86    // Close remaining lists
87    // 闭合所有残留的嵌套列表。
88    while stack.len() > 1 {
89        html.push_str("</li></ul>");
90        stack.pop();
91    }
92    html.push_str("</li></ul>");
93
94    html
95}
96
97/// 将标题文本转换为可用于锚点的 slug。
98///
99/// 汉字转无声调拼音(每字成词,用 `-` 分隔),与 [`crate::api::slug::slugify`]
100/// 保持一致;ASCII 字母数字保留,其余字符作为词分隔。结果为空时回退 `heading`。
101///
102/// 与 `slugify` 有意不合并:这里保留非 ASCII 字母数字(如 `é`、假名)并做 Unicode
103/// 小写、`_` 视为分隔符、不截断;改动任一规则都会改变已发布文章的标题锚点。
104pub(super) fn slugify_heading(text: &str) -> String {
105    use pinyin::ToPinyin;
106
107    let mut slug = String::new();
108    let mut prev_dash = true;
109
110    for c in text.to_lowercase().chars() {
111        // 汉字优先转拼音;非汉字(含 ascii)返回 None。
112        // 每个汉字成词,拼音后补 `-` 与后续内容分隔(连续汉字也会被分开)。
113        if let Some(py) = c.to_pinyin() {
114            slug.push_str(py.plain());
115            slug.push('-');
116            prev_dash = true;
117        } else if c.is_alphanumeric() {
118            slug.push(c);
119            prev_dash = false;
120        } else if !prev_dash {
121            slug.push('-');
122            prev_dash = true;
123        }
124    }
125
126    if slug.ends_with('-') {
127        slug.pop();
128    }
129
130    if slug.is_empty() {
131        slug.push_str("heading");
132    }
133
134    slug
135}
136
137/// 在同一篇文档内为标题分配唯一锚点:首次出现保留 `slugify_heading` 的结果,
138/// 重复时依次追加 `-1`、`-2`…(与 GitHub 一致);候选已被其他标题占用
139/// (如另有标题恰为 `Foo 1`)时继续递增,保证 TOC 链接一一对应。
140pub(super) fn unique_heading_slug(
141    text: &str,
142    used: &mut std::collections::HashSet<String>,
143) -> String {
144    let base = slugify_heading(text);
145    if !used.contains(&base) {
146        used.insert(base.clone());
147        return base;
148    }
149    (1..)
150        .map(|n| format!("{base}-{n}"))
151        .find(|candidate| used.insert(candidate.clone()))
152        .expect("无限序列总能找到未占用的锚点")
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn slugify_heading_simple() {
161        assert_eq!(slugify_heading("Hello World"), "hello-world");
162    }
163
164    #[test]
165    fn slugify_heading_special_chars() {
166        assert_eq!(slugify_heading("What's new? (2024)"), "what-s-new-2024");
167    }
168
169    #[test]
170    fn slugify_heading_chinese() {
171        // 汉字逐字转拼音,用 `-` 分隔。
172        assert_eq!(slugify_heading("你好世界"), "ni-hao-shi-jie");
173    }
174
175    #[test]
176    fn slugify_heading_mixed_chinese_ascii() {
177        // Rust 入门指南 → rust + ru-men-zhi-nan,词之间用 `-` 分隔。
178        assert_eq!(slugify_heading("Rust 入门指南"), "rust-ru-men-zhi-nan");
179    }
180
181    #[test]
182    fn slugify_heading_collapses_dashes() {
183        assert_eq!(slugify_heading("a--b"), "a-b");
184    }
185
186    #[test]
187    fn slugify_heading_strips_trailing_dash() {
188        assert_eq!(slugify_heading("hello!"), "hello");
189    }
190
191    #[test]
192    fn slugify_heading_empty_returns_heading() {
193        assert_eq!(slugify_heading(""), "heading");
194    }
195
196    #[test]
197    fn unique_heading_slug_suffixes_collisions() {
198        let mut used = std::collections::HashSet::new();
199        let ids: Vec<String> = ["Intro", "Intro", "Intro!", "Intro 1", "Intro"]
200            .iter()
201            .map(|t| unique_heading_slug(t, &mut used))
202            .collect();
203        // 重复标题、仅标点不同的标题、恰好撞上后缀的标题都得到不同锚点。
204        assert_eq!(ids, ["intro", "intro-1", "intro-2", "intro-1-1", "intro-3"]);
205    }
206
207    #[test]
208    fn generate_toc_html_empty() {
209        assert_eq!(generate_toc_html(&[]), "");
210    }
211
212    #[test]
213    fn generate_toc_html_single_heading() {
214        let headings = vec![(2u8, "Title".to_string(), "title".to_string())];
215        let html = generate_toc_html(&headings);
216        assert!(html.contains("href=\"#title\""));
217        assert!(html.contains("<ul>"));
218        assert!(html.contains("</ul>"));
219    }
220
221    #[test]
222    fn generate_toc_html_nested() {
223        let headings = vec![
224            (2u8, "Chapter".to_string(), "chapter".to_string()),
225            (3u8, "Section".to_string(), "section".to_string()),
226        ];
227        let html = generate_toc_html(&headings);
228        assert!(html.contains("href=\"#chapter\""));
229        assert!(html.contains("href=\"#section\""));
230        let ul_count = html.matches("<ul>").count();
231        assert_eq!(ul_count, 2);
232    }
233
234    #[test]
235    fn generate_toc_html_escapes_quote_in_attr() {
236        // 标题中的双引号不得越出 aria-label 属性边界。
237        let headings = vec![(
238            2u8,
239            "\" onmouseover=\"alert(1)".to_string(),
240            "heading".to_string(),
241        )];
242        let html = generate_toc_html(&headings);
243        // aria-label 中的双引号被转义为 &quot;,无法越出属性边界注入新属性。
244        assert!(
245            html.contains("aria-label=\"&quot; onmouseover=&quot;alert(1)\""),
246            "aria-label 应转义内部双引号,got: {html}"
247        );
248        // 关键:不得出现「未被引号包裹、可被解析为真实属性」的 onmouseover= 片段。
249        // 正文中作为纯文本出现 "onmouseover" 字符串是安全的(无 < 或属性结构)。
250        let attr_injection = "\" onmouseover=\"";
251        let injected = html.matches(attr_injection).count();
252        // 原始输入里有 1 个裸双引号起头;转义后该模式不应再作为属性边界出现。
253        // 注意 aria-label 内部的双引号已变成 &quot;,因此裸的 `" onmouseover="` 不应存在。
254        assert_eq!(
255            injected, 0,
256            "不应存在未转义的属性边界 `\" onmouseover=\"`,got: {html}"
257        );
258    }
259
260    #[test]
261    fn generate_toc_html_escapes_ampersand_in_attr() {
262        let headings = vec![(2u8, "A & B".to_string(), "heading".to_string())];
263        let html = generate_toc_html(&headings);
264        assert!(
265            html.contains("aria-label=\"A &amp; B\""),
266            "& 应在属性中转义,got: {html}"
267        );
268    }
269
270    #[test]
271    fn generate_toc_html_escapes_less_than_in_attr() {
272        // `<` 在属性与正文中都应被转义,避免被误解析为标签起始。
273        let headings = vec![(2u8, "a < b".to_string(), "heading".to_string())];
274        let html = generate_toc_html(&headings);
275        assert!(
276            html.contains("aria-label=\"a &lt; b\""),
277            "< 应在属性中转义,got: {html}"
278        );
279        assert!(!html.contains("a < b"));
280    }
281}