adrscope/infrastructure/parser/
markdown.rs

1//! Markdown to HTML rendering.
2//!
3//! Uses pulldown-cmark for CommonMark-compliant markdown parsing.
4
5use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
6
7/// Renders markdown content to HTML.
8#[derive(Debug, Clone)]
9pub struct MarkdownRenderer {
10    options: Options,
11}
12
13impl Default for MarkdownRenderer {
14    fn default() -> Self {
15        Self::new()
16    }
17}
18
19impl MarkdownRenderer {
20    /// Creates a new markdown renderer with default options.
21    #[must_use]
22    pub fn new() -> Self {
23        let mut options = Options::empty();
24        options.insert(Options::ENABLE_TABLES);
25        options.insert(Options::ENABLE_STRIKETHROUGH);
26        options.insert(Options::ENABLE_TASKLISTS);
27        options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
28
29        Self { options }
30    }
31
32    /// Renders markdown content to HTML.
33    #[must_use]
34    pub fn render(&self, markdown: &str) -> String {
35        let parser = Parser::new_ext(markdown, self.options);
36        let mut html_output = String::with_capacity(markdown.len() * 2);
37        html::push_html(&mut html_output, parser);
38        html_output
39    }
40
41    /// Extracts plain text from markdown for search indexing.
42    #[must_use]
43    pub fn render_plain_text(&self, markdown: &str) -> String {
44        let parser = Parser::new_ext(markdown, self.options);
45        let mut text = String::with_capacity(markdown.len());
46        let mut in_code_block = false;
47
48        for event in parser {
49            match event {
50                Event::Text(t) | Event::Code(t) => {
51                    if !in_code_block {
52                        if !text.is_empty() && !text.ends_with(' ') {
53                            text.push(' ');
54                        }
55                        text.push_str(&t);
56                    }
57                },
58                Event::Start(Tag::CodeBlock(_)) => {
59                    in_code_block = true;
60                },
61                Event::End(TagEnd::CodeBlock) => {
62                    in_code_block = false;
63                },
64                Event::SoftBreak | Event::HardBreak => {
65                    if !text.is_empty() && !text.ends_with(' ') {
66                        text.push(' ');
67                    }
68                },
69                _ => {},
70            }
71        }
72
73        // Clean up whitespace
74        text.split_whitespace().collect::<Vec<_>>().join(" ")
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn test_render_heading() {
84        let renderer = MarkdownRenderer::new();
85        let html = renderer.render("# Hello World");
86        assert!(html.contains("<h1>Hello World</h1>"));
87    }
88
89    #[test]
90    fn test_render_paragraph() {
91        let renderer = MarkdownRenderer::new();
92        let html = renderer.render("This is a paragraph.");
93        assert!(html.contains("<p>This is a paragraph.</p>"));
94    }
95
96    #[test]
97    fn test_render_list() {
98        let renderer = MarkdownRenderer::new();
99        let html = renderer.render("- Item 1\n- Item 2\n- Item 3");
100        assert!(html.contains("<ul>"));
101        assert!(html.contains("<li>Item 1</li>"));
102        assert!(html.contains("<li>Item 2</li>"));
103        assert!(html.contains("<li>Item 3</li>"));
104    }
105
106    #[test]
107    fn test_render_code_block() {
108        let renderer = MarkdownRenderer::new();
109        let html = renderer.render("```rust\nfn main() {}\n```");
110        assert!(html.contains("<code"));
111        assert!(html.contains("fn main()"));
112    }
113
114    #[test]
115    fn test_render_inline_code() {
116        let renderer = MarkdownRenderer::new();
117        let html = renderer.render("Use `cargo build` to compile.");
118        assert!(html.contains("<code>cargo build</code>"));
119    }
120
121    #[test]
122    fn test_render_table() {
123        let renderer = MarkdownRenderer::new();
124        let md = r"| Header 1 | Header 2 |
125|----------|----------|
126| Cell 1   | Cell 2   |";
127        let html = renderer.render(md);
128        assert!(html.contains("<table>"));
129        assert!(html.contains("<th>"));
130        assert!(html.contains("<td>"));
131    }
132
133    #[test]
134    fn test_render_emphasis() {
135        let renderer = MarkdownRenderer::new();
136        let html = renderer.render("This is *italic* and **bold** text.");
137        assert!(html.contains("<em>italic</em>"));
138        assert!(html.contains("<strong>bold</strong>"));
139    }
140
141    #[test]
142    fn test_render_link() {
143        let renderer = MarkdownRenderer::new();
144        let html = renderer.render("[Link text](https://example.com)");
145        assert!(html.contains("<a href=\"https://example.com\">Link text</a>"));
146    }
147
148    #[test]
149    fn test_render_strikethrough() {
150        let renderer = MarkdownRenderer::new();
151        let html = renderer.render("This is ~~deleted~~ text.");
152        assert!(html.contains("<del>deleted</del>"));
153    }
154
155    #[test]
156    fn test_render_tasklist() {
157        let renderer = MarkdownRenderer::new();
158        let html = renderer.render("- [x] Done\n- [ ] Todo");
159        assert!(html.contains("type=\"checkbox\""));
160        assert!(html.contains("checked"));
161    }
162
163    #[test]
164    fn test_plain_text_extraction() {
165        let renderer = MarkdownRenderer::new();
166        let md = r" Context
167
168We need a **database** for our `application`.
169
170## Decision
171
172Use PostgreSQL.
173
174```sql
175SELECT * FROM users;
176```
177
178This is the end.";
179
180        let text = renderer.render_plain_text(md);
181
182        // Should contain text content
183        assert!(text.contains("Context"));
184        assert!(text.contains("database"));
185        assert!(text.contains("application"));
186        assert!(text.contains("Use PostgreSQL"));
187
188        // Should NOT contain code blocks
189        assert!(!text.contains("SELECT * FROM users"));
190
191        // Should be clean without excessive whitespace
192        assert!(!text.contains("  ")); // no double spaces
193    }
194
195    #[test]
196    fn test_plain_text_basic() {
197        let renderer = MarkdownRenderer::new();
198        let text = renderer.render_plain_text("Hello **world**!");
199        assert_eq!(text, "Hello world !");
200    }
201
202    #[test]
203    fn test_plain_text_removes_formatting() {
204        let renderer = MarkdownRenderer::new();
205        let text = renderer.render_plain_text("This is *italic* and **bold**.");
206        assert!(text.contains("italic"));
207        assert!(text.contains("bold"));
208        assert!(!text.contains("*"));
209    }
210}