1use 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
18pub const RECIPE_STATES: &[&str] = gt::RECIPE_STATES;
20pub const RECIPE_KEYS: &[&str] = gt::RECIPE_KEYS;
22
23#[derive(serde::Deserialize)]
24struct RecipesFile {
25 native: IndexMap<String, PartSpec>,
26}
27
28static 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 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 if let Some(t) = &tpl.template {
58 walk_template(&t.item, parts, props);
59 }
60}
61
62pub 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
73pub 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#[derive(Debug, Clone)]
108pub struct RecipeIndex {
109 pub parts: IndexMap<String, PartSpec>,
110 defaults: IndexMap<String, Props>,
112 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 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 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
170pub 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 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}