Skip to main content

eui_core/
basic_map.rs

1//! The A2UI basic catalog → core catalog map, applied by the reducer. The
2//! declarative part is `catalog/basic-map.json` (embedded); the NAMED
3//! transforms listed under its `transforms` are implemented here, one function
4//! per name. Mirrors `src/basic-map.ts`.
5
6use 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/// One `props` entry of a component rule.
15#[derive(Debug, Clone, PartialEq)]
16pub enum PropRule {
17    /// `"basicProp"`: copy the basic prop when defined.
18    Copy(String),
19    /// `{const}`
20    Const(Value),
21    /// `{from, map?, default?}`; `default: Some(Null)` is an explicit `null`.
22    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    /// `children` | `child`
30    pub children: Option<String>,
31    pub transform: Option<String>,
32}
33
34/// `catalog/basic-map.json`, parsed.
35#[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
98/// `catalog/basic-map.json`, parsed once (top-level tables keep source order).
99pub 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
113/// The transforms this module implements (every name `basic-map.json` lists).
114pub const BASIC_TRANSFORM_NAMES: &[&str] =
115    &["textVariant", "imageVariant", "iconName", "tabs", "modal", "buttonChild", "textField", "fieldName", "choicePicker"];
116
117/// What a basic component becomes before the reducer nests its children.
118#[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
129/// A basic Icon name → an icons.json name (the placeholder when unmapped).
130pub 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
142/// JS `String(x)` for an id-ish value.
143fn id_string(v: &Value) -> String {
144    crate::json::to_js_string(v)
145}
146
147/// `flat[key]` over the A2UI wire form (named fields and the rest).
148struct 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
325/// `Array.prototype.join` element: null → "".
326fn 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
384/// Map one basic component; `None` when the table has no row (→ Unknown).
385pub 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}