1use std::collections::HashSet;
17use std::sync::LazyLock;
18
19use indexmap::IndexMap;
20use serde_json::{Map, Value};
21
22use crate::catalog::{CatalogView, TOKEN_GROUPS};
23use crate::expr::{eval_condition_value, eval_value, is_binding, Decided, ExprContext};
24use crate::json;
25use crate::types::{MacroDef, MacroTemplate, Props, Recipe, ReduceIssue, TemplateChild, UiNode};
26
27const MAX_DEPTH: usize = 16;
28
29pub static BREAKPOINTS: LazyLock<Vec<String>> = LazyLock::new(|| TOKEN_GROUPS.get("breakpoint").cloned().unwrap_or_default());
31
32struct Variant {
33 breakpoint: String,
34 props: Props,
35}
36
37struct Expansion<'a> {
38 view: &'a CatalogView,
39 macro_: &'a str,
40 def: &'a MacroDef,
41 source: &'a UiNode,
42 base_props: Props,
44 variants: Vec<Variant>,
46 claimed: HashSet<String>,
47 issues: Vec<ReduceIssue>,
49}
50
51pub fn is_responsive_value(value: &Value) -> bool {
54 let Some(obj) = value.as_object() else { return false };
55 obj.contains_key("base") && obj.keys().all(|k| k == "base" || BREAKPOINTS.iter().any(|b| b == k))
56}
57
58pub fn responsive_at<'v>(value: &'v Value, breakpoint: Option<&str>) -> Option<&'v Value> {
61 let obj = value.as_object()?;
62 let mut out = obj.get("base");
63 let Some(breakpoint) = breakpoint else { return out };
64 for bp in BREAKPOINTS.iter() {
65 if let Some(v) = obj.get(bp) {
66 out = Some(v);
67 }
68 if bp == breakpoint {
69 break;
70 }
71 }
72 out
73}
74
75pub fn props_at(props: &Props, breakpoint: Option<&str>) -> Props {
78 let mut out = Props::new();
79 for (k, v) in props {
80 if is_responsive_value(v) {
81 if let Some(at) = responsive_at(v, breakpoint) {
82 out.insert(k.clone(), at.clone());
83 }
84 } else {
85 out.insert(k.clone(), v.clone());
86 }
87 }
88 out
89}
90
91fn responsive_variants(props: &Props) -> Vec<Variant> {
92 let mut used: HashSet<&str> = HashSet::new();
93 for v in props.values() {
94 if is_responsive_value(v) {
95 for k in v.as_object().into_iter().flat_map(|o| o.keys()) {
96 if k != "base" {
97 used.insert(k.as_str());
98 }
99 }
100 }
101 }
102 BREAKPOINTS
103 .iter()
104 .filter(|bp| used.contains(bp.as_str()))
105 .map(|bp| Variant { breakpoint: bp.clone(), props: props_at(props, Some(bp)) })
106 .collect()
107}
108
109fn spread(value: Option<&Value>) -> Map<String, Value> {
112 match value {
113 Some(Value::Object(map)) => map.clone(),
114 Some(Value::String(s)) => s.chars().enumerate().map(|(i, c)| (i.to_string(), Value::String(c.to_string()))).collect(),
115 _ => Map::new(),
116 }
117}
118
119fn merge_style(base: Option<&Props>, over: Option<&Props>) -> Option<Props> {
122 if base.is_none() && over.is_none() {
123 return None;
124 }
125 let mut out = base.cloned().unwrap_or_default();
126 for (k, v) in over.into_iter().flatten() {
127 if let Value::Object(nested) = v {
128 let mut merged = spread(out.get(k));
129 for (nk, nv) in nested {
130 merged.insert(nk.clone(), nv.clone());
131 }
132 out.insert(k.clone(), Value::Object(merged));
133 } else {
134 out.insert(k.clone(), v.clone());
135 }
136 }
137 Some(out)
138}
139
140fn routed_action(action: &Value, context: Option<&Value>) -> Value {
142 let mut out = action.clone();
143 if let (Some(context), Some(Value::Object(event))) = (context.filter(|c| js_truthy(c)), out.get_mut("event")) {
144 let mut merged = spread(event.get("context").filter(|c| !c.is_null()));
145 merged.extend(spread(Some(context)));
146 event.insert("context".into(), Value::Object(merged));
147 }
148 out
149}
150
151fn recipe_props(exp: &Expansion, ctx: &ExprContext, extra: Option<&Props>) -> Props {
154 let mut props = Props::new();
155 let schema = exp.view.components.get(exp.macro_).map(|d| &d.props);
156 for name in &exp.def.recipe_props {
157 let value = exp
158 .base_props
159 .get(name)
160 .filter(|v| !v.is_null())
161 .cloned()
162 .or_else(|| schema.and_then(|s| s.get(name)).and_then(|p| p.default.clone()));
163 if let Some(value) = value {
164 props.insert(name.clone(), value);
165 }
166 }
167 if let Some(extra) = extra {
168 if let Some(Value::Object(evaluated)) = eval_value(&Value::Object(extra.clone()), ctx) {
169 for (k, v) in evaluated {
170 props.insert(k, v);
171 }
172 }
173 }
174 props
175}
176
177fn combine(mut conditions: Vec<Value>, op: &str) -> Value {
179 if conditions.len() == 1 {
180 return conditions.pop().expect("one");
181 }
182 crate::expr::call(op, vec![("values", Some(Value::Array(conditions)))])
183}
184
185fn decide_conditions(tpl: &MacroTemplate, ctx: &ExprContext) -> Decided {
188 let mut bound: Vec<Value> = Vec::new();
189 if let Some(cond) = &tpl.if_ {
190 let conds: Vec<&Value> = match cond {
191 Value::Array(list) => list.iter().collect(),
192 single => vec![single],
193 };
194 for c in conds {
195 match eval_condition_value(Some(c), ctx) {
196 Decided::Bool(false) => return Decided::Bool(false),
197 Decided::Bool(true) => {}
198 Decided::Dynamic(v) => bound.push(v),
199 }
200 }
201 }
202 if let Some(any) = &tpl.any {
203 let mut decided = false;
204 let mut dynamic: Vec<Value> = Vec::new();
205 for c in any {
206 match eval_condition_value(Some(c), ctx) {
207 Decided::Bool(true) => {
208 decided = true;
209 break;
210 }
211 Decided::Bool(false) => {}
212 Decided::Dynamic(v) => dynamic.push(v),
213 }
214 }
215 if !decided {
216 if dynamic.is_empty() {
217 return Decided::Bool(false);
218 }
219 bound.push(combine(dynamic, "or"));
220 }
221 }
222 if bound.is_empty() {
223 Decided::Bool(true)
224 } else {
225 Decided::Dynamic(combine(bound, "and"))
226 }
227}
228
229fn expand_template(tpl: &MacroTemplate, exp: &mut Expansion, ctx: &ExprContext, parent_id: &str) -> Vec<UiNode> {
230 let visible = match decide_conditions(tpl, ctx) {
231 Decided::Bool(false) => return Vec::new(),
232 Decided::Bool(true) => None,
233 Decided::Dynamic(v) => Some(v),
234 };
235 if let Some(each) = &tpl.each {
236 let Some(Value::Array(items)) = eval_value(&Value::String(format!("{{{each}}}")), ctx) else {
237 return Vec::new();
238 };
239 let as_ = tpl.as_.as_deref().unwrap_or("item");
240 return items
241 .into_iter()
242 .enumerate()
243 .map(|(index, item)| {
244 let mut vars = ctx.vars.clone();
245 vars.insert(as_.to_string(), item);
246 vars.insert("index".to_string(), json::number(index as f64));
247 let item_ctx = ExprContext { id: ctx.id, props: ctx.props, vars };
248 build_node(tpl, exp, &item_ctx, format!("{parent_id}.{}.{index}", tpl.part), visible.clone())
249 })
250 .collect();
251 }
252 let id = if tpl.part == "root" { exp.source.id.clone() } else { format!("{parent_id}.{}", tpl.part) };
253 vec![build_node(tpl, exp, ctx, id, visible)]
254}
255
256fn part_style(tpl: &MacroTemplate, exp: &Expansion, ctx: &ExprContext) -> Option<Props> {
259 let template = tpl.style.as_ref()?;
260 let Some(Value::Object(mut style)) = eval_value(&Value::Object(template.clone()), ctx) else { return None };
261 let mut previous = style.clone();
262 for variant in &exp.variants {
263 let variant_ctx = ExprContext { id: ctx.id, props: &variant.props, vars: ctx.vars.clone() };
264 let Some(Value::Object(at)) = eval_value(&Value::Object(template.clone()), &variant_ctx) else { continue };
265 let mut diff = Map::new();
266 for (k, v) in &at {
267 if k.starts_with('@') || k.starts_with(':') {
268 continue;
269 }
270 if !previous.get(k).is_some_and(|p| json::equal(p, v)) {
271 diff.insert(k.clone(), v.clone());
272 }
273 }
274 if !diff.is_empty() {
275 let key = format!("@media (min-width: $breakpoint.{})", variant.breakpoint);
276 let mut block = spread(style.get(&key));
277 block.extend(diff);
278 style.insert(key, Value::Object(block));
279 }
280 previous = at;
281 }
282 (!style.is_empty()).then_some(style)
283}
284
285fn build_node(tpl: &MacroTemplate, exp: &mut Expansion, ctx: &ExprContext, id: String, visible: Option<Value>) -> UiNode {
286 let props = match eval_value(&Value::Object(tpl.props.clone().unwrap_or_default()), ctx) {
287 Some(Value::Object(p)) => p,
288 _ => Props::new(),
289 };
290 let mut node = UiNode::new(id.clone(), tpl.component.clone());
291 node.props = props;
292 node.style = part_style(tpl, exp, ctx);
293 node.visible = visible;
294 node.recipe = Some(Recipe {
295 macro_: exp.macro_.to_string(),
296 part: tpl.part.clone(),
297 props: recipe_props(exp, ctx, tpl.recipe.as_ref()),
298 authored: Vec::new(),
299 });
300 if let Some(routes) = &tpl.on {
301 let mut on = IndexMap::new();
302 for (part_event, macro_event) in routes {
303 let Some(action) = exp.source.on.as_ref().and_then(|o| o.get(macro_event)).filter(|a| js_truthy(a)) else {
304 continue;
305 };
306 exp.claimed.insert(macro_event.clone());
307 let context = tpl.context.as_ref().and_then(|c| eval_value(&Value::Object(c.clone()), ctx));
308 on.insert(part_event.clone(), routed_action(action, context.as_ref()));
309 }
310 if !on.is_empty() {
311 node.on = Some(on);
312 }
313 }
314 if let Some(sets) = &tpl.set {
315 for (part_event, spec) in sets {
316 let Some(bound) = exp.base_props.get(&spec.prop).filter(|b| is_binding(b)) else { continue };
317 let has_function = node.on.as_ref().and_then(|on| on.get(part_event)).and_then(|a| a.get("functionCall")).is_some_and(js_truthy);
318 if has_function {
319 let macro_event = tpl.on.as_ref().and_then(|o| o.get(part_event)).unwrap_or(part_event);
323 let message = format!(
324 "on.{macro_event}: a function action replaces the two-way set of props.{}; write it in that function's handler or use an event",
325 spec.prop
326 );
327 if !exp.issues.iter().any(|i| i.id == exp.source.id && i.message == message) {
328 exp.issues.push(ReduceIssue { id: exp.source.id.clone(), message });
329 }
330 continue;
331 }
332 let path = bound["path"].clone();
333 let value = eval_value(&spec.value, ctx);
334 let mut args = Map::new();
335 args.insert("path".into(), path);
336 if let Some(value) = value {
337 args.insert("value".into(), value);
338 }
339 let mut function = Map::new();
340 function.insert("call".into(), Value::String("set".into()));
341 function.insert("args".into(), Value::Object(args));
342 let on = node.on.get_or_insert_with(IndexMap::new);
343 let mut action = match on.get(part_event) {
344 Some(Value::Object(existing)) => existing.clone(),
345 _ => Map::new(),
346 };
347 action.insert("functionCall".into(), Value::Object(function));
348 on.insert(part_event.clone(), Value::Object(action));
349 }
350 }
351 if let Some(a11y) = &tpl.a11y {
352 if let Some(Value::Object(evaluated)) = eval_value(&Value::Object(a11y.clone()), ctx) {
353 if !evaluated.is_empty() {
354 node.accessibility = Some(Value::Object(evaluated));
355 }
356 }
357 }
358 if let Some(slots) = &tpl.slots {
359 for (slot, reference) in slots {
360 match reference {
361 TemplateChild::Splice(reference) => {
362 let Some(name) = reference.strip_prefix("$slot:").filter(|n| !n.is_empty()) else { continue };
363 if let Some(value) = exp.source.slots.as_ref().and_then(|s| s.get(name)) {
364 node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), value.clone());
365 }
366 }
367 TemplateChild::Node(part) => {
368 if let Some(built) = expand_template(part, exp, ctx, &id).into_iter().next() {
369 node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), built);
370 }
371 }
372 }
373 }
374 }
375 for child in tpl.children.iter().flatten() {
376 match child {
377 TemplateChild::Splice(s) if s == "$children" => {
378 node.children.extend(exp.source.children.iter().cloned());
379 if let Some(template) = &exp.source.template {
380 node.template = Some(template.clone());
381 }
382 }
383 TemplateChild::Splice(s) => {
384 if let Some(name) = s.strip_prefix("$slot:") {
385 if let Some(value) = exp.source.slots.as_ref().and_then(|slots| slots.get(name)) {
386 node.children.push(value.clone());
387 }
388 }
389 }
390 TemplateChild::Node(child) => {
391 let built = expand_template(child, exp, ctx, &id);
392 node.children.extend(built);
393 }
394 }
395 }
396 node
397}
398
399#[inline(never)]
403fn expand_macro(node: UiNode, macro_: &str, def: &MacroDef, view: &CatalogView, depth: usize, issues: &mut Vec<ReduceIssue>) -> Result<UiNode, String> {
404 if depth > MAX_DEPTH {
405 return Err(format!("macro {macro_}: expansion deeper than {MAX_DEPTH}"));
406 }
407 let mut base_props = props_at(&node.props, None);
408 if node.on.as_ref().is_some_and(|o| o.contains_key("press")) && !base_props.contains_key("pressable") && view.components.get(macro_).is_some_and(|d| d.props.contains_key("pressable")) {
410 base_props.insert("pressable".into(), Value::Bool(true));
411 }
412 let variants = responsive_variants(&node.props);
413 let mut exp = Expansion { view, macro_, def, source: &node, base_props: base_props.clone(), variants, claimed: HashSet::new(), issues: Vec::new() };
414 let ctx = ExprContext { id: &node.id, props: &base_props, vars: IndexMap::new() };
415 let mut root = expand_template(&def.root, &mut exp, &ctx, &node.id)
416 .into_iter()
417 .next()
418 .ok_or_else(|| format!("macro {macro_}: the root template produced no node"))?;
419 let claimed = exp.claimed;
420 for issue in exp.issues {
421 if !issues.contains(&issue) {
422 issues.push(issue);
423 }
424 }
425 root.style = merge_style(root.style.as_ref(), node.style.as_ref()).filter(|s| !s.is_empty());
426 let authored: Vec<String> = match &node.recipe {
432 Some(outer) => outer.authored.clone(),
433 None => {
434 let mut keys: Vec<String> = node.style.as_ref().map(|s| s.keys().cloned().collect()).unwrap_or_default();
435 keys.sort();
436 keys
437 }
438 };
439 if let Some(recipe) = root.recipe.as_mut() {
440 recipe.authored = authored;
441 }
442 if let Some(visible) = &node.visible {
443 root.visible = Some(visible.clone());
444 }
445 if let Some(Value::Object(accessibility)) = &node.accessibility {
448 let mut merged = spread(root.accessibility.as_ref());
449 for (k, v) in accessibility {
450 merged.insert(k.clone(), v.clone());
451 }
452 root.accessibility = Some(Value::Object(merged));
453 }
454 let mut remaining = root.on.take().unwrap_or_default();
455 for (event, action) in node.on.iter().flatten() {
456 if !claimed.contains(event) {
457 remaining.insert(event.clone(), action.clone());
458 }
459 }
460 root.on = (!remaining.is_empty()).then_some(remaining);
461 expand_tree(&mut root, view, depth + 1, issues)?;
462 Ok(root)
463}
464
465fn js_truthy(value: &Value) -> bool {
467 match value {
468 Value::Null => false,
469 Value::Bool(b) => *b,
470 Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
471 Value::String(s) => !s.is_empty(),
472 _ => true,
473 }
474}
475
476fn expand_tree(node: &mut UiNode, view: &CatalogView, depth: usize, issues: &mut Vec<ReduceIssue>) -> Result<(), String> {
479 crate::deep(|| expand_node(node, view, depth, issues))
480}
481
482fn expand_node(node: &mut UiNode, view: &CatalogView, depth: usize, issues: &mut Vec<ReduceIssue>) -> Result<(), String> {
483 for child in &mut node.children {
484 expand_tree(child, view, depth, issues)?;
485 }
486 if let Some(slots) = &mut node.slots {
487 for child in slots.values_mut() {
488 expand_tree(child, view, depth, issues)?;
489 }
490 }
491 if let Some(def) = view.macros.get(&node.component) {
492 if view.components.get(&node.component).is_some_and(|d| d.is_macro()) {
493 let component = node.component.clone();
494 let source = std::mem::replace(node, UiNode::new("", ""));
495 *node = expand_macro(source, &component, def, view, depth, issues)?;
496 }
497 }
498 Ok(())
499}
500
501pub fn expand_macros(root: &UiNode, view: &CatalogView) -> Result<UiNode, String> {
506 expand_macros_with_issues(root, view).map(|(node, _)| node)
507}
508
509pub fn expand_macros_with_issues(root: &UiNode, view: &CatalogView) -> Result<(UiNode, Vec<ReduceIssue>), String> {
513 crate::roomy(|| expand_all(root, view))
514}
515
516fn expand_all(root: &UiNode, view: &CatalogView) -> Result<(UiNode, Vec<ReduceIssue>), String> {
517 let mut issues = Vec::new();
518 let mut node = root.clone();
519 expand_tree(&mut node, view, 0, &mut issues)?;
520 crate::tree_guides::apply_tree_guides(&mut node);
523 Ok((node, issues))
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use serde_json::json;
530
531 #[test]
532 fn responsive_values_pick_the_nearest_breakpoint_at_or_below() {
533 let v = json!({"base": 1, "md": 2, "xl": 4});
534 assert!(is_responsive_value(&v));
535 assert!(!is_responsive_value(&json!({"base": 1, "huge": 2})));
536 assert!(!is_responsive_value(&json!({"md": 2})));
537 assert_eq!(responsive_at(&v, None), Some(&json!(1)));
538 assert_eq!(responsive_at(&v, Some("sm")), Some(&json!(1)));
539 assert_eq!(responsive_at(&v, Some("md")), Some(&json!(2)));
540 assert_eq!(responsive_at(&v, Some("lg")), Some(&json!(2)));
541 assert_eq!(responsive_at(&v, Some("xl")), Some(&json!(4)));
542 assert_eq!(BREAKPOINTS.as_slice(), ["sm", "md", "lg", "xl"]);
543 }
544}