Skip to main content

eui_core/
recipes.rs

1//! The recipe CONTRACT as code (VAPP-92): which parts each component has and
2//! which props a theme's `when` may key on. Natives are listed by hand in
3//! `catalog/recipes.json`; macro parts are derived from their templates in
4//! `macros.json` (every `part`, the macro's `recipeProps` + each part's
5//! `$recipe` keys), so the two files cannot disagree. Mirrors `src/recipes.ts`.
6//!
7//! The TS recomputes the part table per call; here a [`RecipeIndex`] is built
8//! ONCE per catalog view ([`RecipeIndex::core`] is cached).
9
10use std::sync::{Arc, LazyLock};
11
12use indexmap::IndexMap;
13use crate::catalog::CatalogView;
14use crate::generated::{catalog as gc, themes as gt};
15use crate::types::{MacroDef, MacroTemplate, TemplateChild};
16pub use crate::types::{PartSpec, Props};
17
18/// The interaction states a rule's `when.state` may name.
19pub const RECIPE_STATES: &[&str] = gt::RECIPE_STATES;
20/// The style keys a recipe may set.
21pub const RECIPE_KEYS: &[&str] = gt::RECIPE_KEYS;
22
23#[derive(serde::Deserialize)]
24struct RecipesFile {
25    native: IndexMap<String, PartSpec>,
26}
27
28/// `catalog/recipes.json` `native`, in source order.
29static NATIVE_PARTS: LazyLock<IndexMap<String, PartSpec>> =
30    LazyLock::new(|| serde_json::from_str::<RecipesFile>(gc::RECIPES_JSON).expect("recipes.json").native);
31
32fn push_unique(list: &mut Vec<String>, value: &str) {
33    if !list.iter().any(|v| v == value) {
34        list.push(value.to_string());
35    }
36}
37
38fn walk_template(tpl: &MacroTemplate, parts: &mut Vec<String>, props: &mut Vec<String>) {
39    push_unique(parts, &tpl.part);
40    if let Some(recipe) = &tpl.recipe {
41        for key in recipe.keys() {
42            push_unique(props, key);
43        }
44    }
45    for child in tpl.children.iter().flatten() {
46        if let TemplateChild::Node(node) = child {
47            walk_template(node, parts, props);
48        }
49    }
50    // Round 1: template-built slot parts (AlertDialog's footer) count too.
51    for slot in tpl.slots.iter().flat_map(|s| s.values()) {
52        if let TemplateChild::Node(node) = slot {
53            walk_template(node, parts, props);
54        }
55    }
56    // eApps U3: a data template's item is a part too.
57    if let Some(t) = &tpl.template {
58        walk_template(&t.item, parts, props);
59    }
60}
61
62/// A macro's parts and recipe props, read off its template.
63pub fn macro_parts(def: &MacroDef) -> PartSpec {
64    let mut parts = Vec::new();
65    let mut props = Vec::new();
66    for p in &def.recipe_props {
67        push_unique(&mut props, p);
68    }
69    walk_template(&def.root, &mut parts, &mut props);
70    PartSpec { parts, props }
71}
72
73/// Every component of the view → its parts and `when` props. An extension's
74/// natives declare `recipe: {parts, props}` on their component def; without
75/// it a native gets `root` and its enum/boolean props.
76pub fn recipe_parts(view: &CatalogView) -> IndexMap<String, PartSpec> {
77    let mut out = IndexMap::new();
78    for (name, def) in &view.components {
79        if def.is_macro() {
80            if let Some(m) = view.macros.get(name) {
81                out.insert(name.clone(), macro_parts(m));
82            }
83            continue;
84        }
85        let spec = if let Some(native) = NATIVE_PARTS.get(name) {
86            native.clone()
87        } else if let Some(declared) = &def.recipe {
88            declared.clone()
89        } else {
90            PartSpec {
91                parts: vec!["root".into()],
92                props: def
93                    .props
94                    .iter()
95                    .filter(|(_, p)| p.type_ == "enum" || p.type_ == "boolean")
96                    .map(|(k, _)| k.clone())
97                    .collect(),
98            }
99        };
100        out.insert(name.clone(), spec);
101    }
102    out
103}
104
105/// The part table of one catalog view plus the catalog defaults of every
106/// recipe prop: what the validator and the node resolver look up.
107#[derive(Debug, Clone)]
108pub struct RecipeIndex {
109    pub parts: IndexMap<String, PartSpec>,
110    /// Component → (recipe prop → catalog default), only props with a default.
111    defaults: IndexMap<String, Props>,
112    /// The components the view defines (a native query needs a def).
113    known: IndexMap<String, ()>,
114}
115
116static CORE_INDEX: LazyLock<Arc<RecipeIndex>> = LazyLock::new(|| Arc::new(RecipeIndex::new(&CatalogView::core())));
117
118impl RecipeIndex {
119    pub fn new(view: &CatalogView) -> RecipeIndex {
120        let parts = recipe_parts(view);
121        let mut defaults = IndexMap::new();
122        let mut known = IndexMap::new();
123        for (name, def) in &view.components {
124            known.insert(name.clone(), ());
125            let Some(spec) = parts.get(name) else { continue };
126            let mut d = Props::new();
127            for prop in &spec.props {
128                if let Some(v) = def.props.get(prop).and_then(|p| p.default.as_ref()) {
129                    if !v.is_null() {
130                        d.insert(prop.clone(), v.clone());
131                    }
132                }
133            }
134            defaults.insert(name.clone(), d);
135        }
136        RecipeIndex { parts, defaults, known }
137    }
138
139    /// The core catalog's index, built once per process.
140    pub fn core() -> Arc<RecipeIndex> {
141        CORE_INDEX.clone()
142    }
143
144    pub fn part_spec(&self, component: &str) -> Option<&PartSpec> {
145        self.parts.get(component)
146    }
147
148    /// The recipe props a NATIVE node answers to: each spec prop's authored
149    /// value, else the catalog default, skipping undefined (TS
150    /// `nativeRecipeProps`; `null` counts as missing like `??`).
151    pub fn native_recipe_props(&self, component: &str, props: &Props) -> Props {
152        let mut out = Props::new();
153        let (Some(spec), true) = (self.parts.get(component), self.known.contains_key(component)) else {
154            return out;
155        };
156        let defaults = self.defaults.get(component);
157        for name in &spec.props {
158            let value = match props.get(name) {
159                Some(v) if !v.is_null() => Some(v),
160                _ => defaults.and_then(|d| d.get(name)),
161            };
162            if let Some(v) = value {
163                out.insert(name.clone(), v.clone());
164            }
165        }
166        out
167    }
168}
169
170/// [`RecipeIndex::native_recipe_props`] against the core catalog.
171pub fn native_recipe_props(component: &str, props: &Props) -> Props {
172    CORE_INDEX.native_recipe_props(component, props)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use serde_json::{json, Value};
179
180    #[test]
181    fn every_component_has_parts_macro_parts_come_from_the_templates() {
182        let index = RecipeIndex::core();
183        let parts = &index.parts;
184        assert_eq!(parts["Badge"].parts, ["root", "icon", "label"]);
185        assert_eq!(parts["Badge"].props, ["variant"]);
186        assert_eq!(parts["Meter"].props, ["tone"]);
187        assert_eq!(parts["Row"].props, ["selected", "density", "surface", "depth"]);
188        assert_eq!(parts["Section"].props, ["tint", "tree", "open", "collapsible"]);
189        assert_eq!(parts["Segmented"].parts, ["root", "item", "icon", "label", "badge"]);
190        assert_eq!(parts["Menu"].props, ["openOn"]);
191        assert!(parts["Switch"].parts.iter().any(|p| p == "track"));
192        assert_eq!(parts["Button"].props, ["variant", "size", "disabled", "loading"]);
193        // EXP-1280: + ChoiceTiles; N-12: + DockCard; VAPP-126: + the six layout macros; NEW-77: + VoiceWave;
194        // VAPP-162: + MessageBubble, CallSurface, Plan, QrCode, QrScanner; EXP-1495: + Outline, Mosaic;
195        // EXP-1586: + ZoomableImage; EXP-1604: + Motion; VAPP-232: + Calendar, Timeline; VAPP-217: + RecordForm, RecordTable.
196        assert_eq!(parts.len(), 97);
197        assert!(parts["Carousel"].parts.iter().any(|p| p == "thumbnail"));
198        assert!(parts["Carousel"].props.iter().any(|p| p == "counter"));
199        assert!(parts["DockCard"].parts.iter().any(|p| p == "trigger"));
200    }
201
202    #[test]
203    fn recipe_keys_are_a_subset_of_the_box_visual_keys_plus_the_box_keys_and_native() {
204        let style: Value = serde_json::from_str(gc::STYLE_JSON).unwrap();
205        let mut allowed: Vec<String> = style["visual"].as_object().unwrap().keys().cloned().collect();
206        for k in ["padding", "paddingHorizontal", "paddingVertical", "gap", "width", "height", "minWidth", "minHeight", "native"] {
207            allowed.push(k.into());
208        }
209        for key in RECIPE_KEYS {
210            assert!(allowed.iter().any(|a| a == key), "{key}");
211        }
212        assert!(RECIPE_STATES.contains(&"hover"));
213    }
214
215    #[test]
216    fn native_props_fill_catalog_defaults() {
217        let index = RecipeIndex::core();
218        let props = json!({"text": "x", "variant": "muted"});
219        let out = index.native_recipe_props("Text", props.as_object().unwrap());
220        assert_eq!(out.get("variant"), Some(&json!("muted")));
221        assert!(out.get("text").is_none());
222        assert!(index.native_recipe_props("Nope", &Props::new()).is_empty());
223    }
224}