| ... | @@ -0,0 +1,229 @@ |
| 1 | use markdown_it::Node; |
| 2 | use serde::Serialize; |
| 3 | |
| 4 | #[derive(Debug, Clone, Serialize)] |
| 5 | pub struct HeadingEntry { |
| 6 | pub level: u8, |
| 7 | pub id: String, |
| 8 | pub text: String, |
| 9 | } |
| 10 | |
| 11 | pub fn generate_slug(text: &str, existing_ids: &mut std::collections::HashSet<String>) -> String { |
| 12 | let mut slug = text |
| 13 | .chars() |
| 14 | .map(|c| match c { |
| 15 | 'a'..='z' | '0'..='9' => c.to_ascii_lowercase(), |
| 16 | 'A'..='Z' => c.to_ascii_lowercase(), |
| 17 | ' ' | '\t' => '-', |
| 18 | _ if c.is_alphanumeric() => c.to_ascii_lowercase(), |
| 19 | _ => '-', |
| 20 | }) |
| 21 | .collect::<String>(); |
| 22 | |
| 23 | slug = slug |
| 24 | .split('-') |
| 25 | .filter(|s| !s.is_empty()) |
| 26 | .collect::<Vec<_>>() |
| 27 | .join("-"); |
| 28 | |
| 29 | if slug.is_empty() { |
| 30 | slug = "heading".to_string(); |
| 31 | } |
| 32 | |
| 33 | if !existing_ids.contains(&slug) { |
| 34 | existing_ids.insert(slug.clone()); |
| 35 | return slug; |
| 36 | } |
| 37 | |
| 38 | let mut counter = 1; |
| 39 | loop { |
| 40 | let new_slug = format!("{}-{}", slug, counter); |
| 41 | if !existing_ids.contains(&new_slug) { |
| 42 | existing_ids.insert(new_slug.clone()); |
| 43 | return new_slug; |
| 44 | } |
| 45 | counter += 1; |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | pub fn collect_headings(node: &Node) -> Vec<HeadingEntry> { |
| 50 | let mut headings = Vec::new(); |
| 51 | let mut existing_ids = std::collections::HashSet::new(); |
| 52 | |
| 53 | collect_headings_recursive(node, &mut headings, &mut existing_ids); |
| 54 | |
| 55 | headings |
| 56 | } |
| 57 | |
| 58 | fn collect_headings_recursive( |
| 59 | node: &Node, |
| 60 | headings: &mut Vec<HeadingEntry>, |
| 61 | existing_ids: &mut std::collections::HashSet<String>, |
| 62 | ) { |
| 63 | // Check for markdown-it ATX headings (# ## ### etc) |
| 64 | if let Some(heading) = node.cast::<markdown_it::plugins::cmark::block::heading::ATXHeading>() { |
| 65 | let text = node.collect_text(); |
| 66 | let id = generate_slug(&text, existing_ids); |
| 67 | headings.push(HeadingEntry { |
| 68 | level: heading.level, |
| 69 | id, |
| 70 | text, |
| 71 | }); |
| 72 | } |
| 73 | // Check for setext headings (underline style) |
| 74 | else if let Some(heading) = |
| 75 | node.cast::<markdown_it::plugins::cmark::block::lheading::SetextHeader>() |
| 76 | { |
| 77 | let text = node.collect_text(); |
| 78 | let id = generate_slug(&text, existing_ids); |
| 79 | headings.push(HeadingEntry { |
| 80 | level: heading.level, |
| 81 | id, |
| 82 | text, |
| 83 | }); |
| 84 | } |
| 85 | // Check for MarkoOpen tags that are h1-h6 |
| 86 | else if let Some(open) = node.cast::<super::tags::MarkoOpen>() { |
| 87 | if let Some(level) = parse_heading_level(&open.tag_name) { |
| 88 | let text = node |
| 89 | .children |
| 90 | .iter() |
| 91 | .map(|c| c.collect_text()) |
| 92 | .collect::<Vec<_>>() |
| 93 | .join(""); |
| 94 | let id = if let Some(existing) = open.content.strip_prefix('<').and_then(|s| { |
| 95 | s.find("id=\"") |
| 96 | .map(|pos| { |
| 97 | let start = pos + 4; |
| 98 | s[start..].split('"').next().map(|s| s.to_string()) |
| 99 | }) |
| 100 | .flatten() |
| 101 | }) { |
| 102 | if !existing_ids.contains(&existing) { |
| 103 | existing_ids.insert(existing.clone()); |
| 104 | existing |
| 105 | } else { |
| 106 | generate_slug(&text, existing_ids) |
| 107 | } |
| 108 | } else { |
| 109 | generate_slug(&text, existing_ids) |
| 110 | }; |
| 111 | headings.push(HeadingEntry { level, id, text }); |
| 112 | } |
| 113 | } |
| 114 | // Check for MarkoOpenWithText tags that are h1-h6 |
| 115 | else if let Some(open) = node.cast::<super::tags::MarkoOpenWithText>() { |
| 116 | if let Some(level) = parse_heading_level(open.tag_name.as_str()) { |
| 117 | let text = node |
| 118 | .children |
| 119 | .iter() |
| 120 | .map(|c| c.collect_text()) |
| 121 | .collect::<Vec<_>>() |
| 122 | .join(""); |
| 123 | let id = generate_slug(&text, existing_ids); |
| 124 | headings.push(HeadingEntry { level, id, text }); |
| 125 | } |
| 126 | } |
| 127 | // Check for MarkoBlockComplete tags that are h1-h6 |
| 128 | else if let Some(block) = node.cast::<super::tags::MarkoBlockComplete>() { |
| 129 | if let Some(level) = parse_heading_level(block.tag_name.as_str()) { |
| 130 | let text = node |
| 131 | .children |
| 132 | .iter() |
| 133 | .map(|c| c.collect_text()) |
| 134 | .collect::<Vec<_>>() |
| 135 | .join(""); |
| 136 | let id = generate_slug(&text, existing_ids); |
| 137 | headings.push(HeadingEntry { level, id, text }); |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | for child in &node.children { |
| 142 | collect_headings_recursive(child, headings, existing_ids); |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | fn parse_heading_level(tag_name: &str) -> Option<u8> { |
| 147 | match tag_name { |
| 148 | "h1" => Some(1), |
| 149 | "h2" => Some(2), |
| 150 | "h3" => Some(3), |
| 151 | "h4" => Some(4), |
| 152 | "h5" => Some(5), |
| 153 | "h6" => Some(6), |
| 154 | _ => None, |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | pub fn inject_heading_ids(text: &str, headings: &[HeadingEntry]) -> String { |
| 159 | if headings.is_empty() { |
| 160 | return text.to_string(); |
| 161 | } |
| 162 | |
| 163 | let mut result = String::new(); |
| 164 | let mut pos = 0; |
| 165 | let mut heading_idx = 0; |
| 166 | let bytes = text.as_bytes(); |
| 167 | |
| 168 | while pos < bytes.len() { |
| 169 | if bytes[pos] == b'<' { |
| 170 | let remaining = &bytes[pos..]; |
| 171 | if remaining.starts_with(b"<h1") |
| 172 | || remaining.starts_with(b"<h2") |
| 173 | || remaining.starts_with(b"<h3") |
| 174 | || remaining.starts_with(b"<h4") |
| 175 | || remaining.starts_with(b"<h5") |
| 176 | || remaining.starts_with(b"<h6") |
| 177 | { |
| 178 | let level = match remaining[1] { |
| 179 | b'1' => 1u8, |
| 180 | b'2' => 2, |
| 181 | b'3' => 3, |
| 182 | b'4' => 4, |
| 183 | b'5' => 5, |
| 184 | b'6' => 6, |
| 185 | _ => { |
| 186 | result.push('<'); |
| 187 | pos += 1; |
| 188 | continue; |
| 189 | } |
| 190 | }; |
| 191 | |
| 192 | let close_pos = match remaining[2..].iter().position(|&b| b == b'>') { |
| 193 | Some(p) => p + 2, |
| 194 | None => { |
| 195 | result.push('<'); |
| 196 | pos += 1; |
| 197 | continue; |
| 198 | } |
| 199 | }; |
| 200 | |
| 201 | let tag_end = pos + close_pos; |
| 202 | let tag = std::str::from_utf8(&bytes[pos..=tag_end]).unwrap_or(""); |
| 203 | |
| 204 | if tag.ends_with("/>") || tag.contains(" ") { |
| 205 | result.push_str(tag); |
| 206 | pos = tag_end + 1; |
| 207 | continue; |
| 208 | } |
| 209 | |
| 210 | if heading_idx < headings.len() && headings[heading_idx].level == level { |
| 211 | let id = &headings[heading_idx].id; |
| 212 | result.push_str(&format!("h{level} id=\"{}\">", id)); |
| 213 | heading_idx += 1; |
| 214 | pos = tag_end + 1; |
| 215 | continue; |
| 216 | } else { |
| 217 | result.push_str(tag); |
| 218 | pos = tag_end + 1; |
| 219 | continue; |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | result.push(bytes[pos] as char); |
| 225 | pos += 1; |
| 226 | } |
| 227 | |
| 228 | result |
| 229 | } |