1#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct TocSections {
9 pub total: usize,
10 pub headings: Vec<String>,
11}
12
13pub fn toc_sections(toc_html: &str, limit: usize) -> TocSections {
18 let mut sections = TocSections::default();
19 let mut depth = 0usize;
20 let mut rest = toc_html;
21 while let Some(start) = rest.find('<') {
22 let Some(len) = rest[start..].find('>') else {
23 break;
24 };
25 let tag = &rest[start + 1..start + len];
26 rest = &rest[start + len + 1..];
27 match tag.split_ascii_whitespace().next().unwrap_or_default() {
28 "ul" => depth += 1,
29 "/ul" => depth = depth.saturating_sub(1),
30 "a" => {
31 let end = rest.find("</a>").unwrap_or(rest.len());
32 if depth <= 1 {
33 sections.total += 1;
34 if sections.headings.len() < limit {
35 sections.headings.push(decode_text(&rest[..end]));
36 }
37 }
38 rest = &rest[end..];
39 }
40 _ => {}
41 }
42 }
43 sections
44}
45
46fn decode_text(html: &str) -> String {
48 let mut text = String::with_capacity(html.len());
49 let mut rest = html;
50 while let Some(pos) = rest.find(['<', '&']) {
51 text.push_str(&rest[..pos]);
52 rest = &rest[pos..];
53 if rest.starts_with('<') {
54 rest = rest.find('>').map_or("", |end| &rest[end + 1..]);
55 continue;
56 }
57 match rest.find(';').filter(|&end| end <= 10) {
58 Some(end) => match decode_entity(&rest[1..end]) {
59 Some(c) => {
60 text.push(c);
61 rest = &rest[end + 1..];
62 }
63 None => {
64 text.push('&');
65 rest = &rest[1..];
66 }
67 },
68 None => {
69 text.push('&');
70 rest = &rest[1..];
71 }
72 }
73 }
74 text.push_str(rest);
75 text.split_whitespace().collect::<Vec<_>>().join(" ")
76}
77
78fn decode_entity(name: &str) -> Option<char> {
79 match name {
80 "amp" => Some('&'),
81 "lt" => Some('<'),
82 "gt" => Some('>'),
83 "quot" => Some('"'),
84 "apos" => Some('\''),
85 "nbsp" => Some(' '),
86 _ => {
87 let code = name.strip_prefix('#')?;
88 let value = match code.strip_prefix(['x', 'X']) {
89 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
90 None => code.parse().ok()?,
91 };
92 char::from_u32(value)
93 }
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 fn li(id: &str, text: &str) -> String {
102 format!("<li><a href=\"#{id}\" aria-label=\"{text}\">{text}</a>")
103 }
104
105 #[test]
106 fn empty_toc_has_no_sections() {
107 assert_eq!(toc_sections("", 6), TocSections::default());
108 }
109
110 #[test]
111 fn nested_items_are_not_sections() {
112 let html = format!(
113 "<ul>{}<ul>{}</li>{}</li></ul></li>{}</li></ul>",
114 li("a", "背景"),
115 li("a-1", "细节"),
116 li("a-2", "补充"),
117 li("b", "结论")
118 );
119 let sections = toc_sections(&html, 6);
120 assert_eq!(sections.total, 2);
121 assert_eq!(sections.headings, ["背景", "结论"]);
122 }
123
124 #[test]
125 fn lower_level_only_toc_counts_its_top_level() {
126 let html = format!("<ul>{}</li>{}</li></ul>", li("x", "甲"), li("y", "乙"));
128 assert_eq!(toc_sections(&html, 6).headings, ["甲", "乙"]);
129 }
130
131 #[test]
132 fn early_closed_root_still_counts_shallower_items() {
133 let html = format!(
135 "<ul>{}</li></ul></li>{}</li></ul>",
136 li("x", "深"),
137 li("y", "浅")
138 );
139 let sections = toc_sections(&html, 6);
140 assert_eq!(sections.total, 2);
141 assert_eq!(sections.headings, ["深", "浅"]);
142 }
143
144 #[test]
145 fn keeps_first_headings_and_counts_all() {
146 let items: String = (1..=9)
147 .map(|i| li(&format!("s{i}"), &format!("第 {i} 节")))
148 .collect();
149 let sections = toc_sections(&format!("<ul>{items}</li></ul>"), 6);
150 assert_eq!(sections.total, 9);
151 assert_eq!(sections.headings.len(), 6);
152 assert_eq!(sections.headings[5], "第 6 节");
153 }
154
155 #[cfg(feature = "server")]
156 #[test]
157 fn reads_rendered_markdown_toc() {
158 let md = "## 起步\n\n### 安装\n\n## Tom & Jerry `<T>`\n\n#### 很深\n\n## 收尾\n";
159 let toc = crate::api::markdown::render_markdown_enhanced(md).toc_html;
160 let sections = toc_sections(&toc, 6);
161 assert_eq!(sections.total, 3);
162 assert_eq!(sections.headings, ["起步", "Tom & Jerry <T>", "收尾"]);
163 }
164
165 #[test]
166 fn decodes_entities_in_headings() {
167 let text = crate::utils::html::escape_html("A & B <C> \"D\" 'E'");
168 let html = format!(
169 "<ul>{}</li>{}</li></ul>",
170 li("a", &text),
171 li("b", "© © &unknown; &")
172 );
173 let sections = toc_sections(&html, 6);
174 assert_eq!(
175 sections.headings,
176 ["A & B <C> \"D\" 'E'", "© © &unknown; &"]
177 );
178 }
179}