Skip to main content

shape_runtime/renderers/
html.rs

1//! HTML renderer — renders ContentNode to HTML output.
2//!
3//! Produces HTML with:
4//! - `<span>` elements with inline styles for text styling
5//! - `<table>` elements for tables
6//! - `<pre><code>` for code blocks
7//! - Placeholder `<div>` for charts
8//! - `<dl>` for key-value pairs
9
10use crate::content_renderer::{ContentRenderer, RenderContext, RendererCapabilities};
11use shape_value::content::{ChartSpec, Color, ContentNode, ContentTable, NamedColor, Style};
12use std::fmt::Write;
13
14/// Renders ContentNode trees to HTML.
15///
16/// Carries a [`RenderContext`] — when `ctx.interactive` is true, chart nodes
17/// emit `data-echarts` attributes for client-side hydration.
18pub struct HtmlRenderer {
19    pub ctx: RenderContext,
20}
21
22impl HtmlRenderer {
23    pub fn new() -> Self {
24        Self {
25            ctx: RenderContext::html(),
26        }
27    }
28
29    pub fn with_context(ctx: RenderContext) -> Self {
30        Self { ctx }
31    }
32}
33
34impl Default for HtmlRenderer {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl ContentRenderer for HtmlRenderer {
41    fn capabilities(&self) -> RendererCapabilities {
42        RendererCapabilities::html()
43    }
44
45    fn render(&self, content: &ContentNode) -> String {
46        render_node(content, self.ctx.interactive)
47    }
48}
49
50fn render_node(node: &ContentNode, interactive: bool) -> String {
51    match node {
52        ContentNode::Text(st) => {
53            let mut out = String::new();
54            for span in &st.spans {
55                let css = style_to_css(&span.style);
56                if css.is_empty() {
57                    let _ = write!(out, "{}", html_escape(&span.text));
58                } else {
59                    let _ = write!(
60                        out,
61                        "<span style=\"{}\">{}</span>",
62                        css,
63                        html_escape(&span.text)
64                    );
65                }
66            }
67            out
68        }
69        ContentNode::Table(table) => render_table(table, interactive),
70        ContentNode::Code { language, source } => render_code(language.as_deref(), source),
71        ContentNode::Chart(spec) => render_chart(spec, interactive),
72        ContentNode::KeyValue(pairs) => render_key_value(pairs, interactive),
73        ContentNode::Fragment(parts) => parts.iter().map(|n| render_node(n, interactive)).collect(),
74    }
75}
76
77fn style_to_css(style: &Style) -> String {
78    let mut parts = Vec::new();
79    if style.bold {
80        parts.push("font-weight:bold".to_string());
81    }
82    if style.italic {
83        parts.push("font-style:italic".to_string());
84    }
85    if style.underline {
86        parts.push("text-decoration:underline".to_string());
87    }
88    if style.dim {
89        parts.push("opacity:0.6".to_string());
90    }
91    if let Some(ref color) = style.fg {
92        parts.push(format!("color:{}", color_to_css(color)));
93    }
94    if let Some(ref color) = style.bg {
95        parts.push(format!("background-color:{}", color_to_css(color)));
96    }
97    parts.join(";")
98}
99
100fn color_to_css(color: &Color) -> String {
101    match color {
102        Color::Named(named) => named_to_css(*named).to_string(),
103        Color::Rgb(r, g, b) => format!("rgb({},{},{})", r, g, b),
104    }
105}
106
107fn named_to_css(color: NamedColor) -> &'static str {
108    match color {
109        NamedColor::Red => "red",
110        NamedColor::Green => "green",
111        NamedColor::Blue => "blue",
112        NamedColor::Yellow => "yellow",
113        NamedColor::Magenta => "magenta",
114        NamedColor::Cyan => "cyan",
115        NamedColor::White => "white",
116        NamedColor::Default => "inherit",
117    }
118}
119
120fn html_escape(s: &str) -> String {
121    s.replace('&', "&amp;")
122        .replace('<', "&lt;")
123        .replace('>', "&gt;")
124        .replace('"', "&quot;")
125}
126
127fn render_table(table: &ContentTable, interactive: bool) -> String {
128    let mut out = String::from("<table>\n");
129
130    // Header
131    if !table.headers.is_empty() {
132        out.push_str("<thead><tr>");
133        for header in &table.headers {
134            let _ = write!(out, "<th>{}</th>", html_escape(header));
135        }
136        out.push_str("</tr></thead>\n");
137    }
138
139    // Body
140    let limit = table.max_rows.unwrap_or(table.rows.len());
141    let display_rows = &table.rows[..limit.min(table.rows.len())];
142    let truncated = table.rows.len().saturating_sub(limit);
143
144    out.push_str("<tbody>\n");
145    for row in display_rows {
146        out.push_str("<tr>");
147        for cell in row {
148            let _ = write!(out, "<td>{}</td>", render_node(cell, interactive));
149        }
150        out.push_str("</tr>\n");
151    }
152    if truncated > 0 {
153        let _ = write!(
154            out,
155            "<tr><td colspan=\"{}\">... {} more rows</td></tr>\n",
156            table.headers.len(),
157            truncated
158        );
159    }
160    out.push_str("</tbody>\n</table>");
161    out
162}
163
164fn render_code(language: Option<&str>, source: &str) -> String {
165    let lang_attr = language
166        .map(|l| format!(" class=\"language-{}\"", html_escape(l)))
167        .unwrap_or_default();
168    format!(
169        "<pre><code{}>{}</code></pre>",
170        lang_attr,
171        html_escape(source)
172    )
173}
174
175fn render_chart(spec: &ChartSpec, interactive: bool) -> String {
176    let title = spec.title.as_deref().unwrap_or("untitled");
177    let type_name = match spec.chart_type {
178        shape_value::content::ChartType::Line => "Line",
179        shape_value::content::ChartType::Bar => "Bar",
180        shape_value::content::ChartType::Scatter => "Scatter",
181        shape_value::content::ChartType::Area => "Area",
182        shape_value::content::ChartType::Candlestick => "Candlestick",
183        shape_value::content::ChartType::Histogram => "Histogram",
184    };
185    if interactive {
186        // Emit data-echarts attribute for client-side hydration
187        format!(
188            "<div class=\"chart\" data-echarts=\"true\" data-type=\"{}\" data-series=\"{}\" data-title=\"{}\">[{} Chart: {}]</div>",
189            type_name.to_lowercase(),
190            spec.series.len(),
191            html_escape(title),
192            type_name,
193            html_escape(title)
194        )
195    } else {
196        format!(
197            "<div class=\"chart\" data-type=\"{}\" data-series=\"{}\">[{} Chart: {}]</div>",
198            type_name.to_lowercase(),
199            spec.series.len(),
200            type_name,
201            html_escape(title)
202        )
203    }
204}
205
206fn render_key_value(pairs: &[(String, ContentNode)], interactive: bool) -> String {
207    let mut out = String::from("<dl>\n");
208    for (key, value) in pairs {
209        let _ = write!(
210            out,
211            "<dt>{}</dt><dd>{}</dd>\n",
212            html_escape(key),
213            render_node(value, interactive)
214        );
215    }
216    out.push_str("</dl>");
217    out
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use shape_value::content::{BorderStyle, ContentTable};
224
225    fn renderer() -> HtmlRenderer {
226        HtmlRenderer::new()
227    }
228
229    #[test]
230    fn test_plain_text_html() {
231        let node = ContentNode::plain("hello world");
232        let output = renderer().render(&node);
233        assert_eq!(output, "hello world");
234    }
235
236    #[test]
237    fn test_bold_text_html() {
238        let node = ContentNode::plain("bold").with_bold();
239        let output = renderer().render(&node);
240        assert!(output.contains("font-weight:bold"));
241        assert!(output.contains("<span"));
242        assert!(output.contains("bold"));
243    }
244
245    #[test]
246    fn test_fg_color_html() {
247        let node = ContentNode::plain("red").with_fg(Color::Named(NamedColor::Red));
248        let output = renderer().render(&node);
249        assert!(output.contains("color:red"));
250    }
251
252    #[test]
253    fn test_rgb_color_html() {
254        let node = ContentNode::plain("custom").with_fg(Color::Rgb(255, 128, 0));
255        let output = renderer().render(&node);
256        assert!(output.contains("color:rgb(255,128,0)"));
257    }
258
259    #[test]
260    fn test_html_table() {
261        let table = ContentNode::Table(ContentTable {
262            headers: vec!["Name".into(), "Age".into()],
263            rows: vec![vec![ContentNode::plain("Alice"), ContentNode::plain("30")]],
264            border: BorderStyle::default(),
265            max_rows: None,
266            column_types: None,
267            total_rows: None,
268            sortable: false,
269        });
270        let output = renderer().render(&table);
271        assert!(output.contains("<table>"));
272        assert!(output.contains("<th>Name</th>"));
273        assert!(output.contains("<td>Alice</td>"));
274        assert!(output.contains("</table>"));
275    }
276
277    #[test]
278    fn test_html_table_truncation() {
279        let table = ContentNode::Table(ContentTable {
280            headers: vec!["X".into()],
281            rows: vec![
282                vec![ContentNode::plain("1")],
283                vec![ContentNode::plain("2")],
284                vec![ContentNode::plain("3")],
285            ],
286            border: BorderStyle::default(),
287            max_rows: Some(1),
288            column_types: None,
289            total_rows: None,
290            sortable: false,
291        });
292        let output = renderer().render(&table);
293        assert!(output.contains("... 2 more rows"));
294    }
295
296    #[test]
297    fn test_html_code() {
298        let code = ContentNode::Code {
299            language: Some("rust".into()),
300            source: "fn main() {}".into(),
301        };
302        let output = renderer().render(&code);
303        assert!(output.contains("<pre><code class=\"language-rust\">"));
304        assert!(output.contains("fn main() {}"));
305    }
306
307    #[test]
308    fn test_html_escape() {
309        let node = ContentNode::plain("<script>alert('xss')</script>");
310        let output = renderer().render(&node);
311        assert!(!output.contains("<script>"));
312        assert!(output.contains("&lt;script&gt;"));
313    }
314
315    #[test]
316    fn test_html_kv() {
317        let kv = ContentNode::KeyValue(vec![("name".into(), ContentNode::plain("Alice"))]);
318        let output = renderer().render(&kv);
319        assert!(output.contains("<dl>"));
320        assert!(output.contains("<dt>name</dt>"));
321        assert!(output.contains("<dd>Alice</dd>"));
322    }
323
324    #[test]
325    fn test_html_fragment() {
326        let frag = ContentNode::Fragment(vec![
327            ContentNode::plain("hello "),
328            ContentNode::plain("world"),
329        ]);
330        let output = renderer().render(&frag);
331        assert_eq!(output, "hello world");
332    }
333
334    #[test]
335    fn test_html_chart() {
336        let chart = ContentNode::Chart(shape_value::content::ChartSpec {
337            chart_type: shape_value::content::ChartType::Bar,
338            series: vec![],
339            title: Some("Sales".into()),
340            x_label: None,
341            y_label: None,
342            width: None,
343            height: None,
344            echarts_options: None,
345            interactive: true,
346        });
347        let output = renderer().render(&chart);
348        assert!(output.contains("data-type=\"bar\""));
349        assert!(output.contains("Sales"));
350    }
351}