1use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
7
8use crate::console::{ConsoleOptions, RenderResult, Renderable};
9use crate::rule::Rule;
10use crate::segment::Segment;
11use crate::style::Style;
12
13pub fn render_markdown(md: &str) -> MarkdownRender {
15 MarkdownRender {
16 source: md.to_string(),
17 width: None,
18 code_theme: "default".to_string(),
19 hyperlinks: true,
20 }
21}
22
23#[derive(Debug, Clone)]
24pub struct MarkdownRender {
25 source: String,
26 width: Option<usize>,
27 code_theme: String,
28 hyperlinks: bool,
29}
30
31impl MarkdownRender {
32 pub fn width(mut self, w: usize) -> Self { self.width = Some(w); self }
33
34 fn get_style(name: &str) -> Style {
35 use crate::theme::default_theme;
36 let theme = default_theme();
37 theme.get(name).cloned().unwrap_or(Style::new())
38 }
39}
40
41impl Renderable for MarkdownRender {
42 fn render(&self, options: &ConsoleOptions) -> RenderResult {
43 let width = self.width.unwrap_or(options.max_width);
44 let parser = Parser::new_ext(&self.source, Options::all());
45
46 let mut lines: Vec<Vec<Segment>> = Vec::new();
47 let mut current_line: Vec<Segment> = Vec::new();
48 let mut in_code_block = false;
49 let mut in_heading = false;
50 let mut heading_level = 0u8;
51 let mut in_paragraph = false;
52 let mut list_depth = 0usize;
53 let mut current_link: Option<String> = None;
54 let mut link_text: Option<String> = None;
55
56 for event in parser {
57 match event {
58 Event::Start(Tag::Heading { level, .. }) => {
59 in_heading = true;
60 heading_level = level as u8;
61 let style = match level {
62 HeadingLevel::H1 => Self::get_style("markdown.h1"),
63 HeadingLevel::H2 => Self::get_style("markdown.h2"),
64 _ => Style::new().bold(true),
65 };
66 let prefix = "#".repeat(level as usize);
67 current_line.push(Segment::styled(
68 format!("{prefix} "),
69 style.clone(),
70 ));
71 }
72 Event::End(TagEnd::Heading(_)) => {
73 in_heading = false;
74 lines.push(current_line.clone());
75 current_line.clear();
76 if heading_level <= 2 {
78 let rule_char = if heading_level == 1 { '═' } else { '─' };
79 let rule_line = rule_char.to_string().repeat(width);
80 lines.push(vec![Segment::new(rule_line), Segment::line()]);
81 }
82 }
83 Event::Start(Tag::Paragraph) => {
84 in_paragraph = true;
85 }
86 Event::End(TagEnd::Paragraph) => {
87 in_paragraph = false;
88 if !current_line.is_empty() {
89 current_line.push(Segment::line());
90 lines.push(current_line.clone());
91 current_line.clear();
92 }
93 lines.push(vec![Segment::line()]);
95 }
96 Event::Start(Tag::CodeBlock(kind)) => {
97 in_code_block = true;
98 let lang = match kind {
99 CodeBlockKind::Fenced(lang) => {
100 if lang.is_empty() { String::new() } else { lang.to_string() }
101 }
102 CodeBlockKind::Indented => String::new(),
103 };
104 let title = if lang.is_empty() {
105 "Code".to_string()
106 } else {
107 format!("Code: {lang}")
108 };
109 let code_style = Self::get_style("markdown.code");
111 current_line.push(Segment::styled(
112 format!("┌─ {title} "),
113 code_style.clone(),
114 ));
115 current_line.push(Segment::line());
116 lines.push(current_line.clone());
117 current_line.clear();
118 }
119 Event::End(TagEnd::CodeBlock) => {
120 in_code_block = false;
121 if !current_line.is_empty() {
122 lines.push(current_line.clone());
123 current_line.clear();
124 }
125 let code_style = Self::get_style("markdown.code");
126 lines.push(vec![Segment::styled(
127 format!("└{}", "─".repeat(width.saturating_sub(2))),
128 code_style,
129 ), Segment::line()]);
130 }
131 Event::Start(Tag::List(_)) => {
132 list_depth += 1;
133 }
134 Event::End(TagEnd::List(_)) => {
135 list_depth = list_depth.saturating_sub(1);
136 }
137 Event::Start(Tag::Item) => {
138 let indent = " ".repeat(list_depth.saturating_sub(1));
139 let bullet = if list_depth > 1 { "◦" } else { "•" };
140 current_line.push(Segment::new(format!("{indent}{bullet} ")));
141 }
142 Event::End(TagEnd::Item) => {
143 lines.push(current_line.clone());
144 current_line.clear();
145 }
146 Event::Start(Tag::BlockQuote) => {
147 let quote_style = Self::get_style("markdown.blockquote");
148 current_line.push(Segment::styled("▌ ", quote_style));
149 }
150 Event::End(TagEnd::BlockQuote) => {
151 lines.push(current_line.clone());
152 current_line.clear();
153 }
154 Event::Start(Tag::Emphasis) => {
155 current_line.push(Segment::styled("", Style::new().italic(true)));
156 }
157 Event::End(TagEnd::Emphasis) => {
158 }
160 Event::Start(Tag::Strong) => {
161 }
163 Event::End(TagEnd::Strong) => {}
164 Event::Start(Tag::Link { dest_url, .. }) => {
165 current_link = Some(dest_url.to_string());
166 link_text = Some(String::new());
167 }
168 Event::End(TagEnd::Link) => {
169 if let (Some(url), Some(text)) = (current_link.take(), link_text.take()) {
170 let link_style = Self::get_style("markdown.link");
171 let display = if text.is_empty() { url.clone() } else { text };
172 current_line.push(Segment::styled(
173 format!("{display} ({url})"),
174 link_style,
175 ));
176 }
177 }
178 Event::Text(text) | Event::Code(text) => {
179 let s: &str = &text;
180 if current_link.is_some() {
182 if let Some(ref mut lt) = link_text {
183 lt.push_str(s);
184 }
185 }
186 if in_code_block {
187 for line in s.lines() {
189 current_line.push(Segment::new(format!("│ {line}")));
190 current_line.push(Segment::line());
191 lines.push(current_line.clone());
192 current_line.clear();
193 }
194 } else {
195 current_line.push(Segment::new(s));
196 }
197 }
198 Event::SoftBreak => {
199 current_line.push(Segment::new(" "));
200 }
201 Event::HardBreak => {
202 current_line.push(Segment::line());
203 lines.push(current_line.clone());
204 current_line.clear();
205 }
206 Event::Rule => {
207 let rule = Rule::new().characters("─");
208 let res = rule.render(options);
209 lines.extend(res.lines);
210 }
211 _ => {}
212 }
213 }
214
215 if !current_line.is_empty() {
217 current_line.push(Segment::line());
218 lines.push(current_line);
219 }
220
221 RenderResult { lines, items: Vec::new() }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_markdown_heading() {
231 let md = render_markdown("# Hello\n\nWorld");
232 let opts = ConsoleOptions::default();
233 let result = md.render(&opts);
234 let ansi = result.to_ansi();
235 assert!(ansi.contains("Hello"));
236 }
237}