Skip to main content

systemprompt_generator/prerender/
toc.rs

1//! Extract a table of contents from a Markdown document and inject matching
2//! `id` attributes into the rendered HTML so anchor links Just Work.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use std::collections::HashMap;
8
9use comrak::nodes::{AstNode, NodeValue};
10use comrak::{Arena, Options, parse_document};
11
12#[derive(Debug)]
13struct TocEntry {
14    pub level: u8,
15    pub text: String,
16    pub slug: String,
17}
18
19#[derive(Debug)]
20pub struct TocResult {
21    pub toc_html: String,
22    pub content_html: String,
23}
24
25pub fn generate_toc(markdown: &str, rendered_html: &str) -> TocResult {
26    let entries = extract_headings(markdown);
27
28    if entries.is_empty() {
29        return TocResult {
30            toc_html: String::new(),
31            content_html: rendered_html.to_owned(),
32        };
33    }
34
35    let toc_html = build_toc_html(&entries);
36    let content_html = inject_heading_ids(rendered_html, &entries);
37
38    TocResult {
39        toc_html,
40        content_html,
41    }
42}
43
44fn extract_headings(markdown: &str) -> Vec<TocEntry> {
45    let arena = Arena::new();
46    let options = Options::default();
47    let root = parse_document(&arena, markdown, &options);
48
49    let mut entries = Vec::new();
50    let mut slug_counts: HashMap<String, usize> = HashMap::new();
51    let mut skip_first_h1 = true;
52
53    for node in root.descendants() {
54        if let NodeValue::Heading(heading) = &node.data.borrow().value {
55            let level = heading.level;
56
57            if level == 1 && skip_first_h1 {
58                skip_first_h1 = false;
59                continue;
60            }
61
62            if !(2..=6).contains(&level) {
63                continue;
64            }
65
66            let text = extract_text_from_node(node);
67            if text.is_empty() {
68                continue;
69            }
70
71            let base_slug = slugify(&text);
72            let slug = deduplicate_slug(&base_slug, &mut slug_counts);
73
74            entries.push(TocEntry { level, text, slug });
75        }
76    }
77
78    entries
79}
80
81fn extract_text_from_node<'a>(node: &'a AstNode<'a>) -> String {
82    let mut text = String::new();
83
84    for child in node.descendants() {
85        if let NodeValue::Text(ref content) = child.data.borrow().value {
86            text.push_str(content);
87        } else if let NodeValue::Code(ref code) = child.data.borrow().value {
88            text.push_str(&code.literal);
89        }
90    }
91
92    text.trim().to_owned()
93}
94
95fn slugify(text: &str) -> String {
96    text.to_lowercase()
97        .chars()
98        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
99        .collect::<String>()
100        .split('-')
101        .filter(|s| !s.is_empty())
102        .collect::<Vec<_>>()
103        .join("-")
104}
105
106fn deduplicate_slug(base_slug: &str, counts: &mut HashMap<String, usize>) -> String {
107    let count = counts.entry(base_slug.to_owned()).or_insert(0);
108    let slug = if *count == 0 {
109        base_slug.to_owned()
110    } else {
111        format!("{}-{}", base_slug, count)
112    };
113    *count += 1;
114    slug
115}
116
117fn build_toc_html(entries: &[TocEntry]) -> String {
118    if entries.is_empty() {
119        return String::new();
120    }
121
122    let mut html = String::new();
123    let mut stack: Vec<u8> = Vec::new();
124
125    html.push_str("<ul class=\"toc-list\">\n");
126    stack.push(entries[0].level);
127
128    for entry in entries {
129        while let Some(&current_level) = stack.last() {
130            match entry.level.cmp(&current_level) {
131                std::cmp::Ordering::Greater => {
132                    html.push_str("<ul class=\"toc-list toc-nested\">\n");
133                    stack.push(entry.level);
134                    break;
135                },
136                std::cmp::Ordering::Less => {
137                    html.push_str("</li>\n</ul>\n");
138                    stack.pop();
139                },
140                std::cmp::Ordering::Equal => {
141                    if html.ends_with("</a>\n") {
142                        html.push_str("</li>\n");
143                    }
144                    break;
145                },
146            }
147        }
148
149        html.push_str(&format!(
150            "<li class=\"toc-item toc-level-{}\">\n<a class=\"toc-link\" href=\"#{}\">{}</a>\n",
151            entry.level,
152            entry.slug,
153            escape_html(&entry.text)
154        ));
155    }
156
157    while !stack.is_empty() {
158        html.push_str("</li>\n</ul>\n");
159        stack.pop();
160    }
161
162    html
163}
164
165fn escape_html(text: &str) -> String {
166    text.replace('&', "&amp;")
167        .replace('<', "&lt;")
168        .replace('>', "&gt;")
169        .replace('"', "&quot;")
170}
171
172fn inject_heading_ids(html: &str, entries: &[TocEntry]) -> String {
173    let mut result = html.to_owned();
174
175    for entry in entries {
176        let open_tag = format!("<h{}", entry.level);
177        let close_tag = format!("</h{}>", entry.level);
178
179        if let Some(tag_start) = find_heading_position(&result, &open_tag, &close_tag, &entry.text)
180        {
181            let close_bracket = result[tag_start..].find('>').map(|i| tag_start + i);
182            if let Some(cb) = close_bracket {
183                let attrs = &result[tag_start + open_tag.len()..cb];
184                if !attrs.contains("id=") {
185                    let id_attr = format!(" id=\"{}\"", entry.slug);
186                    result.insert_str(tag_start + open_tag.len(), &id_attr);
187                }
188            }
189        }
190    }
191
192    result
193}
194
195fn find_heading_position(
196    html: &str,
197    open_tag: &str,
198    close_tag: &str,
199    heading_text: &str,
200) -> Option<usize> {
201    let mut search_start = 0;
202    while let Some(pos) = html[search_start..].find(open_tag) {
203        let abs_pos = search_start + pos;
204        if let Some(close_pos) = html[abs_pos..].find(close_tag) {
205            let segment = &html[abs_pos..abs_pos + close_pos + close_tag.len()];
206            if segment.contains(heading_text) {
207                return Some(abs_pos);
208            }
209        }
210        search_start = abs_pos + open_tag.len();
211    }
212    None
213}