diff --git a/.changepacks/changepack_log_emotion_fallback_arrays.json b/.changepacks/changepack_log_emotion_fallback_arrays.json new file mode 100644 index 000000000..3a37d8d99 --- /dev/null +++ b/.changepacks/changepack_log_emotion_fallback_arrays.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "In rule objects written against Emotion, styled-components or vanilla-extract, a property whose value is an array of literals (`background: ['red', 'linear-gradient(...)']`) declares each value in order as a browser fallback, as those libraries do, instead of being read as Devup UI breakpoints; numbers get px as the library gives them. Arrays holding runtime values stay as written", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index c5eb83c46..caaa35613 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -108,6 +108,7 @@ impl LibraryNumbers<'_> { return; } match value { + Expression::ArrayExpression(array) => self.fallbacks(key, array), Expression::ObjectExpression(_) => { if key != "vars" { self.pixelify(value); @@ -126,6 +127,63 @@ impl LibraryNumbers<'_> { } } +impl LibraryNumbers<'_> { + /// `key: [a, b]` in these libraries declares `key` once per value, later + /// ones as fallbacks a browser keeps when it understands them, where Devup + /// UI reads an array as breakpoints. Literal values become one value + /// declaring each in order; others stay as written. + fn fallbacks(&mut self, key: &str, array: &oxc_ast::ast::ArrayExpression) { + let values: Option> = array + .elements + .iter() + .map(|element| { + let element = element.as_expression()?; + if let Some(number) = js_number_literal(element) { + return Some(if number == 0.0 || keeps_bare_number(key) { + number.to_string() + } else { + format!("{number}px") + }); + } + match element { + Expression::StringLiteral(literal) => Some(literal.value.to_string()), + _ => None, + } + }) + .collect(); + let Some(values) = values.filter(|values| values.len() > 1) else { + return; + }; + let property = kebab_property(key); + let joined = values.join(&format!(";{property}:")); + let span = array.span; + self.replacements.push(( + span.start as usize, + span.end as usize, + format!("{joined:?}"), + )); + } +} + +/// `backgroundColor` as the CSS property `background-color`, `WebkitBoxShadow` +/// as `-webkit-box-shadow` +fn kebab_property(key: &str) -> String { + let mut property = String::with_capacity(key.len() + 4); + for (index, c) in key.chars().enumerate() { + if c.is_ascii_uppercase() { + if index > 0 + || matches!(key, k if k.starts_with("Webkit") || k.starts_with("Moz") || k.starts_with("Ms")) + { + property.push('-'); + } + property.push(c.to_ascii_lowercase()); + } else { + property.push(c); + } + } + property +} + impl<'a> Visit<'a> for LibraryNumbers<'_> { fn visit_call_expression(&mut self, call: &CallExpression<'a>) { let rules_at = match &call.callee { @@ -861,6 +919,30 @@ mod tests { )); } + // A property's array of values declares fallbacks in these libraries + #[test] + fn test_property_arrays_become_fallback_declarations() { + let code = transform_import_aliases( + "import { css } from '@emotion/react' +css({ background: ['red', 'linear-gradient(red, blue)'], WebkitBoxShadow: ['none', '0 0 1px red'], margin: [4, 0], zIndex: [1, 2], color: [x, 'red'], top: ['1px'] })", + "test.tsx", + "@devup-ui/react", + &HashMap::from([("@emotion/react".to_string(), ImportAlias::NamedToNamed)]), + ); + for expected in [ + r#"background: "red;background:linear-gradient(red, blue)""#, + r#"WebkitBoxShadow: "none;-webkit-box-shadow:0 0 1px red""#, + r#"margin: "4px;margin:0""#, + r#"zIndex: "1;z-index:2""#, + "color: [x, 'red']", + "top: ['1px']", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + assert_eq!(kebab_property("MozAppearance"), "-moz-appearance"); + assert_eq!(kebab_property("msFlex"), "ms-flex"); + } + // Types are erased, so a type-only import never becomes a runtime import #[test] fn test_type_only_imports_stay_type_only() {