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