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(component: &Component, options: &HtmlRenderOptions) -> String {
63    let mode = options.bandwidth_mode;
64    let prefix = &options.class_prefix;
65
66    match component {
67        // --- Atoms ---
68        Component::Text(text) => {
69            let content = escape_html(&text.content);
70            match text.variant {
71                TextVariant::H1 => format!("<h1>{}</h1>", content),
72                TextVariant::H2 => format!("<h2>{}</h2>", content),
73                TextVariant::H3 => format!("<h3>{}</h3>", content),
74                TextVariant::H4 => format!("<h4>{}</h4>", content),
75                TextVariant::Body => format!("<p>{}</p>", content),
76                TextVariant::Caption => format!("<small>{}</small>", content),
77                TextVariant::Code => format!("<code>{}</code>", content),
78            }
79        }
80
81        Component::Button(button) => {
82            let label = escape_html(&button.label);
83            let action_id = escape_html(&button.action_id);
84            let disabled = if button.disabled { " disabled" } else { "" };
85            format!(
86                "<button data-action-id=\"{}\"{}>{}</button>",
87                action_id, disabled, label
88            )
89        }
90
91        Component::Icon(icon) => {
92            let name = escape_html(&icon.name);
93            format!(
94                "<span class=\"{}\" data-icon=\"{}\">{}</span>",
95                cls(prefix, "icon"),
96                name,
97                name
98            )
99        }
100
101        Component::Image(image) => {
102            if mode == BandwidthMode::Low {
103                return String::new();
104            }
105            let src = escape_html(&image.src);
106            let alt = image.alt.as_deref().map(escape_html).unwrap_or_default();
107            format!("<img src=\"{}\" alt=\"{}\">", src, alt)
108        }
109
110        Component::Badge(badge) => {
111            let label = escape_html(&badge.label);
112            let variant = badge_variant_str(&badge.variant);
113            format!(
114                "<span class=\"{} {}\">{}</span>",
115                cls(prefix, "badge"),
116                cls(prefix, &format!("badge-{}", variant)),
117                label
118            )
119        }
120
121        // --- Inputs ---
122        Component::TextInput(input) => {
123            let label = escape_html(&input.label);
124            let name = escape_html(&input.name);
125            let placeholder = input
126                .placeholder
127                .as_deref()
128                .map(|p| format!(" placeholder=\"{}\"", escape_html(p)))
129                .unwrap_or_default();
130            let required = if input.required { " required" } else { "" };
131            let default_val = input
132                .default_value
133                .as_deref()
134                .map(|v| format!(" value=\"{}\"", escape_html(v)))
135                .unwrap_or_default();
136            format!(
137                "<label>{}<input type=\"text\" name=\"{}\"{}{}{}></label>",
138                label, name, placeholder, required, default_val
139            )
140        }
141
142        Component::NumberInput(input) => {
143            let label = escape_html(&input.label);
144            let name = escape_html(&input.name);
145            let min = input
146                .min
147                .map(|v| format!(" min=\"{}\"", v))
148                .unwrap_or_default();
149            let max = input
150                .max
151                .map(|v| format!(" max=\"{}\"", v))
152                .unwrap_or_default();
153            let step = input
154                .step
155                .map(|v| format!(" step=\"{}\"", v))
156                .unwrap_or_default();
157            let required = if input.required { " required" } else { "" };
158            let default_val = input
159                .default_value
160                .map(|v| format!(" value=\"{}\"", v))
161                .unwrap_or_default();
162            format!(
163                "<label>{}<input type=\"number\" name=\"{}\"{}{}{}{}{}></label>",
164                label, name, min, max, step, required, default_val
165            )
166        }
167
168        Component::Select(select) => {
169            let label = escape_html(&select.label);
170            let name = escape_html(&select.name);
171            let required = if select.required { " required" } else { "" };
172            let options_html: String = select
173                .options
174                .iter()
175                .map(|opt| {
176                    format!(
177                        "<option value=\"{}\">{}</option>",
178                        escape_html(&opt.value),
179                        escape_html(&opt.label)
180                    )
181                })
182                .collect();
183            format!(
184                "<label>{}<select name=\"{}\"{}>{}</select></label>",
185                label, name, required, options_html
186            )
187        }
188
189        Component::MultiSelect(multi) => {
190            let label = escape_html(&multi.label);
191            let name = escape_html(&multi.name);
192            let required = if multi.required { " required" } else { "" };
193            let options_html: String = multi
194                .options
195                .iter()
196                .map(|opt| {
197                    format!(
198                        "<option value=\"{}\">{}</option>",
199                        escape_html(&opt.value),
200                        escape_html(&opt.label)
201                    )
202                })
203                .collect();
204            format!(
205                "<label>{}<select multiple name=\"{}\"{}>{}</select></label>",
206                label, name, required, options_html
207            )
208        }
209
210        Component::Switch(switch) => {
211            let label = escape_html(&switch.label);
212            let name = escape_html(&switch.name);
213            let checked = if switch.default_checked {
214                " checked"
215            } else {
216                ""
217            };
218            format!(
219                "<label>{}<input type=\"checkbox\" role=\"switch\" name=\"{}\"{}></label>",
220                label, name, checked
221            )
222        }
223
224        Component::DateInput(date) => {
225            let label = escape_html(&date.label);
226            let name = escape_html(&date.name);
227            let required = if date.required { " required" } else { "" };
228            format!(
229                "<label>{}<input type=\"date\" name=\"{}\"{}></label>",
230                label, name, required
231            )
232        }
233
234        Component::Slider(slider) => {
235            let label = escape_html(&slider.label);
236            let name = escape_html(&slider.name);
237            let step = slider
238                .step
239                .map(|v| format!(" step=\"{}\"", v))
240                .unwrap_or_default();
241            let default_val = slider
242                .default_value
243                .map(|v| format!(" value=\"{}\"", v))
244                .unwrap_or_default();
245            format!(
246                "<label>{}<input type=\"range\" name=\"{}\" min=\"{}\" max=\"{}\"{}{}></label>",
247                label, name, slider.min, slider.max, step, default_val
248            )
249        }
250
251        Component::Textarea(textarea) => {
252            let label = escape_html(&textarea.label);
253            let name = escape_html(&textarea.name);
254            let placeholder = textarea
255                .placeholder
256                .as_deref()
257                .map(|p| format!(" placeholder=\"{}\"", escape_html(p)))
258                .unwrap_or_default();
259            let required = if textarea.required { " required" } else { "" };
260            let default_val = textarea
261                .default_value
262                .as_deref()
263                .map(escape_html)
264                .unwrap_or_default();
265            format!(
266                "<label>{}<textarea name=\"{}\" rows=\"{}\"{}{}>{}</textarea></label>",
267                label, name, textarea.rows, placeholder, required, default_val
268            )
269        }
270
271        // --- Layouts ---
272        Component::Stack(stack) => {
273            let dir = match stack.direction {
274                StackDirection::Horizontal => "horizontal",
275                StackDirection::Vertical => "vertical",
276            };
277            let children_html = render_children(&stack.children, options);
278            let style_attr = if mode == BandwidthMode::Low {
279                String::new()
280            } else if stack.gap > 0 {
281                format!(" style=\"gap: {}px\"", stack.gap)
282            } else {
283                String::new()
284            };
285            format!(
286                "<div class=\"{} {}\"{}>{}</div>",
287                cls(prefix, "stack"),
288                cls(prefix, &format!("stack-{}", dir)),
289                style_attr,
290                children_html
291            )
292        }
293
294        Component::Grid(grid) => {
295            let children_html = render_children(&grid.children, options);
296            let style_attr = if mode == BandwidthMode::Low {
297                String::new()
298            } else {
299                format!(
300                    " style=\"grid-template-columns: repeat({}, 1fr)\"",
301                    grid.columns
302                )
303            };
304            format!(
305                "<div class=\"{}\"{}>{}</div>",
306                cls(prefix, "grid"),
307                style_attr,
308                children_html
309            )
310        }
311
312        Component::Card(card) => {
313            let mut html = format!("<div class=\"{}\">", cls(prefix, "card"));
314            if let Some(title) = &card.title {
315                html.push_str(&format!("<h3>{}</h3>", escape_html(title)));
316            }
317            if let Some(desc) = &card.description {
318                html.push_str(&format!("<p>{}</p>", escape_html(desc)));
319            }
320            if !card.content.is_empty() {
321                html.push_str(&format!("<div class=\"{}\">", cls(prefix, "card-content")));
322                html.push_str(&render_children(&card.content, options));
323                html.push_str("</div>");
324            }
325            if let Some(footer) = &card.footer {
326                html.push_str(&format!("<div class=\"{}\">", cls(prefix, "card-footer")));
327                html.push_str(&render_children(footer, options));
328                html.push_str("</div>");
329            }
330            html.push_str("</div>");
331            html
332        }
333
334        Component::Container(container) => {
335            let children_html = render_children(&container.children, options);
336            let style_attr = if mode == BandwidthMode::Low || container.padding == 0 {
337                String::new()
338            } else {
339                format!(" style=\"padding: {}px\"", container.padding)
340            };
341            format!(
342                "<div class=\"{}\"{}>{}</div>",
343                cls(prefix, "container"),
344                style_attr,
345                children_html
346            )
347        }
348
349        Component::Divider(_) => "<hr>".to_string(),
350
351        Component::Tabs(tabs) => {
352            let mut html = format!("<div class=\"{}\">", cls(prefix, "tabs"));
353            // Tab buttons
354            html.push_str(&format!("<div class=\"{}\">", cls(prefix, "tab-buttons")));
355            for (i, tab) in tabs.tabs.iter().enumerate() {
356                html.push_str(&format!(
357                    "<button class=\"{}\" data-tab-index=\"{}\">{}</button>",
358                    cls(prefix, "tab-button"),
359                    i,
360                    escape_html(&tab.label)
361                ));
362            }
363            html.push_str("</div>");
364            // Tab content panels
365            for (i, tab) in tabs.tabs.iter().enumerate() {
366                html.push_str(&format!(
367                    "<div class=\"{}\" data-tab-panel=\"{}\">",
368                    cls(prefix, "tab-panel"),
369                    i
370                ));
371                html.push_str(&render_children(&tab.content, options));
372                html.push_str("</div>");
373            }
374            html.push_str("</div>");
375            html
376        }
377
378        // --- Data Display ---
379        Component::Table(table) => {
380            let mut html = String::from("<table>");
381            // Header
382            html.push_str("<thead><tr>");
383            for col in &table.columns {
384                html.push_str(&format!("<th>{}</th>", escape_html(&col.header)));
385            }
386            html.push_str("</tr></thead>");
387            // Body
388            html.push_str("<tbody>");
389            for row in &table.data {
390                html.push_str("<tr>");
391                for col in &table.columns {
392                    let cell_value = row
393                        .get(&col.accessor_key)
394                        .map(|v| match v {
395                            serde_json::Value::String(s) => escape_html(s),
396                            other => escape_html(&other.to_string()),
397                        })
398                        .unwrap_or_default();
399                    html.push_str(&format!("<td>{}</td>", cell_value));
400                }
401                html.push_str("</tr>");
402            }
403            html.push_str("</tbody></table>");
404            html
405        }
406
407        Component::List(list) => {
408            let tag = if list.ordered { "ol" } else { "ul" };
409            let items_html: String = list
410                .items
411                .iter()
412                .map(|item| format!("<li>{}</li>", escape_html(item)))
413                .collect();
414            format!("<{}>{}</{}>", tag, items_html, tag)
415        }
416
417        Component::KeyValue(kv) => {
418            let mut html = String::from("<dl>");
419            for pair in &kv.pairs {
420                html.push_str(&format!(
421                    "<dt>{}</dt><dd>{}</dd>",
422                    escape_html(&pair.key),
423                    escape_html(&pair.value)
424                ));
425            }
426            html.push_str("</dl>");
427            html
428        }
429
430        Component::CodeBlock(code_block) => {
431            let lang_attr = code_block
432                .language
433                .as_deref()
434                .map(|l| format!(" class=\"language-{}\"", escape_html(l)))
435                .unwrap_or_default();
436            format!(
437                "<pre><code{}>{}</code></pre>",
438                lang_attr,
439                escape_html(&code_block.code)
440            )
441        }
442
443        // --- Visualizations ---
444        Component::Chart(chart) => {
445            if mode == BandwidthMode::Low {
446                return String::new();
447            }
448            let chart_json = escape_html(&serde_json::to_string(chart).unwrap_or_default());
449            let title_html = chart
450                .title
451                .as_deref()
452                .map(|t| format!("<p>{}</p>", escape_html(t)))
453                .unwrap_or_default();
454            format!(
455                "<div class=\"{}\" data-chart=\"{}\">{}</div>",
456                cls(prefix, "chart-placeholder"),
457                chart_json,
458                title_html
459            )
460        }
461
462        Component::Scene3d(scene) => {
463            let title = scene
464                .title
465                .as_deref()
466                .map(|value| format!("<strong>{}</strong>", escape_html(value)))
467                .unwrap_or_else(|| "<strong>Interactive 3D scene</strong>".to_string());
468            let description = scene
469                .description
470                .as_deref()
471                .or(scene.fallback.as_deref())
472                .map(|value| format!("<p>{}</p>", escape_html(value)))
473                .unwrap_or_default();
474            format!(
475                "<figure class=\"{}\" data-scene-object-count=\"{}\">{}{}</figure>",
476                cls(prefix, "scene-3d-fallback"),
477                scene.objects.len(),
478                title,
479                description
480            )
481        }
482
483        // --- Feedback ---
484        Component::Alert(alert) => {
485            let variant = alert_variant_str(&alert.variant);
486            let desc = alert
487                .description
488                .as_deref()
489                .map(|d| format!("<p>{}</p>", escape_html(d)))
490                .unwrap_or_default();
491            format!(
492                "<div class=\"{} {}\" role=\"alert\"><strong>{}</strong>{}</div>",
493                cls(prefix, "alert"),
494                cls(prefix, &format!("alert-{}", variant)),
495                escape_html(&alert.title),
496                desc
497            )
498        }
499
500        Component::Progress(progress) => {
501            let label = progress
502                .label
503                .as_deref()
504                .map(|l| format!(" aria-label=\"{}\"", escape_html(l)))
505                .unwrap_or_default();
506            format!(
507                "<progress value=\"{}\" max=\"100\"{}></progress>",
508                progress.value, label
509            )
510        }
511
512        Component::Toast(toast) => {
513            let variant = alert_variant_str(&toast.variant);
514            format!(
515                "<div class=\"{} {}\">{}</div>",
516                cls(prefix, "toast"),
517                cls(prefix, &format!("toast-{}", variant)),
518                escape_html(&toast.message)
519            )
520        }
521
522        Component::Modal(modal) => {
523            let mut html = String::from("<dialog>");
524            html.push_str(&format!("<h2>{}</h2>", escape_html(&modal.title)));
525            html.push_str(&render_children(&modal.content, options));
526            if let Some(footer) = &modal.footer {
527                html.push_str(&format!("<div class=\"{}\">", cls(prefix, "modal-footer")));
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(
699            |value| 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#[cfg(test)]
709mod tests {
710    use super::*;
711    use serde_json::json;
712
713    fn default_opts() -> HtmlRenderOptions {
714        HtmlRenderOptions::default()
715    }
716
717    fn low_bw_opts() -> HtmlRenderOptions {
718        HtmlRenderOptions {
719            bandwidth_mode: BandwidthMode::Low,
720            ..Default::default()
721        }
722    }
723
724    fn prefixed_opts(prefix: &str) -> HtmlRenderOptions {
725        HtmlRenderOptions {
726            class_prefix: Some(prefix.to_string()),
727            ..Default::default()
728        }
729    }
730
731    // --- escape_html ---
732
733    #[test]
734    fn escape_html_escapes_all_special_chars() {
735        assert_eq!(
736            escape_html("<script>alert('xss')&\"</script>"),
737            "&lt;script&gt;alert(&#x27;xss&#x27;)&amp;&quot;&lt;/script&gt;"
738        );
739    }
740
741    #[test]
742    fn escape_html_passes_through_normal_text() {
743        assert_eq!(escape_html("Hello, world!"), "Hello, world!");
744    }
745
746    // --- BandwidthMode ---
747
748    #[test]
749    fn bandwidth_mode_default_is_full() {
750        assert_eq!(BandwidthMode::default(), BandwidthMode::Full);
751    }
752
753    // --- Text variants ---
754
755    #[test]
756    fn text_body_renders_as_p() {
757        let c = Component::Text(Text {
758            id: None,
759            content: "Hello".to_string(),
760            variant: TextVariant::Body,
761        });
762        let html = render_component_html(&c, &default_opts());
763        assert_eq!(html, "<p>Hello</p>");
764    }
765
766    #[test]
767    fn text_h1_renders_as_h1() {
768        let c = Component::Text(Text {
769            id: None,
770            content: "Title".to_string(),
771            variant: TextVariant::H1,
772        });
773        let html = render_component_html(&c, &default_opts());
774        assert_eq!(html, "<h1>Title</h1>");
775    }
776
777    #[test]
778    fn text_caption_renders_as_small() {
779        let c = Component::Text(Text {
780            id: None,
781            content: "Note".to_string(),
782            variant: TextVariant::Caption,
783        });
784        let html = render_component_html(&c, &default_opts());
785        assert_eq!(html, "<small>Note</small>");
786    }
787
788    #[test]
789    fn text_code_renders_as_code() {
790        let c = Component::Text(Text {
791            id: None,
792            content: "let x = 1;".to_string(),
793            variant: TextVariant::Code,
794        });
795        let html = render_component_html(&c, &default_opts());
796        assert_eq!(html, "<code>let x = 1;</code>");
797    }
798
799    // --- Button ---
800
801    #[test]
802    fn button_renders_with_action_id() {
803        let c = Component::Button(Button {
804            id: None,
805            label: "Click me".to_string(),
806            action_id: "btn-1".to_string(),
807            variant: ButtonVariant::Primary,
808            disabled: false,
809            icon: None,
810        });
811        let html = render_component_html(&c, &default_opts());
812        assert!(html.contains("data-action-id=\"btn-1\""));
813        assert!(html.contains("Click me"));
814    }
815
816    #[test]
817    fn button_disabled_renders_disabled_attr() {
818        let c = Component::Button(Button {
819            id: None,
820            label: "Disabled".to_string(),
821            action_id: "btn-2".to_string(),
822            variant: ButtonVariant::Primary,
823            disabled: true,
824            icon: None,
825        });
826        let html = render_component_html(&c, &default_opts());
827        assert!(html.contains(" disabled"));
828    }
829
830    // --- Image ---
831
832    #[test]
833    fn image_renders_in_full_mode() {
834        let c = Component::Image(Image {
835            id: None,
836            src: "https://example.com/img.png".to_string(),
837            alt: Some("A photo".to_string()),
838        });
839        let html = render_component_html(&c, &default_opts());
840        assert!(html.contains("<img"));
841        assert!(html.contains("src=\"https://example.com/img.png\""));
842        assert!(html.contains("alt=\"A photo\""));
843    }
844
845    #[test]
846    fn image_omitted_in_low_bandwidth() {
847        let c = Component::Image(Image {
848            id: None,
849            src: "https://example.com/img.png".to_string(),
850            alt: Some("A photo".to_string()),
851        });
852        let html = render_component_html(&c, &low_bw_opts());
853        assert!(html.is_empty());
854    }
855
856    // --- Badge ---
857
858    #[test]
859    fn badge_renders_with_variant() {
860        let c = Component::Badge(Badge {
861            id: None,
862            label: "New".to_string(),
863            variant: BadgeVariant::Success,
864        });
865        let html = render_component_html(&c, &default_opts());
866        assert!(html.contains("badge"));
867        assert!(html.contains("badge-success"));
868        assert!(html.contains("New"));
869    }
870
871    // --- Stack ---
872
873    #[test]
874    fn stack_vertical_renders_correctly() {
875        let c = Component::Stack(Stack {
876            id: None,
877            direction: StackDirection::Vertical,
878            children: vec![Component::Text(Text {
879                id: None,
880                content: "Child".to_string(),
881                variant: TextVariant::Body,
882            })],
883            gap: 0,
884        });
885        let html = render_component_html(&c, &default_opts());
886        assert!(html.contains("stack-vertical"));
887        assert!(html.contains("<p>Child</p>"));
888    }
889
890    // --- Grid ---
891
892    #[test]
893    fn grid_renders_with_columns() {
894        let c = Component::Grid(Grid {
895            id: None,
896            columns: 3,
897            children: vec![],
898            gap: 0,
899        });
900        let html = render_component_html(&c, &default_opts());
901        assert!(html.contains("grid"));
902        assert!(html.contains("grid-template-columns: repeat(3, 1fr)"));
903    }
904
905    #[test]
906    fn grid_low_bandwidth_strips_style() {
907        let c = Component::Grid(Grid {
908            id: None,
909            columns: 3,
910            children: vec![],
911            gap: 0,
912        });
913        let html = render_component_html(&c, &low_bw_opts());
914        assert!(html.contains("grid"));
915        assert!(!html.contains("style="));
916    }
917
918    // --- Card ---
919
920    #[test]
921    fn card_renders_with_title_and_content() {
922        let c = Component::Card(Card {
923            id: None,
924            title: Some("My Card".to_string()),
925            description: None,
926            content: vec![Component::Text(Text {
927                id: None,
928                content: "Body text".to_string(),
929                variant: TextVariant::Body,
930            })],
931            footer: None,
932        });
933        let html = render_component_html(&c, &default_opts());
934        assert!(html.contains("card"));
935        assert!(html.contains("<h3>My Card</h3>"));
936        assert!(html.contains("<p>Body text</p>"));
937    }
938
939    // --- Table ---
940
941    #[test]
942    fn table_renders_with_headers_and_rows() {
943        let c = Component::Table(Table {
944            id: None,
945            columns: vec![TableColumn {
946                header: "Name".to_string(),
947                accessor_key: "name".to_string(),
948                sortable: true,
949            }],
950            data: vec![{
951                let mut row = std::collections::HashMap::new();
952                row.insert("name".to_string(), json!("Alice"));
953                row
954            }],
955            data_source: None,
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            data_source: None,
1007            x_key: "month".to_string(),
1008            y_keys: vec!["revenue".to_string()],
1009            x_type: ChartXType::Category,
1010            time_format: None,
1011            window: None,
1012            x_label: None,
1013            y_label: None,
1014            show_legend: true,
1015            colors: None,
1016        });
1017        let html = render_component_html(&c, &low_bw_opts());
1018        assert!(html.is_empty());
1019    }
1020
1021    #[test]
1022    fn chart_renders_placeholder_in_full_mode() {
1023        let c = Component::Chart(Chart {
1024            id: None,
1025            title: Some("Sales".to_string()),
1026            kind: ChartKind::Bar,
1027            data: vec![],
1028            data_source: None,
1029            x_key: "month".to_string(),
1030            y_keys: vec!["revenue".to_string()],
1031            x_type: ChartXType::Category,
1032            time_format: None,
1033            window: None,
1034            x_label: None,
1035            y_label: None,
1036            show_legend: true,
1037            colors: None,
1038        });
1039        let html = render_component_html(&c, &default_opts());
1040        assert!(html.contains("chart-placeholder"));
1041        assert!(html.contains("data-chart="));
1042    }
1043
1044    // --- Spinner ---
1045
1046    #[test]
1047    fn spinner_omitted_in_low_bandwidth() {
1048        let c = Component::Spinner(Spinner {
1049            id: None,
1050            size: SpinnerSize::Medium,
1051            label: None,
1052        });
1053        let html = render_component_html(&c, &low_bw_opts());
1054        assert!(html.is_empty());
1055    }
1056
1057    // --- Skeleton ---
1058
1059    #[test]
1060    fn skeleton_omitted_in_low_bandwidth() {
1061        let c = Component::Skeleton(Skeleton {
1062            id: None,
1063            variant: SkeletonVariant::Text,
1064            width: None,
1065            height: None,
1066        });
1067        let html = render_component_html(&c, &low_bw_opts());
1068        assert!(html.is_empty());
1069    }
1070
1071    // --- Modal ---
1072
1073    #[test]
1074    fn modal_renders_as_dialog() {
1075        let c = Component::Modal(Modal {
1076            id: None,
1077            title: "Confirm".to_string(),
1078            content: vec![Component::Text(Text {
1079                id: None,
1080                content: "Are you sure?".to_string(),
1081                variant: TextVariant::Body,
1082            })],
1083            footer: None,
1084            size: ModalSize::Medium,
1085            closable: true,
1086        });
1087        let html = render_component_html(&c, &default_opts());
1088        assert!(html.contains("<dialog>"));
1089        assert!(html.contains("<h2>Confirm</h2>"));
1090        assert!(html.contains("<p>Are you sure?</p>"));
1091    }
1092
1093    // --- Class prefix ---
1094
1095    #[test]
1096    fn class_prefix_applied_to_stack() {
1097        let c = Component::Stack(Stack {
1098            id: None,
1099            direction: StackDirection::Vertical,
1100            children: vec![],
1101            gap: 0,
1102        });
1103        let html = render_component_html(&c, &prefixed_opts("adk-"));
1104        assert!(html.contains("adk-stack"));
1105        assert!(html.contains("adk-stack-vertical"));
1106    }
1107
1108    #[test]
1109    fn class_prefix_applied_to_badge() {
1110        let c = Component::Badge(Badge {
1111            id: None,
1112            label: "Test".to_string(),
1113            variant: BadgeVariant::Info,
1114        });
1115        let html = render_component_html(&c, &prefixed_opts("adk-"));
1116        assert!(html.contains("adk-badge"));
1117        assert!(html.contains("adk-badge-info"));
1118    }
1119
1120    // --- HTML escaping in content ---
1121
1122    #[test]
1123    fn text_content_is_escaped() {
1124        let c = Component::Text(Text {
1125            id: None,
1126            content: "<script>alert('xss')</script>".to_string(),
1127            variant: TextVariant::Body,
1128        });
1129        let html = render_component_html(&c, &default_opts());
1130        assert!(!html.contains("<script>"));
1131        assert!(html.contains("&lt;script&gt;"));
1132    }
1133
1134    // --- render_components_html ---
1135
1136    #[test]
1137    fn render_components_html_wraps_in_shell() {
1138        let components = vec![Component::Text(Text {
1139            id: None,
1140            content: "Hello".to_string(),
1141            variant: TextVariant::Body,
1142        })];
1143        let html = render_components_html(&components, &default_opts());
1144        assert!(html.contains("<!DOCTYPE html>"));
1145        assert!(html.contains("<html>"));
1146        assert!(html.contains("<body>"));
1147        assert!(html.contains("<p>Hello</p>"));
1148    }
1149
1150    #[test]
1151    fn render_components_html_no_external_resources() {
1152        let components = vec![Component::Text(Text {
1153            id: None,
1154            content: "Test".to_string(),
1155            variant: TextVariant::Body,
1156        })];
1157        let html = render_components_html(&components, &default_opts());
1158        assert!(!html.contains("<link rel=\"stylesheet\""));
1159        assert!(!html.contains("<script src="));
1160        assert!(!html.contains("@import"));
1161    }
1162
1163    // --- render_surface_html ---
1164
1165    #[test]
1166    fn render_surface_html_handles_valid_components() {
1167        let surface = UiSurface::new(
1168            "main",
1169            "catalog",
1170            vec![json!({"type": "text", "content": "Hello", "variant": "body"})],
1171        );
1172        let html = render_surface_html(&surface, &default_opts());
1173        assert!(html.contains("<p>Hello</p>"));
1174    }
1175
1176    #[test]
1177    fn render_surface_html_handles_unknown_components() {
1178        let surface = UiSurface::new(
1179            "main",
1180            "catalog",
1181            vec![json!({"type": "unknown_widget", "data": 42})],
1182        );
1183        let html = render_surface_html(&surface, &default_opts());
1184        assert!(html.contains("<!-- unknown component -->"));
1185    }
1186
1187    #[test]
1188    fn render_surface_html_mixes_valid_and_unknown() {
1189        let surface = UiSurface::new(
1190            "main",
1191            "catalog",
1192            vec![
1193                json!({"type": "text", "content": "Valid", "variant": "body"}),
1194                json!({"type": "bogus"}),
1195                json!({"type": "divider"}),
1196            ],
1197        );
1198        let html = render_surface_html(&surface, &default_opts());
1199        assert!(html.contains("<p>Valid</p>"));
1200        assert!(html.contains("<!-- unknown component -->"));
1201        assert!(html.contains("<hr>"));
1202    }
1203
1204    // --- Low bandwidth strips style from shell ---
1205
1206    #[test]
1207    fn low_bandwidth_shell_has_no_style_tag() {
1208        let components = vec![Component::Text(Text {
1209            id: None,
1210            content: "Test".to_string(),
1211            variant: TextVariant::Body,
1212        })];
1213        let html = render_components_html(&components, &low_bw_opts());
1214        assert!(!html.contains("<style>"));
1215        assert!(!html.contains("style="));
1216    }
1217
1218    // --- Select ---
1219
1220    #[test]
1221    fn select_renders_with_options() {
1222        let c = Component::Select(Select {
1223            id: None,
1224            name: "color".to_string(),
1225            label: "Color".to_string(),
1226            options: vec![
1227                SelectOption {
1228                    label: "Red".to_string(),
1229                    value: "red".to_string(),
1230                },
1231                SelectOption {
1232                    label: "Blue".to_string(),
1233                    value: "blue".to_string(),
1234                },
1235            ],
1236            required: false,
1237            error: None,
1238        });
1239        let html = render_component_html(&c, &default_opts());
1240        assert!(html.contains("<select"));
1241        assert!(html.contains("<option value=\"red\">Red</option>"));
1242        assert!(html.contains("<option value=\"blue\">Blue</option>"));
1243    }
1244
1245    // --- MultiSelect ---
1246
1247    #[test]
1248    fn multiselect_renders_with_multiple_attr() {
1249        let c = Component::MultiSelect(MultiSelect {
1250            id: None,
1251            name: "tags".to_string(),
1252            label: "Tags".to_string(),
1253            options: vec![SelectOption {
1254                label: "A".to_string(),
1255                value: "a".to_string(),
1256            }],
1257            required: false,
1258        });
1259        let html = render_component_html(&c, &default_opts());
1260        assert!(html.contains("<select multiple"));
1261    }
1262
1263    // --- List ---
1264
1265    #[test]
1266    fn ordered_list_renders_as_ol() {
1267        let c = Component::List(List {
1268            id: None,
1269            items: vec!["First".to_string(), "Second".to_string()],
1270            ordered: true,
1271        });
1272        let html = render_component_html(&c, &default_opts());
1273        assert!(html.contains("<ol>"));
1274        assert!(html.contains("<li>First</li>"));
1275    }
1276
1277    #[test]
1278    fn unordered_list_renders_as_ul() {
1279        let c = Component::List(List {
1280            id: None,
1281            items: vec!["Item".to_string()],
1282            ordered: false,
1283        });
1284        let html = render_component_html(&c, &default_opts());
1285        assert!(html.contains("<ul>"));
1286    }
1287
1288    // --- KeyValue ---
1289
1290    #[test]
1291    fn keyvalue_renders_as_dl() {
1292        let c = Component::KeyValue(KeyValue {
1293            id: None,
1294            pairs: vec![KeyValuePair {
1295                key: "Name".to_string(),
1296                value: "Alice".to_string(),
1297            }],
1298            data_source: None,
1299        });
1300        let html = render_component_html(&c, &default_opts());
1301        assert!(html.contains("<dl>"));
1302        assert!(html.contains("<dt>Name</dt>"));
1303        assert!(html.contains("<dd>Alice</dd>"));
1304    }
1305
1306    // --- CodeBlock ---
1307
1308    #[test]
1309    fn codeblock_renders_as_pre_code() {
1310        let c = Component::CodeBlock(CodeBlock {
1311            id: None,
1312            code: "fn main() {}".to_string(),
1313            language: Some("rust".to_string()),
1314        });
1315        let html = render_component_html(&c, &default_opts());
1316        assert!(html.contains("<pre><code"));
1317        assert!(html.contains("language-rust"));
1318        assert!(html.contains("fn main() {}"));
1319    }
1320
1321    // --- Toast ---
1322
1323    #[test]
1324    fn toast_renders_with_variant() {
1325        let c = Component::Toast(Toast {
1326            id: None,
1327            message: "Saved!".to_string(),
1328            variant: AlertVariant::Success,
1329            duration: 5000,
1330            dismissible: true,
1331        });
1332        let html = render_component_html(&c, &default_opts());
1333        assert!(html.contains("toast"));
1334        assert!(html.contains("toast-success"));
1335        assert!(html.contains("Saved!"));
1336    }
1337
1338    // --- Tabs ---
1339
1340    #[test]
1341    fn tabs_renders_buttons_and_panels() {
1342        let c = Component::Tabs(Tabs {
1343            id: None,
1344            tabs: vec![
1345                Tab {
1346                    label: "Tab 1".to_string(),
1347                    content: vec![Component::Text(Text {
1348                        id: None,
1349                        content: "Content 1".to_string(),
1350                        variant: TextVariant::Body,
1351                    })],
1352                },
1353                Tab {
1354                    label: "Tab 2".to_string(),
1355                    content: vec![],
1356                },
1357            ],
1358        });
1359        let html = render_component_html(&c, &default_opts());
1360        assert!(html.contains("tab-button"));
1361        assert!(html.contains("Tab 1"));
1362        assert!(html.contains("Tab 2"));
1363        assert!(html.contains("tab-panel"));
1364        assert!(html.contains("<p>Content 1</p>"));
1365    }
1366
1367    // --- Divider ---
1368
1369    #[test]
1370    fn divider_renders_as_hr() {
1371        let c = Component::Divider(Divider { id: None });
1372        let html = render_component_html(&c, &default_opts());
1373        assert_eq!(html, "<hr>");
1374    }
1375
1376    // --- Switch ---
1377
1378    #[test]
1379    fn switch_renders_as_checkbox_with_role() {
1380        let c = Component::Switch(Switch {
1381            id: None,
1382            name: "toggle".to_string(),
1383            label: "Enable".to_string(),
1384            default_checked: true,
1385        });
1386        let html = render_component_html(&c, &default_opts());
1387        assert!(html.contains("role=\"switch\""));
1388        assert!(html.contains("type=\"checkbox\""));
1389        assert!(html.contains(" checked"));
1390    }
1391
1392    // --- DateInput ---
1393
1394    #[test]
1395    fn date_input_renders_correctly() {
1396        let c = Component::DateInput(DateInput {
1397            id: None,
1398            name: "dob".to_string(),
1399            label: "Date of Birth".to_string(),
1400            required: true,
1401        });
1402        let html = render_component_html(&c, &default_opts());
1403        assert!(html.contains("type=\"date\""));
1404        assert!(html.contains("Date of Birth"));
1405        assert!(html.contains(" required"));
1406    }
1407
1408    // --- Slider ---
1409
1410    #[test]
1411    fn slider_renders_as_range() {
1412        let c = Component::Slider(Slider {
1413            id: None,
1414            name: "volume".to_string(),
1415            label: "Volume".to_string(),
1416            min: 0.0,
1417            max: 100.0,
1418            step: Some(1.0),
1419            default_value: Some(50.0),
1420        });
1421        let html = render_component_html(&c, &default_opts());
1422        assert!(html.contains("type=\"range\""));
1423        assert!(html.contains("min=\"0\""));
1424        assert!(html.contains("max=\"100\""));
1425    }
1426
1427    // --- Textarea ---
1428
1429    #[test]
1430    fn textarea_renders_correctly() {
1431        let c = Component::Textarea(Textarea {
1432            id: None,
1433            name: "bio".to_string(),
1434            label: "Bio".to_string(),
1435            placeholder: Some("Tell us about yourself".to_string()),
1436            rows: 4,
1437            required: false,
1438            default_value: None,
1439            error: None,
1440        });
1441        let html = render_component_html(&c, &default_opts());
1442        assert!(html.contains("<textarea"));
1443        assert!(html.contains("name=\"bio\""));
1444        assert!(html.contains("Bio"));
1445    }
1446
1447    // --- Icon ---
1448
1449    #[test]
1450    fn icon_renders_with_data_icon() {
1451        let c = Component::Icon(Icon {
1452            id: None,
1453            name: "heart".to_string(),
1454            size: 24,
1455        });
1456        let html = render_component_html(&c, &default_opts());
1457        assert!(html.contains("data-icon=\"heart\""));
1458        assert!(html.contains("icon"));
1459    }
1460
1461    // --- Container ---
1462
1463    #[test]
1464    fn container_renders_children() {
1465        let c = Component::Container(Container {
1466            id: None,
1467            children: vec![Component::Divider(Divider { id: None })],
1468            padding: 16,
1469        });
1470        let html = render_component_html(&c, &default_opts());
1471        assert!(html.contains("container"));
1472        assert!(html.contains("<hr>"));
1473        assert!(html.contains("style=\"padding: 16px\""));
1474    }
1475
1476    #[test]
1477    fn container_low_bandwidth_strips_style() {
1478        let c = Component::Container(Container {
1479            id: None,
1480            children: vec![],
1481            padding: 16,
1482        });
1483        let html = render_component_html(&c, &low_bw_opts());
1484        assert!(!html.contains("style="));
1485    }
1486}