Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_emotion_rule_numbers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Numbers in rules written against Emotion, styled-components or vanilla-extract now read as px lengths when a constant stands in for the literal, as they do for a literal written in place: css({ padding: gap }) with const gap = 8, styled.div({ margin: SIZE }), <Global styles={{ body: { margin: GAP } }} /> and the same with imported constants and constant style objects give 8px, while unitless properties keep bare numbers, 0 stays 0 and Devup shorthands such as p keep the spacing scale. css`${mixin}; color: red;` outside the css prop composes the mixin by styles, so a later declaration replaces the mixin's, and a css`...` template is a known style a later css() composes. Inline css() calls inside css() arguments (css(css({ ... }), { ... }), css(cond && css({ ... }), base)) are composed with the other parts, the later winning, instead of being compiled to a class of their own. No new build errors",
"date": "2026-10-01T00:00:00.000Z"
}
1 change: 1 addition & 0 deletions libs/extractor/src/build_time_values.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ pub(crate) fn has_build_time_values(
option,
resolver,
aliased.css_prop,
&aliased.px,
);
let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver);
!find(
Expand Down
115 changes: 85 additions & 30 deletions libs/extractor/src/import_alias_visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,17 @@ struct LibraryNumbers<'n> {
class_names_calls: Vec<oxc_syntax::symbol::SymbolId>,
scoping: Option<oxc_semantic::Scoping>,
replacements: Vec<(usize, usize, String)>,
/// Where a constant inlined later is a number or rules of the library, as
/// the source reads them
marks: Vec<(usize, usize)>,
}

impl LibraryNumbers<'_> {
fn mark(expression: &Expression) -> (usize, usize) {
let span = expression.span();
(span.start as usize, span.end as usize)
}

/// `styled.div`, `styled(tag)`, `styled(tag, options)` and their
/// `.attrs()` / `.withConfig()`: whatever a call of it passes are rules
fn is_styled_factory(&self, callee: &Expression) -> bool {
Expand Down Expand Up @@ -94,11 +102,17 @@ impl LibraryNumbers<'_> {
}
}
Expression::ObjectExpression(object) => {
self.marks.push(Self::mark(rules));
for property in &object.properties {
if let ObjectPropertyKind::ObjectProperty(property) = property
&& let Some(key) = get_str_by_property_key(&property.key)
{
self.pixelify_value(&key, &property.value);
match property {
ObjectPropertyKind::ObjectProperty(property) => {
if let Some(key) = get_str_by_property_key(&property.key) {
self.pixelify_value(&key, &property.value);
}
}
ObjectPropertyKind::SpreadProperty(spread) => {
self.marks.push(Self::mark(&spread.argument));
}
}
}
}
Expand All @@ -108,7 +122,7 @@ impl LibraryNumbers<'_> {
}
Expression::LogicalExpression(logical) => self.pixelify(&logical.right),
Expression::ParenthesizedExpression(inner) => self.pixelify(&inner.expression),
_ => {}
rules => self.marks.push(Self::mark(rules)),
}
}

