Skip to main content

shape_runtime/renderers/
markdown.rs

1//! Markdown renderer — renders ContentNode to GitHub-Flavored Markdown.
2//!
3//! Produces:
4//! - Plain text for styled text (markdown has limited inline styling)
5//! - GFM pipe tables
6//! - Fenced code blocks with language tags
7//! - Placeholder text for charts
8//! - Key-value as bold key / plain value lines
9
10use crate::content_renderer::{ContentRenderer, RendererCapabilities};
11use shape_value::content::{ChartSpec, ContentNode, ContentTable};
12use std::fmt::Write;
13
14/// Renders ContentNode trees to GitHub-Flavored Markdown.
15pub struct MarkdownRenderer;
16
17impl ContentRenderer for MarkdownRenderer {
18    fn capabilities(&self) -> RendererCapabilities {
19        RendererCapabilities::markdown()
20    }
21
22    fn render(&self, content: &ContentNode) -> String {
23        render_node(content)
24    }
25}
26
27fn render_node(node: &ContentNode) -> String {
28    match node {
29        ContentNode::Text(st) => {
30            let mut out = String::new();
31            for span in &st.spans {
32                let mut text = span.text.clone();
33                if span.style.bold {
34                    text = format!("**{}**", text);
35                }
36                if span.style.italic {
37                    text = format!("*{}*", text);
38                }
39                out.push_str(&text);
40            }
41            out
42        }
43        ContentNode::Table(table) => render_table(table),
44        ContentNode::Code { language, source } => render_code(language.as_deref(), source),
45        ContentNode::Chart(spec) => render_chart(spec),
46        ContentNode::KeyValue(pairs) => render_key_value(pairs),
47        ContentNode::Fragment(parts) => parts.iter().map(render_node).collect(),
48    }
49}
50
51fn render_table(table: &ContentTable) -> String {
52    let col_count = table.headers.len();
53    if col_count == 0 {
54        return String::new();
55    }
56
57    let mut widths: Vec<usize> = table.headers.iter().map(|h| h.len().max(3)).collect();
58
59    let limit = table.max_rows.unwrap_or(table.rows.len());
60    let display_rows = &table.rows[..limit.min(table.rows.len())];
61    let truncated = table.rows.len().saturating_sub(limit);
62
63    for row in display_rows {
64        for (i, cell) in row.iter().enumerate() {
65            if i < col_count {
66                let cell_text = cell.to_string();
67                widths[i] = widths[i].max(cell_text.len());
68            }
69        }
70    }
71
72    let mut out = String::new();
73
74    // Header row
75    out.push('|');
76    for (i, header) in table.headers.iter().enumerate() {
77        let _ = write!(out, " {:width$} |", header, width = widths[i]);
78    }
79    let _ = writeln!(out);
80
81    // Separator
82    out.push('|');
83    for w in &widths {
84        out.push(' ');
85        for _ in 0..*w {
86            out.push('-');
87        }
88        out.push_str(" |");
89    }
90    let _ = writeln!(out);
91
92    // Data rows
93    for row in display_rows {
94        out.push('|');
95        for i in 0..col_count {
96            let cell_text = row.get(i).map(|c| c.to_string()).unwrap_or_default();
97            let _ = write!(out, " {:width$} |", cell_text, width = widths[i]);
98        }
99        let _ = writeln!(out);
100    }
101
102    if truncated > 0 {
103        let _ = writeln!(out, "\n*... {} more rows*", truncated);
104    }
105
106    out
107}
108
109fn render_code(language: Option<&str>, source: &str) -> String {
110    let lang = language.unwrap_or("");
111    format!("```{}\n{}\n```\n", lang, source)
112}
113
114fn render_chart(spec: &ChartSpec) -> String {
115    let title = spec.title.as_deref().unwrap_or("untitled");
116    let type_name = chart_type_display_name(spec.chart_type);
117    let y_count = spec.channels_by_name("y").len();
118    format!(
119        "*[{} Chart: {} ({} series)]*\n",
120        type_name, title, y_count
121    )
122}
123
124fn chart_type_display_name(ct: shape_value::content::ChartType) -> &'static str {
125    use shape_value::content::ChartType;
126    match ct {
127        ChartType::Line => "Line",
128        ChartType::Bar => "Bar",
129        ChartType::Scatter => "Scatter",
130        ChartType::Area => "Area",
131        ChartType::Candlestick => "Candlestick",
132        ChartType::Histogram => "Histogram",
133        ChartType::BoxPlot => "BoxPlot",
134        ChartType::Heatmap => "Heatmap",
135        ChartType::Bubble => "Bubble",
136    }
137}
138
139fn render_key_value(pairs: &[(String, ContentNode)]) -> String {
140    let mut out = String::new();
141    for (key, value) in pairs {
142        let _ = writeln!(out, "**{}**: {}", key, render_node(value));
143    }
144    out
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use shape_value::content::{BorderStyle, Color, ContentTable, NamedColor};
151
152    fn renderer() -> MarkdownRenderer {
153        MarkdownRenderer
154    }
155
156    #[test]
157    fn test_plain_text_md() {
158        let node = ContentNode::plain("hello");
159        assert_eq!(renderer().render(&node), "hello");
160    }
161
162    #[test]
163    fn test_bold_text_md() {
164        let node = ContentNode::plain("bold").with_bold();
165        let output = renderer().render(&node);
166        assert_eq!(output, "**bold**");
167    }
168
169    #[test]
170    fn test_italic_text_md() {
171        let node = ContentNode::plain("italic").with_italic();
172        let output = renderer().render(&node);
173        assert_eq!(output, "*italic*");
174    }
175
176    #[test]
177    fn test_bold_italic_md() {
178        let node = ContentNode::plain("both").with_bold().with_italic();
179        let output = renderer().render(&node);
180        assert_eq!(output, "***both***");
181    }
182
183    #[test]
184    fn test_gfm_table() {
185        let table = ContentNode::Table(ContentTable {
186            headers: vec!["Name".into(), "Age".into()],
187            rows: vec![vec![ContentNode::plain("Alice"), ContentNode::plain("30")]],
188            border: BorderStyle::default(),
189            max_rows: None,
190            column_types: None,
191            total_rows: None,
192            sortable: false,
193        });
194        let output = renderer().render(&table);
195        assert!(output.contains("| Name"));
196        assert!(output.contains("| ---"));
197        assert!(output.contains("| Alice"));
198    }
199
200    #[test]
201    fn test_gfm_table_truncation() {
202        let table = ContentNode::Table(ContentTable {
203            headers: vec!["X".into()],
204            rows: vec![
205                vec![ContentNode::plain("1")],
206                vec![ContentNode::plain("2")],
207                vec![ContentNode::plain("3")],
208            ],
209            border: BorderStyle::default(),
210            max_rows: Some(1),
211            column_types: None,
212            total_rows: None,
213            sortable: false,
214        });
215        let output = renderer().render(&table);
216        assert!(output.contains("*... 2 more rows*"));
217    }
218
219    #[test]
220    fn test_fenced_code_block() {
221        let code = ContentNode::Code {
222            language: Some("rust".into()),
223            source: "fn main() {}".into(),
224        };
225        let output = renderer().render(&code);
226        assert!(output.starts_with("```rust\n"));
227        assert!(output.contains("fn main() {}"));
228        assert!(output.contains("```"));
229    }
230
231    #[test]
232    fn test_code_block_no_language() {
233        let code = ContentNode::Code {
234            language: None,
235            source: "hello".into(),
236        };
237        let output = renderer().render(&code);
238        assert!(output.starts_with("```\n"));
239    }
240
241    #[test]
242    fn test_kv_md() {
243        let kv = ContentNode::KeyValue(vec![("name".into(), ContentNode::plain("Alice"))]);
244        let output = renderer().render(&kv);
245        assert!(output.contains("**name**: Alice"));
246    }
247
248    #[test]
249    fn test_chart_placeholder_md() {
250        let chart = ContentNode::Chart(shape_value::content::ChartSpec {
251            chart_type: shape_value::content::ChartType::Line,
252            channels: vec![],
253            x_categories: None,
254            title: Some("Revenue".into()),
255            x_label: None,
256            y_label: None,
257            width: None,
258            height: None,
259            echarts_options: None,
260            interactive: true,
261        });
262        let output = renderer().render(&chart);
263        assert!(output.contains("Line Chart: Revenue"));
264    }
265
266    #[test]
267    fn test_fragment_md() {
268        let frag = ContentNode::Fragment(vec![
269            ContentNode::plain("hello "),
270            ContentNode::plain("world"),
271        ]);
272        assert_eq!(renderer().render(&frag), "hello world");
273    }
274
275    #[test]
276    fn test_no_ansi_in_md() {
277        let node = ContentNode::plain("colored").with_fg(Color::Named(NamedColor::Red));
278        let output = renderer().render(&node);
279        assert!(!output.contains("\x1b["));
280    }
281}