From bb86b80d83e192d2cca6443f2d95dbb02b6b5429 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 13:32:55 +0900 Subject: [PATCH] fix(extractor): read property arrays in aliased library rules as fallbacks Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...hangepack_log_emotion_fallback_arrays.json | 7 ++ bindings/devup-ui-wasm/src/lib.rs | 8 +- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/import_alias_visit.rs | 82 +++++++++++++++++++ libs/extractor/src/lib.rs | 8 +- libs/extractor/src/tailwind.rs | 4 +- 6 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 .changepacks/changepack_log_emotion_fallback_arrays.json 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/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..0a2347ac2 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1551,7 +1551,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1951,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1975,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..3e7496d22 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 { @@ -728,6 +786,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"); + } + #[test] fn test_no_matching_alias() { assert_snapshot!(transform_import_aliases( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..96c6ca430 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -836,8 +836,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -13598,7 +13598,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -18778,7 +18778,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..70643aa7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test]