use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use crate::console::{ConsoleOptions, RenderResult, Renderable};
use crate::rule::Rule;
use crate::segment::Segment;
use crate::style::Style;
pub fn render_markdown(md: &str) -> MarkdownRender {
MarkdownRender {
source: md.to_string(),
width: None,
code_theme: "default".to_string(),
hyperlinks: true,
}
}
#[derive(Debug, Clone)]
pub struct MarkdownRender {
source: String,
width: Option<usize>,
code_theme: String,
hyperlinks: bool,
}
impl MarkdownRender {
pub fn width(mut self, w: usize) -> Self { self.width = Some(w); self }
fn get_style(name: &str) -> Style {
use crate::theme::default_theme;
let theme = default_theme();
theme.get(name).cloned().unwrap_or(Style::new())
}
}
impl Renderable for MarkdownRender {
fn render(&self, options: &ConsoleOptions) -> RenderResult {
let width = self.width.unwrap_or(options.max_width);
let parser = Parser::new_ext(&self.source, Options::all());
let mut lines: Vec<Vec<Segment>> = Vec::new();
let mut current_line: Vec<Segment> = Vec::new();
let mut in_code_block = false;
let mut in_heading = false;
let mut heading_level = 0u8;
let mut in_paragraph = false;
let mut list_depth = 0usize;
let mut current_link: Option<String> = None;
let mut link_text: Option<String> = None;
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
in_heading = true;
heading_level = level as u8;
let style = match level {
HeadingLevel::H1 => Self::get_style("markdown.h1"),
HeadingLevel::H2 => Self::get_style("markdown.h2"),
_ => Style::new().bold(true),
};
let prefix = "#".repeat(level as usize);
current_line.push(Segment::styled(
format!("{prefix} "),
style.clone(),
));
}
Event::End(TagEnd::Heading(_)) => {
in_heading = false;
lines.push(current_line.clone());
current_line.clear();
if heading_level <= 2 {
let rule_char = if heading_level == 1 { '═' } else { '─' };
let rule_line = rule_char.to_string().repeat(width);
lines.push(vec![Segment::new(rule_line), Segment::line()]);
}
}
Event::Start(Tag::Paragraph) => {
in_paragraph = true;
}
Event::End(TagEnd::Paragraph) => {
in_paragraph = false;
if !current_line.is_empty() {
current_line.push(Segment::line());
lines.push(current_line.clone());
current_line.clear();
}
lines.push(vec![Segment::line()]);
}
Event::Start(Tag::CodeBlock(kind)) => {
in_code_block = true;
let lang = match kind {
CodeBlockKind::Fenced(lang) => {
if lang.is_empty() { String::new() } else { lang.to_string() }
}
CodeBlockKind::Indented => String::new(),
};
let title = if lang.is_empty() {
"Code".to_string()
} else {
format!("Code: {lang}")
};
let code_style = Self::get_style("markdown.code");
current_line.push(Segment::styled(
format!("┌─ {title} "),
code_style.clone(),
));
current_line.push(Segment::line());
lines.push(current_line.clone());
current_line.clear();
}
Event::End(TagEnd::CodeBlock) => {
in_code_block = false;
if !current_line.is_empty() {
lines.push(current_line.clone());
current_line.clear();
}
let code_style = Self::get_style("markdown.code");
lines.push(vec![Segment::styled(
format!("└{}", "─".repeat(width.saturating_sub(2))),
code_style,
), Segment::line()]);
}
Event::Start(Tag::List(_)) => {
list_depth += 1;
}
Event::End(TagEnd::List(_)) => {
list_depth = list_depth.saturating_sub(1);
}
Event::Start(Tag::Item) => {
let indent = " ".repeat(list_depth.saturating_sub(1));
let bullet = if list_depth > 1 { "◦" } else { "•" };
current_line.push(Segment::new(format!("{indent}{bullet} ")));
}
Event::End(TagEnd::Item) => {
lines.push(current_line.clone());
current_line.clear();
}
Event::Start(Tag::BlockQuote) => {
let quote_style = Self::get_style("markdown.blockquote");
current_line.push(Segment::styled("▌ ", quote_style));
}
Event::End(TagEnd::BlockQuote) => {
lines.push(current_line.clone());
current_line.clear();
}
Event::Start(Tag::Emphasis) => {
current_line.push(Segment::styled("", Style::new().italic(true)));
}
Event::End(TagEnd::Emphasis) => {
}
Event::Start(Tag::Strong) => {
}
Event::End(TagEnd::Strong) => {}
Event::Start(Tag::Link { dest_url, .. }) => {
current_link = Some(dest_url.to_string());
link_text = Some(String::new());
}
Event::End(TagEnd::Link) => {
if let (Some(url), Some(text)) = (current_link.take(), link_text.take()) {
let link_style = Self::get_style("markdown.link");
let display = if text.is_empty() { url.clone() } else { text };
current_line.push(Segment::styled(
format!("{display} ({url})"),
link_style,
));
}
}
Event::Text(text) | Event::Code(text) => {
let s: &str = &text;
if current_link.is_some() {
if let Some(ref mut lt) = link_text {
lt.push_str(s);
}
}
if in_code_block {
for line in s.lines() {
current_line.push(Segment::new(format!("│ {line}")));
current_line.push(Segment::line());
lines.push(current_line.clone());
current_line.clear();
}
} else {
current_line.push(Segment::new(s));
}
}
Event::SoftBreak => {
current_line.push(Segment::new(" "));
}
Event::HardBreak => {
current_line.push(Segment::line());
lines.push(current_line.clone());
current_line.clear();
}
Event::Rule => {
let rule = Rule::new().characters("─");
let res = rule.render(options);
lines.extend(res.lines);
}
_ => {}
}
}
if !current_line.is_empty() {
current_line.push(Segment::line());
lines.push(current_line);
}
RenderResult { lines, items: Vec::new() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_markdown_heading() {
let md = render_markdown("# Hello\n\nWorld");
let opts = ConsoleOptions::default();
let result = md.render(&opts);
let ansi = result.to_ansi();
assert!(ansi.contains("Hello"));
}
}