1use std::collections::HashMap;
2use std::path::Path;
3
4use oxc_allocator::Allocator;
5use oxc_ast::ast::*;
6use oxc_parser::Parser;
7use oxc_span::{GetSpan, SourceType};
8
9use crate::css_registry;
10use crate::node::{IRWindow, IRNode, IREvent};
11use crate::style::IRStyle;
12use crate::tailwind::TailwindResolver;
13use crate::transforms;
14
15pub struct IRBuilder {
16 tailwind: TailwindResolver,
17 counter: std::cell::Cell<usize>,
18 type_mode: morpher::TypeMode,
19}
20
21impl IRBuilder {
22 pub fn new() -> Self {
23 Self {
24 tailwind: TailwindResolver::new(),
25 counter: std::cell::Cell::new(0),
26 type_mode: morpher::TypeMode::default(),
27 }
28 }
29
30 pub fn with_type_mode(mut self, mode: morpher::TypeMode) -> Self {
33 self.type_mode = mode;
34 self
35 }
36
37 fn next_id(&self) -> String {
39 let n = self.counter.get();
40 self.counter.set(n + 1);
41 format!("node_{n:04}")
42 }
43
44 pub fn build(
45 &self,
46 source: &morph_parser::MxSource,
47 css_rules: &[(String, morph_parser::CssRule)],
48 css_keyframes: &HashMap<String, Vec<morph_parser::CssKeyframe>>,
49 ) -> Vec<IRWindow> {
50 let mut windows = Vec::new();
51 let mut ambient_vars: HashMap<String, String> = HashMap::new();
56 let mut ambient_types: HashMap<String, String> = HashMap::new();
57 let mut all_state: Vec<HashMap<String, String>> = Vec::new();
58 for sv in source
59 .state_vars
60 .iter()
61 .chain(source.components.iter().flat_map(|c| c.state_vars.iter()))
62 {
63 let mut m = HashMap::new();
64 m.insert("getter".into(), sv.getter.clone());
65 m.insert("setter".into(), sv.setter.clone());
66 m.insert("init".into(), sv.init.clone());
67 all_state.push(m);
68 if !sv.getter.is_empty() {
69 ambient_vars.insert(sv.getter.clone(), format!("__st_{}.get()", sv.getter));
70 if let Some(ty) = infer_state_type(&sv.init) {
71 ambient_types.insert(sv.getter.clone(), ty);
72 }
73 }
74 if !sv.setter.is_empty() {
75 ambient_vars.insert(sv.setter.clone(), format!("__st_{}.set", sv.getter));
76 }
77 }
78 let wc = source.window_config.as_ref();
79 let mut extra_headers: Vec<String> = source.extra_headers.clone();
80 let mut premain: Vec<String> = Vec::new();
82 for fd in &source.function_declarations {
83 self.push_snippet(
84 &mut premain,
85 &mut extra_headers,
86 &fd.source,
87 &ambient_vars,
88 &ambient_types,
89 );
90 }
91 for gv in &source.global_vars {
92 self.push_snippet(&mut premain, &mut extra_headers, gv, &ambient_vars, &ambient_types);
93 }
94 let mut reactive_consts: Vec<String> = Vec::new();
97 for c in &source.components {
98 for cst in &c.consts {
99 if cst.name.is_empty() || cst.rhs.is_empty() {
100 continue;
101 }
102 if let Some(out) =
103 self.translate_logic(&cst.rhs, &ambient_vars, &ambient_types)
104 {
105 let expr = out.body.trim().trim_end_matches(';').trim();
106 if expr.is_empty() {
107 continue;
108 }
109 extra_headers.extend(include_lines(&out.includes));
110 premain.push(format!(
111 "auto {} = []() {{ return ({}); }};",
112 cst.name, expr
113 ));
114 ambient_vars.insert(cst.name.clone(), format!("{}()", cst.name));
115 reactive_consts.push(cst.name.clone());
116 }
117 }
118 }
119 for f in source
121 .inner_functions
122 .iter()
123 .chain(source.components.iter().flat_map(|c| c.inner_functions.iter()))
124 {
125 self.push_snippet(
126 &mut premain,
127 &mut extra_headers,
128 &f.source,
129 &ambient_vars,
130 &ambient_types,
131 );
132 }
133 let mut all_effects: Vec<HashMap<String, String>> = Vec::new();
135 for e in source
136 .effects
137 .iter()
138 .chain(source.components.iter().flat_map(|c| c.effects.iter()))
139 {
140 if let Some(out) = self.translate_logic(&e.callback, &ambient_vars, &ambient_types)
141 {
142 let lambda = out.body.trim().trim_end_matches(';').trim().to_string();
143 if lambda.is_empty() {
144 continue;
145 }
146 extra_headers.extend(include_lines(&out.includes));
147 let mut m = HashMap::new();
148 m.insert("lambda".into(), lambda);
149 m.insert("deps".into(), e.deps.clone());
150 all_effects.push(m);
151 }
152 }
153 extra_headers.sort();
154 extra_headers.dedup();
155 let mut startup_logs = source.console_logs.clone();
158 for c in &source.components {
159 for log in &c.console_logs {
160 if !startup_logs.contains(log) {
161 startup_logs.push(log.clone());
162 }
163 }
164 }
165 let mut nodes = Vec::new();
166 for comp in &source.components {
167 nodes.push(self.build_node(
168 &comp.jsx,
169 css_rules,
170 0,
171 &[],
172 &ambient_vars,
173 &ambient_types,
174 &mut extra_headers,
175 css_keyframes,
176 ));
177 }
178 extra_headers.sort();
179 extra_headers.dedup();
180 let mut window = IRWindow {
181 window_id: self.next_id(),
182 title: wc.map(|w| w.title.clone()).unwrap_or_else(|| "Morph App".into()),
183 width: wc.map(|w| w.width).unwrap_or(800),
184 height: wc.map(|w| w.height).unwrap_or(600),
185 visible: true,
186 min_width: wc.and_then(|w| w.min_width),
187 max_width: wc.and_then(|w| w.max_width),
188 min_height: wc.and_then(|w| w.min_height),
189 max_height: wc.and_then(|w| w.max_height),
190 modal: wc.map(|w| w.modal).unwrap_or(false),
191 renderer: "flash".into(),
192 nodes: vec![],
193 startup_logs,
194 premain_functions: premain,
195 extra_headers,
196 state_vars: all_state,
197 reactive_consts,
198 effect_decls: all_effects,
199 cpp_imports: source.cpp_imports.iter().map(|ci| {
200 let base = Path::new(&source.filename).parent().unwrap_or_else(|| Path::new("."));
201 let path = base.join(&ci.path);
202 let abs_path = path.canonicalize().unwrap_or_else(|_| path);
203 let mut m = HashMap::new();
204 m.insert("path".into(), abs_path.display().to_string());
205 m.insert("specifiers".into(), ci.specifiers.join(", "));
206 m
207 }).collect(),
208 keyframes: self.convert_keyframes(css_keyframes),
209 };
210 window.nodes = nodes;
211 windows.push(window);
212 windows
213 }
214
215 fn translate_logic(
219 &self,
220 source: &str,
221 ambient_vars: &HashMap<String, String>,
222 ambient_types: &HashMap<String, String>,
223 ) -> Option<morpher::SnippetOutput> {
224 let mut options = morpher::TranslateOptions::default();
225 options.type_mode = self.type_mode;
226 options.state_vars = ambient_vars.clone();
227 options.state_types = ambient_types.clone();
228 morpher::translate_snippet(source, "snippet.ts", options).ok().filter(|out| {
229 !out.body.trim().is_empty()
230 })
231 }
232
233 fn push_snippet(
236 &self,
237 premain: &mut Vec<String>,
238 extra_headers: &mut Vec<String>,
239 source: &str,
240 ambient_vars: &HashMap<String, String>,
241 ambient_types: &HashMap<String, String>,
242 ) {
243 if let Some(out) = self.translate_logic(source, ambient_vars, ambient_types) {
244 extra_headers.extend(include_lines(&out.includes));
245 let body = strip_static_linkage(&out.body);
246 if !body.is_empty() {
247 premain.push(body);
248 }
249 }
250 }
251
252 fn build_node(
253 &self,
254 jsx: &morph_parser::JsxNode,
255 css_rules: &[(String, morph_parser::CssRule)],
256 depth: usize,
257 ancestors: &[AncestorHint],
258 ambient_vars: &HashMap<String, String>,
259 ambient_types: &HashMap<String, String>,
260 extra_headers: &mut Vec<String>,
261 keyframes: &HashMap<String, Vec<morph_parser::CssKeyframe>>,
262 ) -> IRNode {
263 match jsx {
264 morph_parser::JsxNode::Element { tag, props, children, line: _, col: _, .. } => {
265 let node_id = self.next_id();
266 let mut node = IRNode {
267 node_id: node_id.clone(),
268 node_type: tag.clone(),
269 ..Default::default()
270 };
271 let mut style = IRStyle::new();
272 apply_ua_defaults(&mut style, tag);
273 let mut hover_style = IRStyle::new();
274 for (prop, val) in ua_hover_defaults(tag) { apply_css_prop(&mut hover_style, prop, val); }
275 let mut active_style = IRStyle::new();
276 for (prop, val) in ua_active_defaults(tag) { apply_css_prop(&mut active_style, prop, val); }
277 let (classes, id) = element_classes_id(props);
278 let mut declarations: Vec<(Specificity, usize, String, String, PseudoKind)> =
283 Vec::new();
284 for (order, (selector, rule)) in css_rules.iter().enumerate() {
285 if let Some((pseudo, specificity)) =
286 match_selector_detailed(tag, &classes, id.as_deref(), ancestors, selector)
287 {
288 for (prop, val) in &rule.properties {
289 declarations.push((
290 specificity,
291 order,
292 prop.clone(),
293 val.clone(),
294 pseudo,
295 ));
296 }
297 }
298 }
299 declarations.sort();
300 let mut base_props: HashMap<String, String> = HashMap::new();
303 let mut hover_props: HashMap<String, String> = HashMap::new();
304 for (_, _, prop, val, pseudo) in &declarations {
305 let target = match pseudo {
306 PseudoKind::Base => &mut style,
307 PseudoKind::Hover => &mut hover_style,
308 PseudoKind::Active => &mut active_style,
309 };
310 apply_css_prop(target, prop, val);
311 match pseudo {
312 PseudoKind::Base => {
313 base_props.insert(prop.clone(), val.clone());
314 }
315 PseudoKind::Hover => {
316 hover_props.insert(prop.clone(), val.clone());
317 }
318 PseudoKind::Active => {}
319 }
320 }
321 if let Some(morph_parser::JsxPropValue::String(cls)) = props.get("className").or_else(|| props.get("class")) {
322 for (prop, val) in self.tailwind.resolve_many(cls) {
323 apply_css_prop(&mut style, &prop, &val);
324 base_props.insert(prop, val);
325 }
326 }
327 if style.width.is_none() {
331 if let Some(morph_parser::JsxPropValue::String(raw)) = props.get("width") {
332 if let Some(px) = parse_length(raw) {
333 style.width = Some(px);
334 base_props.insert("width".to_string(), raw.clone());
335 }
336 }
337 }
338 if style.height.is_none() {
339 if let Some(morph_parser::JsxPropValue::String(raw)) = props.get("height") {
340 if let Some(px) = parse_length(raw) {
341 style.height = Some(px);
342 base_props.insert("height".to_string(), raw.clone());
343 }
344 }
345 }
346 if let Some(morph_parser::JsxPropValue::Style(map)) = props.get("style") {
347 for (prop, val) in map {
348 match val {
349 morph_parser::StyleValue::Static(s) => {
350 apply_css_prop(&mut style, prop, s);
351 base_props.insert(prop.clone(), s.clone());
352 }
353 morph_parser::StyleValue::Expr(e) => { node.reactive_style.insert(prop.clone(), e.clone()); }
354 }
355 }
356 }
357 node.style = style;
358 if !hover_style.is_empty_style() { node.hover_style = Some(hover_style); }
359 if !active_style.is_empty_style() { node.active_style = Some(active_style); }
360 node.animations = parse_animations(&base_props)
363 .into_iter()
364 .filter(|anim| keyframes.contains_key(&anim.name))
365 .collect();
366 node.hover_animations = parse_animations(&hover_props)
367 .into_iter()
368 .filter(|anim| keyframes.contains_key(&anim.name))
369 .collect();
370 for (k, v) in props {
371 if let morph_parser::JsxPropValue::Fn(f) = v {
372 if let Some(trigger) = event_trigger(k) {
373 node.events.push(IREvent {
374 trigger: trigger.into(),
375 action: "call".into(),
376 target: f.clone(),
377 });
378 continue;
379 }
380 }
381 match (k.as_str(), v) {
382 ("id", morph_parser::JsxPropValue::String(s)) => { node.attrs.insert("id".into(), s.clone()); }
383 ("src", morph_parser::JsxPropValue::String(s)) => { node.attrs.insert("src".into(), s.clone()); }
384 ("placeholder", morph_parser::JsxPropValue::String(s)) => { node.attrs.insert("placeholder".into(), s.clone()); }
385 ("type", morph_parser::JsxPropValue::String(s)) => { node.attrs.insert("type".into(), s.clone()); }
386 ("className", morph_parser::JsxPropValue::Expr(s))
393 | ("class", morph_parser::JsxPropValue::Expr(s))
394 | ("className", morph_parser::JsxPropValue::Template(s))
395 | ("class", morph_parser::JsxPropValue::Template(s))
396 | ("className", morph_parser::JsxPropValue::Ref(s))
397 | ("class", morph_parser::JsxPropValue::Ref(s)) => {
398 if let Some(out) =
400 self.translate_logic(s, ambient_vars, ambient_types)
401 {
402 extra_headers.extend(include_lines(&out.includes));
403 let body = out
404 .body
405 .trim()
406 .trim_end_matches(';')
407 .trim()
408 .to_string();
409 if !body.is_empty() {
410 node.reactive_class = body;
411 }
412 }
413 let mut fx = analyze_dynamic_class(
415 s,
416 tag,
417 css_rules,
418 &self.tailwind,
419 ambient_vars,
420 ambient_types,
421 self.type_mode,
422 extra_headers,
423 );
424 node.class_conditional_effects.append(&mut fx);
425 }
426 _ => {}
427 }
428 }
429 let text_parts: Vec<String> = children.iter().filter_map(|c| if let morph_parser::JsxNode::Text(t) = c { Some(t.clone()) } else { None }).collect();
430 if !text_parts.is_empty() { node.text_content = text_parts.join(""); }
431 let mut child_ancestors = ancestors.to_vec();
432 child_ancestors.insert(0, AncestorHint {
433 tag: tag.clone(),
434 classes: classes.clone(),
435 id: id.clone(),
436 });
437 for child in children.iter() {
438 let child_node = self.build_node(
439 child,
440 css_rules,
441 depth + 1,
442 &child_ancestors,
443 ambient_vars,
444 ambient_types,
445 extra_headers,
446 keyframes,
447 );
448 if child_node.node_type == "__text__" && child_node.text_content.trim().is_empty() {
449 continue;
450 }
451 node.children.push(child_node);
452 }
453 node
454 }
455 morph_parser::JsxNode::Fragment { children, .. } => {
456 let mut node = IRNode { node_id: self.next_id(), node_type: "__fragment__".into(), ..Default::default() };
457 for child in children.iter() {
458 node.children.push(self.build_node(
459 child,
460 css_rules,
461 depth,
462 ancestors,
463 ambient_vars,
464 ambient_types,
465 extra_headers,
466 keyframes,
467 ));
468 }
469 node
470 }
471 morph_parser::JsxNode::Text(t) => {
472 let mut node = IRNode { node_id: self.next_id(), node_type: "__text__".into(), ..Default::default() };
473 node.text_content = t.clone();
474 node
475 }
476 morph_parser::JsxNode::Expression(e) => {
477 let mut node = IRNode { node_id: self.next_id(), node_type: "__expr__".into(), ..Default::default() };
478 node.reactive_text = e.clone();
479 node
480 }
481 morph_parser::JsxNode::Conditional { condition, then_branch, else_branch, .. } => {
482 let mut node = IRNode { node_id: self.next_id(), node_type: "__conditional__".into(), ..Default::default() };
483 node.condition_expr = condition.clone();
484 for c in then_branch.iter() {
485 node.then_nodes.push(self.build_node(
486 c,
487 css_rules,
488 depth,
489 ancestors,
490 ambient_vars,
491 ambient_types,
492 extra_headers,
493 keyframes,
494 ));
495 }
496 for c in else_branch.iter() {
497 node.else_nodes.push(self.build_node(
498 c,
499 css_rules,
500 depth,
501 ancestors,
502 ambient_vars,
503 ambient_types,
504 extra_headers,
505 keyframes,
506 ));
507 }
508 node
509 }
510 morph_parser::JsxNode::List { array_expr, key_expr, item_template, .. } => {
511 let mut node = IRNode { node_id: self.next_id(), node_type: "__list__".into(), ..Default::default() };
512 node.list_expr = array_expr.clone();
513 node.list_key_expr = key_expr.clone();
514 node.item_template = Some(Box::new(self.build_node(
515 item_template,
516 css_rules,
517 depth,
518 ancestors,
519 ambient_vars,
520 ambient_types,
521 extra_headers,
522 keyframes,
523 )));
524 node
525 }
526 }
527 }
528
529 fn convert_keyframes(
530 &self,
531 css_keyframes: &HashMap<String, Vec<morph_parser::CssKeyframe>>,
532 ) -> HashMap<String, Vec<crate::node::IRKeyframe>> {
533 let mut result: HashMap<String, Vec<crate::node::IRKeyframe>> = HashMap::new();
534 for (name, kfs) in css_keyframes {
535 let mut converted = Vec::new();
536 for kf in kfs {
537 let mut raw: HashMap<String, String> = HashMap::new();
538 let mut style = IRStyle::new();
539 let mut declared: Vec<String> = Vec::new();
540 for (prop, val) in &kf.properties {
541 if !is_animatable(prop) { continue; }
542 if prop == "transform" || needs_layout(val) {
543 raw.insert(prop.clone(), val.clone());
544 continue;
545 }
546 if let Some(field) = apply_css_prop(&mut style, prop, val) {
547 declared.push(field.to_string());
548 }
549 }
550 converted.push(crate::node::IRKeyframe {
551 offset: kf.offset,
552 style,
553 declared,
554 raw,
555 });
556 }
557 result.insert(name.clone(), converted);
558 }
559 result
560 }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
565enum PseudoKind {
566 Base,
567 Hover,
568 Active,
569}
570
571fn is_animatable(prop: &str) -> bool {
572 matches!(
573 prop,
574 "opacity" | "background-color" | "color" | "border-radius" | "font-size"
575 | "width" | "height" | "left" | "top" | "transform"
576 )
577}
578
579fn needs_layout(val: &str) -> bool {
580 let v = val.trim();
581 if v.is_empty() || v == "auto" { return true; }
582 v.ends_with('%') || v.ends_with("vh") || v.ends_with("vw")
583}
584
585fn ua_defaults(tag: &str) -> &'static [(&'static str, &'static str)] {
590 match tag {
591 "html" => &[("display", "block")],
593 "body" => &[("display", "block"), ("padding", "8px")],
594
595 "h1" => &[("display", "block"), ("font-size", "32px"), ("font-weight", "bold"), ("margin", "21.44px 0")],
597 "h2" => &[("display", "block"), ("font-size", "24px"), ("font-weight", "bold"), ("margin", "19.92px 0")],
598 "h3" => &[("display", "block"), ("font-size", "18.72px"), ("font-weight", "bold"), ("margin", "18.72px 0")],
599 "h4" => &[("display", "block"), ("font-size", "16px"), ("font-weight", "bold"), ("margin", "21.28px 0")],
600 "h5" => &[("display", "block"), ("font-size", "13.28px"), ("font-weight", "bold"), ("margin", "22.18px 0")],
601 "h6" => &[("display", "block"), ("font-size", "10.72px"), ("font-weight", "bold"), ("margin", "24.97px 0")],
602
603 "div" => &[("display", "block")],
605 "p" => &[("display", "block"), ("margin", "16px 0")],
606 "pre" => &[("display", "block"), ("margin", "16px 0")],
607 "blockquote" => &[("display", "block"), ("margin", "16px 40px")],
608 "hr" => &[("display", "block")],
609 "figure" => &[("display", "block"), ("margin", "16px 40px")],
610 "figcaption" => &[("display", "block")],
611 "main" => &[("display", "block")],
612 "header" => &[("display", "block")],
613 "footer" => &[("display", "block")],
614 "nav" => &[("display", "block")],
615 "section" => &[("display", "block")],
616 "article" => &[("display", "block")],
617 "aside" => &[("display", "block")],
618
619 "ul" => &[("display", "block"), ("margin", "16px 0")],
621 "ol" => &[("display", "block"), ("margin", "16px 0")],
622 "li" => &[("display", "block")],
623 "dl" => &[("display", "block"), ("margin", "16px 0")],
624 "dt" => &[("display", "block")],
625 "dd" => &[("display", "block"), ("margin-left", "40px")],
626
627 "span" => &[("display", "inline")],
629 "a" => &[("display", "inline"), ("color", "#0000ee"), ("cursor", "pointer")],
630 "strong" => &[("font-weight", "bold")],
631 "b" => &[("font-weight", "bold")],
632 "small" => &[("font-size", "13.28px")],
633 "mark" => &[("background-color", "#ffff00"), ("color", "#000000")],
634 "sub" => &[("font-size", "13.28px")],
635 "sup" => &[("font-size", "13.28px")],
636 "code" => &[("display", "inline")],
637 "kbd" => &[("display", "inline")],
638 "samp" => &[("display", "inline")],
639 "em" => &[("display", "inline")],
640 "i" => &[("display", "inline")],
641 "ins" => &[("display", "inline")],
642 "u" => &[("display", "inline")],
643 "del" => &[("display", "inline")],
644 "s" => &[("display", "inline")],
645 "q" => &[("display", "inline")],
646
647 "img" => &[("display", "inline-block")],
649
650 "button" => &[
652 ("display", "inline-block"),
653 ("background-color", "#efefef"),
654 ("color", "#010101"),
655 ("border-width", "1px"),
656 ("border-style", "solid"),
657 ("border-color", "#767676"),
658 ("border-radius", "4px"),
659 ("padding", "1px 6px"),
660 ("font-size", "13.33px"),
661 ("text-align", "center"),
662 ],
663 "input" => &[
664 ("display", "inline-block"),
665 ("background-color", "#ffffff"),
666 ("color", "#010101"),
667 ("border-width", "1px"),
668 ("border-style", "solid"),
669 ("border-color", "#767676"),
670 ("border-radius", "4px"),
671 ("padding", "1px 6px"),
672 ("font-size", "13.33px"),
673 ("cursor", "text"),
674 ],
675 "select" => &[("display", "inline-block")],
676 "textarea" => &[("display", "inline-block")],
677 "label" => &[("display", "inline")],
678 "fieldset" => &[("display", "block"), ("border-width", "2px"), ("border-style", "groove"), ("margin", "0 2px"), ("padding", "5px 12px 10px")],
679 "legend" => &[("display", "block"), ("padding", "0 2px")],
680 "form" => &[("display", "block")],
681
682 "table" => &[("display", "block")],
684 "caption" => &[("display", "block")],
685 "thead" => &[("display", "block")],
686 "tbody" => &[("display", "block")],
687 "tfoot" => &[("display", "block")],
688 "tr" => &[("display", "block")],
689 "td" => &[("display", "block")],
690 "th" => &[("display", "block"), ("font-weight", "bold"), ("text-align", "center")],
691
692 "details" => &[("display", "block")],
694 "summary" => &[("display", "block")],
695 "dialog" => &[("display", "block")],
696
697 _ => &[],
698 }
699}
700
701fn ua_hover_defaults(tag: &str) -> &'static [(&'static str, &'static str)] {
704 match tag {
705 "button" => &[("background-color", "#e6e6e6")],
706 _ => &[],
707 }
708}
709
710fn ua_active_defaults(tag: &str) -> &'static [(&'static str, &'static str)] {
713 match tag {
714 "button" => &[("background-color", "#d4d4d4"), ("border-color", "#5a5a5a")],
715 _ => &[],
716 }
717}
718
719fn apply_ua_defaults(style: &mut IRStyle, tag: &str) {
720 for (prop, val) in ua_defaults(tag) {
721 apply_css_prop(style, prop, val);
722 }
723}
724
725fn infer_state_type(init: &str) -> Option<String> {
729 let s = init.trim();
730 if s == "true" || s == "false" {
731 return Some("bool".to_string());
732 }
733 if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
734 || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
735 {
736 return Some("std::string".to_string());
737 }
738 if s.starts_with('[') && s.ends_with(']') {
739 return Some("JsArray".to_string());
740 }
741 if s.parse::<i64>().is_ok() {
742 return Some("int".to_string());
743 }
744 if s.parse::<f64>().is_ok() {
745 return Some("double".to_string());
746 }
747 None
748}
749
750fn strip_static_linkage(cpp: &str) -> String {
754 let mut text = cpp.trim().to_string();
755 for prefix in ["static inline", "static"] {
756 if text.starts_with(prefix)
757 && text[prefix.len()..].chars().next().map(|c| c.is_whitespace()).unwrap_or(false)
758 {
759 text = text[prefix.len()..].trim_start().to_string();
760 break;
761 }
762 }
763 text
764}
765
766fn analyze_dynamic_class(
774 source: &str,
775 tag: &str,
776 css_rules: &[(String, morph_parser::CssRule)],
777 tailwind: &TailwindResolver,
778 ambient_vars: &HashMap<String, String>,
779 ambient_types: &HashMap<String, String>,
780 type_mode: morpher::TypeMode,
781 extra_headers: &mut Vec<String>,
782) -> Vec<crate::node::IRConditionalClassEffect> {
783 let mut effects = Vec::new();
784 let allocator = Allocator::default();
785 let source_type = SourceType::from_path("snippet.ts")
786 .unwrap_or_default()
787 .with_typescript(true);
788 let parsed = Parser::new(&allocator, source, source_type).parse();
789 if parsed.panicked || !parsed.diagnostics.is_empty() {
790 return effects;
791 }
792 let first = parsed.program.body.first();
793 let expr = match first {
794 Some(Statement::ExpressionStatement(stmt)) => &stmt.expression,
795 _ => return effects,
796 };
797 let mut ternaries: Vec<&ConditionalExpression> = Vec::new();
800 match expr {
801 Expression::TemplateLiteral(tpl) => {
802 for part in tpl.expressions.iter() {
803 if let Expression::ConditionalExpression(cond) = part {
804 ternaries.push(cond);
805 }
806 }
807 }
808 Expression::ConditionalExpression(cond) => ternaries.push(cond),
809 _ => {}
810 }
811 for ternary in ternaries {
812 let on_str = string_branch(&ternary.consequent);
813 let off_str = string_branch(&ternary.alternate);
814 let on_styles = resolve_branch_classes(&on_str, tag, css_rules, tailwind);
815 let off_styles = resolve_branch_classes(&off_str, tag, css_rules, tailwind);
816 if on_styles.is_empty() && off_styles.is_empty() {
817 continue;
818 }
819 let cond_src = &source[ternary.test.span().start as usize..ternary.test.span().end as usize];
820 let mut options = morpher::TranslateOptions::default();
821 options.type_mode = type_mode;
822 options.state_vars = ambient_vars.clone();
823 options.state_types = ambient_types.clone();
824 let cond_cpp = morpher::translate_snippet(cond_src, "snippet.ts", options)
825 .ok()
826 .map(|out| {
827 extra_headers.extend(include_lines(&out.includes));
828 out.body.trim().trim_end_matches(';').trim().to_string()
829 })
830 .unwrap_or_default();
831 if cond_cpp.is_empty() {
832 continue;
833 }
834 effects.push(crate::node::IRConditionalClassEffect {
835 condition: cond_cpp,
836 on_styles,
837 off_styles,
838 });
839 }
840 effects
841}
842
843fn string_branch(expr: &Expression) -> String {
845 match expr {
846 Expression::StringLiteral(lit) => lit.value.to_string(),
847 _ => String::new(),
848 }
849}
850
851fn resolve_branch_classes(
854 class_str: &str,
855 tag: &str,
856 css_rules: &[(String, morph_parser::CssRule)],
857 tailwind: &TailwindResolver,
858) -> HashMap<String, String> {
859 let mut out = HashMap::new();
860 let tokens: Vec<String> = class_str
861 .split_whitespace()
862 .map(|t| t.trim().to_string())
863 .filter(|t| !t.is_empty())
864 .collect();
865 for token in &tokens {
866 for (prop, val) in tailwind.resolve(token) {
867 out.insert(prop, val);
868 }
869 }
870 for (selector, rule) in css_rules {
871 if selector.contains(":hover") || selector.contains(":active") {
872 continue;
873 }
874 if match_selector_detailed(tag, &tokens, None, &[], selector).is_some() {
875 for (prop, val) in &rule.properties {
876 out.insert(prop.clone(), val.clone());
877 }
878 }
879 }
880 out
881}
882
883fn include_lines(header: &str) -> Vec<String> {
887 header
888 .lines()
889 .map(str::trim)
890 .filter_map(|l| l.strip_prefix("#include"))
891 .map(|l| l.trim().to_string())
892 .filter(|l| !l.is_empty())
893 .collect()
894}
895fn split_trailing_pseudo(sel: &str) -> Option<(&str, Option<PseudoKind>)> {
899 let mut pseudo: Option<PseudoKind> = None;
900 let mut s = sel;
901 loop {
902 let t = s.trim_end();
903 if let Some(rest) = t.strip_suffix(":hover") {
904 pseudo = Some(PseudoKind::Hover);
905 s = rest;
906 } else if let Some(rest) = t.strip_suffix(":active") {
907 pseudo = Some(PseudoKind::Active);
908 s = rest;
909 } else {
910 break;
911 }
912 }
913 Some((s.trim_end(), pseudo))
914}
915
916fn match_selector_compound(tag: &str, classes: &[String], id: Option<&str>, sel: &str) -> bool {
919 let sel = sel.trim();
920 if sel.is_empty() { return false; }
921 if sel == "*" { return true; }
922
923 let mut matched_tag = false;
924 let mut tag_found = false;
925 let mut required_classes: Vec<&str> = Vec::new();
926 let mut has_id = false;
927 let mut id_ok = true;
928
929 let bytes = sel.as_bytes();
930 let mut i = 0;
931 let mut buf = String::new();
932
933 while i < bytes.len() {
934 let ch = sel[i..].chars().next().unwrap();
935 match ch {
936 '.' => {
937 flush_tag(&mut buf, &mut tag_found, &mut matched_tag, tag);
938 i += 1;
939 let start = i;
940 while i < bytes.len() && !" .#:[]>~+*".contains(sel[i..].chars().next().unwrap()) {
941 i += sel[i..].chars().next().unwrap().len_utf8();
942 }
943 if i > start { required_classes.push(&sel[start..i]); }
944 }
945 '#' => {
946 flush_tag(&mut buf, &mut tag_found, &mut matched_tag, tag);
947 i += 1;
948 let start = i;
949 while i < bytes.len() && !" .#:[]>~+*".contains(sel[i..].chars().next().unwrap()) {
950 i += sel[i..].chars().next().unwrap().len_utf8();
951 }
952 has_id = true;
953 if id.map(|v| v == &sel[start..i]).unwrap_or(false) {
954 } else {
956 id_ok = false;
957 }
958 }
959 ':' | '[' | '>' | '~' | '+' | ' ' => {
960 break;
963 }
964 _ => {
965 buf.push(ch);
966 i += 1;
967 }
968 }
969 }
970 flush_tag(&mut buf, &mut tag_found, &mut matched_tag, tag);
972
973 if has_id && !id_ok { return false; }
974 if tag_found && !matched_tag { return false; }
975 for c in required_classes {
976 if !classes.iter().any(|cl| cl.as_str() == c) {
977 return false;
978 }
979 }
980 true
981}
982
983fn event_trigger(prop: &str) -> Option<&'static str> {
986 match prop {
987 "onClick" => Some("click"),
988 "onInput" => Some("input"),
989 "onChange" => Some("change"),
990 "onFocus" => Some("focus"),
991 "onBlur" => Some("blur"),
992 "onKeyUp" => Some("keyup"),
993 "onKeyDown" => Some("keydown"),
994 "onMouseEnter" => Some("mouseenter"),
995 "onMouseLeave" => Some("mouseleave"),
996 "onMouseDown" => Some("mousedown"),
997 "onMouseUp" => Some("mouseup"),
998 _ => None,
999 }
1000}
1001
1002fn element_classes_id(
1004 props: &std::collections::HashMap<String, morph_parser::JsxPropValue>,
1005) -> (Vec<String>, Option<String>) {
1006 let classes = match props.get("className").or_else(|| props.get("class")) {
1007 Some(morph_parser::JsxPropValue::String(c)) => {
1008 c.split_whitespace().map(|s| s.to_string()).collect()
1009 }
1010 _ => Vec::new(),
1011 };
1012 let id = props.get("id").and_then(|v| match v {
1013 morph_parser::JsxPropValue::String(s) => Some(s.clone()),
1014 _ => None,
1015 });
1016 (classes, id)
1017}
1018
1019fn flush_tag(buf: &mut String, tag_found: &mut bool, matched_tag: &mut bool, tag: &str) {
1021 let s = buf.trim();
1022 if !s.is_empty() && s != "*" {
1023 *tag_found = true;
1024 if s == tag { *matched_tag = true; }
1025 }
1026 buf.clear();
1027}
1028
1029#[derive(Clone, Default)]
1031struct AncestorHint {
1032 tag: String,
1033 classes: Vec<String>,
1034 id: Option<String>,
1035}
1036
1037#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
1042struct Specificity(u32, u32, u32);
1043
1044fn selector_specificity(alternative: &str) -> Specificity {
1047 let mut ids = 0;
1048 let mut classes = 0;
1049 let mut tags = 0;
1050 let mut word_start = true;
1051 let mut chars = alternative.chars().peekable();
1052 while let Some(ch) = chars.next() {
1053 match ch {
1054 '#' => {
1055 ids += 1;
1056 word_start = false;
1057 }
1058 '.' => {
1059 classes += 1;
1060 word_start = false;
1061 }
1062 ':' => {
1063 if chars.peek() == Some(&':') {
1064 chars.next();
1065 }
1066 classes += 1;
1067 word_start = false;
1068 }
1069 '[' => {
1070 classes += 1;
1071 word_start = false;
1072 }
1073 ' ' | '>' | '+' | '~' | ',' | '*' => {
1074 word_start = true;
1075 }
1076 _ => {
1077 if word_start && ch.is_alphabetic() {
1078 tags += 1;
1079 }
1080 word_start = false;
1081 }
1082 }
1083 }
1084 Specificity(ids, classes, tags)
1085}
1086
1087#[derive(Clone, Copy, PartialEq, Eq)]
1090enum Combinator {
1091 Descendant,
1092 Child,
1093}
1094
1095fn split_selector_sequence(selector: &str) -> Option<Vec<(Option<Combinator>, String)>> {
1099 if selector.contains('[') {
1100 return None;
1101 }
1102 let mut steps: Vec<(Option<Combinator>, String)> = Vec::new();
1103 let mut current = String::new();
1104 let mut pending = None;
1105 let mut chars = selector.chars().peekable();
1106 while let Some(ch) = chars.next() {
1107 match ch {
1108 ' ' | '\t' => {
1109 if !current.trim().is_empty() {
1110 steps.push((pending.take(), current.trim().to_string()));
1111 current = String::new();
1112 }
1113 if pending.is_none() {
1114 pending = Some(Combinator::Descendant);
1115 }
1116 }
1117 '>' => {
1118 if !current.trim().is_empty() {
1119 steps.push((pending.take(), current.trim().to_string()));
1120 current = String::new();
1121 }
1122 pending = Some(Combinator::Child);
1123 }
1124 '+' | '~' => {
1125 return None;
1126 }
1127 _ => {
1128 current.push(ch);
1129 }
1130 }
1131 }
1132 if !current.trim().is_empty() {
1133 steps.push((pending.take(), current.trim().to_string()));
1134 }
1135 if steps.is_empty() {
1136 return None;
1137 }
1138 Some(steps)
1139}
1140
1141fn match_sequence(
1144 tag: &str,
1145 classes: &[String],
1146 id: Option<&str>,
1147 ancestors: &[AncestorHint],
1148 steps: &[(Option<Combinator>, String)],
1149) -> bool {
1150 let Some((_, last)) = steps.last() else {
1151 return false;
1152 };
1153 if !match_selector_compound(tag, classes, id, last) {
1154 return false;
1155 }
1156 let mut ancestor_at = 0;
1157 for index in (1..steps.len()).rev() {
1158 let compound = &steps[index - 1].1;
1159 match steps[index].0 {
1160 None | Some(Combinator::Child) => {
1161 let Some(ancestor) = ancestors.get(ancestor_at) else {
1162 return false;
1163 };
1164 if !match_selector_compound(
1165 &ancestor.tag,
1166 &ancestor.classes,
1167 ancestor.id.as_deref(),
1168 compound,
1169 ) {
1170 return false;
1171 }
1172 ancestor_at += 1;
1173 }
1174 Some(Combinator::Descendant) => {
1175 let mut found = false;
1176 while let Some(ancestor) = ancestors.get(ancestor_at) {
1177 ancestor_at += 1;
1178 if match_selector_compound(
1179 &ancestor.tag,
1180 &ancestor.classes,
1181 ancestor.id.as_deref(),
1182 compound,
1183 ) {
1184 found = true;
1185 break;
1186 }
1187 }
1188 if !found {
1189 return false;
1190 }
1191 }
1192 }
1193 }
1194 true
1195}
1196
1197fn match_selector_detailed(
1201 tag: &str,
1202 classes: &[String],
1203 id: Option<&str>,
1204 ancestors: &[AncestorHint],
1205 selector: &str,
1206) -> Option<(PseudoKind, Specificity)> {
1207 let mut best: Option<(PseudoKind, Specificity)> = None;
1208 for alternative in selector.trim().split(',') {
1209 let alternative = alternative.trim();
1210 if alternative.is_empty() {
1211 continue;
1212 }
1213 let (structural, pseudo) = match split_trailing_pseudo(alternative) {
1214 Some(split) => split,
1215 None => (alternative, None),
1216 };
1217 if structural.contains(':') || structural.contains('[') {
1218 continue;
1219 }
1220 let Some(steps) = split_selector_sequence(structural) else {
1221 continue;
1222 };
1223 if match_sequence(tag, classes, id, ancestors, &steps) {
1224 let specificity = selector_specificity(alternative);
1225 let better = best.map(|(_, held)| specificity > held).unwrap_or(true);
1226 if better {
1227 best = Some((pseudo.unwrap_or(PseudoKind::Base), specificity));
1228 }
1229 }
1230 }
1231 best
1232}
1233
1234fn apply_css_prop(style: &mut IRStyle, prop: &str, val: &str) -> Option<&'static str> {
1237 if !css_registry::is_known_property(prop) { return None; }
1238 match prop {
1239 "background-color" | "background" => if let Some(c) = parse_color(val) { style.bg_color = c; Some("bg_color") } else { None },
1240 "color" => if let Some(c) = parse_color(val) { style.color = c; Some("color") } else { None },
1241 "width" => if let Some(v) = parse_length(val) { style.width = Some(v); Some("width") } else { None },
1242 "height" => if let Some(v) = parse_length(val) { style.height = Some(v); Some("height") } else { None },
1243 "min-width" => if let Some(v) = parse_length(val) { style.min_width = Some(v); Some("min_width") } else { None },
1244 "max-width" => if let Some(v) = parse_length(val) { style.max_width = Some(v); Some("max_width") } else { None },
1245 "min-height" => if let Some(v) = parse_length(val) { style.min_height = Some(v); Some("min_height") } else { None },
1246 "max-height" => if let Some(v) = parse_length(val) { style.max_height = Some(v); Some("max_height") } else { None },
1247 "padding" => if let Some(v) = parse_box_sides(val) { style.padding = v; Some("padding") } else { None },
1248 "margin" => if let Some(v) = parse_box_sides(val) { style.margin = v; Some("margin") } else { None },
1249 "border-radius" => if let Some(v) = parse_length(val) { style.border_radius = v; Some("border_radius") } else { None },
1250 "font-size" => if let Some(v) = parse_length(val) { style.font_size = v; Some("font_size") } else { None },
1251 "font-weight" => { style.font_weight = val.to_string(); Some("font_weight") }
1252 "text-align" => { style.text_align = val.to_string(); Some("text_align") }
1253 "display" => { style.display = val.to_string(); Some("display") }
1254 "flex-direction" => { style.flex_dir = val.to_string(); Some("flex_dir") }
1255 "gap" => if let Some(v) = parse_length(val) { style.gap = v; Some("gap") } else { None },
1256 "position" => { style.position = val.to_string(); Some("position") }
1257 "left" => {
1258 style.left = parse_length(val);
1259 if style.left.is_some() { Some("left") } else { None }
1260 }
1261 "right" => {
1262 style.right = parse_length(val);
1263 if style.right.is_some() { Some("right") } else { None }
1264 }
1265 "top" => {
1266 style.top = parse_length(val);
1267 if style.top.is_some() { Some("top") } else { None }
1268 }
1269 "bottom" => {
1270 style.bottom = parse_length(val);
1271 if style.bottom.is_some() { Some("bottom") } else { None }
1272 }
1273 "justify-content" => { style.justify_content = val.to_string(); Some("justify_content") }
1274 "align-items" => { style.align_items = val.to_string(); Some("align_items") }
1275 "flex-wrap" => { style.flex_wrap = val.to_string(); Some("flex_wrap") }
1276 "flex-grow" => if let Ok(v) = val.trim().parse::<f32>() {
1277 style.flex_grow = v; Some("flex_grow")
1278 } else { None },
1279 "flex-shrink" => if let Ok(v) = val.trim().parse::<f32>() {
1280 style.flex_shrink = v; Some("flex_shrink")
1281 } else { None },
1282 "flex-basis" => {
1283 let v = val.trim();
1284 style.flex_basis = if v == "auto" {
1285 "auto".to_string()
1286 } else if let Some(px) = parse_length(v) {
1287 format!("{}px", px)
1288 } else {
1289 v.to_string()
1290 };
1291 Some("flex_basis")
1292 }
1293 "flex" => { parse_flex_shorthand(&mut *style, val); Some("flex") }
1294 "border" => { parse_border_shorthand(&mut *style, val); Some("border") }
1295 "margin-top" => if let Some(v) = parse_length(val) {
1296 style.margin[0] = v; Some("margin")
1297 } else { None },
1298 "margin-right" => if let Some(v) = parse_length(val) {
1299 style.margin[1] = v; Some("margin")
1300 } else { None },
1301 "margin-bottom" => if let Some(v) = parse_length(val) {
1302 style.margin[2] = v; Some("margin")
1303 } else { None },
1304 "margin-left" => if let Some(v) = parse_length(val) {
1305 style.margin[3] = v; Some("margin")
1306 } else { None },
1307 "padding-top" => if let Some(v) = parse_length(val) {
1308 style.padding[0] = v; Some("padding")
1309 } else { None },
1310 "padding-right" => if let Some(v) = parse_length(val) {
1311 style.padding[1] = v; Some("padding")
1312 } else { None },
1313 "padding-bottom" => if let Some(v) = parse_length(val) {
1314 style.padding[2] = v; Some("padding")
1315 } else { None },
1316 "padding-left" => if let Some(v) = parse_length(val) {
1317 style.padding[3] = v; Some("padding")
1318 } else { None },
1319 "cursor" => { style.cursor = val.to_string(); Some("cursor") }
1320 "overflow" => { style.overflow = val.to_string(); Some("overflow") }
1321 "opacity" => if let Ok(v) = val.trim().parse::<f32>() { style.opacity = v; Some("opacity") } else { None },
1322 "transform" => {
1323 match transforms::parse_transform(val) {
1327 Some(ops) => {
1328 style.transform_ops = Some(ops.clone());
1329 if !ops.is_empty() {
1330 style.transform_matrix =
1331 Some(transforms::compose_transform(&ops, 0.0, 0.0));
1332 }
1333 Some("transform")
1334 }
1335 None => None,
1336 }
1337 }
1338 "transform-origin" => {
1339 match transforms::parse_transform_origin(val) {
1340 Some((raw, resolved)) => {
1341 style.transform_origin = Some(raw);
1342 style.transform_origin_resolved = resolved;
1343 Some("transform_origin")
1344 }
1345 None => None,
1346 }
1347 }
1348 "z-index" => if let Ok(v) = val.parse::<i32>() { style.z_index = Some(v); Some("z_index") } else { None },
1349 "border-width" => if let Some(v) = parse_length(val) { style.border_width = v; Some("border_width") } else { None },
1350 "border-color" => if let Some(c) = parse_color(val) { style.border_color = c; Some("border_color") } else { None },
1351 "border-style" => { style.border_style = val.to_string(); Some("border_style") }
1352 "box-sizing" => { style.box_sizing = val.to_string(); Some("box_sizing") }
1353 _ => None,
1354 }
1355}
1356
1357fn parse_flex_shorthand(style: &mut IRStyle, val: &str) {
1360 let kw = val.trim();
1361 let parts: Vec<&str> = kw.split_whitespace().collect();
1362 match kw {
1363 "none" => {
1364 style.flex_grow = 0.0;
1365 style.flex_shrink = 0.0;
1366 style.flex_basis = "auto".to_string();
1367 }
1368 "auto" => {
1369 style.flex_grow = 1.0;
1370 style.flex_shrink = 1.0;
1371 style.flex_basis = "auto".to_string();
1372 }
1373 "initial" => {
1374 style.flex_grow = 0.0;
1375 style.flex_shrink = 1.0;
1376 style.flex_basis = "auto".to_string();
1377 }
1378 _ => match parts.len() {
1379 1 => {
1380 if let Ok(v) = parts[0].parse::<f32>() {
1381 style.flex_grow = v;
1382 style.flex_shrink = 1.0;
1383 style.flex_basis = "0%".to_string();
1384 }
1385 }
1386 2 => {
1387 if let (Ok(g), Ok(s)) =
1388 (parts[0].parse::<f32>(), parts[1].parse::<f32>())
1389 {
1390 style.flex_grow = g;
1391 style.flex_shrink = s;
1392 style.flex_basis = "0%".to_string();
1393 }
1394 }
1395 _ => {
1396 if parts.len() >= 3 {
1397 if let (Ok(g), Ok(s)) =
1398 (parts[0].parse::<f32>(), parts[1].parse::<f32>())
1399 {
1400 style.flex_grow = g;
1401 style.flex_shrink = s;
1402 style.flex_basis = parts[2].to_string();
1403 }
1404 }
1405 }
1406 },
1407 }
1408}
1409
1410fn parse_border_shorthand(style: &mut IRStyle, val: &str) {
1413 for part in val.split_whitespace() {
1414 if matches!(part, "solid" | "dashed" | "dotted" | "none") {
1415 style.border_style = part.to_string();
1416 } else if part.starts_with('#')
1417 || part.starts_with("rgb")
1418 || part == "transparent"
1419 {
1420 if let Some(c) = parse_color(part) {
1421 style.border_color = c;
1422 }
1423 } else if let Some(w) = parse_length(part) {
1424 style.border_width = w;
1425 }
1426 }
1427}
1428
1429fn parse_box_sides(s: &str) -> Option<[f32; 4]> {
1432 let parts: Vec<Option<f32>> = s.split_whitespace().map(parse_length).collect();
1433 if parts.is_empty() || parts.len() > 4 || parts.iter().any(|p| p.is_none()) {
1434 return None;
1435 }
1436 let v: Vec<f32> = parts.into_iter().map(|p| p.unwrap_or(0.0)).collect();
1437 Some(match v.len() {
1438 1 => [v[0], v[0], v[0], v[0]],
1439 2 => [v[0], v[1], v[0], v[1]],
1440 3 => [v[0], v[1], v[2], v[1]],
1441 _ => [v[0], v[1], v[2], v[3]],
1442 })
1443}
1444
1445fn parse_css_time(raw: &str) -> Option<f32> {
1447 let s = raw.trim().to_lowercase();
1448 if let Some(ms) = s.strip_suffix("ms") {
1449 return ms.trim().parse::<f32>().ok().map(|v| v / 1000.0);
1450 }
1451 if let Some(sec) = s.strip_suffix('s') {
1452 return sec.trim().parse::<f32>().ok();
1453 }
1454 s.parse::<f32>().ok()
1455}
1456
1457fn map_easing(low: &str) -> Option<&'static str> {
1458 Some(match low {
1461 "linear" => "linear",
1462 "ease" | "ease-in-out" => "ease-in-out",
1463 "ease-in" => "ease-in",
1464 "ease-out" => "ease-out",
1465 _ => return None,
1466 })
1467}
1468
1469fn split_animation_list(raw: &str) -> Vec<String> {
1471 let mut parts = Vec::new();
1472 let mut depth = 0i32;
1473 let mut cur = String::new();
1474 for ch in raw.chars() {
1475 match ch {
1476 '(' => {
1477 depth += 1;
1478 cur.push(ch);
1479 }
1480 ')' => {
1481 depth -= 1;
1482 cur.push(ch);
1483 }
1484 ',' if depth == 0 => {
1485 if !cur.trim().is_empty() {
1486 parts.push(cur.trim().to_string());
1487 }
1488 cur = String::new();
1489 }
1490 _ => cur.push(ch),
1491 }
1492 }
1493 if !cur.trim().is_empty() {
1494 parts.push(cur.trim().to_string());
1495 }
1496 parts
1497}
1498
1499fn parse_animation_component(raw: &str) -> crate::node::IRAnimation {
1503 let mut anim = crate::node::IRAnimation::default();
1504 let mut unclassified: Vec<String> = Vec::new();
1505 for tok in raw.split_whitespace() {
1506 let low = tok.to_lowercase();
1507 if let Some(easing) = map_easing(&low) {
1508 anim.easing = easing.to_string();
1509 } else if matches!(low.as_str(), "normal" | "reverse" | "alternate" | "alternate-reverse") {
1510 anim.direction = low;
1511 } else if matches!(low.as_str(), "none" | "forwards" | "backwards" | "both") {
1512 anim.fill_mode = low;
1513 } else if matches!(low.as_str(), "running" | "paused") {
1514 anim.play_state = low;
1515 } else if low == "infinite" {
1516 anim.iterations = -1.0;
1517 } else if low.parse::<f32>().is_ok() {
1518 anim.iterations = low.parse::<f32>().unwrap_or(1.0);
1520 } else if let Some(t) = parse_css_time(&low) {
1521 if anim.duration == 0.0 {
1522 anim.duration = t;
1523 } else {
1524 anim.delay = t;
1525 }
1526 } else if !low.contains('(') {
1527 unclassified.push(tok.to_string());
1529 }
1530 }
1531 if !unclassified.is_empty() {
1532 anim.name = unclassified.into_iter().next().unwrap_or_default();
1533 }
1534 anim
1535}
1536
1537fn parse_animation_shorthand(raw: &str) -> Vec<crate::node::IRAnimation> {
1538 split_animation_list(raw)
1539 .iter()
1540 .map(|part| parse_animation_component(part))
1541 .filter(|anim| !anim.name.is_empty())
1542 .collect()
1543}
1544
1545const ANIMATION_LONGHANDS: &[&str] = &[
1546 "animation-name",
1547 "animation-duration",
1548 "animation-timing-function",
1549 "animation-delay",
1550 "animation-iteration-count",
1551 "animation-direction",
1552 "animation-fill-mode",
1553 "animation-play-state",
1554];
1555
1556fn apply_animation_longhand(
1557 anim: &mut crate::node::IRAnimation,
1558 prop: &str,
1559 value: &str,
1560) -> bool {
1561 let val = value.trim().to_lowercase();
1562 match prop {
1563 "animation-name" => anim.name = val,
1564 "animation-duration" => {
1565 anim.duration = parse_css_time(&val).unwrap_or(anim.duration);
1566 if parse_css_time(&val).is_none() {
1567 return false;
1568 }
1569 }
1570 "animation-timing-function" => {
1571 if let Some(easing) = map_easing(&val) {
1572 anim.easing = easing.to_string();
1573 }
1574 }
1575 "animation-delay" => {
1576 if parse_css_time(&val).is_none() {
1577 return false;
1578 }
1579 anim.delay = parse_css_time(&val).unwrap_or(anim.delay);
1580 }
1581 "animation-iteration-count" => {
1582 if val == "infinite" {
1583 anim.iterations = -1.0;
1584 } else if let Ok(n) = val.parse::<f32>() {
1585 anim.iterations = n;
1586 } else {
1587 return false;
1588 }
1589 }
1590 "animation-direction" => {
1591 if matches!(val.as_str(), "normal" | "reverse" | "alternate" | "alternate-reverse") {
1592 anim.direction = val;
1593 }
1594 }
1595 "animation-fill-mode" => {
1596 if matches!(val.as_str(), "none" | "forwards" | "backwards" | "both") {
1597 anim.fill_mode = val;
1598 }
1599 }
1600 "animation-play-state" => {
1601 if matches!(val.as_str(), "running" | "paused") {
1602 anim.play_state = val;
1603 }
1604 }
1605 _ => {}
1606 }
1607 true
1608}
1609
1610fn parse_animations(merged: &HashMap<String, String>) -> Vec<crate::node::IRAnimation> {
1615 let mut anims: Vec<crate::node::IRAnimation> = merged
1616 .get("animation")
1617 .map(|raw| parse_animation_shorthand(raw))
1618 .unwrap_or_default();
1619 let mut longhands: Vec<(&str, Vec<String>)> = Vec::new();
1620 for prop in ANIMATION_LONGHANDS {
1621 if let Some(raw) = merged.get(*prop) {
1622 longhands.push((prop, split_animation_list(raw)));
1623 }
1624 }
1625 if longhands.is_empty() {
1626 return anims.into_iter().filter(|a| !a.name.is_empty()).collect();
1627 }
1628 let count = longhands
1629 .iter()
1630 .map(|(_, values)| values.len())
1631 .max()
1632 .unwrap_or(0)
1633 .max(anims.len());
1634 while anims.len() < count {
1635 anims.push(crate::node::IRAnimation::default());
1636 }
1637 for (prop, values) in &longhands {
1638 for (i, anim) in anims.iter_mut().enumerate().take(count) {
1639 let val = values.get(i).or_else(|| values.last());
1640 if let Some(val) = val {
1641 apply_animation_longhand(anim, prop, val);
1642 }
1643 }
1644 }
1645 anims.into_iter().filter(|a| !a.name.is_empty()).collect()
1646}
1647
1648fn parse_length(s: &str) -> Option<f32> {
1649 let s = s.trim();
1650 if let Some(num) = s.strip_suffix("px") { return num.trim().parse().ok(); }
1651 if let Some(num) = s.strip_suffix("rem") { return num.trim().parse::<f32>().ok().map(|v| v*16.0); }
1652 if let Some(num) = s.strip_suffix("em") { return num.trim().parse::<f32>().ok().map(|v| v*16.0); }
1653 if s.ends_with('%') { return None; }
1654 s.parse().ok()
1655}
1656
1657fn parse_color(s: &str) -> Option<[f32;4]> {
1658 let s = s.trim().to_lowercase();
1659 if s.starts_with('#') {
1660 let hex = s.trim_start_matches('#');
1661 let (r,g,b,a) = match hex.len() {
1662 3 => {
1663 let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
1664 let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
1665 let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
1666 (r,g,b,255)
1667 }
1668 4 => {
1669 let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
1670 let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
1671 let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
1672 let a = u8::from_str_radix(&hex[3..4].repeat(2), 16).ok()?;
1673 (r,g,b,a)
1674 }
1675 6 => {
1676 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1677 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1678 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1679 (r,g,b,255)
1680 }
1681 8 => {
1682 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1683 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1684 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1685 let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
1686 (r,g,b,a)
1687 }
1688 _ => return None,
1689 };
1690 return Some([r as f32/255.0, g as f32/255.0, b as f32/255.0, a as f32/255.0]);
1691 }
1692 if s.starts_with("rgb") {
1693 return parse_rgb(&s);
1694 }
1695 match s.as_str() {
1696 "transparent" => Some([0.0,0.0,0.0,0.0]),
1697 "white" => Some([1.0,1.0,1.0,1.0]),
1698 "black" => Some([0.0,0.0,0.0,1.0]),
1699 "red" => Some([1.0,0.0,0.0,1.0]),
1700 "green" => Some([0.0,0.5,0.0,1.0]),
1701 "blue" => Some([0.0,0.0,1.0,1.0]),
1702 "gray" | "grey" => Some([0.5,0.5,0.5,1.0]),
1703 _ => None,
1704 }
1705}
1706
1707fn parse_rgb(s: &str) -> Option<[f32;4]> {
1710 let inner = s.find('(')?;
1711 let end = s.rfind(')')?;
1712 let args = &s[inner+1..end];
1713 let parts: Vec<&str> = args.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()).collect();
1714 if parts.len() < 3 { return None; }
1715
1716 let comp = |p: &str| -> Option<f32> {
1717 let p = p.trim();
1718 if let Some(v) = p.strip_suffix('%') {
1719 Some(v.trim().parse::<f32>().ok()? / 100.0)
1720 } else {
1721 Some(p.parse::<f32>().ok()? / 255.0)
1722 }
1723 };
1724
1725 let r = comp(parts[0])?;
1726 let g = comp(parts[1])?;
1727 let b = comp(parts[2])?;
1728 let a = if parts.len() >= 4 {
1729 let p = parts[3].trim();
1730 if let Some(v) = p.strip_suffix('%') {
1731 v.trim().parse::<f32>().ok()? / 100.0
1732 } else {
1733 p.parse::<f32>().ok()?
1734 }
1735 } else {
1736 1.0
1737 };
1738 Some([r, g, b, a])
1739}
1740
1741impl Default for IRBuilder {
1742 fn default() -> Self { Self::new() }
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747 use super::*;
1748 use morph_parser::JsxPropValue;
1749
1750 fn match_sel(sel: &str, cls: &[&str]) -> bool {
1751 let mut props = std::collections::HashMap::new();
1752 props.insert("className".to_string(), JsxPropValue::String(cls.join(" ")));
1753 let (classes, id) = element_classes_id(&props);
1754 match_selector_detailed("button", &classes, id.as_deref(), &[], sel).is_some()
1755 }
1756
1757 #[test]
1758 fn compound_ghost() {
1759 assert!(match_sel(".btn.ghost", &["btn", "ghost"]), ".btn.ghost should match btn ghost");
1760 assert!(!match_sel(".btn.ghost", &["btn"]), ".btn.ghost should NOT match btn only");
1761 assert!(match_sel(".btn", &["btn", "ghost"]), ".btn should match btn ghost");
1762 assert!(match_sel(".btn.ghost:hover", &["btn", "ghost"]), "hover compound should match");
1763 }
1764
1765 #[test]
1766 fn transparent_parsed() {
1767 let close = |a: Option<[f32; 4]>, b: [f32; 4]| -> bool {
1770 match a {
1771 Some(v) => (0..4).all(|i| (v[i] - b[i]).abs() < 0.001),
1772 None => false,
1773 }
1774 };
1775 assert!(close(parse_color("transparent"), [0.0, 0.0, 0.0, 0.0]));
1776 assert!(close(parse_color("#0000"), [0.0, 0.0, 0.0, 0.0]));
1777 assert!(close(parse_color("#4f7cff00"), [0.3098, 0.4863, 1.0, 0.0]));
1778 assert!(close(parse_color("rgba(79, 124, 255, 0)"), [0.3098, 0.4863, 1.0, 0.0]));
1779 assert!(close(parse_color("rgba(255,255,255,0.5)"), [1.0, 1.0, 1.0, 0.5]));
1780 assert!(close(parse_color("rgb(255,0,0)"), [1.0, 0.0, 0.0, 1.0]));
1781 }
1782
1783 #[test]
1784 fn specificity_orders_id_over_class_over_tag() {
1785 assert!(selector_specificity("#a") > selector_specificity(".b.c.d"));
1786 assert!(selector_specificity(".b") > selector_specificity("div"));
1787 assert!(selector_specificity("div p") > selector_specificity("p"));
1788 assert_eq!(selector_specificity(".a"), selector_specificity(".b"));
1789 }
1790
1791 fn detailed(
1792 tag: &str,
1793 classes: &[&str],
1794 ancestors: &[(&str, &[&str])],
1795 selector: &str,
1796 ) -> Option<(PseudoKind, Specificity)> {
1797 let owned_classes: Vec<String> = classes.iter().map(|s| s.to_string()).collect();
1798 let owned_ancestors: Vec<AncestorHint> = ancestors
1799 .iter()
1800 .map(|(tag, classes)| AncestorHint {
1801 tag: tag.to_string(),
1802 classes: classes.iter().map(|s| s.to_string()).collect(),
1803 id: None,
1804 })
1805 .collect();
1806 match_selector_detailed(tag, &owned_classes, None, &owned_ancestors, selector)
1807 }
1808
1809 #[test]
1810 fn descendant_matches_through_ancestors() {
1811 let wrap: &[&str] = &["wrap"];
1812 let ancestors = [("div", wrap)];
1813 assert!(detailed("p", &[], &ancestors, "div p").is_some());
1814 assert!(detailed("p", &[], &[], "div p").is_none());
1815 assert!(detailed("span", &[], &ancestors, "div > span").is_some());
1816 }
1817
1818 #[test]
1819 fn sibling_combinators_never_match() {
1820 let empty: &[&str] = &[];
1821 let ancestors = [("h2", empty)];
1822 assert!(detailed("p", &[], &ancestors, "h2 + p").is_none());
1823 assert!(detailed("p", &[], &ancestors, "h2 ~ p").is_none());
1824 }
1825
1826 #[test]
1827 fn grouped_alternatives_use_matching_specificity() {
1828 let matched = detailed("p", &["note"], &[], ".note, #other").unwrap();
1829 assert_eq!(matched.1, selector_specificity(".note"));
1830 }
1831
1832 #[test]
1833 fn width_attribute_loses_to_stylesheet() {
1834 let builder = IRBuilder::new();
1835 let mut props = std::collections::HashMap::new();
1836 props.insert("width".to_string(), JsxPropValue::String("400".to_string()));
1837 let node = builder.build_node(
1838 &morph_parser::JsxNode::Element {
1839 tag: "img".to_string(),
1840 props,
1841 children: Vec::new(),
1842 self_closing: true,
1843 line: 0,
1844 col: 0,
1845 },
1846 &[],
1847 0,
1848 &[],
1849 &HashMap::new(),
1850 &HashMap::new(),
1851 &mut Vec::new(),
1852 &HashMap::new(),
1853 );
1854 assert_eq!(node.style.width, Some(400.0));
1855
1856 let mut props = std::collections::HashMap::new();
1857 props.insert("width".to_string(), JsxPropValue::String("400".to_string()));
1858 let rules = vec![(
1859 ".wide".to_string(),
1860 morph_parser::CssRule {
1861 selector: ".wide".to_string(),
1862 properties: [("width".to_string(), "100px".to_string())]
1863 .into_iter()
1864 .collect(),
1865 },
1866 )];
1867 props.insert("className".to_string(), JsxPropValue::String("wide".to_string()));
1868 let node = builder.build_node(
1869 &morph_parser::JsxNode::Element {
1870 tag: "img".to_string(),
1871 props,
1872 children: Vec::new(),
1873 self_closing: true,
1874 line: 0,
1875 col: 0,
1876 },
1877 &rules,
1878 0,
1879 &[],
1880 &HashMap::new(),
1881 &HashMap::new(),
1882 &mut Vec::new(),
1883 &HashMap::new(),
1884 );
1885 assert_eq!(node.style.width, Some(100.0));
1886 }
1887
1888 #[test]
1889 fn embedded_logic_transpiles_to_cpp_premain() {
1890 use morph_parser::{
1891 ComponentConst, InnerFunction, MxComponent, MxEffect, MxSource, StateVar,
1892 };
1893 let source = MxSource {
1894 filename: "app.mx".to_string(),
1895 imports: Vec::new(),
1896 window_config: None,
1897 components: vec![MxComponent {
1898 name: "App".to_string(),
1899 exported: true,
1900 params: Vec::new(),
1901 jsx: morph_parser::JsxNode::Text("hi".to_string()),
1902 state_vars: vec![StateVar {
1903 getter: "count".to_string(),
1904 setter: "setCount".to_string(),
1905 init: "0".to_string(),
1906 }],
1907 effects: vec![
1908 MxEffect {
1909 callback: "() => { console.log(count); }".to_string(),
1910 deps: "[]".to_string(),
1911 },
1912 MxEffect {
1913 callback: "() => { console.log(count); }".to_string(),
1914 deps: "[count]".to_string(),
1915 },
1916 ],
1917 inner_functions: vec![InnerFunction {
1918 name: "doLogin".to_string(),
1919 source: "function doLogin() { setCount(count + 1); }".to_string(),
1920 }],
1921 consts: vec![ComponentConst {
1922 name: "doubled".to_string(),
1923 rhs: "count * 2".to_string(),
1924 }],
1925 console_logs: vec!["body log".to_string()],
1926 }],
1927 state_vars: Vec::new(),
1928 effects: Vec::new(),
1929 inner_functions: Vec::new(),
1930 function_declarations: vec![InnerFunction {
1931 name: "helper".to_string(),
1932 source: "function helper() { return 1; }".to_string(),
1933 }],
1934 global_vars: vec!["const API_URL = \"https://api.test\";".to_string()],
1935 console_logs: vec!["module log".to_string()],
1936 extra_headers: Vec::new(),
1937 cpp_imports: Vec::new(),
1938 };
1939 let windows = IRBuilder::new().build(&source, &[], &HashMap::new());
1940 assert_eq!(windows.len(), 1);
1941 let win = &windows[0];
1942 let premain = win.premain_functions.join("\n");
1943 assert!(premain.contains("void doLogin()"), "handler transpiled: {}", premain);
1946 assert!(premain.contains("auto helper"), "module fn transpiled: {}", premain);
1947 assert!(premain.contains("API_URL"), "global transpiled: {}", premain);
1948 assert!(!premain.contains("function "), "no raw JS: {}", premain);
1949 assert!(
1950 premain.contains("auto doubled = []() { return ("),
1951 "const is reactive lambda: {}",
1952 premain
1953 );
1954 assert!(
1955 premain.contains("__st_count.get()"),
1956 "ambient state mapped: {}",
1957 premain
1958 );
1959 assert!(!premain.contains("static "), "external linkage: {}", premain);
1960 assert_eq!(win.reactive_consts, vec!["doubled".to_string()]);
1961 assert_eq!(win.effect_decls.len(), 2);
1963 assert!(win.effect_decls[0].get("lambda").unwrap().starts_with('['));
1964 assert!(!win.effect_decls[0].get("lambda").unwrap().contains("=>"));
1965 assert_eq!(win.effect_decls[0].get("deps").unwrap(), "[]");
1966 assert_eq!(win.effect_decls[1].get("deps").unwrap(), "[count]");
1967 assert!(win.extra_headers.iter().any(|h| h.contains("print")), "{:?}", win.extra_headers);
1969 assert_eq!(win.startup_logs, vec!["module log".to_string(), "body log".to_string()]);
1971 }
1972
1973 #[test]
1974 fn box_shorthands_expand_per_side() {
1975 assert_eq!(parse_box_sides("18px"), Some([18.0, 18.0, 18.0, 18.0]));
1976 assert_eq!(parse_box_sides("10px 6px"), Some([10.0, 6.0, 10.0, 6.0]));
1977 assert_eq!(
1978 parse_box_sides("36px 32px 28px 32px"),
1979 Some([36.0, 32.0, 28.0, 32.0])
1980 );
1981 assert_eq!(parse_box_sides("1px 2px 3px"), Some([1.0, 2.0, 3.0, 2.0]));
1982 assert_eq!(parse_box_sides("10px auto"), None);
1983 assert_eq!(parse_box_sides(""), None);
1984 }
1985
1986 #[test]
1987 fn flex_and_border_shorthands_map() {
1988 let builder = IRBuilder::new();
1989 let mut style = IRStyle::default();
1990 apply_css_prop(&mut style, "flex-grow", "1");
1991 apply_css_prop(&mut style, "flex-shrink", "0");
1992 assert_eq!(style.flex_grow, 1.0);
1993 assert_eq!(style.flex_shrink, 0.0);
1994 apply_css_prop(&mut style, "flex", "2");
1995 assert_eq!(style.flex_grow, 2.0);
1996 assert_eq!(style.flex_shrink, 1.0);
1997 assert_eq!(style.flex_basis, "0%");
1998 apply_css_prop(&mut style, "border", "1px solid #232b3d");
1999 assert_eq!(style.border_width, 1.0);
2000 assert_eq!(style.border_style, "solid");
2001 assert!(style.border_color[0] > 0.1 && style.border_color[0] < 0.2);
2002 apply_css_prop(&mut style, "margin-top", "50px");
2003 apply_css_prop(&mut style, "padding-left", "6px");
2004 assert_eq!(style.margin[0], 50.0);
2005 assert_eq!(style.padding[3], 6.0);
2006 let _ = builder;
2007 }
2008
2009 #[test]
2010 fn static_class_names_stay_out_of_reactive_class() {
2011 let builder = IRBuilder::new();
2014 let mut props = std::collections::HashMap::new();
2015 props.insert(
2016 "className".to_string(),
2017 JsxPropValue::String("key op".to_string()),
2018 );
2019 let node = builder.build_node(
2020 &morph_parser::JsxNode::Element {
2021 tag: "button".to_string(),
2022 props,
2023 children: Vec::new(),
2024 self_closing: true,
2025 line: 0,
2026 col: 0,
2027 },
2028 &[],
2029 0,
2030 &[],
2031 &HashMap::new(),
2032 &HashMap::new(),
2033 &mut Vec::new(),
2034 &HashMap::new(),
2035 );
2036 assert!(node.reactive_class.is_empty());
2037 let mut props = std::collections::HashMap::new();
2038 props.insert(
2039 "className".to_string(),
2040 JsxPropValue::Expr("op === 1 ? \"a\" : \"b\"".to_string()),
2041 );
2042 let node = builder.build_node(
2043 &morph_parser::JsxNode::Element {
2044 tag: "button".to_string(),
2045 props,
2046 children: Vec::new(),
2047 self_closing: true,
2048 line: 0,
2049 col: 0,
2050 },
2051 &[],
2052 0,
2053 &[],
2054 &HashMap::new(),
2055 &HashMap::new(),
2056 &mut Vec::new(),
2057 &HashMap::new(),
2058 );
2059 assert!(!node.reactive_class.is_empty());
2060 }
2061
2062 #[test]
2063 fn template_className_analyzes_ternary_branches() {
2064 use morph_parser::{MxComponent, MxSource, StateVar};
2065 let mut props = std::collections::HashMap::new();
2066 props.insert(
2067 "className".to_string(),
2068 JsxPropValue::Template(
2069 "`header ${theme == \"light\" ? \"bg-white\" : \"bg-gray-900\"}`".to_string(),
2070 ),
2071 );
2072 let source = MxSource {
2073 filename: "app.mx".to_string(),
2074 imports: Vec::new(),
2075 window_config: None,
2076 components: vec![MxComponent {
2077 name: "App".to_string(),
2078 exported: true,
2079 params: Vec::new(),
2080 jsx: morph_parser::JsxNode::Element {
2081 tag: "div".to_string(),
2082 props,
2083 children: Vec::new(),
2084 self_closing: true,
2085 line: 0,
2086 col: 0,
2087 },
2088 state_vars: vec![StateVar {
2089 getter: "theme".to_string(),
2090 setter: "setTheme".to_string(),
2091 init: "\"light\"".to_string(),
2092 }],
2093 effects: Vec::new(),
2094 inner_functions: Vec::new(),
2095 consts: Vec::new(),
2096 console_logs: Vec::new(),
2097 }],
2098 state_vars: Vec::new(),
2099 effects: Vec::new(),
2100 inner_functions: Vec::new(),
2101 function_declarations: Vec::new(),
2102 global_vars: Vec::new(),
2103 console_logs: Vec::new(),
2104 extra_headers: Vec::new(),
2105 cpp_imports: Vec::new(),
2106 };
2107 let windows = IRBuilder::new().build(&source, &[], &HashMap::new());
2108 let node = &windows[0].nodes[0];
2109 assert_eq!(node.class_conditional_effects.len(), 1);
2110 let fx = &node.class_conditional_effects[0];
2111 assert!(fx.condition.contains("__st_theme"), "cond mapped: {}", fx.condition);
2112 assert_eq!(
2113 fx.on_styles.get("background-color").map(String::as_str),
2114 Some("#ffffff")
2115 );
2116 assert_eq!(
2117 fx.off_styles.get("background-color").map(String::as_str),
2118 Some("#111827")
2119 );
2120 assert!(!node.reactive_class.is_empty());
2121 }
2122
2123 #[test]
2124 fn animation_shorthand_parses_and_filters_unknown_keyframes() {
2125 use morph_parser::{CssKeyframe, CssRule};
2126 let rules = vec![(
2127 ".pulse".to_string(),
2128 CssRule {
2129 selector: ".pulse".to_string(),
2130 properties: [(
2131 "animation".to_string(),
2132 "pulse 2s ease-in-out infinite".to_string(),
2133 )]
2134 .into_iter()
2135 .collect(),
2136 },
2137 )];
2138 let mut keyframes = HashMap::new();
2139 keyframes.insert("pulse".to_string(), Vec::<CssKeyframe>::new());
2140 let mut props = std::collections::HashMap::new();
2141 props.insert(
2142 "className".to_string(),
2143 JsxPropValue::String("pulse".to_string()),
2144 );
2145 let jsx = morph_parser::JsxNode::Element {
2146 tag: "div".to_string(),
2147 props,
2148 children: Vec::new(),
2149 self_closing: true,
2150 line: 0,
2151 col: 0,
2152 };
2153 let builder = IRBuilder::new();
2154 let node = builder.build_node(
2155 &jsx,
2156 &rules,
2157 0,
2158 &[],
2159 &HashMap::new(),
2160 &HashMap::new(),
2161 &mut Vec::new(),
2162 &keyframes,
2163 );
2164 assert_eq!(node.animations.len(), 1);
2165 let anim = &node.animations[0];
2166 assert_eq!(anim.name, "pulse");
2167 assert_eq!(anim.duration, 2.0);
2168 assert_eq!(anim.easing, "ease-in-out");
2169 assert_eq!(anim.iterations, -1.0);
2170 let rules = vec![(
2172 ".ghost".to_string(),
2173 CssRule {
2174 selector: ".ghost".to_string(),
2175 properties: [("animation".to_string(), "nope 1s linear infinite".to_string())]
2176 .into_iter()
2177 .collect(),
2178 },
2179 )];
2180 let mut props = std::collections::HashMap::new();
2181 props.insert(
2182 "className".to_string(),
2183 JsxPropValue::String("ghost".to_string()),
2184 );
2185 let jsx = morph_parser::JsxNode::Element {
2186 tag: "div".to_string(),
2187 props,
2188 children: Vec::new(),
2189 self_closing: true,
2190 line: 0,
2191 col: 0,
2192 };
2193 let node = builder.build_node(
2194 &jsx,
2195 &rules,
2196 0,
2197 &[],
2198 &HashMap::new(),
2199 &HashMap::new(),
2200 &mut Vec::new(),
2201 &keyframes,
2202 );
2203 assert!(node.animations.is_empty());
2204 }
2205
2206 #[test]
2207 fn tailwind_class_names_flow_into_style() {
2208 let builder = IRBuilder::new();
2209 let mut props = std::collections::HashMap::new();
2210 props.insert(
2211 "className".to_string(),
2212 JsxPropValue::String("bg-red-500 text-lg".to_string()),
2213 );
2214 let node = builder.build_node(
2215 &morph_parser::JsxNode::Element {
2216 tag: "div".to_string(),
2217 props,
2218 children: Vec::new(),
2219 self_closing: true,
2220 line: 0,
2221 col: 0,
2222 },
2223 &[],
2224 0,
2225 &[],
2226 &HashMap::new(),
2227 &HashMap::new(),
2228 &mut Vec::new(),
2229 &HashMap::new(),
2230 );
2231 assert!((node.style.bg_color[0] - 0xef as f32 / 255.0).abs() < 0.001);
2232 assert!((node.style.bg_color[1] - 0x44 as f32 / 255.0).abs() < 0.001);
2233 assert_eq!(node.style.bg_color[3], 1.0);
2234 assert_eq!(node.style.font_size, 18.0);
2235 }
2236}