1use anyhow::{Context, Result};
2use colored::*;
3use pulldown_cmark::{Parser, Event, Tag, CodeBlockKind, HeadingLevel};
4use regex::Regex;
5
6#[cfg(feature = "images")]
7use viuer::{Config as ViuerConfig, print_from_file};
8
9pub struct MarkdownRenderer {
11 enable_images: bool,
13}
14
15impl MarkdownRenderer {
16 pub fn new(enable_images: bool) -> Self {
17 Self {
18 enable_images,
19 }
20 }
21
22 pub fn render(&mut self, markdown: &str) -> Result<String> {
24 let parser = Parser::new(markdown);
25 let mut output = String::new();
26 let mut in_code_block = false;
27 let mut in_emphasis = false;
28 let mut in_strong = false;
29 let mut in_heading = false;
30 let mut heading_level = HeadingLevel::H1;
31 let mut list_stack: Vec<bool> = Vec::new(); for event in parser {
34 match event {
35 Event::Start(tag) => {
36 match tag {
37 Tag::Heading(level, _, _) => {
38 in_heading = true;
39 heading_level = level;
40 output.push('\n');
41 }
42 Tag::Paragraph => {
43 if !output.is_empty() && !output.ends_with('\n') {
44 output.push('\n');
45 }
46 }
47 Tag::List(start_number) => {
48 list_stack.push(start_number.is_some());
49 output.push('\n');
50 }
51 Tag::Item => {
52 let indent = " ".repeat(list_stack.len().saturating_sub(1));
53 if let Some(&is_ordered) = list_stack.last() {
54 if is_ordered {
55 output.push_str(&format!("{}1. ", indent));
56 } else {
57 output.push_str(&format!("{}• ", indent));
58 }
59 }
60 }
61 Tag::Emphasis => {
62 in_emphasis = true;
63 }
64 Tag::Strong => {
65 in_strong = true;
66 }
67 Tag::CodeBlock(kind) => {
68 in_code_block = true;
69 output.push('\n');
70 if let CodeBlockKind::Fenced(lang) = kind {
71 if !lang.is_empty() {
72 output.push_str(&format!("```{}\n", lang.bright_black()));
73 } else {
74 output.push_str("```\n");
75 }
76 } else {
77 output.push_str("```\n");
78 }
79 }
80 Tag::Link(_link_type, dest_url, title) => {
82 if !title.is_empty() {
84 output.push_str(&format!("{} ({})", title.bright_blue().underline(), dest_url.bright_black()));
85 } else {
86 output.push_str(&dest_url.bright_blue().underline().to_string());
87 }
88 }
89 Tag::Image(_link_type, dest_url, title) => {
90 self.handle_image(&mut output, dest_url.as_ref(), title.as_ref())?;
91 }
92 Tag::BlockQuote => {
93 output.push_str(&"▍ ".bright_black().to_string());
94 }
95 _ => {}
96 }
97 }
98 Event::End(tag) => {
99 match tag {
100 Tag::Heading(_, _, _) => {
101 in_heading = false;
102 output.push('\n');
103 }
104 Tag::Paragraph => {
105 output.push('\n');
106 }
107 Tag::List(_) => {
108 list_stack.pop();
109 output.push('\n');
110 }
111 Tag::Item => {
112 output.push('\n');
113 }
114 Tag::Emphasis => {
115 in_emphasis = false;
116 }
117 Tag::Strong => {
118 in_strong = false;
119 }
120 Tag::CodeBlock(_) => {
121 in_code_block = false;
122 output.push_str("```\n\n");
123 }
124 Tag::BlockQuote => {
125 output.push('\n');
126 }
127 _ => {}
128 }
129 }
130 Event::Text(text) => {
131 let rendered_text = if in_code_block {
132 text.bright_white().on_black().to_string()
134 } else if in_heading {
135 match heading_level {
137 HeadingLevel::H1 => text.bright_white().bold().to_string(),
138 HeadingLevel::H2 => text.bright_cyan().bold().to_string(),
139 HeadingLevel::H3 => text.bright_green().bold().to_string(),
140 HeadingLevel::H4 => text.bright_yellow().bold().to_string(),
141 HeadingLevel::H5 => text.bright_magenta().bold().to_string(),
142 HeadingLevel::H6 => text.bright_blue().bold().to_string(),
143 }
144 } else if in_strong {
145 text.bold().to_string()
146 } else if in_emphasis {
147 text.italic().to_string()
148 } else {
149 text.to_string()
150 };
151 output.push_str(&rendered_text);
152 }
153 Event::Code(code) => {
154 output.push_str(&code.bright_white().on_black().to_string());
156 }
157 Event::Html(html) => {
158 output.push_str(&self.strip_html(&html));
160 }
161 Event::SoftBreak => {
162 output.push(' ');
163 }
164 Event::HardBreak => {
165 output.push('\n');
166 }
167 Event::Rule => {
168 output.push_str(&"─".repeat(80).bright_black().to_string());
169 output.push('\n');
170 }
171 _ => {}
172 }
173 }
174
175 Ok(output)
176 }
177
178 fn handle_image(&mut self, output: &mut String, url: &str, title: &str) -> Result<()> {
180 if !self.enable_images {
181 if !title.is_empty() {
183 output.push_str(&format!("[Image: {}] ({})\n", title.bright_green(), url.bright_black()));
184 } else {
185 output.push_str(&format!("[Image] ({})\n", url.bright_black()));
186 }
187 return Ok(());
188 }
189
190 #[cfg(feature = "images")]
191 {
192 if url.starts_with("data:image/") {
193 self.handle_embedded_image(output, url, title)?;
195 } else if url.starts_with("http://") || url.starts_with("https://") {
196 self.handle_remote_image(output, url, title)?;
198 } else {
199 self.handle_local_image(output, url, title)?;
201 }
202 }
203
204 #[cfg(not(feature = "images"))]
205 {
206 if !title.is_empty() {
208 output.push_str(&format!("[Image: {}] ({})\n", title.bright_green(), url.bright_black()));
209 } else {
210 output.push_str(&format!("[Image] ({})\n", url.bright_black()));
211 }
212 }
213
214 Ok(())
215 }
216
217 #[cfg(feature = "images")]
218 fn handle_embedded_image(&mut self, output: &mut String, data_url: &str, title: &str) -> Result<()> {
219 use base64::Engine;
220
221 let re = Regex::new(r"data:image/([^;]+);base64,(.+)").unwrap();
223 if let Some(captures) = re.captures(data_url) {
224 let format = &captures[1];
225 let base64_data = &captures[2];
226
227 let image_data = base64::engine::general_purpose::STANDARD
229 .decode(base64_data)
230 .context("Failed to decode base64 image data")?;
231
232 let temp_path = format!("/tmp/whois_image_{}.{}",
234 std::process::id(), format);
235 std::fs::write(&temp_path, &image_data)
236 .context("Failed to write temporary image file")?;
237
238 let config = ViuerConfig {
240 width: Some(80),
241 height: Some(24),
242 ..Default::default()
243 };
244
245 match print_from_file(&temp_path, &config) {
246 Ok(_) => {
247 if !title.is_empty() {
248 output.push_str(&format!("\n{}\n", title.bright_green()));
249 }
250 }
251 Err(_) => {
252 output.push_str(&format!("[Image display failed: {}]\n",
253 if !title.is_empty() { title } else { "embedded image" }));
254 }
255 }
256
257 let _ = std::fs::remove_file(&temp_path);
259 } else {
260 output.push_str(&format!("[Invalid data URL: {}]\n",
261 if !title.is_empty() { title } else { "embedded image" }));
262 }
263
264 Ok(())
265 }
266
267 #[cfg(feature = "images")]
268 fn handle_remote_image(&mut self, output: &mut String, url: &str, title: &str) -> Result<()> {
269 if !title.is_empty() {
271 output.push_str(&format!("[Remote Image: {}] ({})\n", title.bright_green(), url.bright_black()));
272 } else {
273 output.push_str(&format!("[Remote Image] ({})\n", url.bright_black()));
274 }
275 Ok(())
276 }
277
278 #[cfg(feature = "images")]
279 fn handle_local_image(&mut self, output: &mut String, path: &str, title: &str) -> Result<()> {
280 let config = ViuerConfig {
281 width: Some(80),
282 height: Some(24),
283 ..Default::default()
284 };
285
286 match print_from_file(path, &config) {
287 Ok(_) => {
288 if !title.is_empty() {
289 output.push_str(&format!("\n{}\n", title.bright_green()));
290 }
291 }
292 Err(_) => {
293 output.push_str(&format!("[Image not found: {}]\n",
294 if !title.is_empty() { title } else { path }));
295 }
296 }
297
298 Ok(())
299 }
300
301 fn strip_html(&self, html: &str) -> String {
303 let re = Regex::new(r"<[^>]*>").unwrap();
304 re.replace_all(html, "").to_string()
305 }
306
307 pub fn is_markdown(text: &str) -> bool {
309 let markdown_patterns = [
311 r"^#{1,6}\s", r"\*\*.*\*\*", r"\*.*\*", r"`.*`", r"```", r"^\s*[-*+]\s", r"^\s*\d+\.\s", r"\[.*\]\(.*\)", r"!\[.*\]\(.*\)", r"^\s*>", ];
322
323 for pattern in &markdown_patterns {
324 let re = Regex::new(pattern).unwrap();
325 if re.is_match(text) {
326 return true;
327 }
328 }
329
330 false
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_is_markdown() {
340 assert!(MarkdownRenderer::is_markdown("# Header"));
341 assert!(MarkdownRenderer::is_markdown("**bold text**"));
342 assert!(MarkdownRenderer::is_markdown("- list item"));
343 assert!(MarkdownRenderer::is_markdown("[link](http://example.com)"));
344 assert!(MarkdownRenderer::is_markdown(""));
345 assert!(MarkdownRenderer::is_markdown("> blockquote"));
346 assert!(MarkdownRenderer::is_markdown("```code```"));
347 assert!(!MarkdownRenderer::is_markdown("plain text"));
348 }
349
350 #[test]
351 fn test_basic_rendering() {
352 let mut renderer = MarkdownRenderer::new(false);
353 let result = renderer.render("# Header\n\nThis is **bold** and *italic*.").unwrap();
354 assert!(!result.is_empty());
356 }
357
358 #[test]
359 fn test_code_rendering() {
360 let mut renderer = MarkdownRenderer::new(false);
361 let result = renderer.render("Inline `code` and\n\n```rust\nfn main() {}\n```").unwrap();
362 assert!(!result.is_empty());
363 }
364
365 #[test]
366 fn test_list_rendering() {
367 let mut renderer = MarkdownRenderer::new(false);
368 let result = renderer.render("- Item 1\n- Item 2\n\n1. Numbered\n2. List").unwrap();
369 assert!(!result.is_empty());
370 }
371}