Skip to main content

adk_ui/
html.rs

1//! HTML renderer for converting Component trees into clean, embeddable HTML.
2//!
3//! This module provides two public entry points:
4//! - [`render_components_html`] — typed path for `Vec<Component>`
5//! - [`render_surface_html`] — deserializes `Vec<Value>` from a `UiSurface`
6//!
7//! Both produce self-contained HTML with inline styles only (no external CSS/JS).
8
9use crate::interop::surface::UiSurface;
10use crate::schema::*;
11use serde::{Deserialize, Serialize};
12
13/// Bandwidth mode controlling adaptive rendering.
14///
15/// In `Low` mode, bandwidth-sensitive components (Chart, Image, Skeleton, Spinner)
16/// are omitted and inline `style="..."` attributes are stripped.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum BandwidthMode {
21    #[default]
22    Full,
23    Low,
24}
25
26/// Options for HTML rendering.
27#[derive(Debug, Clone, Default)]
28pub struct HtmlRenderOptions {
29    /// Bandwidth mode controlling adaptive rendering.
30    pub bandwidth_mode: BandwidthMode,
31    /// Optional CSS class prefix to namespace generated classes (e.g. "adk-").
32    pub class_prefix: Option<String>,
33}
34
35/// Escape user-provided text to prevent HTML injection.
36///
37/// Escapes `<`, `>`, `&`, `"`, and `'`.
38pub fn escape_html(input: &str) -> String {
39    let mut output = String::with_capacity(input.len());
40    for ch in input.chars() {
41        match ch {
42            '<' => output.push_str("&lt;"),
43            '>' => output.push_str("&gt;"),
44            '&' => output.push_str("&amp;"),
45            '"' => output.push_str("&quot;"),
46            '\'' => output.push_str("&#x27;"),
47            _ => output.push(ch),
48        }
49    }
50    output
51}
52
53/// Helper to produce a prefixed CSS class name.
54fn cls(prefix: &Option<String>, name: &str) -> String {
55    match prefix {
56        Some(p) => format!("{}{}", p, name),
57        None => name.to_string(),
58    }
59}
60
61/// Render a single Component to an HTML fragment.
62fn render_component_html(
63    component: &Component,
64    options: &HtmlRenderOptions,
65) -> String {
66    let mode = options.bandwidth_mode;
67    let prefix = &options.class_prefix;
68
69    match component {
70        // --- Atoms ---
71        Component::Text(text) => {
72            let content = escape_html(&text.content);
73            match text.variant {
74                TextVariant::H1 => format!("<h1>{}</h1>", content),
75                TextVariant::H2 => format!("<h2>{}</h2>", content),
76                TextVariant::H3 => format!("<h3>{}</h3>", content),
77                TextVariant::H4 => format!("<h4>{}</h4>", content),
78                TextVariant::Body => format!("<p>{}</p>", content),
79                TextVariant::Caption => format!("<small>{}</small>", content),
80                TextVariant::Code => format!("<code>{}</code>", content),
81            }
82        }
83
84        Component::Button(button) => {
85            let label = escape_html(&button.label);
86            let action_id = escape_html(&button.action_id);
87            let disabled = if button.disabled { " disabled" } else { "" };
88            format!(
89                "<button data-action-id=\"{}\"{}>{}</button>",
90                action_id, disabled, label
91            )
92        }
93
94        Component::Icon(icon) => {
95            let name = escape_html(&icon.name);
96            format!(
97                "<span class=\"{}\" data-icon=\"{}\">{}</span>",
98                cls(prefix, "icon"),
99                name,
100                name
101            )
102        }
103
104        Component::Image(image) => {
105            if mode == BandwidthMode::Low {
106                return String::new();
107            }
108            let src = escape_html(&image.src);
109            let alt = image
110                .alt
111                .as_deref()
112                .map(escape_html)
113                .unwrap_or_default();
114            format!("<img src=\"{}\" alt=\"{}\">", src, alt)
115        }
116
117        Component::Badge(badge) => {
118            let label = escape_html(&badge.label);
119            let variant = badge_variant_str(&badge.variant);
120            format!(
121                "<span class=\"{} {}\">{}</span>",
122                cls(prefix, "badge"),
123                cls(prefix, &format!("badge-{}", variant)),
124                label
125            )
126        }
127
128        // --- Inputs ---
129        Component::TextInput(input) => {
130            let label = escape_html(&input.label);
131            let name = escape_html(&input.name);
132            let placeholder = input
133                .placeholder
134                .as_deref()
135                .map(|p| format!(" placeholder=\"{}\"", escape_html(p)))
136                .unwrap_or_default();
137            let required = if input.required { " required" } else { "" };
138            let default_val = input
139                .default_value
140                .as_deref()
141                .map(|v| format!(" value=\"{}\"", escape_html(v)))
142                .unwrap_or_default();
143            format!(
144                "<label>{}<input type=\"text\" name=\"{}\"{}{}{}></label>",
145                label, name, placeholder, required, default_val
146            )
147        }
148
149        Component::NumberInput(input) => {
150            let label = escape_html(&input.label);
151            let name = escape_html(&input.name);
152            let min = input
153                .min
154                .map(|v| format!(" min=\"{}\"", v))
155                .unwrap_or_default();
156            let max = input
157                .max
158                .map(|v| format!(" max=\"{}\"", v))
159                .unwrap_or_default();
160            let step = input
161                .step
162                .map(|v| format!(" step=\"{}\"", v))
163                .unwrap_or_default();
164            let required = if input.required { " required" } else { "" };
165            let default_val = input
166                .default_value
167                .map(|v| format!(" value=\"{}\"", v))
168                .unwrap_or_default();
169            format!(
170                "<label>{}<input type=\"number\" name=\"{}\"{}{}{}{}{}></label>",
171                label, name, min, max, step, required, default_val
172            )
173        }
174
175        Component::Select(select) => {
176            let label = escape_html(&select.label);
177            let name = escape_html(&select.name);
178            let required = if select.required { " required" } else { "" };
179            let options_html: String = select
180                .options
181                .iter()
182                .map(|opt| {
183                    format!(
184                        "<option value=\"{}\">{}</option>",
185                        escape_html(&opt.value),
186                        escape_html(&opt.label)
187                    )
188                })
189                .collect();
190            format!(
191                "<label>{}<select name=\"{}\"{}>{}</select></label>",
192                label, name, required, options_html
193            )
194        }
195
196        Component::MultiSelect(multi) => {
197            let label = escape_html(&multi.label);
198            let name = escape_html(&multi.name);
199            let required = if multi.required { " required" } else { "" };
200            let options_html: String = multi
201                .options
202                .iter()
203                .map(|opt| {
204                    format!(
205                        "<option value=\"{}\">{}</option>",
206                        escape_html(&opt.value),
207                        escape_html(&opt.label)
208                    )
209                })
210                .collect();
211            format!(
212                "<label>{}<select multiple name=\"{}\"{}>{}</select></label>",
213                label, name, required, options_html
214            )
215        }
216
217        Component::Switch(switch) => {
218            let label = escape_html(&switch.label);
219            let name = escape_html(&switch.name);
220            let checked = if switch.default_checked {
221                " checked"
222            } else {
223                ""
224            };
225            format!(
226                "<label>{}<input type=\"checkbox\" role=\"switch\" name=\"{}\"{}></label>",
227                label, name, checked
228            )
229        }
230
231        Component::DateInput(date) => {
232            let label = escape_html(&date.label);
233            let name = escape_html(&date.name);
234            let required = if date.required { " required" } else { "" };
235            format!(
236                "<label>{}<input type=\"date\" name=\"{}\"{}></label>",
237                label, name, required
238            )
239        }
240
241        Component::Slider(slider) => {
242            let label = escape_html(&slider.label);
243            let name = escape_html(&slider.name);
244            let step = slider
245                .step
246                .map(|v| format!(" step=\"{}\"", v))
247                .unwrap_or_default();
248            let default_val = slider
249                .default_value
250                .map(|v| format!(" value=\"{}\"", v))
251                .unwrap_or_default();
252            format!(
253                "<label>{}<input type=\"range\" name=\"{}\" min=\"{}\" max=\"{}\"{}{}></label>",
254                label, name, slider.min, slider.max, step, default_val
255            )
256        }
257
258        Component::Textarea(textarea) => {
259            let label = escape_html(&textarea.label);
260            let name = escape_html(&textarea.name);
261            let placeholder = textarea
262                .placeholder
263                .as_deref()
264                .map(|p| format!(" placeholder=\"{}\"", escape_html(p)))
265                .unwrap_or_default();
266            let required = if textarea.required { " required" } else { "" };
267            let default_val = textarea
268                .default_value
269                .as_deref()
270                .map(escape_html)
271                .unwrap_or_default();
272            format!(
273                "<label>{}<textarea name=\"{}\" rows=\"{}\"{}{}>{}</textarea></label>",
274                label, name, textarea.rows, placeholder, required, default_val
275            )
276        }
277
278        // --- Layouts ---
279        Component::Stack(stack) => {
280            let dir = match stack.direction {
281                StackDirection::Horizontal => "horizontal",
282                StackDirection::Vertical => "vertical",
283            };
284            let children_html = render_children(&stack.children, options);
285            let style_attr = if mode == BandwidthMode::Low {
286                String::new()
287            } else if stack.gap > 0 {
288                format!(" style=\"gap: {}px\"", stack.gap)
289            } else {
290                String::new()
291            };
292            format!(
293                "<div class=\"{} {}\"{}>{}</div>",
294                cls(prefix, "stack"),
295                cls(prefix, &format!("stack-{}", dir)),
296                style_attr,
297                children_html
298            )
299        }
300
301        Component::Grid(grid) => {
302            let children_html = render_children(&grid.children, options);
303            let style_attr = if mode == BandwidthMode::Low {
304                String::new()
305            } else {
306                format!(
307                    " style=\"grid-template-columns: repeat({}, 1fr)\"",
308                    grid.columns
309                )
310            };
311            format!(
312                "<div class=\"{}\"{}>{}</div>",
313                cls(prefix, "grid"),
314                style_attr,
315                children_html
316            )
317        }
318
319        Component::Card(card) => {
320            let mut html = format!("<div class=\"{}\">", cls(prefix, "card"));
321            if let Some(title) = &card.title {
322                html.push_str(&format!("<h3>{}</h3>", escape_html(title)));
323            }
324            if let Some(desc) = &card.description {
325                html.push_str(&format!("<p>{}</p>", escape_html(desc)));
326            }
327            if !card.content.is_empty() {
328                html.push_str(&format!(
329                    "<div class=\"{}\">",
330                    cls(prefix, "card-content")
331                ));
332                html.push_str(&render_children(&card.content, options));
333                html.push_str("</div>");
334            }
335            if let Some(footer) = &card.footer {
336                html.push_str(&format!(
337                    "<div class=\"{}\">",
338                    cls(prefix, "card-footer")
339                ));
340                html.push_str(&render_children(footer, options));
341                html.push_str("</div>");
342            }
343            html.push_str("</div>");
344            html
345        }
346
347        Component::Container(container) => {
348            let children_html = render_children(&container.children, options);
349            let style_attr = if mode == BandwidthMode::Low || container.padding == 0 {
350                String::new()
351            } else {
352                format!(" style=\"padding: {}px\"", container.padding)
353            };
354            format!(
355                "<div class=\"{}\"{}>{}</div>",
356                cls(prefix, "container"),
357                style_attr,
358                children_html
359            )
360        }
361
362        Component::Divider(_) => "<hr>".to_string(),
363
364        Component::Tabs(tabs) => {
365            let mut html = format!("<div class=\"{}\">", cls(prefix, "tabs"));
366            // Tab buttons
367            html.push_str(&format!(
368                "<div class=\"{}\">",
369                cls(prefix, "tab-buttons")
370            ));
371            for (i, tab) in tabs.tabs.iter().enumerate() {
372                html.push_str(&format!(
373                    "<button class=\"{}\" data-tab-index=\"{}\">{}</button>",
374                    cls(prefix, "tab-button"),
375                    i,
376                    escape_html(&tab.label)
377                ));
378            }
379            html.push_str("</div>");
380            // Tab content panels
381            for (i, tab) in tabs.tabs.iter().enumerate() {
382                html.push_str(&format!(
383                    "<div class=\"{}\" data-tab-panel=\"{}\">",
384                    cls(prefix, "tab-panel"),
385                    i
386                ));
387                html.push_str(&render_children(&tab.content, options));
388                html.push_str("</div>");
389            }
390            html.push_str("</div>");
391            html
392        }
393
394        // --- Data Display ---
395        Component::Table(table) => {
396            let mut html = String::from("<table>");
397            // Header
398            html.push_str("<thead><tr>");
399            for col in &table.columns {
400                html.push_str(&format!("<th>{}</th>", escape_html(&col.header)));
401            }
402            html.push_str("</tr></thead>");
403            // Body
404            html.push_str("<tbody>");
405            for row in &table.data {
406                html.push_str("<tr>");
407                for col in &table.columns {
408                    let cell_value = row
409                        .get(&col.accessor_key)
410                        .map(|v| match v {
411                            serde_json::Value::String(s) => escape_html(s),
412                            other => escape_html(&other.to_string()),
413                        })
414                        .unwrap_or_default();
415                    html.push_str(&format!("<td>{}</td>", cell_value));
416                }
417                html.push_str("</tr>");
418            }
419            html.push_str("</tbody></table>");
420            html
421        }
422
423        Component::List(list) => {
424            let tag = if list.ordered { "ol" } else { "ul" };
425            let items_html: String = list
426                .items
427                .iter()
428                .map(|item| format!("<li>{}</li>", escape_html(item)))
429                .collect();
430            format!("<{}>{}</{}>", tag, items_html, tag)
431        }
432
433        Component::KeyValue(kv) => {
434            let mut html = String::from("<dl>");
435            for pair in &kv.pairs {
436                html.push_str(&format!(
437                    "<dt>{}</dt><dd>{}</dd>",
438                    escape_html(&pair.key),
439                    escape_html(&pair.value)
440                ));
441            }
442            html.push_str("</dl>");
443            html
444        }
445
446        Component::CodeBlock(code_block) => {
447            let lang_attr = code_block
448                .language
449                .as_deref()
450                .map(|l| format!(" class=\"language-{}\"", escape_html(l)))
451                .unwrap_or_default();
452            format!(
453                "<pre><code{}>{}</code></pre>",
454                lang_attr,
455                escape_html(&code_block.code)
456            )
457        }
458
459        // --- Visualizations ---
460        Component::Chart(chart) => {
461            if mode == BandwidthMode::Low {
462                return String::new();
463            }
464            let chart_json = escape_html(
465                &serde_json::to_string(chart).unwrap_or_default(),
466            );
467            let title_html = chart
468                .title
469                .as_deref()
470                .map(|t| format!("<p>{}</p>", escape_html(t)))
471                .unwrap_or_default();
472            format!(
473                "<div class=\"{}\" data-chart=\"{}\">{}</div>",
474                cls(prefix, "chart-placeholder"),
475                chart_json,
476                title_html
477            )
478        }
479
480        // --- Feedback ---
481        Component::Alert(alert) => {
482            let variant = alert_variant_str(&alert.variant);
483            let desc = alert
484                .description
485                .as_deref()
486                .map(|d| format!("<p>{}</p>", escape_html(d)))
487                .unwrap_or_default();
488            format!(
489                "<div class=\"{} {}\" role=\"alert\"><strong>{}</strong>{}</div>",
490                cls(prefix, "alert"),
491                cls(prefix, &format!("alert-{}", variant)),
492                escape_html(&alert.title),
493                desc
494            )
495        }
496
497        Component::Progress(progress) => {
498            let label = progress
499                .label
500                .as_deref()
501                .map(|l| format!(" aria-label=\"{}\"", escape_html(l)))
502                .unwrap_or_default();
503            format!(
504                "<progress value=\"{}\" max=\"100\"{}></progress>",
505                progress.value, label
506            )
507        }
508
509        Component::Toast(toast) => {
510            let variant = alert_variant_str(&toast.variant);
511            format!(
512                "<div class=\"{} {}\">{}</div>",
513                cls(prefix, "toast"),
514                cls(prefix, &format!("toast-{}", variant)),
515                escape_html(&toast.message)
516            )
517        }
518
519        Component::Modal(modal) => {
520            let mut html = String::from("<dialog>");
521            html.push_str(&format!("<h2>{}</h2>", escape_html(&modal.title)));
522            html.push_str(&render_children(&modal.content, options));
523            if let Some(footer) = &modal.footer {
524                html.push_str(&format!(
525                    "<div class=\"{}\">",
526                    cls(prefix, "modal-footer")
527                ));
528                html.push_str(&render_children(footer, options));
529                html.push_str("</div>");
530            }
531            html.push_str("</dialog>");
532            html
533        }
534
535        Component::Spinner(spinner) => {
536            if mode == BandwidthMode::Low {
537                return String::new();
538            }
539            let label = spinner
540                .label
541                .as_deref()
542                .map(|l| format!(" aria-label=\"{}\"", escape_html(l)))
543                .unwrap_or_default();
544            format!(
545                "<div class=\"{}\" role=\"status\"{}></div>",
546                cls(prefix, "spinner"),
547                label
548            )
549        }
550
551        Component::Skeleton(_) => {
552            if mode == BandwidthMode::Low {
553                return String::new();
554            }
555            format!("<div class=\"{}\"></div>", cls(prefix, "skeleton"))
556        }
557    }
558}
559
560/// Render a list of child components.
561fn render_children(children: &[Component], options: &HtmlRenderOptions) -> String {
562    children
563        .iter()
564        .map(|c| render_component_html(c, options))
565        .collect()
566}
567
568/// Helper to convert BadgeVariant to a CSS-friendly string.
569fn badge_variant_str(variant: &BadgeVariant) -> &'static str {
570    match variant {
571        BadgeVariant::Default => "default",
572        BadgeVariant::Info => "info",
573        BadgeVariant::Success => "success",
574        BadgeVariant::Warning => "warning",
575        BadgeVariant::Error => "error",
576        BadgeVariant::Secondary => "secondary",
577        BadgeVariant::Outline => "outline",
578    }
579}
580
581/// Helper to convert AlertVariant to a CSS-friendly string.
582fn alert_variant_str(variant: &AlertVariant) -> &'static str {
583    match variant {
584        AlertVariant::Info => "info",
585        AlertVariant::Success => "success",
586        AlertVariant::Warning => "warning",
587        AlertVariant::Error => "error",
588    }
589}
590
591/// Minimal inline CSS for the HTML shell.
592const INLINE_CSS: &str = r#"
593body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 16px; color: #1a1a1a; }
594.stack { display: flex; }
595.stack-vertical { flex-direction: column; }
596.stack-horizontal { flex-direction: row; }
597.grid { display: grid; }
598.card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
599.card-content { margin-top: 8px; }
600.card-footer { margin-top: 12px; border-top: 1px solid #e0e0e0; padding-top: 8px; }
601.container { padding: 16px; }
602.tabs { margin-bottom: 12px; }
603.tab-buttons { display: flex; gap: 4px; border-bottom: 1px solid #e0e0e0; margin-bottom: 8px; }
604.tab-button { background: none; border: none; padding: 8px 16px; cursor: pointer; }
605.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.85em; }
606.badge-default { background: #e0e0e0; }
607.badge-info { background: #dbeafe; color: #1e40af; }
608.badge-success { background: #dcfce7; color: #166534; }
609.badge-warning { background: #fef3c7; color: #92400e; }
610.badge-error { background: #fee2e2; color: #991b1b; }
611.badge-secondary { background: #f3f4f6; color: #374151; }
612.badge-outline { border: 1px solid #d1d5db; background: transparent; }
613.alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 12px; }
614.alert-info { background: #dbeafe; color: #1e40af; }
615.alert-success { background: #dcfce7; color: #166534; }
616.alert-warning { background: #fef3c7; color: #92400e; }
617.alert-error { background: #fee2e2; color: #991b1b; }
618.toast { padding: 12px 16px; border-radius: 6px; margin-bottom: 8px; }
619.toast-info { background: #dbeafe; }
620.toast-success { background: #dcfce7; }
621.toast-warning { background: #fef3c7; }
622.toast-error { background: #fee2e2; }
623.spinner { width: 24px; height: 24px; border: 3px solid #e0e0e0; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; }
624@keyframes spin { to { transform: rotate(360deg); } }
625.skeleton { background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; min-height: 20px; }
626@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
627.chart-placeholder { border: 1px dashed #d1d5db; padding: 16px; text-align: center; color: #6b7280; }
628.icon { display: inline-flex; align-items: center; }
629.modal-footer { margin-top: 12px; border-top: 1px solid #e0e0e0; padding-top: 8px; }
630table { width: 100%; border-collapse: collapse; }
631th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #e0e0e0; }
632th { font-weight: 600; }
633progress { width: 100%; }
634label { display: block; margin-bottom: 12px; }
635input, select, textarea { display: block; margin-top: 4px; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 4px; width: 100%; box-sizing: border-box; }
636button { padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; cursor: pointer; background: #3b82f6; color: white; }
637button:disabled { opacity: 0.5; cursor: not-allowed; }
638hr { border: none; border-top: 1px solid #e0e0e0; margin: 12px 0; }
639dl { margin: 0; }
640dt { font-weight: 600; margin-top: 8px; }
641dd { margin-left: 0; margin-bottom: 4px; }
642pre { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; }
643code { font-family: ui-monospace, monospace; }
644dialog { border: 1px solid #e0e0e0; border-radius: 8px; padding: 24px; max-width: 600px; }
645"#;
646
647/// Generate the prefixed inline CSS when a class prefix is set.
648fn generate_prefixed_css(prefix: &str) -> String {
649    INLINE_CSS.replace('.', &format!(".{}", prefix))
650}
651
652/// Wrap rendered component HTML in a minimal HTML document shell.
653fn wrap_in_shell(body: &str, options: &HtmlRenderOptions) -> String {
654    let css = match &options.class_prefix {
655        Some(p) => {
656            // Include both unprefixed (for elements like table, button, etc.) and prefixed CSS
657            let prefixed = generate_prefixed_css(p);
658            format!("{}\n{}", INLINE_CSS.trim(), prefixed.trim())
659        }
660        None => INLINE_CSS.trim().to_string(),
661    };
662
663    if options.bandwidth_mode == BandwidthMode::Low {
664        // In low bandwidth mode, omit the style block entirely
665        format!(
666            "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"></head><body>{}</body></html>",
667            body
668        )
669    } else {
670        format!(
671            "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><style>{}</style></head><body>{}</body></html>",
672            css, body
673        )
674    }
675}
676
677/// Render typed components directly to HTML.
678///
679/// Preferred when you have a `UiResponse` with `Vec<Component>`.
680/// Wraps output in a minimal self-contained HTML shell with inline styles only.
681pub fn render_components_html(components: &[Component], options: &HtmlRenderOptions) -> String {
682    let body: String = components
683        .iter()
684        .map(|c| render_component_html(c, options))
685        .collect();
686    wrap_in_shell(&body, options)
687}
688
689/// Render a UiSurface as embeddable HTML.
690///
691/// Deserializes each `Value` in `surface.components` into a `Component`.
692/// Values that fail deserialization are rendered as `<!-- unknown component -->`.
693/// Wraps output in a minimal self-contained HTML shell with inline styles only.
694pub fn render_surface_html(surface: &UiSurface, options: &HtmlRenderOptions) -> String {
695    let body: String = surface
696        .components
697        .iter()
698        .map(|value| {
699            match serde_json::from_value::<Component>(value.clone()) {
700                Ok(component) => render_component_html(&component, options),
701                Err(_) => "<!-- unknown component -->".to_string(),
702            }
703        })
704        .collect();
705    wrap_in_shell(&body, options)
706}
707
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use serde_json::json;
713
714    fn default_opts() -> HtmlRenderOptions {
715        HtmlRenderOptions::default()
716    }
717
718    fn low_bw_opts() -> HtmlRenderOptions {
719        HtmlRenderOptions {
720            bandwidth_mode: BandwidthMode::Low,
721            ..Default::default()
722        }
723    }
724
725    fn prefixed_opts(prefix: &str) -> HtmlRenderOptions {
726        HtmlRenderOptions {
727            class_prefix: Some(prefix.to_string()),
728            ..Default::default()
729        }
730    }
731
732    // --- escape_html ---
733
734    #[test]
735    fn escape_html_escapes_all_special_chars() {
736        assert_eq!(
737            escape_html("<script>alert('xss')&\"</script>"),
738            "&lt;script&gt;alert(&#x27;xss&#x27;)&amp;&quot;&lt;/script&gt;"
739        );
740    }
741
742    #[test]
743    fn escape_html_passes_through_normal_text() {
744        assert_eq!(escape_html("Hello, world!"), "Hello, world!");
745    }
746
747    // --- BandwidthMode ---
748
749    #[test]
750    fn bandwidth_mode_default_is_full() {
751        assert_eq!(BandwidthMode::default(), BandwidthMode::Full);
752    }
753
754    // --- Text variants ---
755
756    #[test]
757    fn text_body_renders_as_p() {
758        let c = Component::Text(Text {
759            id: None,
760            content: "Hello".to_string(),
761            variant: TextVariant::Body,
762        });
763        let html = render_component_html(&c, &default_opts());
764        assert_eq!(html, "<p>Hello</p>");
765    }
766
767    #[test]
768    fn text_h1_renders_as_h1() {
769        let c = Component::Text(Text {
770            id: None,
771            content: "Title".to_string(),
772            variant: TextVariant::H1,
773        });
774        let html = render_component_html(&c, &default_opts());
775        assert_eq!(html, "<h1>Title</h1>");
776    }
777
778    #[test]
779    fn text_caption_renders_as_small() {
780        let c = Component::Text(Text {
781            id: None,
782            content: "Note".to_string(),
783            variant: TextVariant::Caption,
784        });
785        let html = render_component_html(&c, &default_opts());
786        assert_eq!(html, "<small>Note</small>");
787    }
788
789    #[test]
790    fn text_code_renders_as_code() {
791        let c = Component::Text(Text {
792            id: None,
793            content: "let x = 1;".to_string(),
794            variant: TextVariant::Code,
795        });
796        let html = render_component_html(&c, &default_opts());
797        assert_eq!(html, "<code>let x = 1;</code>");
798    }
799
800    // --- Button ---
801
802    #[test]
803    fn button_renders_with_action_id() {
804        let c = Component::Button(Button {
805            id: None,
806            label: "Click me".to_string(),
807            action_id: "btn-1".to_string(),
808            variant: ButtonVariant::Primary,
809            disabled: false,
810            icon: None,
811        });
812        let html = render_component_html(&c, &default_opts());
813        assert!(html.contains("data-action-id=\"btn-1\""));
814        assert!(html.contains("Click me"));
815    }
816
817    #[test]
818    fn button_disabled_renders_disabled_attr() {
819        let c = Component::Button(Button {
820            id: None,
821            label: "Disabled".to_string(),
822            action_id: "btn-2".to_string(),
823            variant: ButtonVariant::Primary,
824            disabled: true,
825            icon: None,
826        });
827        let html = render_component_html(&c, &default_opts());
828        assert!(html.contains(" disabled"));
829    }
830
831    // --- Image ---
832
833    #[test]
834    fn image_renders_in_full_mode() {
835        let c = Component::Image(Image {
836            id: None,
837            src: "https://example.com/img.png".to_string(),
838            alt: Some("A photo".to_string()),
839        });
840        let html = render_component_html(&c, &default_opts());
841        assert!(html.contains("<img"));
842        assert!(html.contains("src=\"https://example.com/img.png\""));
843        assert!(html.contains("alt=\"A photo\""));
844    }
845
846    #[test]
847    fn image_omitted_in_low_bandwidth() {
848        let c = Component::Image(Image {
849            id: None,
850            src: "https://example.com/img.png".to_string(),
851            alt: Some("A photo".to_string()),
852        });
853        let html = render_component_html(&c, &low_bw_opts());
854        assert!(html.is_empty());
855    }
856
857    // --- Badge ---
858
859    #[test]
860    fn badge_renders_with_variant() {
861        let c = Component::Badge(Badge {
862            id: None,
863            label: "New".to_string(),
864            variant: BadgeVariant::Success,
865        });
866        let html = render_component_html(&c, &default_opts());
867        assert!(html.contains("badge"));
868        assert!(html.contains("badge-success"));
869        assert!(html.contains("New"));
870    }
871
872    // --- Stack ---
873
874    #[test]
875    fn stack_vertical_renders_correctly() {
876        let c = Component::Stack(Stack {
877            id: None,
878            direction: StackDirection::Vertical,
879            children: vec![Component::Text(Text {
880                id: None,
881                content: "Child".to_string(),
882                variant: TextVariant::Body,
883            })],
884            gap: 0,
885        });
886        let html = render_component_html(&c, &default_opts());
887        assert!(html.contains("stack-vertical"));
888        assert!(html.contains("<p>Child</p>"));
889    }
890
891    // --- Grid ---
892
893    #[test]
894    fn grid_renders_with_columns() {
895        let c = Component::Grid(Grid {
896            id: None,
897            columns: 3,
898            children: vec![],
899            gap: 0,
900        });
901        let html = render_component_html(&c, &default_opts());
902        assert!(html.contains("grid"));
903        assert!(html.contains("grid-template-columns: repeat(3, 1fr)"));
904    }
905
906    #[test]
907    fn grid_low_bandwidth_strips_style() {
908        let c = Component::Grid(Grid {
909            id: None,
910            columns: 3,
911            children: vec![],
912            gap: 0,
913        });
914        let html = render_component_html(&c, &low_bw_opts());
915        assert!(html.contains("grid"));
916        assert!(!html.contains("style="));
917    }
918
919    // --- Card ---
920
921    #[test]
922    fn card_renders_with_title_and_content() {
923        let c = Component::Card(Card {
924            id: None,
925            title: Some("My Card".to_string()),
926            description: None,
927            content: vec![Component::Text(Text {
928                id: None,
929                content: "Body text".to_string(),
930                variant: TextVariant::Body,
931            })],
932            footer: None,
933        });
934        let html = render_component_html(&c, &default_opts());
935        assert!(html.contains("card"));
936        assert!(html.contains("<h3>My Card</h3>"));
937        assert!(html.contains("<p>Body text</p>"));
938    }
939
940    // --- Table ---
941
942    #[test]
943    fn table_renders_with_headers_and_rows() {
944        let c = Component::Table(Table {
945            id: None,
946            columns: vec![TableColumn {
947                header: "Name".to_string(),
948                accessor_key: "name".to_string(),
949                sortable: true,
950            }],
951            data: vec![{
952                let mut row = std::collections::HashMap::new();
953                row.insert("name".to_string(), json!("Alice"));
954                row
955            }],
956            sortable: false,
957            page_size: None,
958            striped: false,
959        });
960        let html = render_component_html(&c, &default_opts());
961        assert!(html.contains("<table>"));
962        assert!(html.contains("<th>Name</th>"));
963        assert!(html.contains("<td>Alice</td>"));
964    }
965
966    // --- Alert ---
967
968    #[test]
969    fn alert_renders_with_role() {
970        let c = Component::Alert(Alert {
971            id: None,
972            title: "Warning!".to_string(),
973            description: Some("Be careful".to_string()),
974            variant: AlertVariant::Warning,
975        });
976        let html = render_component_html(&c, &default_opts());
977        assert!(html.contains("role=\"alert\""));
978        assert!(html.contains("alert-warning"));
979        assert!(html.contains("Warning!"));
980    }
981
982    // --- Progress ---
983
984    #[test]
985    fn progress_renders_with_value() {
986        let c = Component::Progress(Progress {
987            id: None,
988            value: 75,
989            label: Some("Loading".to_string()),
990        });
991        let html = render_component_html(&c, &default_opts());
992        assert!(html.contains("<progress"));
993        assert!(html.contains("value=\"75\""));
994        assert!(html.contains("max=\"100\""));
995    }
996
997    // --- Chart ---
998
999    #[test]
1000    fn chart_omitted_in_low_bandwidth() {
1001        let c = Component::Chart(Chart {
1002            id: None,
1003            title: Some("Sales".to_string()),
1004            kind: ChartKind::Bar,
1005            data: vec![],
1006            x_key: "month".to_string(),
1007            y_keys: vec!["revenue".to_string()],
1008            x_label: None,
1009            y_label: None,
1010            show_legend: true,
1011            colors: None,
1012        });
1013        let html = render_component_html(&c, &low_bw_opts());
1014        assert!(html.is_empty());
1015    }
1016
1017    #[test]
1018    fn chart_renders_placeholder_in_full_mode() {
1019        let c = Component::Chart(Chart {
1020            id: None,
1021            title: Some("Sales".to_string()),
1022            kind: ChartKind::Bar,
1023            data: vec![],
1024            x_key: "month".to_string(),
1025            y_keys: vec!["revenue".to_string()],
1026            x_label: None,
1027            y_label: None,
1028            show_legend: true,
1029            colors: None,
1030        });
1031        let html = render_component_html(&c, &default_opts());
1032        assert!(html.contains("chart-placeholder"));
1033        assert!(html.contains("data-chart="));
1034    }
1035
1036    // --- Spinner ---
1037
1038    #[test]
1039    fn spinner_omitted_in_low_bandwidth() {
1040        let c = Component::Spinner(Spinner {
1041            id: None,
1042            size: SpinnerSize::Medium,
1043            label: None,
1044        });
1045        let html = render_component_html(&c, &low_bw_opts());
1046        assert!(html.is_empty());
1047    }
1048
1049    // --- Skeleton ---
1050
1051    #[test]
1052    fn skeleton_omitted_in_low_bandwidth() {
1053        let c = Component::Skeleton(Skeleton {
1054            id: None,
1055            variant: SkeletonVariant::Text,
1056            width: None,
1057            height: None,
1058        });
1059        let html = render_component_html(&c, &low_bw_opts());
1060        assert!(html.is_empty());
1061    }
1062
1063    // --- Modal ---
1064
1065    #[test]
1066    fn modal_renders_as_dialog() {
1067        let c = Component::Modal(Modal {
1068            id: None,
1069            title: "Confirm".to_string(),
1070            content: vec![Component::Text(Text {
1071                id: None,
1072                content: "Are you sure?".to_string(),
1073                variant: TextVariant::Body,
1074            })],
1075            footer: None,
1076            size: ModalSize::Medium,
1077            closable: true,
1078        });
1079        let html = render_component_html(&c, &default_opts());
1080        assert!(html.contains("<dialog>"));
1081        assert!(html.contains("<h2>Confirm</h2>"));
1082        assert!(html.contains("<p>Are you sure?</p>"));
1083    }
1084
1085    // --- Class prefix ---
1086
1087    #[test]
1088    fn class_prefix_applied_to_stack() {
1089        let c = Component::Stack(Stack {
1090            id: None,
1091            direction: StackDirection::Vertical,
1092            children: vec![],
1093            gap: 0,
1094        });
1095        let html = render_component_html(&c, &prefixed_opts("adk-"));
1096        assert!(html.contains("adk-stack"));
1097        assert!(html.contains("adk-stack-vertical"));
1098    }
1099
1100    #[test]
1101    fn class_prefix_applied_to_badge() {
1102        let c = Component::Badge(Badge {
1103            id: None,
1104            label: "Test".to_string(),
1105            variant: BadgeVariant::Info,
1106        });
1107        let html = render_component_html(&c, &prefixed_opts("adk-"));
1108        assert!(html.contains("adk-badge"));
1109        assert!(html.contains("adk-badge-info"));
1110    }
1111
1112    // --- HTML escaping in content ---
1113
1114    #[test]
1115    fn text_content_is_escaped() {
1116        let c = Component::Text(Text {
1117            id: None,
1118            content: "<script>alert('xss')</script>".to_string(),
1119            variant: TextVariant::Body,
1120        });
1121        let html = render_component_html(&c, &default_opts());
1122        assert!(!html.contains("<script>"));
1123        assert!(html.contains("&lt;script&gt;"));
1124    }
1125
1126    // --- render_components_html ---
1127
1128    #[test]
1129    fn render_components_html_wraps_in_shell() {
1130        let components = vec![Component::Text(Text {
1131            id: None,
1132            content: "Hello".to_string(),
1133            variant: TextVariant::Body,
1134        })];
1135        let html = render_components_html(&components, &default_opts());
1136        assert!(html.contains("<!DOCTYPE html>"));
1137        assert!(html.contains("<html>"));
1138        assert!(html.contains("<body>"));
1139        assert!(html.contains("<p>Hello</p>"));
1140    }
1141
1142    #[test]
1143    fn render_components_html_no_external_resources() {
1144        let components = vec![Component::Text(Text {
1145            id: None,
1146            content: "Test".to_string(),
1147            variant: TextVariant::Body,
1148        })];
1149        let html = render_components_html(&components, &default_opts());
1150        assert!(!html.contains("<link rel=\"stylesheet\""));
1151        assert!(!html.contains("<script src="));
1152        assert!(!html.contains("@import"));
1153    }
1154
1155    // --- render_surface_html ---
1156
1157    #[test]
1158    fn render_surface_html_handles_valid_components() {
1159        let surface = UiSurface::new(
1160            "main",
1161            "catalog",
1162            vec![json!({"type": "text", "content": "Hello", "variant": "body"})],
1163        );
1164        let html = render_surface_html(&surface, &default_opts());
1165        assert!(html.contains("<p>Hello</p>"));
1166    }
1167
1168    #[test]
1169    fn render_surface_html_handles_unknown_components() {
1170        let surface = UiSurface::new(
1171            "main",
1172            "catalog",
1173            vec![json!({"type": "unknown_widget", "data": 42})],
1174        );
1175        let html = render_surface_html(&surface, &default_opts());
1176        assert!(html.contains("<!-- unknown component -->"));
1177    }
1178
1179    #[test]
1180    fn render_surface_html_mixes_valid_and_unknown() {
1181        let surface = UiSurface::new(
1182            "main",
1183            "catalog",
1184            vec![
1185                json!({"type": "text", "content": "Valid", "variant": "body"}),
1186                json!({"type": "bogus"}),
1187                json!({"type": "divider"}),
1188            ],
1189        );
1190        let html = render_surface_html(&surface, &default_opts());
1191        assert!(html.contains("<p>Valid</p>"));
1192        assert!(html.contains("<!-- unknown component -->"));
1193        assert!(html.contains("<hr>"));
1194    }
1195
1196    // --- Low bandwidth strips style from shell ---
1197
1198    #[test]
1199    fn low_bandwidth_shell_has_no_style_tag() {
1200        let components = vec![Component::Text(Text {
1201            id: None,
1202            content: "Test".to_string(),
1203            variant: TextVariant::Body,
1204        })];
1205        let html = render_components_html(&components, &low_bw_opts());
1206        assert!(!html.contains("<style>"));
1207        assert!(!html.contains("style="));
1208    }
1209
1210    // --- Select ---
1211
1212    #[test]
1213    fn select_renders_with_options() {
1214        let c = Component::Select(Select {
1215            id: None,
1216            name: "color".to_string(),
1217            label: "Color".to_string(),
1218            options: vec![
1219                SelectOption {
1220                    label: "Red".to_string(),
1221                    value: "red".to_string(),
1222                },
1223                SelectOption {
1224                    label: "Blue".to_string(),
1225                    value: "blue".to_string(),
1226                },
1227            ],
1228            required: false,
1229            error: None,
1230        });
1231        let html = render_component_html(&c, &default_opts());
1232        assert!(html.contains("<select"));
1233        assert!(html.contains("<option value=\"red\">Red</option>"));
1234        assert!(html.contains("<option value=\"blue\">Blue</option>"));
1235    }
1236
1237    // --- MultiSelect ---
1238
1239    #[test]
1240    fn multiselect_renders_with_multiple_attr() {
1241        let c = Component::MultiSelect(MultiSelect {
1242            id: None,
1243            name: "tags".to_string(),
1244            label: "Tags".to_string(),
1245            options: vec![SelectOption {
1246                label: "A".to_string(),
1247                value: "a".to_string(),
1248            }],
1249            required: false,
1250        });
1251        let html = render_component_html(&c, &default_opts());
1252        assert!(html.contains("<select multiple"));
1253    }
1254
1255    // --- List ---
1256
1257    #[test]
1258    fn ordered_list_renders_as_ol() {
1259        let c = Component::List(List {
1260            id: None,
1261            items: vec!["First".to_string(), "Second".to_string()],
1262            ordered: true,
1263        });
1264        let html = render_component_html(&c, &default_opts());
1265        assert!(html.contains("<ol>"));
1266        assert!(html.contains("<li>First</li>"));
1267    }
1268
1269    #[test]
1270    fn unordered_list_renders_as_ul() {
1271        let c = Component::List(List {
1272            id: None,
1273            items: vec!["Item".to_string()],
1274            ordered: false,
1275        });
1276        let html = render_component_html(&c, &default_opts());
1277        assert!(html.contains("<ul>"));
1278    }
1279
1280    // --- KeyValue ---
1281
1282    #[test]
1283    fn keyvalue_renders_as_dl() {
1284        let c = Component::KeyValue(KeyValue {
1285            id: None,
1286            pairs: vec![KeyValuePair {
1287                key: "Name".to_string(),
1288                value: "Alice".to_string(),
1289            }],
1290        });
1291        let html = render_component_html(&c, &default_opts());
1292        assert!(html.contains("<dl>"));
1293        assert!(html.contains("<dt>Name</dt>"));
1294        assert!(html.contains("<dd>Alice</dd>"));
1295    }
1296
1297    // --- CodeBlock ---
1298
1299    #[test]
1300    fn codeblock_renders_as_pre_code() {
1301        let c = Component::CodeBlock(CodeBlock {
1302            id: None,
1303            code: "fn main() {}".to_string(),
1304            language: Some("rust".to_string()),
1305        });
1306        let html = render_component_html(&c, &default_opts());
1307        assert!(html.contains("<pre><code"));
1308        assert!(html.contains("language-rust"));
1309        assert!(html.contains("fn main() {}"));
1310    }
1311
1312    // --- Toast ---
1313
1314    #[test]
1315    fn toast_renders_with_variant() {
1316        let c = Component::Toast(Toast {
1317            id: None,
1318            message: "Saved!".to_string(),
1319            variant: AlertVariant::Success,
1320            duration: 5000,
1321            dismissible: true,
1322        });
1323        let html = render_component_html(&c, &default_opts());
1324        assert!(html.contains("toast"));
1325        assert!(html.contains("toast-success"));
1326        assert!(html.contains("Saved!"));
1327    }
1328
1329    // --- Tabs ---
1330
1331    #[test]
1332    fn tabs_renders_buttons_and_panels() {
1333        let c = Component::Tabs(Tabs {
1334            id: None,
1335            tabs: vec![
1336                Tab {
1337                    label: "Tab 1".to_string(),
1338                    content: vec![Component::Text(Text {
1339                        id: None,
1340                        content: "Content 1".to_string(),
1341                        variant: TextVariant::Body,
1342                    })],
1343                },
1344                Tab {
1345                    label: "Tab 2".to_string(),
1346                    content: vec![],
1347                },
1348            ],
1349        });
1350        let html = render_component_html(&c, &default_opts());
1351        assert!(html.contains("tab-button"));
1352        assert!(html.contains("Tab 1"));
1353        assert!(html.contains("Tab 2"));
1354        assert!(html.contains("tab-panel"));
1355        assert!(html.contains("<p>Content 1</p>"));
1356    }
1357
1358    // --- Divider ---
1359
1360    #[test]
1361    fn divider_renders_as_hr() {
1362        let c = Component::Divider(Divider { id: None });
1363        let html = render_component_html(&c, &default_opts());
1364        assert_eq!(html, "<hr>");
1365    }
1366
1367    // --- Switch ---
1368
1369    #[test]
1370    fn switch_renders_as_checkbox_with_role() {
1371        let c = Component::Switch(Switch {
1372            id: None,
1373            name: "toggle".to_string(),
1374            label: "Enable".to_string(),
1375            default_checked: true,
1376        });
1377        let html = render_component_html(&c, &default_opts());
1378        assert!(html.contains("role=\"switch\""));
1379        assert!(html.contains("type=\"checkbox\""));
1380        assert!(html.contains(" checked"));
1381    }
1382
1383    // --- DateInput ---
1384
1385    #[test]
1386    fn date_input_renders_correctly() {
1387        let c = Component::DateInput(DateInput {
1388            id: None,
1389            name: "dob".to_string(),
1390            label: "Date of Birth".to_string(),
1391            required: true,
1392        });
1393        let html = render_component_html(&c, &default_opts());
1394        assert!(html.contains("type=\"date\""));
1395        assert!(html.contains("Date of Birth"));
1396        assert!(html.contains(" required"));
1397    }
1398
1399    // --- Slider ---
1400
1401    #[test]
1402    fn slider_renders_as_range() {
1403        let c = Component::Slider(Slider {
1404            id: None,
1405            name: "volume".to_string(),
1406            label: "Volume".to_string(),
1407            min: 0.0,
1408            max: 100.0,
1409            step: Some(1.0),
1410            default_value: Some(50.0),
1411        });
1412        let html = render_component_html(&c, &default_opts());
1413        assert!(html.contains("type=\"range\""));
1414        assert!(html.contains("min=\"0\""));
1415        assert!(html.contains("max=\"100\""));
1416    }
1417
1418    // --- Textarea ---
1419
1420    #[test]
1421    fn textarea_renders_correctly() {
1422        let c = Component::Textarea(Textarea {
1423            id: None,
1424            name: "bio".to_string(),
1425            label: "Bio".to_string(),
1426            placeholder: Some("Tell us about yourself".to_string()),
1427            rows: 4,
1428            required: false,
1429            default_value: None,
1430            error: None,
1431        });
1432        let html = render_component_html(&c, &default_opts());
1433        assert!(html.contains("<textarea"));
1434        assert!(html.contains("name=\"bio\""));
1435        assert!(html.contains("Bio"));
1436    }
1437
1438    // --- Icon ---
1439
1440    #[test]
1441    fn icon_renders_with_data_icon() {
1442        let c = Component::Icon(Icon {
1443            id: None,
1444            name: "heart".to_string(),
1445            size: 24,
1446        });
1447        let html = render_component_html(&c, &default_opts());
1448        assert!(html.contains("data-icon=\"heart\""));
1449        assert!(html.contains("icon"));
1450    }
1451
1452    // --- Container ---
1453
1454    #[test]
1455    fn container_renders_children() {
1456        let c = Component::Container(Container {
1457            id: None,
1458            children: vec![Component::Divider(Divider { id: None })],
1459            padding: 16,
1460        });
1461        let html = render_component_html(&c, &default_opts());
1462        assert!(html.contains("container"));
1463        assert!(html.contains("<hr>"));
1464        assert!(html.contains("style=\"padding: 16px\""));
1465    }
1466
1467    #[test]
1468    fn container_low_bandwidth_strips_style() {
1469        let c = Component::Container(Container {
1470            id: None,
1471            children: vec![],
1472            padding: 16,
1473        });
1474        let html = render_component_html(&c, &low_bw_opts());
1475        assert!(!html.contains("style="));
1476    }
1477}