Add standalone session exports
This commit is contained in:
881
src/a2ui/export.rs
Normal file
881
src/a2ui/export.rs
Normal file
@@ -0,0 +1,881 @@
|
||||
use super::{Surface, bound_value_at, display_value, validate_surface_composition};
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::fmt::Write;
|
||||
|
||||
const WIDTH: usize = 900;
|
||||
const HEIGHT: usize = 720;
|
||||
const COLORS: [&str; 6] = [
|
||||
"#75beff", "#89d185", "#d18616", "#f14c4c", "#b180d7", "#e2c440",
|
||||
];
|
||||
|
||||
pub(crate) fn surface_svg(
|
||||
surface: &Surface,
|
||||
images: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
validate_surface_composition(surface)?;
|
||||
let renderer = Renderer { surface, images };
|
||||
let content = renderer.component("root", &surface.data, BTreeSet::new());
|
||||
let agent = surface
|
||||
.surface_properties
|
||||
.get("agentDisplayName")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("A2UI");
|
||||
let label = format!("{agent} surface {}", surface.id);
|
||||
let mut svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" class="a2ui-snapshot" viewBox="0 0 {WIDTH} {HEIGHT}" role="img" aria-label="{}"><foreignObject x="0" y="0" width="{WIDTH}" height="{HEIGHT}"><div xmlns="http://www.w3.org/1999/xhtml" class="a2ui-document"><style>{}</style><header><strong>{}</strong><span>{}</span></header><main>"#,
|
||||
escaped(&label),
|
||||
STYLE,
|
||||
escaped(agent),
|
||||
escaped(&surface.id),
|
||||
);
|
||||
svg.push_str(&content);
|
||||
svg.push_str("</main></div></foreignObject></svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
const STYLE: &str = r#"
|
||||
*{box-sizing:border-box} .a2ui-document{height:100%;overflow:auto;background:#17181b;color:#e8e8ea;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;border:1px solid #34363d;border-radius:12px}.a2ui-document>header{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;padding:11px 14px;background:#202125;border-bottom:1px solid #34363d;color:#aeb0b6;font-size:12px}.a2ui-document>main{display:flex;flex-direction:column;gap:10px;padding:16px}.row,.column,.list{display:flex;gap:10px}.row,.list.horizontal{flex-direction:row;align-items:flex-start}.column,.list{flex-direction:column}.card,.map,.table{padding:13px;background:#202125;border:1px solid #34363d;border-radius:9px}.button,.chip{display:inline-block;padding:7px 11px;background:#30323a;border:1px solid #464951;border-radius:7px}.button.primary{background:#416a91}.button.borderless{background:transparent;border-color:transparent}.field{display:flex;flex-direction:column;gap:5px}.field-label,.muted,.chart-kind,.caption{color:#999ba3;font-size:12px}.input{min-height:34px;padding:7px 9px;background:#101114;border:1px solid #393b42;border-radius:6px;white-space:pre-wrap}.check{display:flex;gap:7px;align-items:center}.check-mark{width:18px;height:18px;text-align:center;border:1px solid #555862;border-radius:5px}.divider{height:1px;background:#3a3c43}.divider.vertical{width:1px;height:auto;min-height:32px}.metric-value{font-size:28px}.error{color:#ff8585}.bar{display:grid;grid-template-columns:minmax(80px,150px) 1fr auto;gap:8px;align-items:center}.bar-track{height:9px;overflow:hidden;background:#30323a;border-radius:5px}.bar-fill{height:100%;background:#75beff}.pie{width:150px;height:150px;border-radius:50%}.donut{position:relative}.donut:after{content:"";position:absolute;inset:34px;background:#17181b;border-radius:50%}.legend{display:flex;flex-wrap:wrap;gap:8px 14px}.swatch{display:inline-block;width:10px;height:10px;margin-right:5px;border-radius:2px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:7px 9px;border:1px solid #3a3c43;text-align:left}.timeline-item,.location,.mind-node{display:flex;gap:8px}.tabs>details{margin-top:7px;padding:7px 9px;border:1px solid #34363d;border-radius:7px}.tabs summary{cursor:pointer}.media{padding:10px;border:1px solid #34363d;border-radius:7px}.surface-image{display:block;max-width:100%;max-height:260px;margin:auto;border-radius:7px}.chart,.timeline,.mindmap{display:flex;flex-direction:column;gap:8px}h1,h2,h3,p{margin-top:0}pre{overflow:auto}a{color:#8bbceb}
|
||||
"#;
|
||||
|
||||
struct Renderer<'a> {
|
||||
surface: &'a Surface,
|
||||
images: &'a HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Renderer<'_> {
|
||||
fn component(&self, id: &str, context: &Value, mut ancestors: BTreeSet<String>) -> String {
|
||||
if !ancestors.insert(id.to_owned()) {
|
||||
return format!(
|
||||
"<div class=\"error\">Cyclic component {}</div>",
|
||||
escaped(id)
|
||||
);
|
||||
}
|
||||
let Some(component) = self.surface.components.get(id).and_then(Value::as_object) else {
|
||||
return format!(
|
||||
"<div class=\"error\">Missing component {}</div>",
|
||||
escaped(id)
|
||||
);
|
||||
};
|
||||
let kind = component
|
||||
.get("component")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Unknown");
|
||||
match kind {
|
||||
"Text" => {
|
||||
let content = safe_markdown(&self.value(component, "text", context));
|
||||
if component.get("variant").and_then(Value::as_str) == Some("caption") {
|
||||
format!("<div class=\"caption\">{content}</div>")
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
"Image" => self.image(component, context),
|
||||
"Video" | "AudioPlayer" => self.media(component, context, kind),
|
||||
"Icon" => format!(
|
||||
"<span aria-label=\"{}\">{}</span>",
|
||||
escaped(&self.value(component, "name", context)),
|
||||
icon(&self.value(component, "name", context))
|
||||
),
|
||||
"Divider" => format!(
|
||||
"<div class=\"divider{}\"></div>",
|
||||
if component.get("axis").and_then(Value::as_str) == Some("vertical") {
|
||||
" vertical"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
"Row" | "Column" | "List" => {
|
||||
let horizontal = kind == "Row"
|
||||
|| (kind == "List"
|
||||
&& component.get("direction").and_then(Value::as_str)
|
||||
== Some("horizontal"));
|
||||
let class = if kind == "List" {
|
||||
if horizontal {
|
||||
"list horizontal"
|
||||
} else {
|
||||
"list"
|
||||
}
|
||||
} else if kind == "Row" {
|
||||
"row"
|
||||
} else {
|
||||
"column"
|
||||
};
|
||||
format!(
|
||||
"<div class=\"{class}\"{}>{}</div>",
|
||||
layout_style(component),
|
||||
self.children(component.get("children"), context, &ancestors)
|
||||
)
|
||||
}
|
||||
"Card" => format!(
|
||||
"<div class=\"card\">{}</div>",
|
||||
self.child(component, "child", context, &ancestors)
|
||||
),
|
||||
"Tabs" => self.tabs(component, context, &ancestors),
|
||||
"Modal" => format!(
|
||||
"<details><summary>{}</summary><div class=\"card\">{}</div></details>",
|
||||
self.child(component, "trigger", context, &ancestors),
|
||||
self.child(component, "content", context, &ancestors)
|
||||
),
|
||||
"Button" => self.with_error(
|
||||
component,
|
||||
format!(
|
||||
"<span class=\"button {}\">{}</span>",
|
||||
component
|
||||
.get("variant")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default"),
|
||||
self.child(component, "child", context, &ancestors)
|
||||
),
|
||||
),
|
||||
"TextField" => self.text_field(component, context),
|
||||
"CheckBox" => self.checkbox(component, context),
|
||||
"Slider" => self.slider(component, context),
|
||||
"DateTimeInput" => self.with_error(
|
||||
component,
|
||||
self.field(
|
||||
&self.value(component, "label", context),
|
||||
&self.value(component, "value", context),
|
||||
),
|
||||
),
|
||||
"ChoicePicker" => self.choices(component, context),
|
||||
"Chart" => self.chart(component, context),
|
||||
"Table" => self.table(component, context),
|
||||
"Metric" => self.metric(component, context),
|
||||
"Timeline" => self.timeline(component, context),
|
||||
"Map" => self.map(component, context),
|
||||
"MindMap" => self.mind_map(component, context),
|
||||
"Form" => format!(
|
||||
"<section class=\"card column\"><strong>{}</strong>{}<span class=\"button\">{}</span></section>",
|
||||
escaped(&self.value(component, "title", context)),
|
||||
self.children(component.get("children"), context, &ancestors),
|
||||
escaped(
|
||||
component
|
||||
.get("submitLabel")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Submit")
|
||||
)
|
||||
),
|
||||
_ => format!(
|
||||
"<div class=\"error\">Unsupported component {}</div>",
|
||||
escaped(kind)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn value(&self, component: &Map<String, Value>, key: &str, context: &Value) -> String {
|
||||
display_value(&bound_value_at(
|
||||
component.get(key),
|
||||
&self.surface.data,
|
||||
context,
|
||||
))
|
||||
}
|
||||
|
||||
fn with_error(&self, component: &Map<String, Value>, mut content: String) -> String {
|
||||
if let Some(error) = super::first_failed_check(component, &self.surface.data) {
|
||||
let _ = write!(content, "<div class=\"error\">{}</div>", escaped(&error));
|
||||
}
|
||||
content
|
||||
}
|
||||
|
||||
fn child(
|
||||
&self,
|
||||
component: &Map<String, Value>,
|
||||
key: &str,
|
||||
context: &Value,
|
||||
ancestors: &BTreeSet<String>,
|
||||
) -> String {
|
||||
component
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(|id| self.component(id, context, ancestors.clone()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn children(
|
||||
&self,
|
||||
children: Option<&Value>,
|
||||
context: &Value,
|
||||
ancestors: &BTreeSet<String>,
|
||||
) -> String {
|
||||
if let Some(children) = children.and_then(Value::as_array) {
|
||||
return children
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(|id| self.weighted_component(id, context, ancestors))
|
||||
.collect();
|
||||
}
|
||||
let Some(template) = children.and_then(Value::as_object) else {
|
||||
return String::new();
|
||||
};
|
||||
let id = template
|
||||
.get("componentId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let path = template.get("path").and_then(Value::as_str).unwrap_or("/");
|
||||
self.surface
|
||||
.data
|
||||
.pointer(path)
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
super::with_template_index(index, || self.weighted_component(id, item, ancestors))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn weighted_component(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &Value,
|
||||
ancestors: &BTreeSet<String>,
|
||||
) -> String {
|
||||
let content = self.component(id, context, ancestors.clone());
|
||||
self.surface
|
||||
.components
|
||||
.get(id)
|
||||
.and_then(|component| component.get("weight"))
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|weight| weight.is_finite() && *weight > 0.0)
|
||||
.map_or(content.clone(), |weight| {
|
||||
format!("<div style=\"flex:{weight} 1 0\">{content}</div>")
|
||||
})
|
||||
}
|
||||
|
||||
fn tabs(
|
||||
&self,
|
||||
component: &Map<String, Value>,
|
||||
context: &Value,
|
||||
ancestors: &BTreeSet<String>,
|
||||
) -> String {
|
||||
let mut output = String::from("<div class=\"tabs\">");
|
||||
for (index, tab) in component
|
||||
.get("tabs")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
{
|
||||
let title = display_value(&bound_value_at(
|
||||
tab.get("title"),
|
||||
&self.surface.data,
|
||||
context,
|
||||
));
|
||||
let child = tab.get("child").and_then(Value::as_str).unwrap_or("");
|
||||
let _ = write!(
|
||||
output,
|
||||
"<details{}><summary>{}</summary>{}</details>",
|
||||
if index == 0 { " open" } else { "" },
|
||||
escaped(&title),
|
||||
self.component(child, context, ancestors.clone())
|
||||
);
|
||||
}
|
||||
output.push_str("</div>");
|
||||
output
|
||||
}
|
||||
|
||||
fn image(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let url = self.value(component, "url", context);
|
||||
let description = self.value(component, "description", context);
|
||||
let source = self
|
||||
.images
|
||||
.get(&url)
|
||||
.map(String::as_str)
|
||||
.or_else(|| url.starts_with("data:image/").then_some(url.as_str()));
|
||||
source.map_or_else(
|
||||
|| {
|
||||
format!(
|
||||
"<div class=\"media\">▧ {}<div class=\"muted\">{}</div></div>",
|
||||
escaped(&description),
|
||||
escaped(&url)
|
||||
)
|
||||
},
|
||||
|source| {
|
||||
format!(
|
||||
"<img class=\"surface-image\" src=\"{}\" alt=\"{}\"/>",
|
||||
escaped(source),
|
||||
escaped(&description)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn media(&self, component: &Map<String, Value>, context: &Value, kind: &str) -> String {
|
||||
format!(
|
||||
"<div class=\"media\">{} {}<div class=\"muted\">{}</div></div>",
|
||||
if kind == "Video" { "▶" } else { "♫" },
|
||||
escaped(&self.value(component, "description", context)),
|
||||
escaped(&self.value(component, "url", context))
|
||||
)
|
||||
}
|
||||
|
||||
fn text_field(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let mut value = self.value(component, "value", context);
|
||||
if component.get("variant").and_then(Value::as_str) == Some("obscured") {
|
||||
value = "••••••••".to_owned();
|
||||
} else if value.is_empty() {
|
||||
value = self.value(component, "placeholder", context);
|
||||
}
|
||||
self.with_error(
|
||||
component,
|
||||
self.field(&self.value(component, "label", context), &value),
|
||||
)
|
||||
}
|
||||
|
||||
fn field(&self, label: &str, value: &str) -> String {
|
||||
format!(
|
||||
"<div class=\"field\"><span class=\"field-label\">{}</span><div class=\"input\">{}</div></div>",
|
||||
escaped(label),
|
||||
escaped(value)
|
||||
)
|
||||
}
|
||||
|
||||
fn checkbox(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let checked = bound_value_at(component.get("value"), &self.surface.data, context)
|
||||
.as_bool()
|
||||
.unwrap_or(false);
|
||||
self.with_error(
|
||||
component,
|
||||
format!(
|
||||
"<div class=\"check\"><span class=\"check-mark\">{}</span>{}</div>",
|
||||
if checked { "✓" } else { "" },
|
||||
escaped(&self.value(component, "label", context))
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn slider(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let value = bound_value_at(component.get("value"), &self.surface.data, context)
|
||||
.as_f64()
|
||||
.unwrap_or(0.0);
|
||||
let min = component.get("min").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let max = component
|
||||
.get("max")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(100.0);
|
||||
let percent = if max > min {
|
||||
((value - min) / (max - min) * 100.0).clamp(0.0, 100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.with_error(component, format!(
|
||||
"<div class=\"field\"><span class=\"field-label\">{}</span><div class=\"bar-track\"><div class=\"bar-fill\" style=\"width:{percent:.2}%\"></div></div><span>{}</span></div>",
|
||||
escaped(&self.value(component, "label", context)),
|
||||
escaped(&display_value(&Value::from(value)))
|
||||
))
|
||||
}
|
||||
|
||||
fn choices(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let selected = bound_value_at(component.get("value"), &self.surface.data, context);
|
||||
let multiple =
|
||||
component.get("variant").and_then(Value::as_str) == Some("multipleSelection");
|
||||
let mut output = format!(
|
||||
"<div class=\"field\"><span class=\"field-label\">{}</span><div class=\"row\">",
|
||||
escaped(&self.value(component, "label", context))
|
||||
);
|
||||
for option in component
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let value = option.get("value").unwrap_or(&Value::Null);
|
||||
let active = selected
|
||||
.as_array()
|
||||
.is_some_and(|values| values.contains(value))
|
||||
|| (!multiple && &selected == value);
|
||||
let _ = write!(
|
||||
output,
|
||||
"<span class=\"chip\">{} {}</span>",
|
||||
if active { "✓" } else { "○" },
|
||||
escaped(&option.get("label").map(display_value).unwrap_or_default())
|
||||
);
|
||||
}
|
||||
output.push_str("</div></div>");
|
||||
self.with_error(component, output)
|
||||
}
|
||||
|
||||
fn chart(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let series = bound_value_at(component.get("series"), &self.surface.data, context);
|
||||
let series = series.as_array().map(Vec::as_slice).unwrap_or(&[]);
|
||||
let chart_type = component
|
||||
.get("chartType")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("bar");
|
||||
let mut output = format!(
|
||||
"<section class=\"chart\"><strong>{}</strong><span class=\"chart-kind\">{}</span>",
|
||||
escaped(&self.value(component, "title", context)),
|
||||
escaped(&chart_type.to_uppercase())
|
||||
);
|
||||
if matches!(chart_type, "pie" | "donut") {
|
||||
let total = series
|
||||
.iter()
|
||||
.filter_map(|point| point.get("value")?.as_f64())
|
||||
.filter(|value| *value > 0.0)
|
||||
.sum::<f64>();
|
||||
let mut position = 0.0;
|
||||
let mut stops = Vec::new();
|
||||
for (index, point) in series.iter().enumerate() {
|
||||
let value = point
|
||||
.get("value")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(0.0)
|
||||
.max(0.0);
|
||||
let end = if total > 0.0 {
|
||||
position + value / total * 100.0
|
||||
} else {
|
||||
position
|
||||
};
|
||||
stops.push(format!(
|
||||
"{} {position:.3}% {end:.3}%",
|
||||
COLORS[index % COLORS.len()]
|
||||
));
|
||||
position = end;
|
||||
}
|
||||
let _ = write!(
|
||||
output,
|
||||
"<div class=\"row\"><div class=\"pie {}\" style=\"background:conic-gradient({})\"></div><div class=\"legend\">",
|
||||
if chart_type == "donut" { "donut" } else { "" },
|
||||
stops.join(",")
|
||||
);
|
||||
for (index, point) in series.iter().enumerate() {
|
||||
let _ = write!(
|
||||
output,
|
||||
"<span><i class=\"swatch\" style=\"background:{}\"></i>{} {}</span>",
|
||||
COLORS[index % COLORS.len()],
|
||||
escaped(&point.get("label").map(display_value).unwrap_or_default()),
|
||||
escaped(&point.get("value").map(display_value).unwrap_or_default())
|
||||
);
|
||||
}
|
||||
output.push_str("</div></div>");
|
||||
} else if chart_type == "heatmap" {
|
||||
let columns = series
|
||||
.iter()
|
||||
.flat_map(|row| {
|
||||
row.get("segments")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
.filter_map(|segment| segment.get("label").and_then(Value::as_str))
|
||||
.fold(Vec::<String>::new(), |mut columns, label| {
|
||||
if !columns.iter().any(|column| column == label) {
|
||||
columns.push(label.to_owned());
|
||||
}
|
||||
columns
|
||||
});
|
||||
let max = series
|
||||
.iter()
|
||||
.flat_map(|row| {
|
||||
row.get("segments")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
.filter_map(|segment| segment.get("value").and_then(Value::as_f64))
|
||||
.fold(0.0_f64, f64::max)
|
||||
.max(1.0);
|
||||
output.push_str("<table class=\"table\"><thead><tr><th></th>");
|
||||
for column in &columns {
|
||||
let _ = write!(output, "<th>{}</th>", escaped(column));
|
||||
}
|
||||
output.push_str("</tr></thead><tbody>");
|
||||
for row in series {
|
||||
let segments = row
|
||||
.get("segments")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
let _ = write!(
|
||||
output,
|
||||
"<tr><th>{}</th>",
|
||||
escaped(&row.get("label").map(display_value).unwrap_or_default())
|
||||
);
|
||||
for column in &columns {
|
||||
let value = segments
|
||||
.iter()
|
||||
.find(|segment| {
|
||||
segment.get("label").and_then(Value::as_str) == Some(column)
|
||||
})
|
||||
.and_then(|segment| segment.get("value").and_then(Value::as_f64))
|
||||
.unwrap_or(0.0);
|
||||
let alpha = (value / max).clamp(0.0, 1.0);
|
||||
let _ = write!(
|
||||
output,
|
||||
"<td style=\"background:rgba(183,72,72,{alpha:.3})\">{}</td>",
|
||||
escaped(&display_value(&Value::from(value)))
|
||||
);
|
||||
}
|
||||
output.push_str("</tr>");
|
||||
}
|
||||
output.push_str("</tbody></table>");
|
||||
} else {
|
||||
let max = series
|
||||
.iter()
|
||||
.filter_map(|point| point.get("value")?.as_f64())
|
||||
.fold(0.0_f64, f64::max)
|
||||
.max(1.0);
|
||||
for point in series {
|
||||
let value = point.get("value").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let percent = (value / max * 100.0).clamp(0.0, 100.0);
|
||||
let _ = write!(
|
||||
output,
|
||||
"<div class=\"bar\"><span>{}</span><div class=\"bar-track\"><div class=\"bar-fill\" style=\"width:{percent:.2}%\"></div></div><span>{}</span></div>",
|
||||
escaped(&point.get("label").map(display_value).unwrap_or_default()),
|
||||
escaped(&point.get("value").map(display_value).unwrap_or_default())
|
||||
);
|
||||
if let Some(segments) = point.get("segments").and_then(Value::as_array) {
|
||||
let details = segments
|
||||
.iter()
|
||||
.map(|segment| {
|
||||
format!(
|
||||
"{} {}",
|
||||
segment.get("label").map(display_value).unwrap_or_default(),
|
||||
segment.get("value").map(display_value).unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let _ = write!(output, "<div class=\"muted\">{}</div>", escaped(&details));
|
||||
}
|
||||
}
|
||||
}
|
||||
output.push_str("</section>");
|
||||
output
|
||||
}
|
||||
|
||||
fn table(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let columns = bound_value_at(component.get("columns"), &self.surface.data, context);
|
||||
let rows = bound_value_at(component.get("rows"), &self.surface.data, context);
|
||||
let mut output = format!(
|
||||
"<section><strong>{}</strong><table class=\"table\"><thead><tr>",
|
||||
escaped(&self.value(component, "title", context))
|
||||
);
|
||||
for column in columns.as_array().into_iter().flatten() {
|
||||
let _ = write!(output, "<th>{}</th>", escaped(&display_value(column)));
|
||||
}
|
||||
output.push_str("</tr></thead><tbody>");
|
||||
for row in rows
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_array)
|
||||
{
|
||||
output.push_str("<tr>");
|
||||
for cell in row {
|
||||
let _ = write!(output, "<td>{}</td>", escaped(&display_value(cell)));
|
||||
}
|
||||
output.push_str("</tr>");
|
||||
}
|
||||
output.push_str("</tbody></table></section>");
|
||||
output
|
||||
}
|
||||
|
||||
fn metric(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
format!(
|
||||
"<section><div class=\"muted\">{}</div><div class=\"metric-value\">{}</div><div class=\"muted\">{} {}</div></section>",
|
||||
escaped(&self.value(component, "label", context)),
|
||||
escaped(&self.value(component, "value", context)),
|
||||
escaped(&self.value(component, "detail", context)),
|
||||
escaped(&self.value(component, "trend", context))
|
||||
)
|
||||
}
|
||||
|
||||
fn timeline(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let events = bound_value_at(component.get("events"), &self.surface.data, context);
|
||||
let mut output = format!(
|
||||
"<section class=\"timeline\"><strong>{}</strong>",
|
||||
escaped(&self.value(component, "title", context))
|
||||
);
|
||||
for event in events.as_array().into_iter().flatten() {
|
||||
let _ = write!(
|
||||
output,
|
||||
"<div class=\"timeline-item\"><span>●</span><div><strong>{}</strong><div class=\"muted\">{} · {} · {}</div></div></div>",
|
||||
escaped(&event.get("title").map(display_value).unwrap_or_default()),
|
||||
escaped(&event.get("time").map(display_value).unwrap_or_default()),
|
||||
escaped(
|
||||
&event
|
||||
.get("description")
|
||||
.map(display_value)
|
||||
.unwrap_or_default()
|
||||
),
|
||||
escaped(&event.get("status").map(display_value).unwrap_or_default())
|
||||
);
|
||||
}
|
||||
output.push_str("</section>");
|
||||
output
|
||||
}
|
||||
|
||||
fn map(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let locations = bound_value_at(component.get("locations"), &self.surface.data, context);
|
||||
let mut output = format!(
|
||||
"<section class=\"map\"><strong>{}</strong>",
|
||||
escaped(&self.value(component, "title", context))
|
||||
);
|
||||
for location in locations.as_array().into_iter().flatten() {
|
||||
let _ = write!(
|
||||
output,
|
||||
"<div class=\"location\"><span>⌖</span><div><strong>{}</strong><div class=\"muted\">{}, {} · {}</div></div></div>",
|
||||
escaped(&location.get("label").map(display_value).unwrap_or_default()),
|
||||
escaped(
|
||||
&location
|
||||
.get("latitude")
|
||||
.map(display_value)
|
||||
.unwrap_or_default()
|
||||
),
|
||||
escaped(
|
||||
&location
|
||||
.get("longitude")
|
||||
.map(display_value)
|
||||
.unwrap_or_default()
|
||||
),
|
||||
escaped(
|
||||
&location
|
||||
.get("detail")
|
||||
.map(display_value)
|
||||
.unwrap_or_default()
|
||||
)
|
||||
);
|
||||
}
|
||||
output.push_str("</section>");
|
||||
output
|
||||
}
|
||||
|
||||
fn mind_map(&self, component: &Map<String, Value>, context: &Value) -> String {
|
||||
let nodes = bound_value_at(component.get("nodes"), &self.surface.data, context);
|
||||
let nodes = nodes.as_array().map(Vec::as_slice).unwrap_or(&[]);
|
||||
let mut output = format!(
|
||||
"<section class=\"mindmap\"><strong>{}</strong>",
|
||||
escaped(&self.value(component, "title", context))
|
||||
);
|
||||
if let Some(root) = nodes.first() {
|
||||
output.push_str(&mind_node(nodes, root, 0, BTreeSet::new()));
|
||||
}
|
||||
output.push_str("</section>");
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
fn mind_node(nodes: &[Value], node: &Value, depth: usize, mut seen: BTreeSet<String>) -> String {
|
||||
let id = node.get("id").map(display_value).unwrap_or_default();
|
||||
if !seen.insert(id) {
|
||||
return "<div class=\"muted\">Cycle</div>".to_owned();
|
||||
}
|
||||
let mut output = format!(
|
||||
"<div class=\"mind-node\" style=\"padding-left:{}px\"><span>{}</span><span>{}</span></div>",
|
||||
depth * 18,
|
||||
if depth == 0 { "◆" } else { "↳" },
|
||||
escaped(&node.get("label").map(display_value).unwrap_or_default())
|
||||
);
|
||||
for child in node
|
||||
.get("children")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(child) = nodes
|
||||
.iter()
|
||||
.find(|candidate| candidate.get("id") == Some(child))
|
||||
{
|
||||
output.push_str(&mind_node(nodes, child, depth + 1, seen.clone()));
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn layout_style(component: &Map<String, Value>) -> String {
|
||||
let justify = match component.get("justify").and_then(Value::as_str) {
|
||||
Some("center") => "center",
|
||||
Some("end") => "flex-end",
|
||||
Some("spaceBetween") => "space-between",
|
||||
Some("spaceAround") => "space-around",
|
||||
Some("spaceEvenly") => "space-evenly",
|
||||
_ => "flex-start",
|
||||
};
|
||||
let align = match component.get("align").and_then(Value::as_str) {
|
||||
Some("center") => "center",
|
||||
Some("end") => "flex-end",
|
||||
Some("stretch") => "stretch",
|
||||
_ => "flex-start",
|
||||
};
|
||||
format!(" style=\"justify-content:{justify};align-items:{align}\"")
|
||||
}
|
||||
|
||||
fn safe_markdown(markdown: &str) -> String {
|
||||
let options = Options::ENABLE_TABLES
|
||||
| Options::ENABLE_FOOTNOTES
|
||||
| Options::ENABLE_STRIKETHROUGH
|
||||
| Options::ENABLE_TASKLISTS;
|
||||
let parser = Parser::new_ext(markdown, options).scan(Vec::new(), |images, event| {
|
||||
Some(match event {
|
||||
Event::Html(html) | Event::InlineHtml(html) => Event::Text(html),
|
||||
Event::Start(tag @ Tag::Image { .. }) => {
|
||||
let embedded = matches!(
|
||||
&tag,
|
||||
Tag::Image { dest_url, .. } if dest_url.starts_with("data:image/")
|
||||
);
|
||||
images.push(embedded);
|
||||
if embedded {
|
||||
Event::Start(tag)
|
||||
} else {
|
||||
Event::Text("[Image: ".into())
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::Image) => {
|
||||
if images.pop().unwrap_or(false) {
|
||||
Event::End(TagEnd::Image)
|
||||
} else {
|
||||
Event::Text("]".into())
|
||||
}
|
||||
}
|
||||
event => event,
|
||||
})
|
||||
});
|
||||
let mut output = String::new();
|
||||
html::push_html(&mut output, parser);
|
||||
output
|
||||
}
|
||||
|
||||
fn escaped(text: &str) -> String {
|
||||
let mut output = String::with_capacity(text.len());
|
||||
for character in text.chars() {
|
||||
match character {
|
||||
'&' => output.push_str("&"),
|
||||
'<' => output.push_str("<"),
|
||||
'>' => output.push_str(">"),
|
||||
'"' => output.push_str("""),
|
||||
'\'' => output.push_str("'"),
|
||||
_ => output.push(character),
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn icon(name: &str) -> &'static str {
|
||||
match name {
|
||||
"accountCircle" | "person" => "●",
|
||||
"add" => "+",
|
||||
"arrowBack" => "←",
|
||||
"arrowForward" => "→",
|
||||
"attachFile" => "⌕",
|
||||
"calendarToday" | "event" => "▣",
|
||||
"call" | "phone" => "☎",
|
||||
"camera" => "◉",
|
||||
"check" => "✓",
|
||||
"close" => "×",
|
||||
"delete" => "⌫",
|
||||
"download" => "⇩",
|
||||
"edit" => "✎",
|
||||
"error" => "⊗",
|
||||
"fastForward" => "≫",
|
||||
"favorite" => "♥",
|
||||
"favoriteOff" => "♡",
|
||||
"folder" => "▰",
|
||||
"help" => "?",
|
||||
"home" => "⌂",
|
||||
"info" => "ⓘ",
|
||||
"locationOn" => "⌖",
|
||||
"lock" => "▣",
|
||||
"lockOpen" => "□",
|
||||
"mail" => "✉",
|
||||
"menu" => "☰",
|
||||
"moreVert" => "⋮",
|
||||
"moreHoriz" => "…",
|
||||
"notifications" => "◈",
|
||||
"notificationsOff" => "◇",
|
||||
"pause" => "Ⅱ",
|
||||
"payment" => "¤",
|
||||
"photo" => "▧",
|
||||
"play" => "▶",
|
||||
"print" => "▤",
|
||||
"refresh" => "↻",
|
||||
"rewind" => "≪",
|
||||
"search" => "⌕",
|
||||
"send" => "➤",
|
||||
"settings" => "⚙",
|
||||
"share" => "↗",
|
||||
"shoppingCart" => "⌑",
|
||||
"skipNext" => "▸|",
|
||||
"skipPrevious" => "|◂",
|
||||
"star" => "★",
|
||||
"starHalf" | "starOff" => "☆",
|
||||
"stop" => "■",
|
||||
"upload" => "⇧",
|
||||
"visibility" => "◉",
|
||||
"visibilityOff" => "○",
|
||||
"volumeDown" => "◖",
|
||||
"volumeMute" => "◁",
|
||||
"volumeOff" => "×",
|
||||
"volumeUp" => "◀",
|
||||
"warning" => "⚠",
|
||||
_ => "•",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn static_svg_renderer_covers_the_complete_catalog() {
|
||||
let entries = [
|
||||
json!({"id":"text","component":"Text","text":"Hello"}),
|
||||
json!({"id":"image","component":"Image","url":"https://example.com/a.png","description":"Preview"}),
|
||||
json!({"id":"icon","component":"Icon","name":"settings"}),
|
||||
json!({"id":"video","component":"Video","url":"https://example.com/a.mp4"}),
|
||||
json!({"id":"audio","component":"AudioPlayer","url":"https://example.com/a.mp3"}),
|
||||
json!({"id":"divider","component":"Divider"}),
|
||||
json!({"id":"row","component":"Row","children":[]}),
|
||||
json!({"id":"list","component":"List","children":[]}),
|
||||
json!({"id":"card","component":"Card","child":"text"}),
|
||||
json!({"id":"modal","component":"Modal","trigger":"text","content":"text"}),
|
||||
json!({"id":"tabs","component":"Tabs","tabs":[{"title":"One","child":"text"}]}),
|
||||
json!({"id":"button","component":"Button","child":"text"}),
|
||||
json!({"id":"field","component":"TextField","label":"Name","value":"Ada"}),
|
||||
json!({"id":"check","component":"CheckBox","label":"Ready","value":true}),
|
||||
json!({"id":"slider","component":"Slider","label":"Amount","value":4,"max":10}),
|
||||
json!({"id":"date","component":"DateTimeInput","label":"When","value":"2026-08-31"}),
|
||||
json!({"id":"choice","component":"ChoicePicker","label":"Pick","options":[{"label":"One","value":"one"}],"value":["one"]}),
|
||||
json!({"id":"chart","component":"Chart","title":"Chart","chartType":"heatmap","series":[{"label":"A","segments":[{"label":"B","value":2}]}]}),
|
||||
json!({"id":"table","component":"Table","title":"Table","columns":["A"],"rows":[["B"]]}),
|
||||
json!({"id":"metric","component":"Metric","label":"Count","value":"2"}),
|
||||
json!({"id":"timeline","component":"Timeline","title":"Timeline","events":[{"time":"Now","title":"Done"}]}),
|
||||
json!({"id":"map","component":"Map","title":"Map","locations":[{"label":"Here","latitude":1,"longitude":2}]}),
|
||||
json!({"id":"mind","component":"MindMap","title":"Mind","nodes":[{"id":"n","label":"Root","children":[]}]}),
|
||||
json!({"id":"form","component":"Form","title":"Form","children":[]}),
|
||||
];
|
||||
let ids = entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.get("id").and_then(Value::as_str))
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
let mut components = entries
|
||||
.into_iter()
|
||||
.map(|entry| (entry["id"].as_str().unwrap().to_owned(), entry))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
components.insert(
|
||||
"root".into(),
|
||||
json!({"id":"root","component":"Column","children":ids}),
|
||||
);
|
||||
let surface = Surface {
|
||||
id: "catalog".into(),
|
||||
catalog_id: super::super::CATALOG_ID.into(),
|
||||
surface_properties: json!({}),
|
||||
send_data_model: false,
|
||||
components,
|
||||
data: json!({}),
|
||||
owner_message_id: 1,
|
||||
};
|
||||
|
||||
let svg = surface_svg(&surface, &HashMap::new()).unwrap();
|
||||
assert!(svg.starts_with("<svg"));
|
||||
assert!(!svg.contains("Unsupported component"));
|
||||
assert!(svg.contains("HEATMAP"));
|
||||
assert!(svg.contains("Submit"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user