Skip to main content

yggdrasil/utils/
toc.rs

1//! 已存储目录 HTML(`toc_html`)的一级小节解析。
2//!
3//! 首页「最新一篇」的目录预览与笔记列表的「N 节」共用:只读 Markdown 渲染管线
4//! 产出的嵌套 `<ul>`,取最外层列表项的链接文字,不重新渲染 Markdown。
5
6/// 目录的一级小节:总数与前若干个标题(已解码为纯文本)。
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct TocSections {
9    pub total: usize,
10    pub headings: Vec<String>,
11}
12
13/// 解析目录最外层列表的小节,最多保留 `limit` 个标题,`total` 计全部。
14///
15/// 目录由第一个标题的层级起算,所以「只有三级标题」的文章,其三级标题就是一级小节。
16/// 层级先深后浅时渲染器会提前闭合根列表,深度不足 1 的链接也按一级处理。
17pub 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
46/// 链接文字是转义后的纯文本:去掉残留标签,解码实体并合并空白。
47fn 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        // 只有三级标题时目录从三级起算,它们就是一级小节。
127        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        // 先三级后二级:渲染器提前闭合根列表,后面的二级标题落在列表外。
134        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", "&#169; &#xA9; &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}