1use std::sync::LazyLock;
7
8use indexmap::IndexMap;
9use serde_json::{json, Map, Value};
10
11use crate::generated::catalog as g;
12use crate::types::{FlatComponent, Props, Template};
13
14#[derive(Debug, Clone, PartialEq)]
16pub enum PropRule {
17 Copy(String),
19 Const(Value),
21 From { from: Option<String>, map: Option<Map<String, Value>>, default: Option<Value> },
23}
24
25#[derive(Debug, Clone, PartialEq)]
26pub struct ComponentRule {
27 pub to: String,
28 pub props: IndexMap<String, PropRule>,
29 pub children: Option<String>,
31 pub transform: Option<String>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct BasicMap {
37 pub from: String,
38 pub to: String,
39 pub version: String,
40 pub placeholder_icon: String,
41 pub components: IndexMap<String, ComponentRule>,
42 pub transforms: IndexMap<String, String>,
43 pub icons: IndexMap<String, Option<String>>,
44 pub functions: IndexMap<String, String>,
45}
46
47fn str_field(v: &Value, k: &str) -> String {
48 v.get(k).and_then(Value::as_str).unwrap_or_default().to_string()
49}
50
51fn parse_rule(rule: &Value) -> ComponentRule {
52 let props = rule
53 .get("props")
54 .and_then(Value::as_object)
55 .map(|props| {
56 props
57 .iter()
58 .map(|(core, spec)| {
59 let parsed = match spec {
60 Value::String(s) => PropRule::Copy(s.clone()),
61 Value::Object(o) if o.contains_key("const") => PropRule::Const(o["const"].clone()),
62 Value::Object(o) => PropRule::From {
63 from: o.get("from").and_then(Value::as_str).map(str::to_string),
64 map: o.get("map").and_then(Value::as_object).cloned(),
65 default: o.get("default").cloned(),
66 },
67 _ => PropRule::From { from: None, map: None, default: None },
68 };
69 (core.clone(), parsed)
70 })
71 .collect()
72 })
73 .unwrap_or_default();
74 ComponentRule {
75 to: str_field(rule, "to"),
76 props,
77 children: rule.get("children").and_then(Value::as_str).map(str::to_string),
78 transform: rule.get("transform").and_then(Value::as_str).map(str::to_string),
79 }
80}
81
82#[derive(serde::Deserialize)]
83#[serde(rename_all = "camelCase")]
84struct RawBasicMap {
85 from: String,
86 to: String,
87 version: String,
88 placeholder_icon: String,
89 components: IndexMap<String, Value>,
90 #[serde(default)]
91 transforms: IndexMap<String, String>,
92 #[serde(default)]
93 icons: IndexMap<String, Option<String>>,
94 #[serde(default)]
95 functions: IndexMap<String, String>,
96}
97
98pub static BASIC_MAP: LazyLock<BasicMap> = LazyLock::new(|| {
100 let raw: RawBasicMap = serde_json::from_str(g::BASIC_MAP_JSON).expect("basic-map.json");
101 BasicMap {
102 from: raw.from,
103 to: raw.to,
104 version: raw.version,
105 placeholder_icon: raw.placeholder_icon,
106 components: raw.components.iter().map(|(k, v)| (k.clone(), parse_rule(v))).collect(),
107 transforms: raw.transforms,
108 icons: raw.icons,
109 functions: raw.functions,
110 }
111});
112
113pub const BASIC_TRANSFORM_NAMES: &[&str] =
115 &["textVariant", "imageVariant", "iconName", "tabs", "modal", "buttonChild", "textField", "fieldName", "choicePicker"];
116
117#[derive(Debug, Clone, PartialEq, Default)]
119pub struct MappedComponent {
120 pub component: String,
121 pub props: Props,
122 pub children_ids: Vec<String>,
123 pub template: Option<Template>,
124 pub slots: IndexMap<String, String>,
125 pub on: Option<IndexMap<String, Value>>,
126 pub style: Option<Props>,
127}
128
129pub fn map_basic_icon(name: Option<&Value>) -> String {
131 let map = &*BASIC_MAP;
132 match name {
133 Some(Value::String(n)) => map.icons.get(n).cloned().flatten().unwrap_or_else(|| map.placeholder_icon.clone()),
134 _ => map.placeholder_icon.clone(),
135 }
136}
137
138fn is_binding(value: Option<&Value>) -> Option<&str> {
139 value.and_then(|v| v.as_object()).and_then(|o| o.get("path")).and_then(Value::as_str)
140}
141
142fn id_string(v: &Value) -> String {
144 crate::json::to_js_string(v)
145}
146
147struct FlatView {
149 obj: Map<String, Value>,
150}
151
152impl FlatView {
153 fn new(flat: &FlatComponent) -> FlatView {
154 match serde_json::to_value(flat) {
155 Ok(Value::Object(obj)) => FlatView { obj },
156 _ => FlatView { obj: Map::new() },
157 }
158 }
159 fn get(&self, key: &str) -> Option<&Value> {
160 self.obj.get(key)
161 }
162 fn str(&self, key: &str) -> Option<&str> {
163 self.get(key).and_then(Value::as_str)
164 }
165}
166
167fn set(props: &mut Props, key: &str, value: Option<Value>) {
168 match value {
169 Some(v) => {
170 props.insert(key.to_string(), v);
171 }
172 None => {
173 props.remove(key);
174 }
175 }
176}
177
178fn text_variant(out: &mut MappedComponent, flat: &FlatView) {
179 let variant = flat.str("variant");
180 if let Some(level @ ("h1" | "h2" | "h3" | "h4")) = variant {
181 out.component = "Heading".into();
182 let mut props = Props::new();
183 set(&mut props, "text", out.props.get("text").cloned());
184 props.insert("level".into(), json!(level));
185 out.props = props;
186 return;
187 }
188 let v = match variant {
189 Some("h5") => "label",
190 Some("caption") => "caption",
191 _ => "body",
192 };
193 out.props.insert("variant".into(), json!(v));
194}
195
196fn image_variant(out: &mut MappedComponent, flat: &FlatView) {
197 let variant = match flat.get("variant") {
198 None | Some(Value::Null) => "mediumFeature".to_string(),
199 Some(v) => id_string(v),
200 };
201 if variant == "avatar" {
202 out.component = "Avatar".into();
203 let mut props = Props::new();
204 set(&mut props, "src", out.props.get("src").cloned());
205 let name = match flat.get("description") {
206 None | Some(Value::Null) => json!(""),
207 Some(v) => v.clone(),
208 };
209 props.insert("name".into(), name);
210 out.props = props;
211 return;
212 }
213 let style = match variant.as_str() {
214 "icon" => json!({"width": 24, "height": 24}),
215 "smallFeature" => json!({"width": 120, "height": 120}),
216 "header" => json!({"width": "100%", "aspectRatio": 3}),
217 _ => json!({"width": "100%", "aspectRatio": 1.7777778}),
218 };
219 let mut merged = out.style.take().unwrap_or_default();
220 if let Value::Object(style) = style {
221 merged.extend(style);
222 }
223 out.style = Some(merged);
224}
225
226fn icon_name(out: &mut MappedComponent, flat: &FlatView) {
227 out.props.insert("name".into(), json!(map_basic_icon(flat.get("name"))));
228}
229
230fn tabs(out: &mut MappedComponent, flat: &FlatView) {
231 let tabs: Vec<Value> = flat.get("tabs").and_then(Value::as_array).cloned().unwrap_or_default();
232 let field = |tab: &Value, k: &str| tab.as_object().and_then(|o| o.get(k)).cloned();
233 let headers: Vec<Value> = tabs
234 .iter()
235 .map(|tab| {
236 let mut header = Map::new();
237 if let Some(title) = field(tab, "title") {
238 header.insert("label".into(), title);
239 }
240 if let Some(child) = field(tab, "child") {
241 header.insert("value".into(), child);
242 }
243 Value::Object(header)
244 })
245 .collect();
246 out.props.insert("tabs".into(), Value::Array(headers));
247 out.children_ids = tabs.iter().map(|tab| field(tab, "child").map(|c| id_string(&c)).unwrap_or_else(|| "undefined".into())).collect();
248 if let Some(first) = tabs.first() {
249 set(&mut out.props, "value", field(first, "child"));
250 }
251}
252
253fn modal(out: &mut MappedComponent, flat: &FlatView) {
254 if let Some(trigger) = flat.str("trigger") {
255 out.slots.insert("trigger".into(), trigger.to_string());
256 }
257 if let Some(content) = flat.str("content") {
258 out.children_ids = vec![content.to_string()];
259 }
260 out.props.insert("open".into(), json!(false));
261}
262
263fn button_child(out: &mut MappedComponent, flat: &FlatView, lookup: &dyn Fn(&str) -> Option<FlatComponent>) {
264 let child_id = flat.str("child").map(str::to_string);
265 let child = child_id.as_deref().and_then(lookup);
266 match &child {
267 Some(c) if c.component == "Text" => set(&mut out.props, "label", c.rest.get("text").cloned()),
268 Some(c) if c.component == "Icon" => {
269 out.props.insert("icon".into(), json!(map_basic_icon(c.rest.get("name"))));
270 }
271 _ => {
272 if let Some(id) = &child_id {
273 out.children_ids = vec![id.clone()];
274 }
275 }
276 }
277 if out.props.contains_key("icon") && !out.props.contains_key("label") {
278 out.props.insert("size".into(), json!("icon"));
279 }
280 if let Some(action) = flat.get("action") {
281 let mut on = IndexMap::new();
282 on.insert("press".to_string(), action.clone());
283 out.on = Some(on);
284 }
285}
286
287fn flat_id(flat: &FlatView) -> Value {
288 flat.get("id").cloned().unwrap_or(Value::Null)
289}
290
291fn text_field(out: &mut MappedComponent, flat: &FlatView) {
292 out.props.insert("name".into(), flat_id(flat));
293 let variant = match flat.get("variant") {
294 None | Some(Value::Null) => "shortText".to_string(),
295 Some(v) => id_string(v),
296 };
297 if variant == "longText" {
298 out.component = "Textarea".into();
299 } else {
300 let ty = match variant.as_str() {
301 "number" => "number",
302 "obscured" => "password",
303 _ => "text",
304 };
305 out.props.insert("type".into(), json!(ty));
306 }
307 if let Some(pattern) = flat.str("validationRegexp") {
308 let value = match is_binding(flat.get("value")) {
309 Some(path) => json!(path),
310 None => flat_id(flat),
311 };
312 let mut checks = out.props.get("checks").and_then(Value::as_array).cloned().unwrap_or_default();
313 checks.push(json!({
314 "condition": {"call": "regex", "args": {"value": {"path": value}, "pattern": pattern}},
315 "message": "Invalid value",
316 }));
317 out.props.insert("checks".into(), Value::Array(checks));
318 }
319}
320
321fn field_name(out: &mut MappedComponent, flat: &FlatView) {
322 out.props.insert("name".into(), flat_id(flat));
323}
324
325fn join_part(v: &Value) -> String {
327 if v.is_null() {
328 String::new()
329 } else {
330 crate::json::to_js_string(v)
331 }
332}
333
334fn choice_picker(out: &mut MappedComponent, flat: &FlatView) {
335 out.props.insert("name".into(), flat_id(flat));
336 let multiple = flat.str("variant") == Some("multipleSelection");
337 let joined = match flat.get("value") {
338 Some(Value::Array(items)) => Some(if multiple {
339 json!(items.iter().map(join_part).collect::<Vec<_>>().join(","))
340 } else {
341 match items.first() {
342 None | Some(Value::Null) => json!(""),
343 Some(v) => v.clone(),
344 }
345 }),
346 other => other.cloned(),
347 };
348 if let Some(joined) = joined {
349 out.props.insert("value".into(), joined);
350 }
351 if flat.get("filterable") == Some(&Value::Bool(true)) {
352 out.component = "Select".into();
353 out.props.insert("searchable".into(), json!(true));
354 out.props.insert("multiple".into(), json!(multiple));
355 } else if flat.str("displayStyle") == Some("chips") {
356 out.component = "Segmented".into();
357 let mut props = Props::new();
358 set(&mut props, "items", out.props.get("options").cloned());
359 props.insert("type".into(), json!(if multiple { "multiple" } else { "single" }));
360 props.insert("variant".into(), json!("outline"));
361 set(&mut props, "value", out.props.get("value").cloned());
362 out.props = props;
363 } else if multiple {
364 out.component = "Select".into();
365 out.props.insert("multiple".into(), json!(true));
366 }
367}
368
369fn apply_transform(name: &str, out: &mut MappedComponent, flat: &FlatView, lookup: &dyn Fn(&str) -> Option<FlatComponent>) {
370 match name {
371 "textVariant" => text_variant(out, flat),
372 "imageVariant" => image_variant(out, flat),
373 "iconName" => icon_name(out, flat),
374 "tabs" => tabs(out, flat),
375 "modal" => modal(out, flat),
376 "buttonChild" => button_child(out, flat, lookup),
377 "textField" => text_field(out, flat),
378 "fieldName" => field_name(out, flat),
379 "choicePicker" => choice_picker(out, flat),
380 other => panic!("basic-map.json names an unimplemented transform: {other}"),
381 }
382}
383
384pub fn map_basic_component(flat: &FlatComponent, lookup: &dyn Fn(&str) -> Option<FlatComponent>) -> Option<MappedComponent> {
386 let rule = BASIC_MAP.components.get(&flat.component)?;
387 let view = FlatView::new(flat);
388 let mut props = Props::new();
389 for (core_prop, spec) in &rule.props {
390 match spec {
391 PropRule::Copy(key) => {
392 if let Some(v) = view.get(key) {
393 props.insert(core_prop.clone(), v.clone());
394 }
395 }
396 PropRule::Const(v) => {
397 props.insert(core_prop.clone(), v.clone());
398 }
399 PropRule::From { from, map, default } => {
400 let mut value = from.as_deref().and_then(|f| view.get(f)).cloned();
401 if let (Some(map), Some(Value::String(s))) = (map, &value) {
402 if let Some(mapped) = map.get(s) {
403 value = Some(mapped.clone());
404 }
405 }
406 if value.is_none() {
407 value = default.clone();
408 }
409 if let Some(v) = value {
410 props.insert(core_prop.clone(), v);
411 }
412 }
413 }
414 }
415 let mut out = MappedComponent { component: rule.to.clone(), props, ..Default::default() };
416 match rule.children.as_deref() {
417 Some("children") => match &flat.children {
418 Some(crate::types::FlatChildren::Ids(ids)) => out.children_ids = ids.clone(),
419 Some(t @ crate::types::FlatChildren::Template { .. }) => {
420 out.template = t.template()
421 }
422 None => {}
423 },
424 Some("child") => {
425 if let Some(child) = view.str("child") {
426 out.children_ids = vec![child.to_string()];
427 }
428 }
429 _ => {}
430 }
431 if let Some(transform) = &rule.transform {
432 apply_transform(transform, &mut out, &view, lookup);
433 }
434 Some(out)
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn every_listed_transform_is_implemented() {
443 let listed: Vec<&str> = BASIC_MAP.transforms.keys().map(String::as_str).collect();
444 assert_eq!(listed, BASIC_TRANSFORM_NAMES);
445 for rule in BASIC_MAP.components.values() {
446 if let Some(t) = &rule.transform {
447 assert!(BASIC_TRANSFORM_NAMES.contains(&t.as_str()), "{t}");
448 }
449 }
450 }
451
452 #[test]
453 fn icons_map_or_fall_back_to_the_placeholder() {
454 assert_eq!(map_basic_icon(Some(&json!("add"))), "ui-add");
455 assert_eq!(map_basic_icon(Some(&json!("nope"))), BASIC_MAP.placeholder_icon);
456 assert_eq!(map_basic_icon(Some(&json!({"path": "/x"}))), BASIC_MAP.placeholder_icon);
457 assert_eq!(BASIC_MAP.placeholder_icon, g::BASIC_PLACEHOLDER_ICON);
458 }
459}