systemprompt_generator/prerender/
toc.rs1use 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(¤t_level) = stack.last() {
130 match entry.level.cmp(¤t_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('&', "&")
167 .replace('<', "<")
168 .replace('>', ">")
169 .replace('"', """)
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}