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            sortable: false,
956            page_size: None,
957            striped: false,
958        });
959        let html = render_component_html(&c, &default_opts());
960        assert!(html.contains("<table>"));
961        assert!(html.contains("<th>Name</th>"));
962        assert!(html.contains("<td>Alice</td>"));
963    }
964
965    // --- Alert ---
966
967    #[test]
968    fn alert_renders_with_role() {
969        let c = Component::Alert(Alert {
970            id: None,
971            title: "Warning!".to_string(),
972            description: Some("Be careful".to_string()),
973            variant: AlertVariant::Warning,
974        });
975        let html = render_component_html(&c, &default_opts());
976        assert!(html.contains("role=\"alert\""));
977        assert!(html.contains("alert-warning"));
978        assert!(html.contains("Warning!"));
979    }
980
981    // --- Progress ---
982
983    #[test]
984    fn progress_renders_with_value() {
985        let c = Component::Progress(Progress {
986            id: None,
987            value: 75,
988            label: Some("Loading".to_string()),
989        });
990        let html = render_component_html(&c, &default_opts());
991        assert!(html.contains("<progress"));
992        assert!(html.contains("value=\"75\""));
993        assert!(html.contains("max=\"100\""));
994    }
995
996    // --- Chart ---
997
998    #[test]
999    fn chart_omitted_in_low_bandwidth() {
1000        let c = Component::Chart(Chart {
1001            id: None,
1002            title: Some("Sales".to_string()),
1003            kind: ChartKind::Bar,
1004            data: vec![],
1005            x_key: "month".to_string(),
1006            y_keys: vec!["revenue".to_string()],
1007            x_label: None,
1008            y_label: None,
1009            show_legend: true,
1010            colors: None,
1011        });
1012        let html = render_component_html(&c, &low_bw_opts());
1013        assert!(html.is_empty());
1014    }
1015
1016    #[test]
1017    fn chart_renders_placeholder_in_full_mode() {
1018        let c = Component::Chart(Chart {
1019            id: None,
1020            title: Some("Sales".to_string()),
1021            kind: ChartKind::Bar,
1022            data: vec![],
1023            x_key: "month".to_string(),
1024            y_keys: vec!["revenue".to_string()],
1025            x_label: None,
1026            y_label: None,
1027            show_legend: true,
1028            colors: None,
1029        });
1030        let html = render_component_html(&c, &default_opts());
1031        assert!(html.contains("chart-placeholder"));
1032        assert!(html.contains("data-chart="));
1033    }
1034
1035    // --- Spinner ---
1036
1037    #[test]
1038    fn spinner_omitted_in_low_bandwidth() {
1039        let c = Component::Spinner(Spinner {
1040            id: None,
1041            size: SpinnerSize::Medium,
1042            label: None,
1043        });
1044        let html = render_component_html(&c, &low_bw_opts());
1045        assert!(html.is_empty());
1046    }
1047
1048    // --- Skeleton ---
1049
1050    #[test]
1051    fn skeleton_omitted_in_low_bandwidth() {
1052        let c = Component::Skeleton(Skeleton {
1053            id: None,
1054            variant: SkeletonVariant::Text,
1055            width: None,
1056            height: None,
1057        });
1058        let html = render_component_html(&c, &low_bw_opts());
1059        assert!(html.is_empty());
1060    }
1061
1062    // --- Modal ---
1063
1064    #[test]
1065    fn modal_renders_as_dialog() {
1066        let c = Component::Modal(Modal {
1067            id: None,
1068            title: "Confirm".to_string(),
1069            content: vec![Component::Text(Text {
1070                id: None,
1071                content: "Are you sure?".to_string(),
1072                variant: TextVariant::Body,
1073            })],
1074            footer: None,
1075            size: ModalSize::Medium,
1076            closable: true,
1077        });
1078        let html = render_component_html(&c, &default_opts());
1079        assert!(html.contains("<dialog>"));
1080        assert!(html.contains("<h2>Confirm</h2>"));
1081        assert!(html.contains("<p>Are you sure?</p>"));
1082    }
1083
1084    // --- Class prefix ---
1085
1086    #[test]
1087    fn class_prefix_applied_to_stack() {
1088        let c = Component::Stack(Stack {
1089            id: None,
1090            direction: StackDirection::Vertical,
1091            children: vec![],
1092            gap: 0,
1093        });
1094        let html = render_component_html(&c, &prefixed_opts("adk-"));
1095        assert!(html.contains("adk-stack"));
1096        assert!(html.contains("adk-stack-vertical"));
1097    }
1098
1099    #[test]
1100    fn class_prefix_applied_to_badge() {
1101        let c = Component::Badge(Badge {
1102            id: None,
1103            label: "Test".to_string(),
1104            variant: BadgeVariant::Info,
1105        });
1106        let html = render_component_html(&c, &prefixed_opts("adk-"));
1107        assert!(html.contains("adk-badge"));
1108        assert!(html.contains("adk-badge-info"));
1109    }
1110
1111    // --- HTML escaping in content ---
1112
1113    #[test]
1114    fn text_content_is_escaped() {
1115        let c = Component::Text(Text {
1116            id: None,
1117            content: "<script>alert('xss')</script>".to_string(),
1118            variant: TextVariant::Body,
1119        });
1120        let html = render_component_html(&c, &default_opts());
1121        assert!(!html.contains("<script>"));
1122        assert!(html.contains("&lt;script&gt;"));
1123    }
1124
1125    // --- render_components_html ---
1126
1127    #[test]
1128    fn render_components_html_wraps_in_shell() {
1129        let components = vec![Component::Text(Text {
1130            id: None,
1131            content: "Hello".to_string(),
1132            variant: TextVariant::Body,
1133        })];
1134        let html = render_components_html(&components, &default_opts());
1135        assert!(html.contains("<!DOCTYPE html>"));
1136        assert!(html.contains("<html>"));
1137        assert!(html.contains("<body>"));
1138        assert!(html.contains("<p>Hello</p>"));
1139    }
1140
1141    #[test]
1142    fn render_components_html_no_external_resources() {
1143        let components = vec![Component::Text(Text {
1144            id: None,
1145            content: "Test".to_string(),
1146            variant: TextVariant::Body,
1147        })];
1148        let html = render_components_html(&components, &default_opts());
1149        assert!(!html.contains("<link rel=\"stylesheet\""));
1150        assert!(!html.contains("<script src="));
1151        assert!(!html.contains("@import"));
1152    }
1153
1154    // --- render_surface_html ---
1155
1156    #[test]
1157    fn render_surface_html_handles_valid_components() {
1158        let surface = UiSurface::new(
1159            "main",
1160            "catalog",
1161            vec![json!({"type": "text", "content": "Hello", "variant": "body"})],
1162        );
1163        let html = render_surface_html(&surface, &default_opts());
1164        assert!(html.contains("<p>Hello</p>"));
1165    }
1166
1167    #[test]
1168    fn render_surface_html_handles_unknown_components() {
1169        let surface = UiSurface::new(
1170            "main",
1171            "catalog",
1172            vec![json!({"type": "unknown_widget", "data": 42})],
1173        );
1174        let html = render_surface_html(&surface, &default_opts());
1175        assert!(html.contains("<!-- unknown component -->"));
1176    }
1177
1178    #[test]
1179    fn render_surface_html_mixes_valid_and_unknown() {
1180        let surface = UiSurface::new(
1181            "main",
1182            "catalog",
1183            vec![
1184                json!({"type": "text", "content": "Valid", "variant": "body"}),
1185                json!({"type": "bogus"}),
1186                json!({"type": "divider"}),
1187            ],
1188        );
1189        let html = render_surface_html(&surface, &default_opts());
1190        assert!(html.contains("<p>Valid</p>"));
1191        assert!(html.contains("<!-- unknown component -->"));
1192        assert!(html.contains("<hr>"));
1193    }
1194
1195    // --- Low bandwidth strips style from shell ---
1196
1197    #[test]
1198    fn low_bandwidth_shell_has_no_style_tag() {
1199        let components = vec![Component::Text(Text {
1200            id: None,
1201            content: "Test".to_string(),
1202            variant: TextVariant::Body,
1203        })];
1204        let html = render_components_html(&components, &low_bw_opts());
1205        assert!(!html.contains("<style>"));
1206        assert!(!html.contains("style="));
1207    }
1208
1209    // --- Select ---
1210
1211    #[test]
1212    fn select_renders_with_options() {
1213        let c = Component::Select(Select {
1214            id: None,
1215            name: "color".to_string(),
1216            label: "Color".to_string(),
1217            options: vec![
1218                SelectOption {
1219                    label: "Red".to_string(),
1220                    value: "red".to_string(),
1221                },
1222                SelectOption {
1223                    label: "Blue".to_string(),
1224                    value: "blue".to_string(),
1225                },
1226            ],
1227            required: false,
1228            error: None,
1229        });
1230        let html = render_component_html(&c, &default_opts());
1231        assert!(html.contains("<select"));
1232        assert!(html.contains("<option value=\"red\">Red</option>"));
1233        assert!(html.contains("<option value=\"blue\">Blue</option>"));
1234    }
1235
1236    // --- MultiSelect ---
1237
1238    #[test]
1239    fn multiselect_renders_with_multiple_attr() {
1240        let c = Component::MultiSelect(MultiSelect {
1241            id: None,
1242            name: "tags".to_string(),
1243            label: "Tags".to_string(),
1244            options: vec![SelectOption {
1245                label: "A".to_string(),
1246                value: "a".to_string(),
1247            }],
1248            required: false,
1249        });
1250        let html = render_component_html(&c, &default_opts());
1251        assert!(html.contains("<select multiple"));
1252    }
1253
1254    // --- List ---
1255
1256    #[test]
1257    fn ordered_list_renders_as_ol() {
1258        let c = Component::List(List {
1259            id: None,
1260            items: vec!["First".to_string(), "Second".to_string()],
1261            ordered: true,
1262        });
1263        let html = render_component_html(&c, &default_opts());
1264        assert!(html.contains("<ol>"));
1265        assert!(html.contains("<li>First</li>"));
1266    }
1267
1268    #[test]
1269    fn unordered_list_renders_as_ul() {
1270        let c = Component::List(List {
1271            id: None,
1272            items: vec!["Item".to_string()],
1273            ordered: false,
1274        });
1275        let html = render_component_html(&c, &default_opts());
1276        assert!(html.contains("<ul>"));
1277    }
1278
1279    // --- KeyValue ---
1280
1281    #[test]
1282    fn keyvalue_renders_as_dl() {
1283        let c = Component::KeyValue(KeyValue {
1284            id: None,
1285            pairs: vec![KeyValuePair {
1286                key: "Name".to_string(),
1287                value: "Alice".to_string(),
1288            }],
1289        });
1290        let html = render_component_html(&c, &default_opts());
1291        assert!(html.contains("<dl>"));
1292        assert!(html.contains("<dt>Name</dt>"));
1293        assert!(html.contains("<dd>Alice</dd>"));
1294    }
1295
1296    // --- CodeBlock ---
1297
1298    #[test]
1299    fn codeblock_renders_as_pre_code() {
1300        let c = Component::CodeBlock(CodeBlock {
1301            id: None,
1302            code: "fn main() {}".to_string(),
1303            language: Some("rust".to_string()),
1304        });
1305        let html = render_component_html(&c, &default_opts());
1306        assert!(html.contains("<pre><code"));
1307        assert!(html.contains("language-rust"));
1308        assert!(html.contains("fn main() {}"));
1309    }
1310
1311    // --- Toast ---
1312
1313    #[test]
1314    fn toast_renders_with_variant() {
1315        let c = Component::Toast(Toast {
1316            id: None,
1317            message: "Saved!".to_string(),
1318            variant: AlertVariant::Success,
1319            duration: 5000,
1320            dismissible: true,
1321        });
1322        let html = render_component_html(&c, &default_opts());
1323        assert!(html.contains("toast"));
1324        assert!(html.contains("toast-success"));
1325        assert!(html.contains("Saved!"));
1326    }
1327
1328    // --- Tabs ---
1329
1330    #[test]
1331    fn tabs_renders_buttons_and_panels() {
1332        let c = Component::Tabs(Tabs {
1333            id: None,
1334            tabs: vec![
1335                Tab {
1336                    label: "Tab 1".to_string(),
1337                    content: vec![Component::Text(Text {
1338                        id: None,
1339                        content: "Content 1".to_string(),
1340                        variant: TextVariant::Body,
1341                    })],
1342                },
1343                Tab {
1344                    label: "Tab 2".to_string(),
1345                    content: vec![],
1346                },
1347            ],
1348        });
1349        let html = render_component_html(&c, &default_opts());
1350        assert!(html.contains("tab-button"));
1351        assert!(html.contains("Tab 1"));
1352        assert!(html.contains("Tab 2"));
1353        assert!(html.contains("tab-panel"));
1354        assert!(html.contains("<p>Content 1</p>"));
1355    }
1356
1357    // --- Divider ---
1358
1359    #[test]
1360    fn divider_renders_as_hr() {
1361        let c = Component::Divider(Divider { id: None });
1362        let html = render_component_html(&c, &default_opts());
1363        assert_eq!(html, "<hr>");
1364    }
1365
1366    // --- Switch ---
1367
1368    #[test]
1369    fn switch_renders_as_checkbox_with_role() {
1370        let c = Component::Switch(Switch {
1371            id: None,
1372            name: "toggle".to_string(),
1373            label: "Enable".to_string(),
1374            default_checked: true,
1375        });
1376        let html = render_component_html(&c, &default_opts());
1377        assert!(html.contains("role=\"switch\""));
1378        assert!(html.contains("type=\"checkbox\""));
1379        assert!(html.contains(" checked"));
1380    }
1381
1382    // --- DateInput ---
1383
1384    #[test]
1385    fn date_input_renders_correctly() {
1386        let c = Component::DateInput(DateInput {
1387            id: None,
1388            name: "dob".to_string(),
1389            label: "Date of Birth".to_string(),
1390            required: true,
1391        });
1392        let html = render_component_html(&c, &default_opts());
1393        assert!(html.contains("type=\"date\""));
1394        assert!(html.contains("Date of Birth"));
1395        assert!(html.contains(" required"));
1396    }
1397
1398    // --- Slider ---
1399
1400    #[test]
1401    fn slider_renders_as_range() {
1402        let c = Component::Slider(Slider {
1403            id: None,
1404            name: "volume".to_string(),
1405            label: "Volume".to_string(),
1406            min: 0.0,
1407            max: 100.0,
1408            step: Some(1.0),
1409            default_value: Some(50.0),
1410        });
1411        let html = render_component_html(&c, &default_opts());
1412        assert!(html.contains("type=\"range\""));
1413        assert!(html.contains("min=\"0\""));
1414        assert!(html.contains("max=\"100\""));
1415    }
1416
1417    // --- Textarea ---
1418
1419    #[test]
1420    fn textarea_renders_correctly() {
1421        let c = Component::Textarea(Textarea {
1422            id: None,
1423            name: "bio".to_string(),
1424            label: "Bio".to_string(),
1425            placeholder: Some("Tell us about yourself".to_string()),
1426            rows: 4,
1427            required: false,
1428            default_value: None,
1429            error: None,
1430        });
1431        let html = render_component_html(&c, &default_opts());
1432        assert!(html.contains("<textarea"));
1433        assert!(html.contains("name=\"bio\""));
1434        assert!(html.contains("Bio"));
1435    }
1436
1437    // --- Icon ---
1438
1439    #[test]
1440    fn icon_renders_with_data_icon() {
1441        let c = Component::Icon(Icon {
1442            id: None,
1443            name: "heart".to_string(),
1444            size: 24,
1445        });
1446        let html = render_component_html(&c, &default_opts());
1447        assert!(html.contains("data-icon=\"heart\""));
1448        assert!(html.contains("icon"));
1449    }
1450
1451    // --- Container ---
1452
1453    #[test]
1454    fn container_renders_children() {
1455        let c = Component::Container(Container {
1456            id: None,
1457            children: vec![Component::Divider(Divider { id: None })],
1458            padding: 16,
1459        });
1460        let html = render_component_html(&c, &default_opts());
1461        assert!(html.contains("container"));
1462        assert!(html.contains("<hr>"));
1463        assert!(html.contains("style=\"padding: 16px\""));
1464    }
1465
1466    #[test]
1467    fn container_low_bandwidth_strips_style() {
1468        let c = Component::Container(Container {
1469            id: None,
1470            children: vec![],
1471            padding: 16,
1472        });
1473        let html = render_component_html(&c, &low_bw_opts());
1474        assert!(!html.contains("style="));
1475    }
1476}