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