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 = chart_type_display_name(spec.chart_type);
178    let y_count = spec.channels_by_name("y").len();
179    if interactive {
180        // Build ECharts option JSON for client-side hydration
181        let echarts_json = build_echarts_option(spec, type_name);
182        let escaped_json = html_escape(&echarts_json);
183        format!(
184            "<div class=\"chart\" data-echarts=\"true\" data-type=\"{}\" data-title=\"{}\" data-chart-options=\"{}\">[{} Chart: {}]</div>",
185            type_name.to_lowercase(),
186            html_escape(title),
187            escaped_json,
188            type_name,
189            html_escape(title)
190        )
191    } else {
192        format!(
193            "<div class=\"chart\" data-type=\"{}\" data-series=\"{}\">[{} Chart: {}]</div>",
194            type_name.to_lowercase(),
195            y_count,
196            type_name,
197            html_escape(title)
198        )
199    }
200}
201
202fn chart_type_display_name(ct: shape_value::content::ChartType) -> &'static str {
203    use shape_value::content::ChartType;
204    match ct {
205        ChartType::Line => "Line",
206        ChartType::Bar => "Bar",
207        ChartType::Scatter => "Scatter",
208        ChartType::Area => "Area",
209        ChartType::Candlestick => "Candlestick",
210        ChartType::Histogram => "Histogram",
211        ChartType::BoxPlot => "BoxPlot",
212        ChartType::Heatmap => "Heatmap",
213        ChartType::Bubble => "Bubble",
214    }
215}
216
217/// Build an ECharts option JSON string from a ChartSpec.
218fn build_echarts_option(spec: &ChartSpec, type_name: &str) -> String {
219    // If echarts_options is already set, use it directly
220    if let Some(ref opts) = spec.echarts_options {
221        return serde_json::to_string(opts).unwrap_or_default();
222    }
223
224    let chart_type = type_name.to_lowercase();
225
226    // Bar/histogram charts use category xAxis; line/scatter/area use value xAxis
227    let use_category = matches!(chart_type.as_str(), "bar" | "histogram");
228
229    // Get x channel data and y channels
230    let x_channel = spec.channel("x");
231    let y_channels = spec.channels_by_name("y");
232
233    // Extract x-axis categories from x channel
234    let categories: Vec<serde_json::Value> = if use_category {
235        if let Some(ref cats) = spec.x_categories {
236            cats.iter().map(|c| serde_json::json!(c)).collect()
237        } else if let Some(xc) = x_channel {
238            xc.values
239                .iter()
240                .map(|x| {
241                    if x.fract() == 0.0 {
242                        serde_json::json!(*x as i64)
243                    } else {
244                        serde_json::json!(x)
245                    }
246                })
247                .collect()
248        } else {
249            vec![]
250        }
251    } else {
252        vec![]
253    };
254
255    // Build ECharts series array from y channels
256    let series: Vec<serde_json::Value> = if y_channels.is_empty() {
257        vec![serde_json::json!({"type": chart_type, "data": []})]
258    } else {
259        y_channels
260            .iter()
261            .map(|yc| {
262                if use_category {
263                    let data: Vec<serde_json::Value> =
264                        yc.values.iter().map(|y| serde_json::json!(y)).collect();
265                    serde_json::json!({
266                        "name": yc.label,
267                        "type": chart_type,
268                        "data": data,
269                    })
270                } else {
271                    // Pair x and y values
272                    let x_vals = x_channel.map(|xc| &xc.values[..]).unwrap_or(&[]);
273                    let data: Vec<serde_json::Value> = yc
274                        .values
275                        .iter()
276                        .enumerate()
277                        .map(|(i, y)| {
278                            let x = x_vals.get(i).copied().unwrap_or(i as f64);
279                            serde_json::json!([x, y])
280                        })
281                        .collect();
282                    serde_json::json!({
283                        "name": yc.label,
284                        "type": chart_type,
285                        "data": data,
286                        "smooth": false,
287                    })
288                }
289            })
290            .collect()
291    };
292
293    let mut option = serde_json::json!({
294        "tooltip": {"trigger": "axis"},
295        "series": series,
296        "backgroundColor": "transparent",
297    });
298
299    if let Some(ref t) = spec.title {
300        option["title"] = serde_json::json!({"text": t, "textStyle": {"color": "#ccc", "fontSize": 14}});
301    }
302
303    // xAxis: category for bar/histogram, value for others
304    let x_axis_type = if use_category { "category" } else { "value" };
305    let mut x_axis = serde_json::json!({
306        "type": x_axis_type,
307        "axisLabel": {"color": "#888"},
308        "axisLine": {"lineStyle": {"color": "#555"}},
309    });
310    if use_category && !categories.is_empty() {
311        x_axis["data"] = serde_json::json!(categories);
312    }
313    if let Some(ref xl) = spec.x_label {
314        x_axis["name"] = serde_json::json!(xl);
315        x_axis["nameTextStyle"] = serde_json::json!({"color": "#888"});
316    }
317    option["xAxis"] = x_axis;
318
319    let mut y_axis = serde_json::json!({
320        "type": "value",
321        "axisLabel": {"color": "#888"},
322        "splitLine": {"lineStyle": {"color": "#333"}},
323    });
324    if let Some(ref yl) = spec.y_label {
325        y_axis["name"] = serde_json::json!(yl);
326        y_axis["nameTextStyle"] = serde_json::json!({"color": "#888"});
327    }
328    option["yAxis"] = y_axis;
329
330    if y_channels.len() > 1 {
331        option["legend"] = serde_json::json!({"show": true, "textStyle": {"color": "#ccc"}});
332    }
333
334    option["grid"] = serde_json::json!({"left": "10%", "right": "10%", "bottom": "10%", "top": "15%"});
335
336    serde_json::to_string(&option).unwrap_or_default()
337}
338
339fn render_key_value(pairs: &[(String, ContentNode)], interactive: bool) -> String {
340    let mut out = String::from("<dl>\n");
341    for (key, value) in pairs {
342        let _ = write!(
343            out,
344            "<dt>{}</dt><dd>{}</dd>\n",
345            html_escape(key),
346            render_node(value, interactive)
347        );
348    }
349    out.push_str("</dl>");
350    out
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use shape_value::content::{BorderStyle, ContentTable};
357
358    fn renderer() -> HtmlRenderer {
359        HtmlRenderer::new()
360    }
361
362    #[test]
363    fn test_plain_text_html() {
364        let node = ContentNode::plain("hello world");
365        let output = renderer().render(&node);
366        assert_eq!(output, "hello world");
367    }
368
369    #[test]
370    fn test_bold_text_html() {
371        let node = ContentNode::plain("bold").with_bold();
372        let output = renderer().render(&node);
373        assert!(output.contains("font-weight:bold"));
374        assert!(output.contains("<span"));
375        assert!(output.contains("bold"));
376    }
377
378    #[test]
379    fn test_fg_color_html() {
380        let node = ContentNode::plain("red").with_fg(Color::Named(NamedColor::Red));
381        let output = renderer().render(&node);
382        assert!(output.contains("color:red"));
383    }
384
385    #[test]
386    fn test_rgb_color_html() {
387        let node = ContentNode::plain("custom").with_fg(Color::Rgb(255, 128, 0));
388        let output = renderer().render(&node);
389        assert!(output.contains("color:rgb(255,128,0)"));
390    }
391
392    #[test]
393    fn test_html_table() {
394        let table = ContentNode::Table(ContentTable {
395            headers: vec!["Name".into(), "Age".into()],
396            rows: vec![vec![ContentNode::plain("Alice"), ContentNode::plain("30")]],
397            border: BorderStyle::default(),
398            max_rows: None,
399            column_types: None,
400            total_rows: None,
401            sortable: false,
402        });
403        let output = renderer().render(&table);
404        assert!(output.contains("<table>"));
405        assert!(output.contains("<th>Name</th>"));
406        assert!(output.contains("<td>Alice</td>"));
407        assert!(output.contains("</table>"));
408    }
409
410    #[test]
411    fn test_html_table_truncation() {
412        let table = ContentNode::Table(ContentTable {
413            headers: vec!["X".into()],
414            rows: vec![
415                vec![ContentNode::plain("1")],
416                vec![ContentNode::plain("2")],
417                vec![ContentNode::plain("3")],
418            ],
419            border: BorderStyle::default(),
420            max_rows: Some(1),
421            column_types: None,
422            total_rows: None,
423            sortable: false,
424        });
425        let output = renderer().render(&table);
426        assert!(output.contains("... 2 more rows"));
427    }
428
429    #[test]
430    fn test_html_code() {
431        let code = ContentNode::Code {
432            language: Some("rust".into()),
433            source: "fn main() {}".into(),
434        };
435        let output = renderer().render(&code);
436        assert!(output.contains("<pre><code class=\"language-rust\">"));
437        assert!(output.contains("fn main() {}"));
438    }
439
440    #[test]
441    fn test_html_escape() {
442        let node = ContentNode::plain("<script>alert('xss')</script>");
443        let output = renderer().render(&node);
444        assert!(!output.contains("<script>"));
445        assert!(output.contains("&lt;script&gt;"));
446    }
447
448    #[test]
449    fn test_html_kv() {
450        let kv = ContentNode::KeyValue(vec![("name".into(), ContentNode::plain("Alice"))]);
451        let output = renderer().render(&kv);
452        assert!(output.contains("<dl>"));
453        assert!(output.contains("<dt>name</dt>"));
454        assert!(output.contains("<dd>Alice</dd>"));
455    }
456
457    #[test]
458    fn test_html_fragment() {
459        let frag = ContentNode::Fragment(vec![
460            ContentNode::plain("hello "),
461            ContentNode::plain("world"),
462        ]);
463        let output = renderer().render(&frag);
464        assert_eq!(output, "hello world");
465    }
466
467    #[test]
468    fn test_html_chart() {
469        let chart = ContentNode::Chart(shape_value::content::ChartSpec {
470            chart_type: shape_value::content::ChartType::Bar,
471            channels: vec![],
472            x_categories: None,
473            title: Some("Sales".into()),
474            x_label: None,
475            y_label: None,
476            width: None,
477            height: None,
478            echarts_options: None,
479            interactive: true,
480        });
481        let output = renderer().render(&chart);
482        assert!(output.contains("data-type=\"bar\""));
483        assert!(output.contains("Sales"));
484    }
485}