Expand Down Expand Up @@ -184,6 +198,9 @@ impl LibraryNumbers<'_> {
}
return;
}
if !matches!(value, Expression::ObjectExpression(_)) && !keeps_bare_number(key) {
self.marks.push(Self::mark(value));
}
match value {
Expression::ArrayExpression(array) => self.fallbacks(key, array),
Expression::ObjectExpression(_) => {
Expand All @@ -195,7 +212,12 @@ impl LibraryNumbers<'_> {
self.pixelify_value(key, &conditional.consequent);
self.pixelify_value(key, &conditional.alternate);
}
Expression::LogicalExpression(logical) => self.pixelify_value(key, &logical.right),
Expression::LogicalExpression(logical) => {
if logical.operator != LogicalOperator::And {
self.pixelify_value(key, &logical.left);
}
self.pixelify_value(key, &logical.right);
}
Expression::ParenthesizedExpression(inner) => {
self.pixelify_value(key, &inner.expression);
}
Expand All @@ -210,29 +232,9 @@ impl LibraryNumbers<'_> {
/// 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<Vec<String>> = 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 {
let Some(joined) = fallback_value(key, array) else {
return;
};
let property = kebab_property(key);
let joined = values.join(&format!(";{property}:"));
let span = array.span;
self.replacements.push((
span.start as usize,
Expand All @@ -242,6 +244,30 @@ impl LibraryNumbers<'_> {
}
}

pub(crate) fn fallback_value(key: &str, array: &oxc_ast::ast::ArrayExpression) -> Option<String> {
let values: Option<Vec<String>> = 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 values = values.filter(|values| values.len() > 1)?;
let property = kebab_property(key);
Some(values.join(&format!(";{property}:")))
}

/// `backgroundColor` as the CSS property `background-color`, `WebkitBoxShadow`
/// as `-webkit-box-shadow`
fn kebab_property(key: &str) -> String {
Expand Down Expand Up @@ -434,8 +460,16 @@ pub struct Aliased<'a> {
pub edits: Vec<Edit>,
/// Which elements take Emotion's `css` prop
pub css_prop: CssProp,
/// Where, in `code`, a constant read is a number or rules the library
/// reads as `px` lengths
pub px: PxMarks,
}

/// The spans of the style values and rules written as an expression a
/// constant can stand in, which Emotion, styled-components and vanilla-extract
/// read numbers in as `px` lengths
pub type PxMarks = rustc_hash::FxHashSet<(u32, u32)>;

/// Packages generating CSS on top of vanilla-extract's, which Devup UI does not
/// compile
const VANILLA_EXTRACT_COMPANIONS: [&str; 2] =
Expand Down Expand Up @@ -496,10 +530,11 @@ pub fn transform_import_aliases_with_edits<'a>(
package: &str,
import_aliases: &HashMap<String, ImportAlias>,
) -> Aliased<'a> {
let unchanged = |css_prop| Aliased {
let unchanged = |css_prop, px| Aliased {
code: Cow::Borrowed(code),
edits: Vec::new(),
css_prop,
px,
};
let emotion_aliased = import_aliases.contains_key(EMOTION_REACT);
let emotion_jsx = builds_jsx_with_emotion(import_aliases);
Expand All @@ -509,7 +544,7 @@ pub fn transform_import_aliases_with_edits<'a>(
&& CssProp::of(import_aliases, code, false) == CssProp::Off
&& (import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)))
{
return unchanged(CssProp::Off);
return unchanged(CssProp::Off, PxMarks::default());
}

let allocator = Allocator::default();
Expand Down Expand Up @@ -645,13 +680,32 @@ pub fn transform_import_aliases_with_edits<'a>(

// Apply transformations in reverse order to preserve positions
if transformations.is_empty() {
return unchanged(numbers.css_prop);
let px = numbers
.marks
.iter()
.map(|&(start, end)| (start as u32, end as u32))
.collect();
return unchanged(numbers.css_prop, px);
}

let edits = transformations
.iter()
.map(|(start, end, replacement)| (*start, *end, replacement.len()))
.collect();
let shifted = |offset: usize| {
let before = transformations
.iter()
.take_while(|(_, end, _)| *end <= offset);
let (added, removed) = before.fold((0, 0), |(added, removed), (start, end, text)| {
(added + text.len(), removed + end - start)
});
(offset + added - removed) as u32
};
let px = numbers
.marks
.iter()
.map(|&(start, end)| (shifted(start), shifted(end)))
.collect();
let mut result = code.to_string();
for (start, end, replacement) in transformations.into_iter().rev() {
result.replace_range(start..end, &replacement);
Expand All @@ -661,6 +715,7 @@ pub fn transform_import_aliases_with_edits<'a>(
code: Cow::Owned(result),
edits,
css_prop: numbers.css_prop,
px,
}
}

Expand Down
Loading
Loading