1use crate::content_renderer::{ContentRenderer, RenderContext, RendererCapabilities};
11use shape_value::content::{ChartSpec, Color, ContentNode, ContentTable, NamedColor, Style};
12use std::fmt::Write;
13
14pub 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('&', "&")
122 .replace('<', "<")
123 .replace('>', ">")
124 .replace('"', """)
125}
126
127fn render_table(table: &ContentTable, interactive: bool) -> String {
128 let mut out = String::from("<table>\n");
129
130 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 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 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("<script>"));
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}