yggdrasil/api/markdown/
heading.rs1fn escape_heading_text(s: &str) -> String {
9 crate::utils::html::escape_html(s)
10}
11
12pub(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
24pub(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
36pub(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 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 for _ in prev_level..level {
57 html.push_str("<ul>");
58 stack.push(level);
59 }
60 } else if level < prev_level {
61 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 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 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
97pub(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 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
137pub(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 assert_eq!(slugify_heading("你好世界"), "ni-hao-shi-jie");
173 }
174
175 #[test]
176 fn slugify_heading_mixed_chinese_ascii() {
177 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 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 let headings = vec![(
238 2u8,
239 "\" onmouseover=\"alert(1)".to_string(),
240 "heading".to_string(),
241 )];
242 let html = generate_toc_html(&headings);
243 assert!(
245 html.contains("aria-label=\"" onmouseover="alert(1)\""),
246 "aria-label 应转义内部双引号,got: {html}"
247 );
248 let attr_injection = "\" onmouseover=\"";
251 let injected = html.matches(attr_injection).count();
252 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 & B\""),
266 "& 应在属性中转义,got: {html}"
267 );
268 }
269
270 #[test]
271 fn generate_toc_html_escapes_less_than_in_attr() {
272 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 < b\""),
277 "< 应在属性中转义,got: {html}"
278 );
279 assert!(!html.contains("a < b"));
280 }
281}