adrscope/infrastructure/parser/
markdown.rs1use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
6
7#[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 #[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 #[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 #[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 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 assert!(text.contains("Context"));
184 assert!(text.contains("database"));
185 assert!(text.contains("application"));
186 assert!(text.contains("Use PostgreSQL"));
187
188 assert!(!text.contains("SELECT * FROM users"));
190
191 assert!(!text.contains(" ")); }
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}