1use crate::error::MarkdownError;
9use comrak::nodes::{NodeHtmlBlock, NodeValue};
10use regex::Regex;
11use std::cell::RefCell;
12use std::collections::HashMap;
13use std::str::FromStr;
14use std::sync::LazyLock;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Heading {
24 pub level: u8,
26 pub text: String,
28 pub id: String,
32}
33
34pub fn collect_headings<'a>(
44 root: comrak::nodes::Node<'a>,
45 prefix: Option<&str>,
46) -> Vec<Heading> {
47 let mut anchorizer = comrak::Anchorizer::new();
48 let mut out = Vec::new();
49 for node in root.descendants() {
50 let level = match node.data.borrow().value {
51 NodeValue::Heading(h) => h.level,
52 _ => continue,
53 };
54 let text = extract_text(node);
55 let slug = anchorizer.anchorize(&text);
56 let id = match prefix {
57 Some(p) if !p.is_empty() => format!("{p}{slug}"),
58 _ => slug,
59 };
60 out.push(Heading { level, text, id });
61 }
62 out
63}
64
65pub fn collect_all_text<'a>(root: comrak::nodes::Node<'a>) -> String {
73 let mut buf = String::new();
74 for d in root.descendants() {
75 match &d.data.borrow().value {
76 NodeValue::Text(t) => buf.push_str(t),
77 NodeValue::Code(c) => buf.push_str(&c.literal),
78 NodeValue::CodeBlock(cb) => {
79 if !buf.is_empty() && !buf.ends_with(' ') {
80 buf.push(' ');
81 }
82 buf.push_str(&cb.literal);
83 }
84 NodeValue::SoftBreak | NodeValue::LineBreak
85 if !buf.is_empty() && !buf.ends_with(' ') =>
86 {
87 buf.push(' ');
88 }
89 NodeValue::Paragraph
91 | NodeValue::Heading(_)
92 | NodeValue::Item(_)
93 | NodeValue::BlockQuote
94 | NodeValue::Table(_)
95 | NodeValue::TableRow(_)
96 | NodeValue::TableCell
97 if !buf.is_empty() && !buf.ends_with(' ') =>
98 {
99 buf.push(' ');
100 }
101 _ => {}
102 }
103 }
104 buf.trim().to_string()
105}
106
107fn extract_text<'a>(node: comrak::nodes::Node<'a>) -> String {
111 let mut buf = String::new();
112 for d in node.descendants() {
113 match &d.data.borrow().value {
114 NodeValue::Text(t) => buf.push_str(t),
115 NodeValue::Code(c) => buf.push_str(&c.literal),
116 NodeValue::Image(img) => buf.push_str(&img.title),
117 _ => {}
118 }
119 }
120 buf
121}
122
123static TABLE_CELL_RE: LazyLock<Regex> =
131 LazyLock::new(|| Regex::new(r"<td([^>]*)>").unwrap());
132
133static CUSTOM_BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
135 Regex::new(
136 r#"(?si)<div\s+class=["']?(note|warning|tip|info|important|caution)["']?>(.*?)</div>"#,
137 )
138 .unwrap()
139});
140
141#[derive(Debug, Clone, Copy, PartialEq)]
145pub enum ColumnAlignment {
146 Left,
148 Center,
150 Right,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
158pub enum CustomBlockType {
159 Note,
161 Warning,
163 Tip,
165 Info,
167 Important,
169 Caution,
171}
172
173impl CustomBlockType {
174 pub fn default_alert_class(&self) -> &'static str {
176 match self {
177 Self::Note => "alert-info",
178 Self::Warning => "alert-warning",
179 Self::Tip => "alert-success",
180 Self::Info => "alert-primary",
181 Self::Important => "alert-danger",
182 Self::Caution => "alert-secondary",
183 }
184 }
185
186 pub fn default_title(&self) -> &'static str {
188 match self {
189 Self::Note => "Note",
190 Self::Warning => "Warning",
191 Self::Tip => "Tip",
192 Self::Info => "Info",
193 Self::Important => "Important",
194 Self::Caution => "Caution",
195 }
196 }
197
198 pub fn get_alert_class(&self) -> &'static str {
200 self.default_alert_class()
201 }
202
203 pub fn get_title(&self) -> &'static str {
205 self.default_title()
206 }
207
208 pub fn alert_class_with<'a>(
210 &self,
211 config: &'a CustomBlockConfig,
212 ) -> &'a str {
213 config
214 .class_overrides
215 .get(self)
216 .map(|s| s.as_str())
217 .unwrap_or_else(move || self.default_alert_class())
218 }
219
220 pub fn title_with<'a>(
222 &self,
223 config: &'a CustomBlockConfig,
224 ) -> &'a str {
225 config
226 .title_overrides
227 .get(self)
228 .map(|s| s.as_str())
229 .unwrap_or_else(move || self.default_title())
230 }
231}
232
233impl FromStr for CustomBlockType {
234 type Err = MarkdownError;
235
236 fn from_str(block_type: &str) -> Result<Self, Self::Err> {
237 match block_type.to_lowercase().as_str() {
238 "note" => Ok(Self::Note),
239 "warning" => Ok(Self::Warning),
240 "tip" => Ok(Self::Tip),
241 "info" => Ok(Self::Info),
242 "important" => Ok(Self::Important),
243 "caution" => Ok(Self::Caution),
244 _ => Err(MarkdownError::CustomBlockError(format!(
245 "Unknown block type: {block_type}"
246 ))),
247 }
248 }
249}
250
251#[derive(Debug, Clone, Default)]
258pub struct CustomBlockConfig {
259 pub class_overrides: HashMap<CustomBlockType, String>,
261 pub title_overrides: HashMap<CustomBlockType, String>,
263}
264
265impl CustomBlockConfig {
266 pub fn new() -> Self {
268 Self::default()
269 }
270
271 pub fn with_class(
273 mut self,
274 block_type: CustomBlockType,
275 class: impl Into<String>,
276 ) -> Self {
277 self.class_overrides.insert(block_type, class.into());
278 self
279 }
280
281 pub fn with_title(
283 mut self,
284 block_type: CustomBlockType,
285 title: impl Into<String>,
286 ) -> Self {
287 self.title_overrides.insert(block_type, title.into());
288 self
289 }
290}
291
292pub fn process_custom_block_nodes<'a>(
300 root: comrak::nodes::Node<'a>,
301 config: &CustomBlockConfig,
302) {
303 for node in root.descendants() {
304 let mut ast = node.data.borrow_mut();
305 if let NodeValue::HtmlBlock(ref mut block) = ast.value {
306 block.literal =
307 transform_custom_blocks(&block.literal, config);
308 }
309 }
310}
311
312fn transform_custom_blocks(
314 html: &str,
315 config: &CustomBlockConfig,
316) -> String {
317 CUSTOM_BLOCK_RE
318 .replace_all(html, |caps: ®ex::Captures| {
319 let block_type = CustomBlockType::from_str(
320 caps.get(1).unwrap().as_str(),
321 )
322 .expect("regex only matches known block types");
323 generate_custom_block_html(block_type, &caps[2], config)
324 })
325 .to_string()
326}
327
328fn generate_custom_block_html(
330 block_type: CustomBlockType,
331 content: &str,
332 config: &CustomBlockConfig,
333) -> String {
334 format!(
335 r#"<div class="alert {}" role="alert"><strong>{}:</strong> {}</div>"#,
336 block_type.alert_class_with(config),
337 block_type.title_with(config),
338 content
339 )
340}
341
342pub fn enhance_table_nodes<'a>(
349 root: comrak::nodes::Node<'a>,
350 arena: &'a comrak::Arena<'a>,
351 options: &comrak::Options,
352) {
353 let table_nodes: Vec<comrak::nodes::Node<'a>> = root
355 .descendants()
356 .filter(|node| {
357 matches!(node.data.borrow().value, NodeValue::Table(_))
358 })
359 .collect();
360
361 for table_node in table_nodes {
362 let mut table_html = String::new();
364 if comrak::format_html(table_node, options, &mut table_html)
365 .is_err()
366 {
367 continue;
368 }
369
370 let enhanced = process_tables(&table_html);
372
373 let start = comrak::nodes::LineColumn { line: 0, column: 0 };
375 let replacement = arena.alloc(comrak::nodes::AstNode::new(
376 RefCell::new(comrak::nodes::Ast::new(
377 NodeValue::HtmlBlock(NodeHtmlBlock {
378 block_type: 6, literal: enhanced,
380 }),
381 start,
382 )),
383 ));
384
385 table_node.insert_before(replacement);
387 table_node.detach();
388 }
389}
390
391pub fn process_custom_blocks(content: &str) -> String {
398 transform_custom_blocks(content, &CustomBlockConfig::default())
399}
400
401pub fn process_tables(table_html: &str) -> String {
405 let table_html = table_html.replace(
406 "<table>",
407 r#"<div class="table-responsive"><table class="table">"#,
408 );
409 let table_html = table_html.replace("</table>", "</table></div>");
410
411 TABLE_CELL_RE
412 .replace_all(&table_html, |caps: ®ex::Captures| {
413 let attrs = &caps[1];
414 if attrs.contains("align=\"center\"") {
415 format!(r#"<td{attrs} class="text-center">"#)
416 } else if attrs.contains("align=\"right\"") {
417 format!(r#"<td{attrs} class="text-right">"#)
418 } else {
419 format!(r#"<td{attrs} class="text-left">"#)
420 }
421 })
422 .to_string()
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn test_process_custom_blocks_default_config() {
431 let input = r#"
432 <div class="note">This is a note.</div>
433 <div class="WARNING">This is a warning.</div>
434 <div class="Tip">This is a tip.</div>
435 "#;
436 let processed = process_custom_blocks(input);
437 assert!(processed.contains(r#"alert alert-info"#));
438 assert!(processed.contains(r#"alert alert-warning"#));
439 assert!(processed.contains(r#"alert alert-success"#));
440 }
441
442 #[test]
443 fn test_custom_block_config_overrides() {
444 let config = CustomBlockConfig::new()
445 .with_class(CustomBlockType::Note, "callout-info")
446 .with_title(CustomBlockType::Note, "Did you know?");
447
448 let html = generate_custom_block_html(
449 CustomBlockType::Note,
450 "test content",
451 &config,
452 );
453 assert!(html.contains("callout-info"));
454 assert!(html.contains("Did you know?:"));
455 }
456
457 #[test]
458 fn test_unknown_block_passthrough() {
459 let input =
460 r#"<div class="unknown">Should pass through.</div>"#;
461 let processed = process_custom_blocks(input);
462 assert_eq!(processed, input);
463 }
464
465 #[test]
466 fn test_process_tables() {
467 let input = r#"<table><tr><td align="center">Center</td><td align="right">Right</td><td>Left</td></tr></table>"#;
468 let processed = process_tables(input);
469 assert!(processed.contains(r#"table-responsive"#));
470 assert!(processed.contains(r#"text-center"#));
471 assert!(processed.contains(r#"text-right"#));
472 assert!(processed.contains(r#"text-left"#));
473 }
474
475 #[test]
476 fn test_process_multiple_tables() {
477 let input = "<table><tr><td>A</td></tr></table>\n<table><tr><td>B</td></tr></table>";
478 let processed = process_tables(input);
479 assert_eq!(processed.matches("table-responsive").count(), 2);
480 }
481
482 #[test]
483 fn test_unknown_block_type_from_str() {
484 let result = CustomBlockType::from_str("unknown");
485 assert!(result.is_err());
486 let err = result.unwrap_err();
487 assert!(
488 err.to_string().contains("Unknown block type: unknown"),
489 "Error message should contain the unknown type"
490 );
491 }
492
493 #[test]
494 fn test_unknown_block_type_from_str_various() {
495 for name in ["foobar", "alert", "danger", "success", ""] {
496 let result = CustomBlockType::from_str(name);
497 assert!(
498 result.is_err(),
499 "'{name}' should not parse as a valid block type"
500 );
501 }
502 }
503}