diff --git a/apps/landing/src/app/(detail)/docs/LeftMenu.tsx b/apps/landing/src/app/(detail)/docs/LeftMenu.tsx index c573a89ec..85250e4ff 100644 --- a/apps/landing/src/app/(detail)/docs/LeftMenu.tsx +++ b/apps/landing/src/app/(detail)/docs/LeftMenu.tsx @@ -36,6 +36,28 @@ export function LeftMenu() { Core Concepts Features + + Migration + ( +
+) +``` + +```css +.a { + color: red; +} +``` + +The import is not merely re-pointed at `@devup-ui/react`: the extractor consumes the call and removes the specifier, so the import statement disappears from the output entirely. + +## Two passes + +1. **Import rewrite** — `import { style } from '@vanilla-extract/css'` becomes `import { css as style } from '@devup-ui/react'`. Only names with a real Devup UI counterpart move; the rest stay put so their own types keep working. +2. **Extraction** — `style({ ... })` is compiled to a class name, the specifier is dropped, and the now-empty import is deleted. + +Because of the second pass, the intermediate `css as style` alias never reaches your bundle. + +## Enabling it + +Every plugin turns the default aliases on automatically: + +```ts +// vite.config.ts +import { DevupUI } from '@devup-ui/vite-plugin' + +export default DevupUI({}) +``` + +The defaults cover `styled-components`, `@emotion/styled`, `@emotion/react` and `@vanilla-extract/css`. StyleX needs no alias — `@stylexjs/stylex` is recognised directly. + +Opt a package out when you want to keep using the real thing: + +```ts +DevupUI({ + importAliases: { + 'styled-components': false, + }, +}) +``` + +## Uninstalling the original package + +Once the imports are rewritten you can drop the dependency. TypeScript still has to resolve the specifier you wrote, so the plugins generate `/compat.d.ts` with ambient declarations for the aliases you have enabled: + +```ts +/// +/// +/// +/// +``` + +That file lands next to `theme.d.ts` inside your project, so `tsconfig.json` picks it up with no extra configuration. Disabled aliases are left out on purpose — an ambient declaration wins over an installed package, and a library you opted out of must keep its own types. + +## Where the absorbed APIs live + +Anything that exists only to absorb another library — `ThemeProvider`, `createGlobalStyle`, `ServerStyleSheet`, and friends — is exported from `@devup-ui/react/compat`, never from `@devup-ui/react`. Using Devup UI directly means never seeing them, and it lets the compat entry keep the original spelling: its `useTheme` cannot collide with Devup UI's own `useTheme`. + +```tsx +// rewritten for you +``` diff --git a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx new file mode 100644 index 000000000..52707e389 --- /dev/null +++ b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx @@ -0,0 +1,96 @@ +export const metadata = { + title: 'Migration', + alternates: { + canonical: '/docs/migration/styled-components', + }, +} + +# styled-components & Emotion + +Both libraries are covered by the same default aliases, and every spelling of `styled` compiles to a static component. + +## Supported call shapes + +```tsx +import styled from 'styled-components' + +const A = styled.div` + color: red; +` +const B = styled('div')` + color: red; +` +const C = styled.div({ bg: 'red' }) +const D = styled('div')({ bg: 'red' }) +const E = styled(Component)({ bg: 'red' }) +const F = styled('div', { bg: 'red' }) +``` + +Namespace imports work too. `@emotion/styled` and `styled-components` export a single callable, so the namespace binding _is_ that value and it is bound straight to the Devup UI export: + +```tsx +import * as Emotion from '@emotion/styled' +// → import { styled as Emotion } from '@devup-ui/react' + +const Card = Emotion.div`color: red;` +``` + +## Theme interpolation + +`ThemeProvider` redefines CSS variables on a `display: contents` wrapper, so it scopes the theme to its subtree without touching layout. Interpolations that read the theme are resolved at build time — no runtime lookup: + +```tsx +const Card = styled.div` + color: ${(p) => p.theme.brand}; + border: 1px solid ${({ theme }) => theme.colors.line}; +` +``` + +```css +.a { + color: var(--brand); +} +.b { + border: 1px solid var(--colors-line); +} +``` + +Nested themes flatten with a hyphen, so `theme.colors.line` and `--colors-line` always agree. The object form resolves identically: + +```tsx +const Card = styled('div')({ color: (p) => p.theme.brand }) +``` + +Reading the theme in JS gives the same references back: + +```tsx +import { useTheme } from 'styled-components' + +function Divider() { + const theme = useTheme() + // `${theme.colors.line}` === 'var(--colors-line)' + return
+} +``` + +## Global styles + +`createGlobalStyle` keeps its render site — the rules are lifted into the stylesheet and the call collapses to a component that renders nothing: + +```tsx +const GlobalStyle = createGlobalStyle`body { margin: 0; }` +// → const GlobalStyle = () => null +``` + +Emotion's `` behaves the same way: the `styles` prop is extracted and the element stops rendering markup. + +## The rest of the surface + +| API | Result | +| ---------------------------------------- | -------------------------------------------------------------------------------- | +| `css`, `keyframes` | Devup UI equivalents | +| `ThemeProvider`, `useTheme`, `withTheme` | `@devup-ui/react/compat`, CSS-variable backed | +| `createGlobalStyle`, `Global` | extracted, render nothing | +| `ServerStyleSheet`, `StyleSheetManager` | inert — Devup UI already emits a real stylesheet, so there is nothing to collect | +| `isStyledComponent` | always `false` | +| `ClassNames`, `CacheProvider` | no equivalent; stays on its own package with a build warning | diff --git a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx new file mode 100644 index 000000000..2dc60054b --- /dev/null +++ b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx @@ -0,0 +1,98 @@ +export const metadata = { + title: 'Migration', + alternates: { + canonical: '/docs/migration/stylex', + }, +} + +# StyleX + +`@stylexjs/stylex` needs no alias configuration — the extractor recognises the package directly, through the default import, a namespace import, or named imports. + +## Styles and props + +`create()` becomes a namespace-to-class-name object and `props()` becomes the attributes to spread: + +```tsx +import stylex from '@stylexjs/stylex' + +const styles = stylex.create({ + base: { display: 'inline-block' }, +}) +const colors = stylex.create({ red: { color: 'red' }, blue: { color: 'blue' } }) + +const el =
+``` + +```tsx +const styles = { base: 'a' } +const colors = { red: 'b', blue: 'c' } + +const el =
+``` + +Computed access such as `colors[color]` is resolved against the object the `create()` call was rewritten into, so every generated atom stays reachable. `stylex.attrs()` behaves the same but emits `class` instead of `className`. + +Accepted argument shapes: + +```tsx +stylex.props(styles.base) // member access +stylex.props(styles['base']) // literal key +stylex.props(colors[color]) // runtime key +stylex.props([styles.a, styles.b]) // StyleXArray, nestable +stylex.props(on && styles.active) // conditional +stylex.props(styles?.base) // optional chaining +``` + +## Variables and themes + +```tsx +const colors = stylex.defineVars({ primary: 'blue', secondary: 'grey' }) +const dark = stylex.createTheme(colors, { primary: 'navy' }) +const styles = stylex.create({ box: { color: colors.primary } }) + +const el =
+``` + +```css +:root { + --a: blue; + --b: grey; +} +.c { + --a: navy; +} +.d { + color: var(--a); +} +``` + +```tsx +const colors = { primary: 'var(--a)', secondary: 'var(--b)' } +const dark = 'c' +const styles = { box: 'd' } +const el =
+``` + +`createThemeContract()` produces the same references without publishing a `:root` block, and `defineConsts()` inlines its values into the `create()` calls that read them. + +## At-rules + +```tsx +const fallback = stylex.positionTry({ top: '0', insetBlockEnd: 'auto' }) +const transition = stylex.viewTransitionClass({ animationDuration: '300ms' }) +``` + +```css +@position-try --a { + top: 0; + inset-block-end: auto; +} +.b { + animation-duration: 300ms; +} +``` + +## Value helpers + +`firstThatWorks()` emits its fallbacks in CSS order, `types.*()` unwraps to the inner value, and `include()` merges another namespace's classes. Values may be plain literals, condition objects (`{ default, ':hover', '@media ...' }`), or arrow functions, which become CSS variables bound at the call site. diff --git a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx new file mode 100644 index 000000000..bb86dca45 --- /dev/null +++ b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx @@ -0,0 +1,58 @@ +export const metadata = { + title: 'Migration', + alternates: { + canonical: '/docs/migration/vanilla-extract', + }, +} + +# vanilla-extract + +## Stylesheet files + +Inside `.css.ts` / `.css.js` — the only place vanilla-extract allows its APIs — Devup UI evaluates the module and replaces every call with its generated output. The whole surface works there: `style`, `globalStyle`, `keyframes`, `styleVariants`, `fontFace`, `createVar`, `fallbackVar`, `createTheme`, `createThemeContract`, `layer`, `createContainer`. + +```ts +// theme.css.ts +import { createTheme, createThemeContract, style } from '@vanilla-extract/css' + +const vars = createThemeContract({ colors: { bg: null } }) +export const light = createTheme(vars, { colors: { bg: 'white' } }) +export const box = style({ background: vars.colors.bg }) +``` + +## Ordinary modules + +`style` and `globalStyle` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts: + +```tsx +import { globalStyle, style } from '@vanilla-extract/css' + +export const a = style({ color: 'red' }) +globalStyle('body', { margin: 0 }) +``` + +```tsx +// output — no import left +export const a = 'a' +``` + +`globalStyle(selector, rules)` keeps vanilla-extract's two-argument shape; the extractor folds the selector back into the object `globalCss` takes. + +The remaining APIs stay on `@vanilla-extract/css` outside a stylesheet file, because evaluating a module that also contains React components is not possible. A build warning names them: + +``` +[devup-ui] WARNING: '@vanilla-extract/css' keeps styleVariants, createVar because +devup-ui has no equivalent export, so the package stays a runtime dependency. +``` + +Moving those calls into a `.css.ts` file — where vanilla-extract wants them anyway — removes the dependency. + +## Namespace imports + +A namespace stands for many named exports whose Devup UI counterparts are renamed (`style` → `css`), which a namespace access cannot express, so it is left alone: + +```ts +// unchanged +``` + +Use named imports to get the rewrite. diff --git a/e2e/exported-routes.ts b/e2e/exported-routes.ts index 1b297c2c0..5c8edd2bc 100644 --- a/e2e/exported-routes.ts +++ b/e2e/exported-routes.ts @@ -80,6 +80,10 @@ export const EXPECTED_EXPORTED_ROUTES = [ '/docs/figma-and-theme-integration/devup-figma-plugin', '/docs/figma-and-theme-integration/devup-json', '/docs/installation', + '/docs/migration/overview', + '/docs/migration/styled-components', + '/docs/migration/stylex', + '/docs/migration/vanilla-extract', '/docs/overview', '/docs/quick-start', '/showcase', diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index 183fa3d61..be4b5e863 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::collections::BTreeMap; use std::fmt::Write as _; -use crate::utils::{get_string_by_literal_expression, wrap_direct_call}; +use crate::utils::{get_str_by_property_key, get_string_by_literal_expression, wrap_direct_call}; use css::{ optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}, rm_css_comment::rm_css_comment, @@ -12,6 +12,7 @@ use oxc_allocator::Allocator; use oxc_span::SPAN; use crate::utils::expression_to_code; +use oxc_ast::ast::BindingPattern; use oxc_ast::ast::Expression; use oxc_ast::ast::TemplateLiteral; use oxc_ast::builder::AstBuilder; @@ -40,6 +41,66 @@ pub fn rm_last_semi_colon(code: &str) -> &str { code.trim_end_matches(';') } +/// Resolve a styled-components theme accessor to the CSS variable it reads. +/// +/// `p => p.theme.colors.brand` and `({ theme }) => theme.colors.brand` both mean +/// "whatever `ThemeProvider` declared for `colors.brand`", which `ThemeProvider` +/// publishes as `--colors-brand`. Reading it as a build-time `var()` keeps the +/// style static; left alone the arrow would be called with the element's props, +/// which carry no `theme`, and silently evaluate to `undefined`. +pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { + let Expression::ArrowFunctionExpression(arrow) = expr else { + return None; + }; + let [param] = arrow.params.items.as_slice() else { + return None; + }; + let root = match ¶m.pattern { + BindingPattern::BindingIdentifier(ident) => ThemeRoot::Props(ident.name.as_str()), + BindingPattern::ObjectPattern(pattern) => pattern + .properties + .iter() + .any(|p| get_str_by_property_key(&p.key).is_some_and(|key| key == "theme")) + .then_some(ThemeRoot::Theme)?, + _ => return None, + }; + + let mut path = Vec::new(); + let mut cursor = arrow.body.as_expression()?; + loop { + match cursor { + Expression::StaticMemberExpression(member) => { + path.push(member.property.name.as_str()); + cursor = &member.object; + } + Expression::Identifier(ident) => { + let matches_root = match root { + ThemeRoot::Props(name) => ident.name.as_str() == name, + ThemeRoot::Theme => ident.name.as_str() == "theme", + }; + if !matches_root { + return None; + } + break; + } + _ => return None, + } + } + if matches!(root, ThemeRoot::Props(_)) && path.pop()? != "theme" { + return None; + } + if path.is_empty() { + return None; + } + path.reverse(); + Some(format!("var(--{})", path.join("-"))) +} + +enum ThemeRoot<'a> { + Props(&'a str), + Theme, +} + /// Convert a dynamic template-literal expression into identifier code, /// wrapping arrow/function expressions in a direct call with `rest` and /// trimming the trailing semicolon. @@ -201,6 +262,10 @@ pub fn css_to_style_literal( get_string_by_literal_expression(&css.expressions[*idx]) { literal_values.push((*idx, literal_value)); + } else if *idx < css.expressions.len() + && let Some(theme_reference) = theme_var_reference(&css.expressions[*idx]) + { + literal_values.push((*idx, Cow::Owned(theme_reference))); } else { all_literals = false; } diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index 3d546aa4c..15ba1c04f 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -13,6 +13,7 @@ use crate::{ }, utils::{ get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, + unwrap_syntax_only_mut, }, }; use css::{ @@ -31,6 +32,7 @@ pub fn extract_global_style_from_expression<'a>( file: &str, ) -> GlobalExtractResult<'a> { let mut styles = vec![]; + let expression = unwrap_syntax_only_mut(expression); if let Expression::ObjectExpression(obj) = expression { for p in &mut obj.properties { @@ -40,7 +42,11 @@ pub fn extract_global_style_from_expression<'a>( if name == "imports" { if let Expression::ArrayExpression(arr) = &o.value { for p in &arr.elements { - if let Expression::ObjectExpression(obj) = p.to_expression() { + // `...spread` elements carry no statically readable url. + let Some(element) = p.as_expression() else { + continue; + }; + if let Expression::ObjectExpression(obj) = element { let mut url = None; let mut query = None; for p in &obj.properties { @@ -82,11 +88,8 @@ pub fn extract_global_style_from_expression<'a>( }), )); } - } else if !matches!( - p.to_expression(), - Expression::NumericLiteral(_) - ) && let Some(url) = - get_string_by_literal_expression(p.to_expression()) + } else if !matches!(element, Expression::NumericLiteral(_)) + && let Some(url) = get_string_by_literal_expression(element) { styles.push(ExtractStyleProp::Static( ExtractStyleValue::Import(ExtractImport { diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 167078af7..cc4a9311b 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -1,6 +1,6 @@ use crate::{ ExtractStyleProp, - css_utils::{css_to_style, css_to_style_literal}, + css_utils::{css_to_style, css_to_style_literal, theme_var_reference}, extract_style::{ extract_dynamic_style::ExtractDynamicStyle, extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}, @@ -12,6 +12,7 @@ use crate::{ utils::{ expression_to_code, get_number_by_literal_expression, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_same_expression, + unwrap_syntax_only_mut, }, }; use css::{ @@ -103,6 +104,7 @@ pub fn extract_style_from_expression<'a>( literal_handling: LiteralHandling, ) -> ExtractResult<'a> { let mut typo = false; + let expression = unwrap_syntax_only_mut(expression); if name.is_none() && selector.is_none() { let mut style_order = None; @@ -207,14 +209,6 @@ pub fn extract_style_from_expression<'a>( style_order, style_vars, }, - Expression::ParenthesizedExpression(parenthesized) => extract_style_from_expression( - ast_builder, - None, - &mut parenthesized.expression, - level, - &None, - literal_handling, - ), Expression::TemplateLiteral(tmp) => ExtractResult { styles: css_to_style_literal(tmp, level, selector) .into_iter() @@ -474,8 +468,25 @@ pub fn extract_style_from_expression<'a>( // if/else branch region — `name == None` happens only under // `_xxx={...}` pseudo-selector recursion, where no dynamic_style // can be emitted because the selector has no CSS property slot. + // `styled.div({ color: (p) => p.theme.brand })` — the object spelling of + // the template interpolation, resolved to the same build-time `var()`. + Expression::ArrowFunctionExpression(_) => match (name, theme_var_reference(expression)) + { + (Some(name), Some(reference)) => ExtractResult { + styles: create_static_styles( + name, + &reference, + &[level], + selector, + ThemeTokenResolution::default(), + ), + ..ExtractResult::default() + }, + _ => ExtractResult::default(), + }, Expression::BinaryExpression(_) | Expression::StaticMemberExpression(_) + | Expression::ChainExpression(_) | Expression::CallExpression(_) => name .map(|name| ExtractResult { styles: vec![dynamic_style( @@ -488,14 +499,6 @@ pub fn extract_style_from_expression<'a>( ..ExtractResult::default() }) .unwrap_or_default(), - Expression::TSAsExpression(exp) => extract_style_from_expression( - ast_builder, - name, - &mut exp.expression, - level, - selector, - literal_handling, - ), Expression::ComputedMemberExpression(mem) => { extract_style_from_member_expression(ast_builder, name, mem, level, selector) } @@ -695,14 +698,6 @@ pub fn extract_style_from_expression<'a>( }, } } - Expression::ParenthesizedExpression(parenthesized) => extract_style_from_expression( - ast_builder, - name, - &mut parenthesized.expression, - level, - selector, - literal_handling, - ), Expression::ArrayExpression(array) => { let mut props = vec![]; diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 88f978ea1..492264a49 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -11,13 +11,13 @@ use crate::{ }, gen_class_name::gen_class_names, gen_style::gen_styles, - utils::{merge_object_expressions, wrap_array_filter}, + utils::{merge_object_expressions, unwrap_syntax_only, wrap_array_filter}, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::{ ast::{ - Argument, BindingPattern, BindingProperty, BindingRestElement, Expression, FormalParameter, - FormalParameterKind, FormalParameters, JSXAttributeItem, JSXAttributeName, + Argument, BindingPattern, BindingProperty, BindingRestElement, CallExpression, Expression, + FormalParameter, FormalParameterKind, FormalParameters, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElementName, JSXOpeningElement, PropertyKey, Str, }, builder::AstBuilder, @@ -28,32 +28,69 @@ fn extract_base_tag_and_class_name( input: &Expression<'_>, imports: &FxHashMap, ) -> (Option, Option>) { + let input = unwrap_syntax_only(input); if let Expression::StaticMemberExpression(member) = input { (Some(member.property.name.to_string()), None) } else if let Expression::CallExpression(call) = input && call.arguments.len() == 1 + && let Some((tag_name, default_class_name)) = tag_from_argument(&call.arguments[0], imports) { // styled("div") or styled(Component) - if let Argument::StringLiteral(lit) = &call.arguments[0] { - (Some(lit.value.to_string()), None) - } else if let Argument::Identifier(ident) = &call.arguments[0] { - if let Some(export_variable_kind) = imports.get(ident.name.as_str()) { - ( - Some(export_variable_kind.to_tag().to_string()), - Some(export_variable_kind.extract()), - ) - } else { - (Some(ident.name.to_string()), None) - } - } else { - // Component reference - we'll handle this later - (None, None) - } + (Some(tag_name), default_class_name) } else { (None, None) } } +/// Read the base tag out of a `styled(...)` argument, resolving a devup-ui component +/// reference to both its HTML tag and the styles that component contributes by default. +fn tag_from_argument( + argument: &Argument<'_>, + imports: &FxHashMap, +) -> Option<(String, Option>)> { + match argument { + Argument::StringLiteral(lit) => Some((lit.value.to_string(), None)), + Argument::Identifier(ident) => Some(match imports.get(ident.name.as_str()) { + Some(export_variable_kind) => ( + export_variable_kind.to_tag().to_string(), + Some(export_variable_kind.extract()), + ), + None => (ident.name.to_string(), None), + }), + _ => None, + } +} + +/// Resolve a `styled(...)` call to its base tag, default styles, and the index of the +/// argument holding the style object. +/// +/// Two spellings build the same component: the curried `styled.div({...})` / +/// `styled("div")({...})`, whose callee already carries the tag, and the two-argument +/// `styled("div", {...})`, whose callee is the bare `styled` identifier. +fn resolve_styled_call_target( + call: &CallExpression<'_>, + imports: &FxHashMap, +) -> Option<(String, Option>, usize)> { + if call.arguments.len() == 1 + && let (Some(tag_name), default_class_name) = + extract_base_tag_and_class_name(&call.callee, imports) + { + return Some((tag_name, default_class_name, 0)); + } + // The style object must be a literal: it is the only way to tell `styled(tag, styles)` + // apart from a malformed `styled("div", "span")`, which must be left untouched. + if call.arguments.len() == 2 + && matches!(unwrap_syntax_only(&call.callee), Expression::Identifier(_)) + && call.arguments[1].as_expression().is_some_and(|styles| { + matches!(unwrap_syntax_only(styles), Expression::ObjectExpression(_)) + }) + && let Some((tag_name, default_class_name)) = tag_from_argument(&call.arguments[0], imports) + { + return Some((tag_name, default_class_name, 1)); + } + None +} + /// Extract styles from styled function calls /// Handles patterns like: /// - styled.div`css` @@ -108,12 +145,11 @@ pub fn extract_style_from_styled<'a>( (Some(result), Some(styled_component)) } else if let Expression::CallExpression(call) = expression - && let (Some(tag_name), default_class_name) = - extract_base_tag_and_class_name(&call.callee, imports) - && call.arguments.len() == 1 + && let Some((tag_name, default_class_name, style_index)) = + resolve_styled_call_target(call, imports) { - // Case 2: styled.div({ bg: "red" }) or styled("div")({ bg: "red" }) - // Check if this is a call to styled.div or styled("div") + // Case 2: styled.div({ bg: "red" }), styled("div")({ bg: "red" }), + // or styled("div", { bg: "red" }) // Extract styles from object expression let ExtractResult { @@ -125,10 +161,10 @@ pub fn extract_style_from_styled<'a>( } = extract_style_from_expression( ast_builder, None, - if let Argument::SpreadElement(spread) = &mut call.arguments[0] { + if let Argument::SpreadElement(spread) = &mut call.arguments[style_index] { &mut spread.argument } else { - call.arguments[0].to_expression_mut() + call.arguments[style_index].to_expression_mut() }, 0, &None, diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index cfb37f054..568ec9b42 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -33,6 +33,45 @@ fn raw_static_style<'a>( })) } +/// Flatten an object literal of literal-valued properties into kebab-cased CSS +/// declarations, the shape `positionTry` and `viewTransitionClass` bodies take. +pub fn extract_stylex_declarations(value: &Expression<'_>) -> Vec<(String, String)> { + let Expression::ObjectExpression(obj) = value else { + return vec![]; + }; + obj.properties + .iter() + .filter_map(|prop| { + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + return None; + }; + let name = get_str_by_property_key(&prop.key)?; + let value = get_string_by_literal_expression(&prop.value)?; + Some(( + normalize_stylex_property(name.as_ref()), + optimize_value(&value).into_owned(), + )) + }) + .collect() +} + +/// Resolve a `vars.key` member access against the contracts `stylex.defineVars()` +/// produced, yielding the `var(--x)` reference the value compiles to. +fn var_reference<'v>( + value: &Expression<'_>, + var_refs: &'v FxHashMap, +) -> Option<&'v str> { + let Expression::StaticMemberExpression(member) = value else { + return None; + }; + let Expression::Identifier(object) = &member.object else { + return None; + }; + var_refs + .get(&format!("{}.{}", object.name, member.property.name)) + .map(String::as_str) +} + /// Shorthand CSS properties that trigger a `StyleX` specificity warning. /// Promoted from an 18-element `&[&str]` linear `.contains` scan to a /// module-level `phf::Set` for an O(1) membership probe per `create()` property. @@ -67,6 +106,7 @@ static SHORTHAND_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { pub fn extract_stylex_namespace_styles<'a>( expression: &mut Expression<'a>, keyframe_names: &FxHashMap, + var_refs: &FxHashMap, ) -> Vec<( String, Vec>, @@ -127,11 +167,10 @@ pub fn extract_stylex_namespace_styles<'a>( if let ObjectPropertyKind::SpreadProperty(spread) = style_prop && let Expression::CallExpression(call) = &spread.argument && is_include_call_static(&call.callee) - && !call.arguments.is_empty() { // Parse include(base.member) - if let Expression::StaticMemberExpression(member) = - call.arguments[0].to_expression() + if let Some(Expression::StaticMemberExpression(member)) = + call.arguments.first().and_then(|arg| arg.as_expression()) && let Expression::Identifier(ident) = &member.object { include_refs.push(StylexIncludeRef { @@ -204,6 +243,12 @@ pub fn extract_stylex_namespace_styles<'a>( continue; } + // Resolve `defineVars` members (e.g., color: colors.primary) + if let Some(reference) = var_reference(&style_prop.value, var_refs) { + styles.push(raw_static_style(css_property, reference, None)); + continue; + } + // Phase 1: static string/number values let css_value = if let Some(s) = get_string_by_literal_expression(&style_prop.value) { s @@ -226,18 +271,18 @@ pub fn extract_stylex_namespace_styles<'a>( // firstThatWorks('a', 'b', 'c'): last arg is least preferred, first is most preferred. // CSS fallback: output in reverse order (least preferred first, most preferred last). for arg in call.arguments.iter().rev() { - let arg_expr = arg.to_expression(); - if let Some(s) = get_string_by_literal_expression(arg_expr) { + if let Some(arg_expr) = arg.as_expression() + && let Some(s) = get_string_by_literal_expression(arg_expr) + { styles.push(raw_static_style(css_property.clone(), &s, None)); } } continue; } else if let Expression::CallExpression(call) = &style_prop.value && is_types_call(&call.callee) - && !call.arguments.is_empty() - { // stylex.types.length('100px') → extract inner value '100px' - let inner = call.arguments[0].to_expression(); + && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) + { let css_value = if let Some(s) = get_string_by_literal_expression(inner) { s } else { diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 91c626739..259bf357f 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,6 +8,7 @@ //! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` use crate::ImportAlias; +use crate::utils::is_vanilla_extract_file; use oxc_allocator::Allocator; use oxc_ast::ast::{ImportDeclarationSpecifier, ModuleExportName}; use oxc_parser::Parser; @@ -15,6 +16,45 @@ use oxc_span::SourceType; use std::borrow::Cow; use std::collections::HashMap; +/// Map an aliased package's export onto the `@devup-ui/react` export that implements the +/// same behaviour, so the extractor consumes the call and drops the import entirely — no +/// dependency on either package survives. +/// +/// `None` means the name has no devup-ui counterpart. Redirecting it anyway would produce +/// an ESM "does not provide an export" error, so the specifier stays on its own package +/// and the source library remains a real dependency. +/// Where a redirected specifier lands. +/// +/// `Main` names are genuine Devup UI APIs. `Compat` names only exist to absorb another +/// library, so they live in the `/compat` entry and never widen what a project +/// using Devup UI directly sees — which also lets them keep their original spelling +/// (`useTheme` there cannot collide with Devup UI's own `useTheme`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DevupTarget<'n> { + Main(&'n str), + Compat(&'n str), +} + +fn devup_equivalent(source: &str, imported: &str) -> Option> { + match (source, imported) { + // `style({...})` and `css({...})` both hand back a class name for a style object, + // and `globalStyle(selector, rules)` is `globalCss` with the selector split out. + ("@vanilla-extract/css", "style") | (_, "css") => Some(DevupTarget::Main("css")), + ("@vanilla-extract/css", "globalStyle") => Some(DevupTarget::Main("globalCss")), + (_, "keyframes") => Some(DevupTarget::Main("keyframes")), + (_, "styled") => Some(DevupTarget::Main("styled")), + (_, "createGlobalStyle") => Some(DevupTarget::Compat("createGlobalStyle")), + (_, "Global") => Some(DevupTarget::Compat("Global")), + (_, "ThemeProvider") => Some(DevupTarget::Compat("ThemeProvider")), + (_, "ServerStyleSheet") => Some(DevupTarget::Compat("ServerStyleSheet")), + (_, "StyleSheetManager") => Some(DevupTarget::Compat("StyleSheetManager")), + (_, "isStyledComponent") => Some(DevupTarget::Compat("isStyledComponent")), + (_, "withTheme") => Some(DevupTarget::Compat("withTheme")), + (_, "useTheme") => Some(DevupTarget::Compat("useTheme")), + _ => None, + } +} + /// Transform source code by rewriting aliased imports to the target package /// /// # Arguments @@ -43,6 +83,8 @@ pub fn transform_import_aliases<'a>( let parser_ret = Parser::new(&allocator, code, source_type).parse(); let program = parser_ret.program; + let redirect_every_name = is_vanilla_extract_file(filename); + // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); @@ -52,7 +94,8 @@ pub fn transform_import_aliases<'a>( if let Some(alias) = import_aliases.get(source_value) { let span = import_decl.span; - let new_import = generate_transformed_import(import_decl, alias, package); + let new_import = + generate_transformed_import(import_decl, alias, package, redirect_every_name); transformations.push((span.start as usize, span.end as usize, new_import)); } } @@ -71,12 +114,70 @@ pub fn transform_import_aliases<'a>( Cow::Owned(result) } +/// Pick the name a specifier should import from the target package, or `None` to leave it +/// on its own package. A vanilla-extract stylesheet bypasses the mapping because +/// `execute_vanilla_extract` destructures its mock namespace by vanilla-extract's own names. +fn redirect_target<'n>( + source: &str, + imported: &'n str, + redirect_every_name: bool, +) -> Option> { + if redirect_every_name { + Some(DevupTarget::Main(imported)) + } else { + devup_equivalent(source, imported) + } +} + +fn split_target<'n>(target: DevupTarget<'n>, package: &str) -> (&'n str, String) { + match target { + DevupTarget::Main(name) => (name, package.to_string()), + DevupTarget::Compat(name) => (name, format!("{package}/compat")), + } +} + +fn push_redirect( + redirected: &mut String, + compat: &mut String, + target: DevupTarget<'_>, + local: &str, +) { + match target { + DevupTarget::Main(name) => push_specifier(redirected, name, local), + DevupTarget::Compat(name) => push_specifier(compat, name, local), + } +} + +fn push_specifier(parts: &mut String, imported: &str, local: &str) { + if !parts.is_empty() { + parts.push_str(", "); + } + parts.push_str(imported); + if imported != local { + parts.push_str(" as "); + parts.push_str(local); + } +} + +/// Borrow the exported name for the common identifier cases to avoid a per-specifier +/// heap allocation. Only the rare string-literal export name (`import { "x" as y }`) +/// needs an owned `String`, and its `Display` output is quoted. +fn imported_name<'a>(imported: &'a ModuleExportName) -> Cow<'a, str> { + match imported { + ModuleExportName::IdentifierName(id) => Cow::Borrowed(id.name.as_str()), + ModuleExportName::IdentifierReference(id) => Cow::Borrowed(id.name.as_str()), + ModuleExportName::StringLiteral(_) => Cow::Owned(imported.to_string()), + } +} + /// Generate the transformed import statement fn generate_transformed_import( import_decl: &oxc_ast::ast::ImportDeclaration, alias: &ImportAlias, package: &str, + redirect_every_name: bool, ) -> String { + let source = import_decl.source.value.as_str(); let specifiers = match &import_decl.specifiers { Some(specs) => specs, None => return format!("import '{package}';"), @@ -103,16 +204,30 @@ fn generate_transformed_import( } } - // Check for namespace import first (early return, identical for both variants) if let Some(ns_spec) = namespace { - return format!( - "import * as {} from '{}';", - ns_spec.local.name.as_str(), - package - ); + let local = ns_spec.local.name.as_str(); + // A `DefaultToNamed` package exports a single callable, so its namespace binding + // *is* that value — bind it straight to the devup-ui export and member calls such + // as `Emotion.div` keep resolving, with no dependency left behind. + if let ImportAlias::DefaultToNamed(named_export) = alias + && let Some(target) = redirect_target(source, named_export, redirect_every_name) + { + let (devup_name, entry) = split_target(target, package); + let mut parts = String::new(); + push_specifier(&mut parts, devup_name, local); + return format!("import {{ {parts} }} from '{entry}';"); + } + // Otherwise the namespace stands for many named exports whose devup-ui + // counterparts can be renamed (`style` -> `css`), which a namespace access + // cannot express. Leave it on its own package rather than break the members. + let target = if redirect_every_name { package } else { source }; + return format!("import * as {local} from '{target}';"); } - let mut parts = String::new(); + let mut redirected = String::new(); + let mut compat = String::new(); + let mut retained = String::new(); + let mut retained_default = None; // Handle default specifier first (at most one in valid JS); only its // rendering differs between the alias variants. @@ -121,16 +236,23 @@ fn generate_transformed_import( match alias { // `import foo from 'pkg'` → `import { named as foo } from 'target'` ImportAlias::DefaultToNamed(named_export) => { - parts.push_str(named_export); - if local_name != named_export { - parts.push_str(" as "); - parts.push_str(local_name); + match redirect_target(source, named_export, redirect_every_name) { + Some(target) => push_redirect(&mut redirected, &mut compat, target, local_name), + None => retained_default = Some(local_name), } } // `import foo from 'pkg'` → `import { default as foo } from 'target'` ImportAlias::NamedToNamed => { - parts.push_str("default as "); - parts.push_str(local_name); + if redirect_every_name { + push_redirect( + &mut redirected, + &mut compat, + DevupTarget::Main("default"), + local_name, + ); + } else { + retained_default = Some(local_name); + } } } } @@ -138,44 +260,57 @@ fn generate_transformed_import( // Handle named specifiers (kept as-is for both variants) for specifier in specifiers { if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { - if !parts.is_empty() { - parts.push_str(", "); - } let local = spec.local.name.as_str(); - // Borrow the imported name for the common identifier cases to avoid a - // per-specifier heap allocation. Only the rare string-literal export - // name (`import { "x" as y }`) needs an owned `String`, and its - // `Display` output is quoted — matching the prior `to_string()` bytes. - match &spec.imported { - ModuleExportName::IdentifierName(id) => { - let imported = id.name.as_str(); - parts.push_str(imported); - if imported != local { - parts.push_str(" as "); - parts.push_str(local); - } - } - ModuleExportName::IdentifierReference(id) => { - let imported = id.name.as_str(); - parts.push_str(imported); - if imported != local { - parts.push_str(" as "); - parts.push_str(local); - } - } - ModuleExportName::StringLiteral(_) => { - let imported = spec.imported.to_string(); - parts.push_str(&imported); - if imported != local { - parts.push_str(" as "); - parts.push_str(local); - } - } + let imported = imported_name(&spec.imported); + match redirect_target(source, &imported, redirect_every_name) { + Some(target) => push_redirect(&mut redirected, &mut compat, target, local), + None => push_specifier(&mut retained, &imported, local), } } } - format!("import {{ {parts} }} from '{package}';") + let mut result = String::new(); + for (parts, entry) in [ + (&redirected, package.to_string()), + (&compat, format!("{package}/compat")), + ] { + if parts.is_empty() { + continue; + } + if !result.is_empty() { + result.push(' '); + } + result.push_str("import { "); + result.push_str(parts); + result.push_str(" } from '"); + result.push_str(&entry); + result.push_str("';"); + } + if retained_default.is_some() || !retained.is_empty() { + eprintln!( + "[devup-ui] WARNING: '{source}' keeps {} because devup-ui has no equivalent export, so the package stays a runtime dependency.", + retained_default.map_or_else(|| retained.clone(), ToString::to_string) + ); + if !result.is_empty() { + result.push(' '); + } + result.push_str("import "); + if let Some(local) = retained_default { + result.push_str(local); + if !retained.is_empty() { + result.push_str(", "); + } + } + if !retained.is_empty() { + result.push_str("{ "); + result.push_str(&retained); + result.push_str(" }"); + } + result.push_str(" from '"); + result.push_str(source); + result.push_str("';"); + } + result } #[cfg(test)] @@ -255,6 +390,76 @@ mod tests { )); } + #[test] + fn test_default_specifier_in_vanilla_extract_stylesheet() { + assert_snapshot!(transform_import_aliases( + r"import veDefault from '@vanilla-extract/css'", + "styles.css.ts", + "@devup-ui/react", + &vanilla_extract_alias() + )); + } + + #[test] + fn test_default_and_named_both_retained_on_source() { + assert_snapshot!(transform_import_aliases( + r"import veDefault, { styleVariants } from '@vanilla-extract/css'", + "test.tsx", + "@devup-ui/react", + &vanilla_extract_alias() + )); + } + + #[test] + fn test_default_export_without_devup_equivalent_stays_on_source() { + let mut aliases = HashMap::new(); + aliases.insert( + "some-lib".to_string(), + ImportAlias::DefaultToNamed("someUnmappedExport".to_string()), + ); + assert_snapshot!(transform_import_aliases( + r"import sheet from 'some-lib'", + "test.tsx", + "@devup-ui/react", + &aliases + )); + } + + #[test] + fn test_namespace_import_of_a_compat_only_default() { + let mut aliases = HashMap::new(); + aliases.insert( + "some-lib".to_string(), + ImportAlias::DefaultToNamed("ThemeProvider".to_string()), + ); + assert_snapshot!(transform_import_aliases( + r"import * as Sheet from 'some-lib'", + "test.tsx", + "@devup-ui/react", + &aliases + )); + } + + #[test] + fn test_vanilla_extract_names_map_onto_devup_equivalents() { + assert_snapshot!(transform_import_aliases( + r"import { style, globalStyle, styleVariants } from '@vanilla-extract/css'", + "test.tsx", + "@devup-ui/react", + &vanilla_extract_alias() + )); + } + + #[test] + fn test_named_imports_without_devup_export_stay_on_source() { + assert_snapshot!(transform_import_aliases( + r"import styled, { css, keyframes, createGlobalStyle, ThemeProvider } from 'styled-components'", + "test.tsx", + "@devup-ui/react", + &styled_components_alias() + )); + } + #[test] fn test_no_matching_alias() { assert_snapshot!(transform_import_aliases( @@ -439,7 +644,8 @@ const x = 1;", generate_transformed_import( import_decl, &ImportAlias::NamedToNamed, - "@devup-ui/react" + "@devup-ui/react", + true ), expected ); @@ -482,7 +688,8 @@ const x = 1;", generate_transformed_import( import_decl, &ImportAlias::NamedToNamed, - "@devup-ui/react" + "@devup-ui/react", + true ), expected ); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 391f23713..b47133892 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -264,7 +264,7 @@ fn extract_with_source_map( // `processed_code` is Some only when vanilla-extract generation succeeded; // otherwise the untouched `transformed_code` is parsed directly (no copy). #[cfg(feature = "vanilla-extract")] - let processed_code: Option = if vanilla_extract::is_vanilla_extract_file(filename) { + let processed_code: Option = if utils::is_vanilla_extract_file(filename) { // Use transformed code (with imports already pointing to @devup-ui/react) match vanilla_extract::execute_vanilla_extract(&transformed_code, &option.package, filename) { @@ -15142,6 +15142,172 @@ const el =
;", )); } + #[test] + #[serial] + fn test_stylex_props_computed_literal_key() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const styles = stylex.create({ + base: { color: 'red' }, + active: { backgroundColor: 'blue' }, +}); +const el =
;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_stylex_props_computed_dynamic_key() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const styles = stylex.create({ base: { display: 'inline-block', fontWeight: '500' } }); +const colorStyles = stylex.create({ red: { color: 'red' }, blue: { color: 'blue' } }); +const sizeStyles = stylex.create({ sm: { fontSize: '12px' }, lg: { fontSize: '20px' } }); +const el =
;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_stylex_props_computed_key_in_conditional() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const colorStyles = stylex.create({ red: { color: 'red' }, blue: { color: 'blue' } }); +const el =
;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_stylex_props_computed_key_unresolvable() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const styles = stylex.create({ base: { color: 'red' } }); +const el =
;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_stylex_props_computed_key_only_dynamic_namespaces() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const styles = stylex.create({ bar: (h) => ({ height: h }) }); +const el =
;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_stylex_props_computed_key_empty_namespace() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const styles = stylex.create({ empty: {}, filled: { color: 'red' } }); +const el =
;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_stylex_props_computed_key_non_identifier_object() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const styles = stylex.create({ base: { color: 'red' } }); +const el =
;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + }, + ) + .unwrap() + )); + } + #[test] #[serial] fn test_stylex_props_conditional_and() { @@ -17340,4 +17506,488 @@ export const A = () => ; ); } } + + fn extract_tsx(code: &str) -> ExtractOutput { + reset_class_map(); + reset_file_map(); + extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .expect("extract should not fail") + } + + /// `Argument::to_expression` panics on `...spread`. Every call site that can receive + /// user-written arguments must reject the spread instead of unwrapping it. + #[test] + #[serial] + fn test_spread_arguments_never_panic() { + let stylex = "import stylex from '@stylexjs/stylex';\n"; + for source in [format!("{stylex}const e =
;"), format!("{stylex}const s = stylex.create(...a);"), format!("{stylex}const k = stylex.keyframes(...a);"), format!("{stylex}const s = stylex.create({{ bar: (h) => ({{ height: h }}) }});\nconst e =
;"), format!("{stylex}const s = stylex.create({{ b: {{ position: stylex.firstThatWorks(...a) }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ width: stylex.types.length(...a) }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ ...stylex.include(...a) }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ color: {{ default: stylex.firstThatWorks(...a) }} }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ width: {{ default: stylex.types.length(...a) }} }} }});"), "import { jsx } from 'react/jsx-runtime';\nimport { Box } from '@devup-ui/react';\nconst e = jsx(...a);".to_string(), "import { jsx } from 'react/jsx-runtime';\nimport { Box } from '@devup-ui/react';\nconst e = jsx(Box, ...a);".to_string(), "import { globalCss } from '@devup-ui/react';\nglobalCss({ imports: [...list] });".to_string()] { + extract_tsx(&source); + } + } + + #[test] + #[serial] + fn test_stylex_props_style_x_array() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const s = stylex.create({ a: { color: 'red' }, b: { marginTop: '1px' } }); +const flat =
; +const nested =
; +const conditional =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_props_ts_wrapper_and_optional_chain() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const s = stylex.create({ a: { color: 'red' }, b: { marginTop: '1px' } }); +const cast =
; +const satisfied =
; +const nonNull =
; +const parens =
; +const chained =
; +const computedChain =
; +const chainedCall =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_attrs_emits_class_attribute() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const s = stylex.create({ a: { color: 'red' } }); +const e =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_define_vars_and_create_theme() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const colors = stylex.defineVars({ primary: 'blue', secondary: 'grey' }); +const dark = stylex.createTheme(colors, { primary: 'navy' }); +const styles = stylex.create({ box: { color: colors.primary, backgroundColor: colors.secondary } }); +const el =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_theme_apis_with_named_imports() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { defineVars, createTheme, create, props } from '@stylexjs/stylex'; +const colors = defineVars({ primary: 'blue' }); +const dark = createTheme(colors, { primary: 'navy' }); +const styles = create({ box: { color: colors.primary } }); +const el =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_theme_apis_ignore_unresolvable_input() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const empty = stylex.defineVars({ dynamic: someValue }); +const unknownContract = stylex.createTheme(notAContract, { primary: 'navy' }); +const noOverlap = stylex.createTheme(empty, { missing: 'navy' }); +const notAnObject = stylex.defineVars(someVariable); +const deepRef = stylex.create({ box: { color: theme.colors.primary } }); +const spreadVars = stylex.defineVars({ ...other, kept: 'red' }); +const spreadTheme = stylex.createTheme(empty, { ...other });" + ))); + } + + #[test] + #[serial] + fn test_stylex_theme_contract_and_constants() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +import { defineConsts } from '@stylexjs/stylex'; +const vars = stylex.createThemeContract({ primary: null }); +const consts = defineConsts({ gap: '8px' }); +const dark = stylex.createTheme(vars, { primary: 'navy' }); +const styles = stylex.create({ box: { color: vars.primary, marginTop: consts.gap } }); +const el =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_position_try_and_view_transition_class() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const fallback = stylex.positionTry({ top: '0', insetBlockEnd: 'auto' }); +const transition = stylex.viewTransitionClass({ animationDuration: '300ms' }); +const empty = stylex.positionTry(notAnObject);" + ))); + } + + #[test] + #[serial] + fn test_styled_object_form_theme_interpolation() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { styled } from '@devup-ui/react'; +const Themed = styled('div')({ color: (p) => p.theme.brand }); +const Plain = styled('span')({ color: (p) => p.color });" + ))); + } + + #[test] + #[serial] + fn test_emotion_global_component_and_import_surface() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import styled from '@emotion/styled'; +import { css, keyframes, Global, ThemeProvider, useTheme, ClassNames } from '@emotion/react'; +const S = styled.div`color: ${p => p.theme.brand};`; +export const App = () => <>;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()) + ) + ]) + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_devup_props_typescript_wrappers() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box } from '@devup-ui/react'; +const cast = ; +const satisfied = ; +const nonNull = ; +const parens = ;" + ))); + } + + #[test] + #[serial] + fn test_devup_props_optional_chaining() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box } from '@devup-ui/react'; +const dotted = ; +const computed = ;" + ))); + } + + #[test] + #[serial] + fn test_css_and_global_css_typescript_wrappers() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { css, globalCss, keyframes } from '@devup-ui/react'; +const a = css({ bg: 'red' } as any); +const b = css({ color: 'blue' } satisfies object); +globalCss({ body: { bg: 'green' } } as any); +const k = keyframes({ from: { opacity: 0 } } as any);" + ))); + } + + #[test] + #[serial] + fn test_styled_typescript_wrappers() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { styled } from '@devup-ui/react'; +const A = styled('div')({ bg: 'red' } as any); +const B = styled('span')({ color: 'blue' } satisfies object); +const C = (styled('p') as any)`margin-top: 1px;`; +const D = (styled.div satisfies object)({ pb: '3px' }); +const E = styled.span!({ pl: '4px' }); +const F = (styled.p)`padding-right: 5px;`; +const G = (styled)('div', { pr: '6px' });" + ))); + } + + #[test] + #[serial] + fn test_styled_accepts_both_call_forms() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { styled, Box } from '@devup-ui/react'; +const twoArg = styled('div', { bg: 'red' }); +const twoArgComponent = styled(Box, { mt: '1px' }); +const curried = styled('span')({ color: 'blue' }); +const member = styled.p({ pt: '2px' }); +const malformed = styled('div', 'span')`color: red;`; +const creatorOnly = styled('div');" + ))); + } + + #[test] + #[serial] + fn test_create_global_style_collapses_to_a_null_component() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import styled, { createGlobalStyle } from 'styled-components'; +const GlobalStyle = createGlobalStyle`body { margin: 0; }`; +const FromObject = createGlobalStyle({ html: { pt: '1px' } }); +const S = styled.div`color: red;`; +export const App = () => <>;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "styled-components".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()) + )]) + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_styled_components_theme_resolves_to_css_variables() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import styled from 'styled-components'; +const Flat = styled.div`color: ${p => p.theme.brand};`; +const Nested = styled.span`color: ${p => p.theme.colors.brand};`; +const Destructured = styled.p`color: ${({ theme }) => theme.colors.accent};`; +const Surrounded = styled.b`border: 1px solid ${p => p.theme.line};`; +const NotTheme = styled.i`color: ${p => p.color};`; +const BareTheme = styled.u`color: ${p => p.theme};`; +const OtherRoot = styled.s`color: ${p => q.theme.brand};`; +const ArrayParam = styled.q`color: ${([p]) => p.theme.brand};`; +const NoParam = styled.em`color: ${() => 'red'};`; +const CallBody = styled.strong`color: ${p => p.theme.brand()};`;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "styled-components".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()) + )]) + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_namespace_import_of_a_default_only_package_redirects() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import * as Emotion from '@emotion/styled'; +const Member = Emotion.div({ bg: 'red' }); +const Tagged = Emotion.span`color: ${p => p.theme.brand};`;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()) + )]) + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_styled_components_import_surface_fully_redirects() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import styled, { css, keyframes, createGlobalStyle, ThemeProvider, useTheme, withTheme, ServerStyleSheet, StyleSheetManager, isStyledComponent } from 'styled-components'; +const S = styled.div`color: red;`;", + ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::from([("styled-components".to_string(), ImportAlias::DefaultToNamed("styled".to_string()))]) }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_vanilla_extract_names_extract_without_source_dependency() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import { style, globalStyle, styleVariants } from '@vanilla-extract/css'; +export const a = style({ color: 'red' }); +globalStyle('body', { margin: '0px' }); +export const v = styleVariants({});", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed + )]) + }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_tailwind_conditional_class_name() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box } from '@devup-ui/react'; +const ternary = ; +const logical = ;" + ))); + } + + #[test] + #[serial] + fn test_raw_selector_key_without_parent() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box } from '@devup-ui/react'; +const e = b, i': { color: 'blue' } }} />;" + ))); + } + + #[test] + #[serial] + fn test_minus_zero_is_normalized() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box } from '@devup-ui/react'; +const e = ;" + ))); + } + + #[test] + #[serial] + fn test_member_expression_with_dynamic_values() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box } from '@devup-ui/react'; +const e = ;" + ))); + } + + #[test] + #[serial] + fn test_props_prop_becomes_spread_attribute() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box } from '@devup-ui/react'; +const e = ;" + ))); + } + + #[test] + #[serial] + fn test_styled_tag_that_is_neither_member_nor_call() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { styled } from '@devup-ui/react'; +const S = styled['div']`color: red;`;" + ))); + } + + #[test] + #[serial] + fn test_stylex_variable_declarations_skip_unreadable_entries() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const consts = stylex.defineConsts({ ...other, gap: '8px', dynamic: someVar }); +const vars = stylex.defineVars({ [computed]: 'red', primary: 'blue' }); +const tryBlock = stylex.positionTry({ ...spread, top: '0' }); +const styles = stylex.create({ box: { marginTop: consts.gap, color: vars.primary } });" + ))); + } + + #[test] + #[serial] + fn test_type_instantiation_expression_as_style_value() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box } from '@devup-ui/react'; +const e = } />;" + ))); + } + + #[test] + #[serial] + fn test_parenthesized_string_literals() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box } from '@devup-ui/react'; +const e = ; +const g = ; +const h = ;" + ))); + } + + #[test] + #[serial] + fn test_stylex_attrs_via_named_import() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { create, attrs } from '@stylexjs/stylex'; +const s = create({ a: { color: 'red' } }); +const e =
;" + ))); + } + + #[test] + #[serial] + fn test_emotion_global_with_spread_attribute() { + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import { Global } from '@emotion/react'; +export const App = () => ;", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed + )]) + }, + ) + .unwrap() + )); + } } diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index 89a466482..bf26a8bb6 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -318,9 +318,11 @@ pub fn get_class_name_expression<'a>( // Determine the className expression to use: // - If we extracted Tailwind styles, use generated class names (replace original) // - Otherwise, preserve the original className - let class_name_to_use = if tailwind_class_expr.is_some() { - // Tailwind className → replaced with generated class names - tailwind_class_expr + let class_name_to_use = if let Some(tailwind_class_expr) = tailwind_class_expr { + // Tailwind className → replaced with generated class names. A rebuilt + // `cond && "a"` still evaluates to `false`, which React would render as + // `class="false"`, so it needs the same falsy guard as a passthrough. + Some(convert_class_name(ast_builder, &tailwind_class_expr)) } else { // Non-Tailwind className → keep original class_name_prop @@ -410,9 +412,11 @@ fn extract_tailwind_from_class_name<'a>( } } - // Extract from template literals (e.g., `${cond ? 'text-red' : 'text-blue'} p-4`) - if let Some(Expression::TemplateLiteral(template)) = class_name_prop { - let all_classes = extract_all_classes_from_template_literal(template); + // Extract from any expression that can still carry static class strings: + // `` `${cond ? 'text-red' : 'text-blue'} p-4` ``, `cond ? 'p-4' : 'p-8'`, `cond && 'p-4'`. + if let Some(expression) = class_name_prop { + let mut all_classes = String::new(); + extract_classes_from_expression(expression, &mut all_classes); if has_tailwind_classes(&all_classes) { // Single pass over every class: parse ONCE, then build both the // `Tailwind class → generated class name` mapping and the styles vec for @@ -443,11 +447,14 @@ fn extract_tailwind_from_class_name<'a>( } if !class_mapping.is_empty() { - // Build new template literal with replaced class names - let new_template = - rebuild_template_literal_with_mapping(ast_builder, template, &class_mapping); + // Build the same expression back with replaced class names + let new_expression = rebuild_expression_with_mapping_unsorted( + ast_builder, + expression, + &class_mapping, + ); - return (tailwind_styles, Some(new_template)); + return (tailwind_styles, Some(new_expression)); } } } @@ -456,9 +463,9 @@ fn extract_tailwind_from_class_name<'a>( } /// Rebuild a template literal, replacing Tailwind classes with generated class names -fn rebuild_template_literal_with_mapping<'a>( +fn rebuild_expression_with_mapping_unsorted<'a>( ast_builder: &AstBuilder<'a>, - template: &oxc_ast::ast::TemplateLiteral<'a>, + expression: &Expression<'a>, class_mapping: &FxHashMap, ) -> Expression<'a> { // Sort the mapping ONCE by key length descending (avoids partial replacements, @@ -466,7 +473,7 @@ fn rebuild_template_literal_with_mapping<'a>( // and nested expression instead of re-sorting per call. let mut sorted_classes: Vec<(&String, &String)> = class_mapping.iter().collect(); sorted_classes.sort_by_key(|(k, _)| std::cmp::Reverse(k.len())); - rebuild_template_literal_with_sorted(ast_builder, template, &sorted_classes) + rebuild_expression_with_mapping(ast_builder, expression, &sorted_classes) } /// Rebuild a template literal using a pre-sorted class mapping slice. diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_and_named_both_retained_on_source.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_and_named_both_retained_on_source.snap new file mode 100644 index 000000000..1e5025002 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_and_named_both_retained_on_source.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/import_alias_visit.rs +expression: "transform_import_aliases(r\"import veDefault, { styleVariants } from '@vanilla-extract/css'\",\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" +--- +import veDefault, { styleVariants } from '@vanilla-extract/css'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_export_without_devup_equivalent_stays_on_source.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_export_without_devup_equivalent_stays_on_source.snap new file mode 100644 index 000000000..354afa215 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_export_without_devup_equivalent_stays_on_source.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/import_alias_visit.rs +expression: "transform_import_aliases(r\"import sheet from 'some-lib'\", \"test.tsx\",\n\"@devup-ui/react\", &aliases)" +--- +import sheet from 'some-lib'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_specifier_in_vanilla_extract_stylesheet.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_specifier_in_vanilla_extract_stylesheet.snap new file mode 100644 index 000000000..767661f9a --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_specifier_in_vanilla_extract_stylesheet.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/import_alias_visit.rs +expression: "transform_import_aliases(r\"import veDefault from '@vanilla-extract/css'\",\n\"styles.css.ts\", \"@devup-ui/react\", &vanilla_extract_alias())" +--- +import { default as veDefault } from '@devup-ui/react'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_to_named_namespace_import.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_to_named_namespace_import.snap index bafcafe1b..165c10359 100644 --- a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_to_named_namespace_import.snap +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_to_named_namespace_import.snap @@ -1,5 +1,5 @@ --- source: libs/extractor/src/import_alias_visit.rs -expression: "transform_import_aliases(r#\"import * as Emotion from '@emotion/styled'\"#,\n\"test.tsx\", \"@devup-ui/react\", &emotion_alias())" +expression: "transform_import_aliases(r\"import * as Emotion from '@emotion/styled'\",\n\"test.tsx\", \"@devup-ui/react\", &emotion_alias())" --- -import * as Emotion from '@devup-ui/react'; +import { styled as Emotion } from '@devup-ui/react'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__multiple_imports_same_file.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__multiple_imports_same_file.snap index 50c8089b2..512be1d5f 100644 --- a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__multiple_imports_same_file.snap +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__multiple_imports_same_file.snap @@ -1,7 +1,7 @@ --- source: libs/extractor/src/import_alias_visit.rs -expression: "transform_import_aliases(r#\"import styled from '@emotion/styled'\nimport { style } from '@vanilla-extract/css'\nimport { useState } from 'react'\"#,\n\"test.tsx\", \"@devup-ui/react\", &combined_aliases())" +expression: "transform_import_aliases(r\"import styled from '@emotion/styled'\nimport { style } from '@vanilla-extract/css'\nimport { useState } from 'react'\",\n\"test.tsx\", \"@devup-ui/react\", &combined_aliases())" --- import { styled } from '@devup-ui/react'; -import { style } from '@devup-ui/react'; +import { css as style } from '@devup-ui/react'; import { useState } from 'react' diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_import_with_alias.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_import_with_alias.snap index b63d81ab1..61b8225bc 100644 --- a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_import_with_alias.snap +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_import_with_alias.snap @@ -1,5 +1,5 @@ --- source: libs/extractor/src/import_alias_visit.rs -expression: "transform_import_aliases(r#\"import { style as myStyle } from '@vanilla-extract/css'\"#,\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" +expression: "transform_import_aliases(r\"import { style as myStyle } from '@vanilla-extract/css'\",\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" --- -import { style as myStyle } from '@devup-ui/react'; +import { css as myStyle } from '@devup-ui/react'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_imports_without_devup_export_stay_on_source.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_imports_without_devup_export_stay_on_source.snap new file mode 100644 index 000000000..5ccaf5df4 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_imports_without_devup_export_stay_on_source.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/import_alias_visit.rs +expression: "transform_import_aliases(r\"import styled, { css, keyframes, createGlobalStyle, ThemeProvider } from 'styled-components'\",\n\"test.tsx\", \"@devup-ui/react\", &styled_components_alias())" +--- +import { styled, css, keyframes } from '@devup-ui/react'; import { createGlobalStyle, ThemeProvider } from '@devup-ui/react/compat'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named.snap index 26d93eaf9..79f24c7c5 100644 --- a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named.snap +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named.snap @@ -1,5 +1,5 @@ --- source: libs/extractor/src/import_alias_visit.rs -expression: "transform_import_aliases(r#\"import { style, globalStyle } from '@vanilla-extract/css'\"#,\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" +expression: "transform_import_aliases(r\"import { style, globalStyle } from '@vanilla-extract/css'\",\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" --- -import { style, globalStyle } from '@devup-ui/react'; +import { css as style, globalCss as globalStyle } from '@devup-ui/react'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named_namespace_import.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named_namespace_import.snap index 52120f70c..1e1173fe8 100644 --- a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named_namespace_import.snap +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named_namespace_import.snap @@ -1,5 +1,5 @@ --- source: libs/extractor/src/import_alias_visit.rs -expression: "transform_import_aliases(r#\"import * as VE from '@vanilla-extract/css'\"#,\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" +expression: "transform_import_aliases(r\"import * as VE from '@vanilla-extract/css'\",\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" --- -import * as VE from '@devup-ui/react'; +import * as VE from '@vanilla-extract/css'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named_with_default_specifier.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named_with_default_specifier.snap index 4491be929..bd0687fe6 100644 --- a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named_with_default_specifier.snap +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_to_named_with_default_specifier.snap @@ -1,5 +1,5 @@ --- source: libs/extractor/src/import_alias_visit.rs -expression: "transform_import_aliases(r#\"import vanillaDefault, { style } from '@vanilla-extract/css'\"#,\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" +expression: "transform_import_aliases(r\"import vanillaDefault, { style } from '@vanilla-extract/css'\",\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" --- -import { default as vanillaDefault, style } from '@devup-ui/react'; +import { css as style } from '@devup-ui/react'; import vanillaDefault from '@vanilla-extract/css'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__namespace_import_of_a_compat_only_default.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__namespace_import_of_a_compat_only_default.snap new file mode 100644 index 000000000..93fcbeb3e --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__namespace_import_of_a_compat_only_default.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/import_alias_visit.rs +expression: "transform_import_aliases(r\"import * as Sheet from 'some-lib'\", \"test.tsx\",\n\"@devup-ui/react\", &aliases)" +--- +import { ThemeProvider as Sheet } from '@devup-ui/react/compat'; diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__vanilla_extract_names_map_onto_devup_equivalents.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__vanilla_extract_names_map_onto_devup_equivalents.snap new file mode 100644 index 000000000..3dc04b0a1 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__vanilla_extract_names_map_onto_devup_equivalents.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/import_alias_visit.rs +expression: "transform_import_aliases(r\"import { style, globalStyle, styleVariants } from '@vanilla-extract/css'\",\n\"test.tsx\", \"@devup-ui/react\", &vanilla_extract_alias())" +--- +import { css as style, globalCss as globalStyle } from '@devup-ui/react'; import { styleVariants } from '@vanilla-extract/css'; diff --git a/libs/extractor/src/snapshots/extractor__tests__create_global_style_collapses_to_a_null_component.snap b/libs/extractor/src/snapshots/extractor__tests__create_global_style_collapses_to_a_null_component.snap new file mode 100644 index 000000000..20f438589 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__create_global_style_collapses_to_a_null_component.snap @@ -0,0 +1,42 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import styled, { createGlobalStyle } from 'styled-components';\nconst GlobalStyle = createGlobalStyle`body { margin: 0; }`;\nconst FromObject = createGlobalStyle({ html: { pt: '1px' } });\nconst S = styled.div`color: red;`;\nexport const App = () => <>;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"styled-components\".to_string(),\n ImportAlias::DefaultToNamed(\"styled\".to_string()))])\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding-top", + value: "1px", + level: 0, + selector: Some( + Global( + "html", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Css( + ExtractCss { + css: "body{margin:0}", + file: "test.tsx", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst GlobalStyle = () => null;\nconst FromObject = () => null;\nconst S = ({ style, className, ...rest }) =>
;\nexport const App = () => <>;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__css_and_global_css_typescript_wrappers.snap b/libs/extractor/src/snapshots/extractor__tests__css_and_global_css_typescript_wrappers.snap new file mode 100644 index 000000000..8449ab222 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__css_and_global_css_typescript_wrappers.snap @@ -0,0 +1,51 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { css, globalCss, keyframes } from '@devup-ui/react';\nconst a = css({ bg: 'red' } as any);\nconst b = css({ color: 'blue' } satisfies object);\nglobalCss({ body: { bg: 'green' } } as any);\nconst k = keyframes({ from: { opacity: 0 } } as any);\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "green", + level: 0, + selector: Some( + Global( + "body", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Keyframes( + ExtractKeyframes { + keyframes: {}, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst a = \"a\";\nconst b = \"b\";\n;\nconst k = \"c\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__devup_props_optional_chaining.snap b/libs/extractor/src/snapshots/extractor__tests__devup_props_optional_chaining.snap new file mode 100644 index 000000000..ccd562a6b --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__devup_props_optional_chaining.snap @@ -0,0 +1,27 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst dotted = ;\nconst computed = ;\"))" +--- +ToBTreeSet { + styles: { + Dynamic( + ExtractDynamicStyle { + property: "background", + level: 0, + identifier: "map?.k", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "map?.[k]", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst dotted =
;\nconst computed =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__devup_props_typescript_wrappers.snap b/libs/extractor/src/snapshots/extractor__tests__devup_props_typescript_wrappers.snap new file mode 100644 index 000000000..2f05d6d16 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__devup_props_typescript_wrappers.snap @@ -0,0 +1,48 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst cast = ;\nconst satisfied = ;\nconst nonNull = ;\nconst parens = ;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding-top", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "margin-top", + level: 0, + identifier: "v", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst cast =
;\nconst satisfied =
;\nconst nonNull =
;\nconst parens =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap new file mode 100644 index 000000000..bca9438c4 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap @@ -0,0 +1,36 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import styled from '@emotion/styled';\nimport { css, keyframes, Global, ThemeProvider, useTheme, ClassNames } from '@emotion/react';\nconst S = styled.div`color: ${p => p.theme.brand};`;\nexport const App = () => <>;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@emotion/react\".to_string(), ImportAlias::NamedToNamed),\n (\"@emotion/styled\".to_string(),\n ImportAlias::DefaultToNamed(\"styled\".to_string()))])\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "var(--brand)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "0", + level: 0, + selector: Some( + Global( + "body", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { ThemeProvider, useTheme } from \"@devup-ui/react/compat\";\nimport { ClassNames } from \"@emotion/react\";\nconst S = ({ style, className, ...rest }) =>
;\nexport const App = () => <>;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_global_with_spread_attribute.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_global_with_spread_attribute.snap new file mode 100644 index 000000000..86b4c5547 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_global_with_spread_attribute.snap @@ -0,0 +1,26 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { Global } from '@emotion/react';\nexport const App = () => ;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@emotion/react\".to_string(), ImportAlias::NamedToNamed)])\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "margin", + value: "0", + level: 0, + selector: Some( + Global( + "body", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const App = () => ;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__member_expression_with_dynamic_values.snap b/libs/extractor/src/snapshots/extractor__tests__member_expression_with_dynamic_values.snap new file mode 100644 index 000000000..14c731cfa --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__member_expression_with_dynamic_values.snap @@ -0,0 +1,27 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = ;\"))" +--- +ToBTreeSet { + styles: { + Dynamic( + ExtractDynamicStyle { + property: "background", + level: 0, + identifier: "first", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "background", + level: 0, + identifier: "second", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst e =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__minus_zero_is_normalized.snap b/libs/extractor/src/snapshots/extractor__tests__minus_zero_is_normalized.snap new file mode 100644 index 000000000..a254fa3c2 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__minus_zero_is_normalized.snap @@ -0,0 +1,19 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = ;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "transform", + value: "translate(0,0)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst e =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__namespace_import_of_a_default_only_package_redirects.snap b/libs/extractor/src/snapshots/extractor__tests__namespace_import_of_a_default_only_package_redirects.snap new file mode 100644 index 000000000..361a2cc45 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__namespace_import_of_a_default_only_package_redirects.snap @@ -0,0 +1,29 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import * as Emotion from '@emotion/styled';\nconst Member = Emotion.div({ bg: 'red' });\nconst Tagged = Emotion.span`color: ${p => p.theme.brand};`;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@emotion/styled\".to_string(),\n ImportAlias::DefaultToNamed(\"styled\".to_string()))])\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "var(--brand)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst Member = ({ style, className, ...rest }) =>
;\nconst Tagged = ({ style, className, ...rest }) => ;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__parenthesized_string_literals.snap b/libs/extractor/src/snapshots/extractor__tests__parenthesized_string_literals.snap new file mode 100644 index 000000000..638d374b9 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__parenthesized_string_literals.snap @@ -0,0 +1,39 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = ;\nconst g = ;\nconst h = ;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "background", + value: "teal", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "navy", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst e = ;\nconst g =
;\nconst h =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__props_prop_becomes_spread_attribute.snap b/libs/extractor/src/snapshots/extractor__tests__props_prop_becomes_spread_attribute.snap new file mode 100644 index 000000000..d0c10f988 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__props_prop_becomes_spread_attribute.snap @@ -0,0 +1,19 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = ;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst e =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap b/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap new file mode 100644 index 000000000..3ac2686c8 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap @@ -0,0 +1,51 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = b, i': { color: 'blue' } }} />;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: Some( + Selector( + "&:a > b", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: Some( + Selector( + "&:i", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: Some( + Selector( + "&:div p", + ), + ), + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst e =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_accepts_both_call_forms.snap b/libs/extractor/src/snapshots/extractor__tests__styled_accepts_both_call_forms.snap new file mode 100644 index 000000000..754b8ecdf --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_accepts_both_call_forms.snap @@ -0,0 +1,49 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { styled, Box } from '@devup-ui/react';\nconst twoArg = styled('div', { bg: 'red' });\nconst twoArgComponent = styled(Box, { mt: '1px' });\nconst curried = styled('span')({ color: 'blue' });\nconst member = styled.p({ pt: '2px' });\nconst malformed = styled('div', 'span')`color: red;`;\nconst creatorOnly = styled('div');\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin-top", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding-top", + value: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst twoArg = ({ style, className, ...rest }) =>
;\nconst twoArgComponent = ({ style, className, ...rest }) =>
;\nconst curried = ({ style, className, ...rest }) => ;\nconst member = ({ style, className, ...rest }) =>

;\nconst malformed = styled(\"div\", \"span\")`color: red;`;\nconst creatorOnly = styled(\"div\");\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_components_import_surface_fully_redirects.snap b/libs/extractor/src/snapshots/extractor__tests__styled_components_import_surface_fully_redirects.snap new file mode 100644 index 000000000..3d908e9f2 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_components_import_surface_fully_redirects.snap @@ -0,0 +1,19 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import styled, { css, keyframes, createGlobalStyle, ThemeProvider, useTheme, withTheme, ServerStyleSheet, StyleSheetManager, isStyledComponent } from 'styled-components';\nconst S = styled.div`color: red;`;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"styled-components\".to_string(),\n ImportAlias::DefaultToNamed(\"styled\".to_string()))])\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { ThemeProvider, useTheme, withTheme, ServerStyleSheet, StyleSheetManager, isStyledComponent } from \"@devup-ui/react/compat\";\nconst S = ({ style, className, ...rest }) =>

;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_components_theme_resolves_to_css_variables.snap b/libs/extractor/src/snapshots/extractor__tests__styled_components_theme_resolves_to_css_variables.snap new file mode 100644 index 000000000..777369542 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_components_theme_resolves_to_css_variables.snap @@ -0,0 +1,103 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import styled from 'styled-components';\nconst Flat = styled.div`color: ${p => p.theme.brand};`;\nconst Nested = styled.span`color: ${p => p.theme.colors.brand};`;\nconst Destructured = styled.p`color: ${({ theme }) => theme.colors.accent};`;\nconst Surrounded = styled.b`border: 1px solid ${p => p.theme.line};`;\nconst NotTheme = styled.i`color: ${p => p.color};`;\nconst BareTheme = styled.u`color: ${p => p.theme};`;\nconst OtherRoot = styled.s`color: ${p => q.theme.brand};`;\nconst ArrayParam = styled.q`color: ${([p]) => p.theme.brand};`;\nconst NoParam = styled.em`color: ${() => 'red'};`;\nconst CallBody = styled.strong`color: ${p => p.theme.brand()};`;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"styled-components\".to_string(),\n ImportAlias::DefaultToNamed(\"styled\".to_string()))])\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "border", + value: "1px solid var(--line)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "var(--brand)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "var(--colors-accent)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "var(--colors-brand)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "(()=>`red`)(rest)", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "(([p])=>p.theme.brand)(rest)", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "(p=>p.color)(rest)", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "(p=>p.theme)(rest)", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "(p=>p.theme.brand())(rest)", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "(p=>q.theme.brand)(rest)", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst Flat = ({ style, className, ...rest }) =>
;\nconst Nested = ({ style, className, ...rest }) => ;\nconst Destructured = ({ style, className, ...rest }) =>

;\nconst Surrounded = ({ style, className, ...rest }) => ;\nconst NotTheme = ({ style, className, ...rest }) => p.color)(rest) },\n\t...style\n}} />;\nconst BareTheme = ({ style, className, ...rest }) => p.theme)(rest) },\n\t...style\n}} />;\nconst OtherRoot = ({ style, className, ...rest }) => q.theme.brand)(rest) },\n\t...style\n}} />;\nconst ArrayParam = ({ style, className, ...rest }) => p.theme.brand)(rest) },\n\t...style\n}} />;\nconst NoParam = ({ style, className, ...rest }) => `red`)(rest) },\n\t...style\n}} />;\nconst CallBody = ({ style, className, ...rest }) => p.theme.brand())(rest) },\n\t...style\n}} />;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation.snap b/libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation.snap new file mode 100644 index 000000000..8c1fdf0bb --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation.snap @@ -0,0 +1,19 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { styled } from '@devup-ui/react';\nconst Themed = styled('div')({ color: (p) => p.theme.brand });\nconst Plain = styled('span')({ color: (p) => p.color });\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "var(--brand)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst Themed = ({ style, className, ...rest }) =>

;\nconst Plain = ({ style, className, ...rest }) => ;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_tag_that_is_neither_member_nor_call.snap b/libs/extractor/src/snapshots/extractor__tests__styled_tag_that_is_neither_member_nor_call.snap new file mode 100644 index 000000000..550c3973f --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_tag_that_is_neither_member_nor_call.snap @@ -0,0 +1,8 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { styled } from '@devup-ui/react';\nconst S = styled['div']`color: red;`;\"))" +--- +ToBTreeSet { + styles: {}, + code: "const S = styled[\"div\"]`color: red;`;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_typescript_wrappers.snap b/libs/extractor/src/snapshots/extractor__tests__styled_typescript_wrappers.snap new file mode 100644 index 000000000..c8bd504f4 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_typescript_wrappers.snap @@ -0,0 +1,79 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { styled } from '@devup-ui/react';\nconst A = styled('div')({ bg: 'red' } as any);\nconst B = styled('span')({ color: 'blue' } satisfies object);\nconst C = (styled('p') as any)`margin-top: 1px;`;\nconst D = (styled.div satisfies object)({ pb: '3px' });\nconst E = styled.span!({ pl: '4px' });\nconst F = (styled.p)`padding-right: 5px;`;\nconst G = (styled)('div', { pr: '6px' });\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin-top", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding-bottom", + value: "3px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding-left", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding-right", + value: "5px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding-right", + value: "6px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst A = ({ style, className, ...rest }) =>
;\nconst B = ({ style, className, ...rest }) => ;\nconst C = ({ style, className, ...rest }) =>

;\nconst D = ({ style, className, ...rest }) =>

;\nconst E = ({ style, className, ...rest }) => ;\nconst F = ({ style, className, ...rest }) =>

;\nconst G = ({ style, className, ...rest }) =>

;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_attrs_emits_class_attribute.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_attrs_emits_class_attribute.snap new file mode 100644 index 000000000..11e75a92d --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_attrs_emits_class_attribute.snap @@ -0,0 +1,19 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import stylex from '@stylexjs/stylex';\nconst s = stylex.create({ a: { color: 'red' } });\nconst e =
;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst s = { \"a\": \"a\" };\nconst e =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_attrs_via_named_import.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_attrs_via_named_import.snap new file mode 100644 index 000000000..196574cf2 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_attrs_via_named_import.snap @@ -0,0 +1,19 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { create, attrs } from '@stylexjs/stylex';\nconst s = create({ a: { color: 'red' } });\nconst e =
;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { create, attrs } from \"@stylexjs/stylex\";\nconst s = { \"a\": \"a\" };\nconst e =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_define_vars_and_create_theme.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_define_vars_and_create_theme.snap new file mode 100644 index 000000000..e7f728fad --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_define_vars_and_create_theme.snap @@ -0,0 +1,41 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import stylex from '@stylexjs/stylex';\nconst colors = stylex.defineVars({ primary: 'blue', secondary: 'grey' });\nconst dark = stylex.createTheme(colors, { primary: 'navy' });\nconst styles = stylex.create({ box: { color: colors.primary, backgroundColor: colors.secondary } });\nconst el =
;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background-color", + value: "var(--b)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "var(--a)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Css( + ExtractCss { + css: ".c{--a:navy;}", + file: "test.tsx", + }, + ), + Css( + ExtractCss { + css: ":root{--a:blue;--b:grey;}", + file: "test.tsx", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst colors = {\n\t\"primary\": \"var(--a)\",\n\t\"secondary\": \"var(--b)\"\n};\nconst dark = \"c\";\nconst styles = { \"box\": \"d e\" };\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_position_try_and_view_transition_class.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_position_try_and_view_transition_class.snap new file mode 100644 index 000000000..0d3bb2c03 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_position_try_and_view_transition_class.snap @@ -0,0 +1,21 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import stylex from '@stylexjs/stylex';\nconst fallback = stylex.positionTry({ top: '0', insetBlockEnd: 'auto' });\nconst transition = stylex.viewTransitionClass({ animationDuration: '300ms' });\nconst empty = stylex.positionTry(notAnObject);\"))" +--- +ToBTreeSet { + styles: { + Css( + ExtractCss { + css: ".b{animation-duration:300ms;}", + file: "test.tsx", + }, + ), + Css( + ExtractCss { + css: "@position-try --a{top:0;inset-block-end:auto;}", + file: "test.tsx", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst fallback = \"--a\";\nconst transition = \"b\";\nconst empty = \"--a\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_dynamic_key.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_dynamic_key.snap new file mode 100644 index 000000000..03966eb7f --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_dynamic_key.snap @@ -0,0 +1,69 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import stylex from '@stylexjs/stylex';\nconst styles = stylex.create({ base: { display: 'inline-block', fontWeight: '500' } });\nconst colorStyles = stylex.create({ red: { color: 'red' }, blue: { color: 'blue' } });\nconst sizeStyles = stylex.create({ sm: { fontSize: '12px' }, lg: { fontSize: '20px' } });\nconst el =
;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "display", + value: "inline-block", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "font-size", + value: "12px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "font-size", + value: "20px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "font-weight", + value: "500", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"base\": \"a b\" };\nconst colorStyles = {\n\t\"red\": \"c\",\n\t\"blue\": \"d\"\n};\nconst sizeStyles = {\n\t\"sm\": \"e\",\n\t\"lg\": \"f\"\n};\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_empty_namespace.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_empty_namespace.snap new file mode 100644 index 000000000..d9cc27243 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_empty_namespace.snap @@ -0,0 +1,19 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import stylex from '@stylexjs/stylex';\nconst styles = stylex.create({ empty: {}, filled: { color: 'red' } });\nconst el =
;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = {\n\t\"empty\": \"\",\n\t\"filled\": \"a\"\n};\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_in_conditional.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_in_conditional.snap new file mode 100644 index 000000000..f3192b86b --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_in_conditional.snap @@ -0,0 +1,29 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import stylex from '@stylexjs/stylex';\nconst colorStyles = stylex.create({ red: { color: 'red' }, blue: { color: 'blue' } });\nconst el =
;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst colorStyles = {\n\t\"red\": \"a\",\n\t\"blue\": \"b\"\n};\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_non_identifier_object.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_non_identifier_object.snap new file mode 100644 index 000000000..cf55b662a --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_non_identifier_object.snap @@ -0,0 +1,19 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import stylex from '@stylexjs/stylex';\nconst styles = stylex.create({ base: { color: 'red' } });\nconst el =
;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"base\": \"a\" };\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_only_dynamic_namespaces.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_only_dynamic_namespaces.snap new file mode 100644 index 000000000..5d3e63643 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_only_dynamic_namespaces.snap @@ -0,0 +1,18 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import stylex from '@stylexjs/stylex';\nconst styles = stylex.create({ bar: (h) => ({ height: h }) });\nconst el =
;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Dynamic( + ExtractDynamicStyle { + property: "height", + level: 0, + identifier: "h", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b\" };\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_unresolvable.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_unresolvable.snap new file mode 100644 index 000000000..6c95fa935 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_unresolvable.snap @@ -0,0 +1,19 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import stylex from '@stylexjs/stylex';\nconst styles = stylex.create({ base: { color: 'red' } });\nconst el =
;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"base\": \"a\" };\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_literal_key.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_literal_key.snap new file mode 100644 index 000000000..717cbfd29 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_literal_key.snap @@ -0,0 +1,29 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import stylex from '@stylexjs/stylex';\nconst styles = stylex.create({\n base: { color: 'red' },\n active: { backgroundColor: 'blue' },\n});\nconst el =
;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background-color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = {\n\t\"base\": \"a\",\n\t\"active\": \"b\"\n};\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_props_style_x_array.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_props_style_x_array.snap new file mode 100644 index 000000000..40178469e --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_props_style_x_array.snap @@ -0,0 +1,29 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import stylex from '@stylexjs/stylex';\nconst s = stylex.create({ a: { color: 'red' }, b: { marginTop: '1px' } });\nconst flat =
;\nconst nested =
;\nconst conditional =
;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin-top", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst s = {\n\t\"a\": \"a\",\n\t\"b\": \"b\"\n};\nconst flat =
;\nconst nested =
;\nconst conditional =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_props_ts_wrapper_and_optional_chain.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_props_ts_wrapper_and_optional_chain.snap new file mode 100644 index 000000000..da3233160 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_props_ts_wrapper_and_optional_chain.snap @@ -0,0 +1,29 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import stylex from '@stylexjs/stylex';\nconst s = stylex.create({ a: { color: 'red' }, b: { marginTop: '1px' } });\nconst cast =
;\nconst satisfied =
;\nconst nonNull =
;\nconst parens =
;\nconst chained =
;\nconst computedChain =
;\nconst chainedCall =
;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin-top", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst s = {\n\t\"a\": \"a\",\n\t\"b\": \"b\"\n};\nconst cast =
;\nconst satisfied =
;\nconst nonNull =
;\nconst parens =
;\nconst chained =
;\nconst computedChain =
;\nconst chainedCall =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_theme_apis_ignore_unresolvable_input.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_theme_apis_ignore_unresolvable_input.snap new file mode 100644 index 000000000..33466214a --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_theme_apis_ignore_unresolvable_input.snap @@ -0,0 +1,15 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import stylex from '@stylexjs/stylex';\nconst empty = stylex.defineVars({ dynamic: someValue });\nconst unknownContract = stylex.createTheme(notAContract, { primary: 'navy' });\nconst noOverlap = stylex.createTheme(empty, { missing: 'navy' });\nconst notAnObject = stylex.defineVars(someVariable);\nconst deepRef = stylex.create({ box: { color: theme.colors.primary } });\nconst spreadVars = stylex.defineVars({ ...other, kept: 'red' });\nconst spreadTheme = stylex.createTheme(empty, { ...other });\"))" +--- +ToBTreeSet { + styles: { + Css( + ExtractCss { + css: ":root{--b:red;}", + file: "test.tsx", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst empty = {};\nconst unknownContract = stylex.createTheme(notAContract, { primary: \"navy\" });\nconst noOverlap = \"a\";\nconst notAnObject = stylex.defineVars(someVariable);\nconst deepRef = { \"box\": \"\" };\nconst spreadVars = { \"kept\": \"var(--b)\" };\nconst spreadTheme = \"a\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_theme_apis_with_named_imports.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_theme_apis_with_named_imports.snap new file mode 100644 index 000000000..8313728b6 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_theme_apis_with_named_imports.snap @@ -0,0 +1,31 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { defineVars, createTheme, create, props } from '@stylexjs/stylex';\nconst colors = defineVars({ primary: 'blue' });\nconst dark = createTheme(colors, { primary: 'navy' });\nconst styles = create({ box: { color: colors.primary } });\nconst el =
;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "var(--a)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Css( + ExtractCss { + css: ".b{--a:navy;}", + file: "test.tsx", + }, + ), + Css( + ExtractCss { + css: ":root{--a:blue;}", + file: "test.tsx", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { defineVars, createTheme, create, props } from \"@stylexjs/stylex\";\nconst colors = { \"primary\": \"var(--a)\" };\nconst dark = \"b\";\nconst styles = { \"box\": \"c\" };\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_theme_contract_and_constants.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_theme_contract_and_constants.snap new file mode 100644 index 000000000..0de4b83da --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_theme_contract_and_constants.snap @@ -0,0 +1,35 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import stylex from '@stylexjs/stylex';\nimport { defineConsts } from '@stylexjs/stylex';\nconst vars = stylex.createThemeContract({ primary: null });\nconst consts = defineConsts({ gap: '8px' });\nconst dark = stylex.createTheme(vars, { primary: 'navy' });\nconst styles = stylex.create({ box: { color: vars.primary, marginTop: consts.gap } });\nconst el =
;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "var(--a)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin-top", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Css( + ExtractCss { + css: ".b{--a:navy;}", + file: "test.tsx", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nimport { defineConsts } from \"@stylexjs/stylex\";\nconst vars = { \"primary\": \"var(--a)\" };\nconst consts = { \"gap\": \"8px\" };\nconst dark = \"b\";\nconst styles = { \"box\": \"c d\" };\nconst el =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_variable_declarations_skip_unreadable_entries.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_variable_declarations_skip_unreadable_entries.snap new file mode 100644 index 000000000..ea5202d45 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_variable_declarations_skip_unreadable_entries.snap @@ -0,0 +1,41 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import stylex from '@stylexjs/stylex';\nconst consts = stylex.defineConsts({ ...other, gap: '8px', dynamic: someVar });\nconst vars = stylex.defineVars({ [computed]: 'red', primary: 'blue' });\nconst tryBlock = stylex.positionTry({ ...spread, top: '0' });\nconst styles = stylex.create({ box: { marginTop: consts.gap, color: vars.primary } });\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "var(--a)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin-top", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Css( + ExtractCss { + css: ":root{--a:blue;}", + file: "test.tsx", + }, + ), + Css( + ExtractCss { + css: "@position-try --b{top:0;}", + file: "test.tsx", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst consts = { \"gap\": \"8px\" };\nconst vars = { \"primary\": \"var(--a)\" };\nconst tryBlock = \"--b\";\nconst styles = { \"box\": \"c d\" };\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap new file mode 100644 index 000000000..23f909148 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap @@ -0,0 +1,39 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst ternary = ;\nconst logical = ;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "#EF4444", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "1rem", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "2rem", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst ternary =
;\nconst logical =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__type_instantiation_expression_as_style_value.snap b/libs/extractor/src/snapshots/extractor__tests__type_instantiation_expression_as_style_value.snap new file mode 100644 index 000000000..48ca60a4e --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__type_instantiation_expression_as_style_value.snap @@ -0,0 +1,18 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = } />;\"))" +--- +ToBTreeSet { + styles: { + Dynamic( + ExtractDynamicStyle { + property: "background", + level: 0, + identifier: "pick", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst e =
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_names_extract_without_source_dependency.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_names_extract_without_source_dependency.snap new file mode 100644 index 000000000..004555015 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_names_extract_without_source_dependency.snap @@ -0,0 +1,36 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { style, globalStyle, styleVariants } from '@vanilla-extract/css';\nexport const a = style({ color: 'red' });\nglobalStyle('body', { margin: '0px' });\nexport const v = styleVariants({});\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@vanilla-extract/css\".to_string(),\n ImportAlias::NamedToNamed)])\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "0", + level: 0, + selector: Some( + Global( + "body", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { styleVariants } from \"@vanilla-extract/css\";\nexport const a = \"a\";\n;\nexport const v = styleVariants({});\n", +} diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs index 3e37bc8a6..9f3519ab0 100644 --- a/libs/extractor/src/stylex.rs +++ b/libs/extractor/src/stylex.rs @@ -8,7 +8,48 @@ use crate::utils::{get_string_by_literal_expression, get_string_by_property_key} pub enum StylexFunction { Create, Props, + Attrs, Keyframes, + DefineVars, + CreateTheme, + CreateThemeContract, + DefineConsts, + PositionTry, + ViewTransitionClass, +} + +impl StylexFunction { + #[must_use] + pub fn from_export_name(value: &str) -> Option { + match value { + "create" => Some(StylexFunction::Create), + "props" => Some(StylexFunction::Props), + "attrs" => Some(StylexFunction::Attrs), + "keyframes" => Some(StylexFunction::Keyframes), + "defineVars" => Some(StylexFunction::DefineVars), + "createTheme" => Some(StylexFunction::CreateTheme), + "createThemeContract" => Some(StylexFunction::CreateThemeContract), + "defineConsts" => Some(StylexFunction::DefineConsts), + "positionTry" => Some(StylexFunction::PositionTry), + "viewTransitionClass" => Some(StylexFunction::ViewTransitionClass), + _ => None, + } + } +} + +#[must_use] +pub fn css_variable_block(selector: &str, assignments: &[(String, String)]) -> String { + let mut css = String::new(); + css.push_str(selector); + css.push('{'); + for (name, value) in assignments { + css.push_str(name); + css.push(':'); + css.push_str(value); + css.push(';'); + } + css.push('}'); + css } /// Check if a call expression is `stylex.firstThatWorks()` or named `firstThatWorks()`. @@ -146,8 +187,9 @@ pub fn decompose_value_conditions( { let mut results = vec![]; for arg in call.arguments.iter().rev() { - let arg_expr = arg.to_expression(); - if let Some(s) = get_string_by_literal_expression(arg_expr) { + if let Some(arg_expr) = arg.as_expression() + && let Some(s) = get_string_by_literal_expression(arg_expr) + { results.push(DecomposedStyle { property: css_property.to_string(), value: Some(s.into_owned()), @@ -161,9 +203,8 @@ pub fn decompose_value_conditions( // CallExpression: types.*() → extract inner value, pass through selectors if let Expression::CallExpression(call) = value && is_types_call(&call.callee) - && !call.arguments.is_empty() + && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) { - let inner = call.arguments[0].to_expression(); if let Some(s) = get_string_by_literal_expression(inner) { return vec![DecomposedStyle { property: css_property.to_string(), diff --git a/libs/extractor/src/util_type.rs b/libs/extractor/src/util_type.rs index 059c353dc..0ae752f95 100644 --- a/libs/extractor/src/util_type.rs +++ b/libs/extractor/src/util_type.rs @@ -2,6 +2,10 @@ pub enum UtilType { Css, GlobalCss, + /// `createGlobalStyle` — global CSS that callers render as a component + /// (``), so the call must collapse to a component rather than + /// to the empty statement `globalCss` becomes. + GlobalCssComponent, Keyframes, } @@ -11,8 +15,19 @@ impl UtilType { match value { "css" => Some(UtilType::Css), "globalCss" => Some(UtilType::GlobalCss), + "createGlobalStyle" => Some(UtilType::GlobalCssComponent), "keyframes" => Some(UtilType::Keyframes), _ => None, } } + + #[must_use] + pub const fn is_component(&self) -> bool { + matches!(self, UtilType::GlobalCssComponent) + } + + #[must_use] + pub const fn is_global(&self) -> bool { + matches!(self, UtilType::GlobalCss | UtilType::GlobalCssComponent) + } } diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index efd8a9ac6..d976d7ed6 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -16,6 +16,44 @@ use oxc_parser::Parser; use oxc_span::{SPAN, SourceType}; use oxc_syntax::operator::{LogicalOperator, UnaryOperator}; +/// Check if a filename is a vanilla-extract style file. +/// +/// This lives here rather than in `vanilla_extract` because that module is behind +/// the `vanilla-extract` feature (it pulls in the Boa evaluator), while import +/// rewriting needs the check in every build — including the lite WASM variant. +pub(super) fn is_vanilla_extract_file(filename: &str) -> bool { + filename.ends_with(".css.ts") || filename.ends_with(".css.js") +} + +/// Strip the wrappers that exist only in the source text: TypeScript's `as` / +/// `satisfies` / `!` / explicit type arguments, plus redundant parentheses. Every +/// one is erased before the code runs, so extraction must see through them — +/// otherwise a plain `as const` silently turns styling off. +pub(super) fn unwrap_syntax_only<'a, 'b>(expression: &'b Expression<'a>) -> &'b Expression<'a> { + match expression { + Expression::TSAsExpression(e) => unwrap_syntax_only(&e.expression), + Expression::TSSatisfiesExpression(e) => unwrap_syntax_only(&e.expression), + Expression::TSNonNullExpression(e) => unwrap_syntax_only(&e.expression), + Expression::TSInstantiationExpression(e) => unwrap_syntax_only(&e.expression), + Expression::ParenthesizedExpression(e) => unwrap_syntax_only(&e.expression), + _ => expression, + } +} + +/// Mutable counterpart of [`unwrap_syntax_only`]. +pub(super) fn unwrap_syntax_only_mut<'a, 'b>( + expression: &'b mut Expression<'a>, +) -> &'b mut Expression<'a> { + match expression { + Expression::TSAsExpression(e) => unwrap_syntax_only_mut(&mut e.expression), + Expression::TSSatisfiesExpression(e) => unwrap_syntax_only_mut(&mut e.expression), + Expression::TSNonNullExpression(e) => unwrap_syntax_only_mut(&mut e.expression), + Expression::TSInstantiationExpression(e) => unwrap_syntax_only_mut(&mut e.expression), + Expression::ParenthesizedExpression(e) => unwrap_syntax_only_mut(&mut e.expression), + _ => expression, + } +} + /// Convert a value to a pixel value. /// /// Returns `Cow::Borrowed(value)` for the overwhelmingly common non-numeric @@ -437,6 +475,16 @@ mod tests { use super::*; + #[test] + fn test_is_vanilla_extract_file() { + assert!(is_vanilla_extract_file("styles.css.ts")); + assert!(is_vanilla_extract_file("theme.css.js")); + assert!(is_vanilla_extract_file("path/to/styles.css.ts")); + assert!(!is_vanilla_extract_file("styles.ts")); + assert!(!is_vanilla_extract_file("styles.css")); + assert!(!is_vanilla_extract_file("component.tsx")); + } + #[test] fn test_convert_value() { assert_eq!(convert_value("1px").as_ref(), "1px"); diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 2095a0a68..7c0bd3cef 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -96,11 +96,6 @@ pub struct CollectedStyles { pub constant_exports: FxHashMap, } -/// Check if a filename is a vanilla-extract style file -pub fn is_vanilla_extract_file(filename: &str) -> bool { - filename.ends_with(".css.ts") || filename.ends_with(".css.js") -} - /// Internal state for collecting styles during JS execution #[derive(Default)] struct StyleCollectorInner { @@ -1607,18 +1602,9 @@ fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; + use crate::utils::is_vanilla_extract_file; use smallvec::smallvec; - #[test] - fn test_is_vanilla_extract_file() { - assert!(is_vanilla_extract_file("styles.css.ts")); - assert!(is_vanilla_extract_file("theme.css.js")); - assert!(is_vanilla_extract_file("path/to/styles.css.ts")); - assert!(!is_vanilla_extract_file("styles.ts")); - assert!(!is_vanilla_extract_file("styles.css")); - assert!(!is_vanilla_extract_file("component.tsx")); - } - #[test] fn test_preprocess_typescript() { let code = r#"import { style } from '@devup-ui/react' diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 98f0ea5a6..6da7cceed 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -7,7 +7,9 @@ use crate::extract_style::extract_keyframes::ExtractKeyframes; use crate::extract_style::style_property::StyleProperty; use crate::extractor::KeyframesExtractResult; use crate::extractor::extract_keyframes_from_expression::extract_keyframes_from_expression; -use crate::extractor::extract_style_from_stylex::extract_stylex_namespace_styles; +use crate::extractor::extract_style_from_stylex::{ + extract_stylex_declarations, extract_stylex_namespace_styles, +}; use crate::extractor::{ ExtractResult, GlobalExtractResult, extract_global_style_from_expression::extract_global_style_from_expression, @@ -16,21 +18,23 @@ use crate::extractor::{ extract_style_from_styled::extract_style_from_styled, }; use crate::gen_class_name::{gen_class_names, merge_expression_for_class_name}; -use crate::prop_modify_utils::{modify_prop_object, modify_props}; -use crate::stylex::{StylexDynamicInfo, StylexFunction, StylexNamespaceValue}; +use crate::prop_modify_utils::{convert_class_name, modify_prop_object, modify_props}; +use crate::stylex::{StylexDynamicInfo, StylexFunction, StylexNamespaceValue, css_variable_block}; use crate::util_type::UtilType; use crate::{ExtractStyleProp, ExtractStyleValue}; use css::disassemble_property; use css::is_special_property::is_special_property; +use css::keyframes_to_keyframes_name; use oxc_allocator::{Allocator, CloneIn, FromIn, GetAllocator}; use oxc_ast::ast::ImportDeclarationSpecifier::{self, ImportSpecifier}; use oxc_ast::ast::JSXAttributeItem::Attribute; use oxc_ast::ast::JSXAttributeName::Identifier; use oxc_ast::ast::{ - Argument, BindingPattern, CallExpression, Expression, ExpressionStatement, IdentifierName, - ImportDeclaration, ImportOrExportKind, JSXAttributeItem, JSXAttributeValue, JSXChild, - JSXClosingFragment, JSXElement, JSXElementName, JSXExpressionContainer, JSXOpeningFragment, - ObjectPropertyKind, Program, PropertyKey, PropertyKind, Statement, Str, StringLiteral, + Argument, BindingPattern, CallExpression, ChainElement, ComputedMemberExpression, Expression, + ExpressionStatement, FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, + ImportOrExportKind, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXClosingFragment, + JSXElement, JSXElementName, JSXExpressionContainer, JSXOpeningFragment, ObjectPropertyKind, + Program, PropertyKey, PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, VariableDeclarator, }; use oxc_ast_visit::VisitMut; @@ -42,7 +46,8 @@ use strum::IntoEnumIterator; use crate::utils::{ ParsedStyleOrder, expression_to_style_order, get_str_by_property_key, - get_string_by_property_key, jsx_expression_to_style_order, + get_string_by_literal_expression, get_string_by_property_key, jsx_expression_to_style_order, + unwrap_syntax_only, }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; @@ -71,6 +76,9 @@ pub struct DevupVisitor<'a> { util_imports: FxHashMap>, jsx_object: Option, package: String, + /// Entry the rewritten imports of absorbed third-party APIs land on. Its specifiers + /// register exactly like the main package's so those calls still compile away. + compat_package: String, split_filename: Option, pub css_files: Vec, pub styles: FxHashSet, @@ -85,6 +93,24 @@ pub struct DevupVisitor<'a> { /// Maps variable names to their namespace→className mappings from `stylex.create()`. /// e.g., "styles" → { "base" → "a b", "active" → "c" } stylex_namespaces: FxHashMap>, + /// Local names bound to the `Global` component, whose `styles` prop declares + /// global CSS instead of rendering markup. + global_style_components: FxHashSet, + + /// `defineVars` members flattened to `"vars.key"` -> `"var(--x)"`, so a + /// `stylex.create()` value referencing one resolves to a static CSS value. + stylex_var_refs: FxHashMap, + /// `defineVars` bindings as `vars` -> (`key` -> `--x`), the contract + /// `createTheme` reassigns. + stylex_var_names: FxHashMap>, + /// `createTheme` bindings as `theme` -> `class`, so `stylex.props(theme)` resolves. + stylex_theme_classes: FxHashMap, + /// Pending `defineVars` contract awaiting its variable declarator. + stylex_pending_vars: Option>, + /// Pending `createTheme` class awaiting its variable declarator. + stylex_pending_theme_class: Option, + /// Pending `defineConsts` values awaiting their variable declarator. + stylex_pending_consts: Option>, /// Pending keyframe animation name from most recent `stylex.keyframes()` call. stylex_pending_keyframe_name: Option, /// Maps variable names to their keyframe animation names. @@ -110,6 +136,7 @@ impl<'a> DevupVisitor<'a> { imports: FxHashMap::default(), jsx_imports: FxHashMap::default(), package: package.to_string(), + compat_package: format!("{package}/compat"), css_files, styles: FxHashSet::default(), import_object: None, @@ -119,6 +146,13 @@ impl<'a> DevupVisitor<'a> { styled_import: None, stylex_import: None, stylex_named_imports: FxHashMap::default(), + global_style_components: FxHashSet::default(), + stylex_var_refs: FxHashMap::default(), + stylex_var_names: FxHashMap::default(), + stylex_theme_classes: FxHashMap::default(), + stylex_pending_vars: None, + stylex_pending_theme_class: None, + stylex_pending_consts: None, stylex_pending_create: None, stylex_namespaces: FxHashMap::default(), stylex_pending_keyframe_name: None, @@ -152,25 +186,69 @@ impl<'a> DevupVisitor<'a> { false } - /// Check if a callee expression is a `stylex.props(...)` or named `props(...)` call. - fn is_stylex_props_call(&self, callee: &Expression) -> bool { + /// Resolve a `stylex.props(...)` / `stylex.attrs(...)` callee to the class attribute + /// it produces. `props()` targets React (`className`), `attrs()` targets raw HTML + /// (`class`); everything else about the two calls is identical. + fn stylex_class_attribute(&self, callee: &Expression) -> Option<&'static str> { // Check namespace/default call: stylex.props(...) if let Some(stylex_name) = &self.stylex_import && let Expression::StaticMemberExpression(member) = callee && let Expression::Identifier(ident) = &member.object && ident.name.as_str() == stylex_name.as_str() - && member.property.name.as_str() == "props" { - return true; + return match member.property.name.as_str() { + "props" => Some("className"), + "attrs" => Some("class"), + _ => None, + }; } // Check named import call: props(...) - if let Expression::Identifier(ident) = callee - && matches!( - self.stylex_named_imports.get(ident.name.as_str()), - Some(StylexFunction::Props) - ) + if let Expression::Identifier(ident) = callee { + return match self.stylex_named_imports.get(ident.name.as_str()) { + Some(StylexFunction::Props) => Some("className"), + Some(StylexFunction::Attrs) => Some("class"), + _ => None, + }; + } + None + } + + fn string_property(&self, key: &str, value: &str) -> ObjectPropertyKind<'a> { + ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + PropertyKey::StringLiteral(StringLiteral::boxed( + SPAN, + Str::from_in(key, self.ast.allocator()), + None, + &self.ast, + )), + Expression::new_string_literal( + SPAN, + Str::from_in(value, self.ast.allocator()), + None, + &self.ast, + ), + false, + false, + false, + &self.ast, + ) + } + + /// Check if a callee resolves to the given `StyleX` API, through either the + /// namespace form (`stylex.defineVars`) or a named import. + fn is_stylex_call(&self, callee: &Expression, function: &StylexFunction) -> bool { + if let Some(stylex_name) = &self.stylex_import + && let Expression::StaticMemberExpression(member) = callee + && let Expression::Identifier(ident) = &member.object + && ident.name.as_str() == stylex_name.as_str() { - return true; + return StylexFunction::from_export_name(member.property.name.as_str()) + .is_some_and(|found| &found == function); + } + if let Expression::Identifier(ident) = callee { + return self.stylex_named_imports.get(ident.name.as_str()) == Some(function); } false } @@ -207,7 +285,10 @@ impl<'a> DevupVisitor<'a> { let mut style_props: Vec> = vec![]; for arg in arguments { - let expr = arg.to_expression(); + // `...spread` carries no statically resolvable namespace reference. + let Some(expr) = arg.as_expression() else { + continue; + }; // Check for dynamic namespace call first: styles.bar(h) if let Expression::CallExpression(call) = expr && let Some((class_expr, props)) = self.resolve_stylex_dynamic_call(call) @@ -244,8 +325,10 @@ impl<'a> DevupVisitor<'a> { let mut props = Vec::with_capacity(info.css_vars.len()); for (param_idx, var_name) in &info.css_vars { - if let Some(arg) = call.arguments.get(*param_idx) { - let arg_expr = arg.to_expression().clone_in(self.ast.allocator()); + if let Some(arg) = call.arguments.get(*param_idx) + && let Some(arg_expr) = arg.as_expression() + { + let arg_expr = arg_expr.clone_in(self.ast.allocator()); props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, @@ -270,26 +353,138 @@ impl<'a> DevupVisitor<'a> { } } + /// What a global-CSS call collapses to once its rules are extracted: `createGlobalStyle` + /// callers render the result (``), so it must stay a component, while + /// `globalCss` is a bare statement and leaves nothing behind. + fn global_css_result(&self, is_component: bool) -> Expression<'a> { + if is_component { + Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::new_in(&self.ast), + None::>>, + &self.ast, + ), + None::>>, + Expression::new_null_literal(SPAN, &self.ast).into(), + &self.ast, + ) + } else { + Expression::new_identifier(SPAN, "", &self.ast) + } + } + + /// Build a className string literal for a resolved static `StyleX` namespace. + /// An empty namespace (`stylex.create({ empty: {} })`) contributes nothing. + fn stylex_class_literal(&self, class_name: &str) -> Option> { + (!class_name.is_empty()).then(|| { + Expression::new_string_literal( + SPAN, + Str::from_in(class_name, self.ast.allocator()), + None, + &self.ast, + ) + }) + } + + /// Resolve a dotted namespace access (`styles.base`) to a className expression. + fn resolve_stylex_static_member( + &self, + member: &StaticMemberExpression<'a>, + ) -> Option> { + if let Expression::Identifier(obj) = &member.object + && let Some(ns_map) = self.stylex_namespaces.get(obj.name.as_str()) + && let Some(StylexNamespaceValue::Static(cn)) = + ns_map.get(member.property.name.as_str()) + { + return self.stylex_class_literal(cn); + } + None + } + + /// Resolve a computed namespace access (`styles[key]`) to a className expression. + /// + /// A literal key (`styles['base']`) folds to the same string literal as + /// `styles.base`. A non-literal key (`colorStyles[color]`) cannot be resolved at + /// build time, so it defers to a runtime lookup on the declaration `stylex.create()` + /// was rewritten into — `colorStyles[color] || ""` — instead of dropping the + /// argument and leaving the generated atoms unreferenced. + fn resolve_stylex_computed_member( + &self, + member: &ComputedMemberExpression<'a>, + ) -> Option> { + let Expression::Identifier(obj) = &member.object else { + return None; + }; + let ns_map = self.stylex_namespaces.get(obj.name.as_str())?; + + if let Some(key) = get_string_by_literal_expression(&member.expression) { + return match ns_map.get(key.as_ref()) { + Some(StylexNamespaceValue::Static(cn)) => self.stylex_class_literal(cn), + _ => None, + }; + } + + // A dynamic namespace only yields its CSS variables when called + // (`styles[key](x)`), so a variable holding nothing else is unresolvable here. + if !ns_map + .values() + .any(|value| matches!(value, StylexNamespaceValue::Static(cn) if !cn.is_empty())) + { + return None; + } + + // `|| ""` guards keys with no matching namespace, which would otherwise + // interpolate `undefined` into the className. + Some(convert_class_name( + &self.ast, + &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( + SPAN, + member.object.clone_in(self.ast.allocator()), + member.expression.clone_in(self.ast.allocator()), + member.optional, + &self.ast, + )), + )) + } + /// Resolve a single `stylex.props()` argument to a className expression. fn resolve_stylex_arg(&self, expr: &Expression<'a>) -> Option> { - match expr { + match unwrap_syntax_only(expr) { + // stylex.props([a, b]) → StyleXArray, nestable to any depth + Expression::ArrayExpression(array) => merge_expression_for_class_name( + &self.ast, + array + .elements + .iter() + .filter_map(|element| element.as_expression()) + .filter_map(|element| self.resolve_stylex_arg(element)), + ), // styles.base → StaticMemberExpression - Expression::StaticMemberExpression(member) => { - if let Expression::Identifier(obj) = &member.object - && let Some(ns_map) = self.stylex_namespaces.get(obj.name.as_str()) - && let Some(StylexNamespaceValue::Static(cn)) = - ns_map.get(member.property.name.as_str()) - && !cn.is_empty() - { - return Some(Expression::new_string_literal( - SPAN, - Str::from_in(cn, self.ast.allocator()), - None, - &self.ast, - )); - } - None + Expression::StaticMemberExpression(member) => self.resolve_stylex_static_member(member), + // colorStyles[color] / styles['base'] → ComputedMemberExpression + Expression::ComputedMemberExpression(member) => { + self.resolve_stylex_computed_member(member) } + // darkTheme → Identifier bound to a stylex.createTheme() class + Expression::Identifier(ident) => self + .stylex_theme_classes + .get(ident.name.as_str()) + .and_then(|class_name| self.stylex_class_literal(class_name)), + // styles?.base / styles?.[color] → ChainExpression + Expression::ChainExpression(chain) => match &chain.expression { + ChainElement::StaticMemberExpression(member) => { + self.resolve_stylex_static_member(member) + } + ChainElement::ComputedMemberExpression(member) => { + self.resolve_stylex_computed_member(member) + } + _ => None, + }, // isActive && styles.active → LogicalExpression(And) Expression::LogicalExpression(logical) if logical.operator == oxc_ast::ast::LogicalOperator::And => @@ -404,7 +599,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { for i in (0..it.body.len()).rev() { if let Statement::ImportDeclaration(decl) = &it.body[i] - && decl.source.value == self.package + && (decl.source.value == self.package + || decl.source.value == self.compat_package.as_str()) && decl.specifiers.iter().all(|s| s.is_empty()) { it.body.remove(i); @@ -416,15 +612,13 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // Handle styled function calls if let Some(styled_name) = &self.styled_import { - let tag_or_call = if let Expression::TaggedTemplateExpression(tag) = it { - Some(&tag.tag) - } else if let Expression::CallExpression(call) = it { - Some(&call.callee) - } else { - None + let (tag_or_call, argument_count) = match it { + Expression::TaggedTemplateExpression(tag) => (Some(&tag.tag), 0), + Expression::CallExpression(call) => (Some(&call.callee), call.arguments.len()), + _ => (None, 0), }; - let is_styled = if let Some(tag_or_call) = tag_or_call { + let is_styled = if let Some(tag_or_call) = tag_or_call.map(unwrap_syntax_only) { if let Expression::StaticMemberExpression(member) = tag_or_call { if let Expression::Identifier(ident) = &member.object { ident.name.as_str() == styled_name.as_str() @@ -437,6 +631,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } else { false } + } else if let Expression::Identifier(ident) = tag_or_call { + // styled("div", { ... }) puts the tag in the arguments, so the callee is + // the bare identifier. One argument is the curried creator `styled("div")`, + // which only becomes a component once its result is called. + ident.name.as_str() == styled_name.as_str() && argument_count == 2 } else { false } @@ -464,10 +663,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // Handle StyleX: stylex.create({...}) calls if let Expression::CallExpression(call) = it && self.is_stylex_create_call(&call.callee) - && call.arguments.len() == 1 + && let [arg] = call.arguments.as_mut_slice() + && let Some(arg) = arg.as_expression_mut() { - let arg = call.arguments[0].to_expression_mut(); - let namespaces = extract_stylex_namespace_styles(arg, &self.stylex_keyframe_names); + let namespaces = extract_stylex_namespace_styles( + arg, + &self.stylex_keyframe_names, + &self.stylex_var_refs, + ); let mut namespace_map: FxHashMap = FxHashMap::default(); let mut properties = oxc_allocator::Vec::new_in(&self.ast); @@ -550,12 +753,175 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { *it = Expression::new_object_expression(SPAN, properties, &self.ast); } + // Handle StyleX: stylex.defineConsts({...}) calls — build-time constants that + // produce no CSS, so the call collapses to the literal object it described. + if let Expression::CallExpression(call) = it + && self.is_stylex_call(&call.callee, &StylexFunction::DefineConsts) + && let [arg] = call.arguments.as_slice() + && let Some(Expression::ObjectExpression(obj)) = arg.as_expression() + { + let mut contract = FxHashMap::default(); + let mut properties = oxc_allocator::Vec::new_in(&self.ast); + for prop in &obj.properties { + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + continue; + }; + let (Some(key), Some(value)) = ( + get_string_by_property_key(&prop.key), + get_string_by_literal_expression(&prop.value), + ) else { + continue; + }; + properties.push(self.string_property(&key, &value)); + contract.insert(key, value.into_owned()); + } + self.stylex_pending_consts = Some(contract); + *it = Expression::new_object_expression(SPAN, properties, &self.ast); + } + + // Handle StyleX: stylex.defineVars({...}) / stylex.createThemeContract({...}) + // calls. A contract has no values to publish, so only `defineVars` emits the + // `:root` block; both hand back the same `var()` references. + if let Expression::CallExpression(call) = it + && let Some(publishes_values) = [ + (StylexFunction::DefineVars, true), + (StylexFunction::CreateThemeContract, false), + ] + .into_iter() + .find_map(|(function, publishes)| { + self.is_stylex_call(&call.callee, &function) + .then_some(publishes) + }) + && let [arg] = call.arguments.as_slice() + && let Some(Expression::ObjectExpression(obj)) = arg.as_expression() + { + let mut contract = FxHashMap::default(); + let mut assignments = vec![]; + let mut properties = oxc_allocator::Vec::new_in(&self.ast); + for prop in &obj.properties { + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + continue; + }; + let Some(key) = get_string_by_property_key(&prop.key) else { + continue; + }; + let value = get_string_by_literal_expression(&prop.value); + if publishes_values && value.is_none() { + continue; + } + let variable = format!( + "--{}", + keyframes_to_keyframes_name( + &format!("sxv-{}-{key}", self.filename), + self.split_filename.as_deref(), + ) + ); + if let Some(value) = value { + assignments.push((variable.clone(), value.into_owned())); + } + properties.push(self.string_property(&key, &format!("var({variable})"))); + contract.insert(key, variable); + } + if publishes_values && !assignments.is_empty() { + self.styles.insert(ExtractStyleValue::Css(ExtractCss { + css: css_variable_block(":root", &assignments), + file: self.filename.clone(), + })); + } + self.stylex_pending_vars = Some(contract); + *it = Expression::new_object_expression(SPAN, properties, &self.ast); + } + + // Handle StyleX: stylex.createTheme(contract, {...}) calls + if let Expression::CallExpression(call) = it + && self.is_stylex_call(&call.callee, &StylexFunction::CreateTheme) + && let [contract_arg, values_arg] = call.arguments.as_slice() + && let Some(Expression::Identifier(contract_ident)) = contract_arg.as_expression() + && let Some(contract) = self.stylex_var_names.get(contract_ident.name.as_str()) + && let Some(Expression::ObjectExpression(obj)) = values_arg.as_expression() + { + let assignments: Vec<(String, String)> = obj + .properties + .iter() + .filter_map(|prop| { + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + return None; + }; + let key = get_string_by_property_key(&prop.key)?; + let value = get_string_by_literal_expression(&prop.value)?; + Some((contract.get(&key)?.clone(), value.into_owned())) + }) + .collect(); + let class_name = keyframes_to_keyframes_name( + &format!("sxt-{}-{}", self.filename, contract_ident.name), + self.split_filename.as_deref(), + ); + if !assignments.is_empty() { + self.styles.insert(ExtractStyleValue::Css(ExtractCss { + css: css_variable_block(&format!(".{class_name}"), &assignments), + file: self.filename.clone(), + })); + } + self.stylex_pending_theme_class = Some(class_name.clone()); + *it = Expression::new_string_literal( + SPAN, + Str::from_in(&class_name, self.ast.allocator()), + None, + &self.ast, + ); + } + + // Handle StyleX: stylex.positionTry({...}) / stylex.viewTransitionClass({...}). + // Both name a block of rules and hand the name back: `@position-try` takes a + // dashed-ident, a view-transition class takes a plain class name. + if let Expression::CallExpression(call) = it + && let Some(is_position_try) = [ + (StylexFunction::PositionTry, true), + (StylexFunction::ViewTransitionClass, false), + ] + .into_iter() + .find_map(|(function, position_try)| { + self.is_stylex_call(&call.callee, &function) + .then_some(position_try) + }) + && let [arg] = call.arguments.as_mut_slice() + && let Some(arg) = arg.as_expression_mut() + { + let generated = keyframes_to_keyframes_name( + &format!("sxp-{}-{}", self.filename, u8::from(is_position_try)), + self.split_filename.as_deref(), + ); + let name = if is_position_try { + format!("--{generated}") + } else { + generated + }; + let declarations = extract_stylex_declarations(arg); + if !declarations.is_empty() { + let css = if is_position_try { + css_variable_block(&format!("@position-try {name}"), &declarations) + } else { + css_variable_block(&format!(".{name}"), &declarations) + }; + self.styles.insert(ExtractStyleValue::Css(ExtractCss { + css, + file: self.filename.clone(), + })); + } + *it = Expression::new_string_literal( + SPAN, + Str::from_in(&name, self.ast.allocator()), + None, + &self.ast, + ); + } + // Handle StyleX: stylex.keyframes({...}) calls if let Expression::CallExpression(call) = it && self.is_stylex_keyframes_call(&call.callee) - && call.arguments.len() == 1 + && let [arg] = call.arguments.as_mut_slice() + && let Some(arg) = arg.as_expression_mut() { - let arg = call.arguments[0].to_expression_mut(); let KeyframesExtractResult { keyframes } = extract_keyframes_from_expression(&self.ast, arg); let name = @@ -570,9 +936,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); } - // Handle StyleX: stylex.props(...) calls + // Handle StyleX: stylex.props(...) / stylex.attrs(...) calls if let Expression::CallExpression(call) = it - && self.is_stylex_props_call(&call.callee) + && let Some(class_attribute) = self.stylex_class_attribute(&call.callee) { let (class_exprs, style_props) = self.resolve_stylex_props_args(&call.arguments); @@ -585,7 +951,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, - PropertyKey::StaticIdentifier(IdentifierName::boxed(SPAN, "className", &self.ast)), + PropertyKey::StaticIdentifier(IdentifierName::boxed( + SPAN, + class_attribute, + &self.ast, + )), class_name_expr, false, false, @@ -615,6 +985,17 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { *it = Expression::new_object_expression(SPAN, props, &self.ast); } + // Reached only when none of the blocks above replaced the call, so a surviving + // create/keyframes call is one whose argument could not be read statically. + if let Expression::CallExpression(call) = it + && (self.is_stylex_create_call(&call.callee) + || self.is_stylex_keyframes_call(&call.callee)) + { + eprintln!( + "[stylex] ERROR: stylex.create()/keyframes() require exactly one object literal argument. Spread arguments cannot be resolved at build time." + ); + } + if let Expression::CallExpression(call) = it { let util_type = if let Expression::Identifier(ident) = &call.callee { self.util_imports.get(ident.name.as_str()) @@ -717,14 +1098,60 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } ex.into_extract() })); - Expression::new_identifier(SPAN, "", &self.ast) + self.global_css_result(r.is_component()) } + } else if call.arguments.len() == 2 + && util_type.is_global() + && let Some(selector) = call.arguments[0] + .as_expression() + .and_then(get_string_by_literal_expression) + && let Some(rules) = call.arguments[1].as_expression() + { + // vanilla-extract spells global rules `globalStyle(selector, rules)`; + // fold the selector back into the object `globalCss` expects. + let mut folded = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::from_array_in( + [ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + PropertyKey::StringLiteral(StringLiteral::boxed( + SPAN, + Str::from_in(selector.as_ref(), self.ast.allocator()), + None, + &self.ast, + )), + rules.clone_in(self.ast.allocator()), + false, + false, + false, + &self.ast, + )], + &self.ast, + ), + &self.ast, + ); + let GlobalExtractResult { + styles, + style_order, + } = extract_global_style_from_expression( + &self.ast, + &mut folded, + &self.filename, + ); + self.styles.extend(styles.into_iter().flat_map(|mut ex| { + if let ExtractStyleProp::Static(css) = &mut ex { + css.set_style_order(style_order.unwrap_or(0)); + } + ex.into_extract() + })); + *it = self.global_css_result(util_type.is_component()); } else { *it = match util_type.as_ref() { UtilType::Css | UtilType::Keyframes => { Expression::new_string_literal(SPAN, "", None, &self.ast) } - UtilType::GlobalCss => Expression::new_identifier(SPAN, "", &self.ast), + global => self.global_css_result(global.is_component()), }; } } @@ -788,7 +1215,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }); self.styles.insert(css); } - Expression::new_identifier(SPAN, "", &self.ast) + self.global_css_result(r.is_component()) } } @@ -817,9 +1244,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }; if let Some(j) = jsx && (j == "jsx" || j == "jsxs") - && !it.arguments.is_empty() + && let Some(expr) = it.arguments.first().and_then(|arg| arg.as_expression()) { - let expr = it.arguments[0].to_expression(); let element_kind = if let Expression::Identifier(ident) = expr { self.imports.get(ident.name.as_str()).cloned() } else if let Expression::StaticMemberExpression(member) = expr @@ -836,7 +1262,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { None }; if let Some(kind) = element_kind - && it.arguments.len() > 1 + && it + .arguments + .get(1) + .is_some_and(|arg| arg.as_expression().is_some()) { // Pre-scan: detect conditional styleOrder before extract_style_from_expression // consumes the property (which only handles static values) @@ -1049,6 +1478,36 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.stylex_keyframe_names .insert(ident.name.to_string(), name); } + + // Capture stylex.defineVars() variable binding + if let Some(contract) = self.stylex_pending_vars.take() + && let Some(ident) = it.id.get_binding_identifier() + { + for (key, variable) in &contract { + self.stylex_var_refs + .insert(format!("{}.{key}", ident.name), format!("var({variable})")); + } + self.stylex_var_names + .insert(ident.name.to_string(), contract); + } + + // Capture stylex.createTheme() variable binding + if let Some(class_name) = self.stylex_pending_theme_class.take() + && let Some(ident) = it.id.get_binding_identifier() + { + self.stylex_theme_classes + .insert(ident.name.to_string(), class_name); + } + + // Capture stylex.defineConsts() variable binding + if let Some(constants) = self.stylex_pending_consts.take() + && let Some(ident) = it.id.get_binding_identifier() + { + for (key, value) in constants { + self.stylex_var_refs + .insert(format!("{}.{key}", ident.name), value); + } + } } fn visit_import_declaration(&mut self, it: &mut ImportDeclaration<'a>) { if it.source.value != self.package @@ -1061,7 +1520,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { .insert(import.local.to_string(), import.imported.to_string()); } } - } else if it.source.value == self.package + } else if (it.source.value == self.package + || it.source.value == self.compat_package.as_str()) && let Some(specifiers) = &mut it.specifiers { for i in (0..specifiers.len()).rev() { @@ -1078,6 +1538,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } else if imported_str == "styled" { self.styled_import = Some(import.local.to_string()); specifiers.remove(i); + } else if imported_str == "Global" { + self.global_style_components + .insert(import.local.to_string()); + specifiers.remove(i); } } ImportDeclarationSpecifier::ImportDefaultSpecifier( @@ -1132,13 +1596,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ImportSpecifier(named_spec) => { let imported = named_spec.imported.to_string(); let local = named_spec.local.name.to_string(); - let func = match imported.as_str() { - "create" => Some(StylexFunction::Create), - "props" => Some(StylexFunction::Props), - "keyframes" => Some(StylexFunction::Keyframes), - _ => None, - }; - if let Some(func) = func { + if let Some(func) = StylexFunction::from_export_name(&imported) { self.stylex_named_imports.insert(local, func); } } @@ -1152,6 +1610,41 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { #[allow(clippy::set_contains_or_insert)] fn visit_jsx_element(&mut self, elem: &mut JSXElement<'a>) { walk_jsx_element(self, elem); + + // `` is Emotion's spelling of a global stylesheet. + // Lift the rules out and strip every attribute, leaving a component that + // renders nothing — the same shape `createGlobalStyle` collapses to. + if let Some(name) = match &elem.opening_element.name { + JSXElementName::Identifier(id) => Some(id.name.as_str()), + JSXElementName::IdentifierReference(id) => Some(id.name.as_str()), + _ => None, + } && self.global_style_components.contains(name) + { + for i in (0..elem.opening_element.attributes.len()).rev() { + let Attribute(attr) = &mut elem.opening_element.attributes[i] else { + continue; + }; + if let Identifier(attr_name) = &attr.name + && attr_name.name == "styles" + && let Some(JSXAttributeValue::ExpressionContainer(container)) = &mut attr.value + && let Some(expression) = container.expression.as_expression_mut() + { + let GlobalExtractResult { + styles, + style_order, + } = extract_global_style_from_expression(&self.ast, expression, &self.filename); + self.styles.extend(styles.into_iter().flat_map(|mut ex| { + if let ExtractStyleProp::Static(css) = &mut ex { + css.set_style_order(style_order.unwrap_or(0)); + } + ex.into_extract() + })); + } + elem.opening_element.attributes.remove(i); + } + return; + } + // after run to convert css literal let kind = match &elem.opening_element.name { // Fast path: probe with `&str` directly, no allocation diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 9b2230fc2..f35377fb3 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join, relative, resolve } from 'node:path' import { + createCompatTypes, createThemeInterfaceArgs, type CustomShorthands, loadDevupConfig, @@ -59,6 +60,11 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { registerShorthands(shorthands ?? {}) if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(distDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) await writeDataFiles() } diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index c9fdde1b4..c6ffe926a 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -423,6 +423,7 @@ describe('DevupUINextPlugin', () => { defaultClassMap: {}, defaultFileMap: {}, importAliases: { + '@emotion/react': null, '@emotion/styled': 'styled', '@vanilla-extract/css': null, 'styled-components': 'styled', @@ -513,6 +514,7 @@ describe('DevupUINextPlugin', () => { classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), importAliases: { + '@emotion/react': null, '@emotion/styled': 'styled', '@vanilla-extract/css': null, 'styled-components': 'styled', @@ -610,6 +612,7 @@ describe('DevupUINextPlugin', () => { defaultClassMap: {}, defaultFileMap: {}, importAliases: { + '@emotion/react': null, '@emotion/styled': 'styled', '@vanilla-extract/css': null, 'styled-components': 'styled', @@ -661,6 +664,7 @@ describe('DevupUINextPlugin', () => { classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), importAliases: { + '@emotion/react': null, '@emotion/styled': 'styled', '@vanilla-extract/css': null, 'styled-components': 'styled', @@ -944,6 +948,7 @@ export const box = style({ color })` classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), importAliases: { + '@emotion/react': null, '@emotion/styled': 'styled', '@vanilla-extract/css': null, 'styled-components': 'styled', diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index a026f1694..b520692ec 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -13,6 +13,7 @@ import { buildStaticImportGraph, computeCompiledFiles, computeFileRoutes, + createCompatTypes, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type DevupUIBasePluginOptions, @@ -299,6 +300,10 @@ export function DevupUI( setPrefix(prefix) } + writeFileSync( + join(distDir, 'compat.d.ts'), + createCompatTypes(importAliases), + ) // Import previous session state to handle Turbopack persistent cache. // When the dev server restarts, Turbopack may skip re-running loaders for // unchanged files. Without importing previous state, the coordinator's WASM diff --git a/packages/plugin-utils/src/__tests__/create-compat-types.test.ts b/packages/plugin-utils/src/__tests__/create-compat-types.test.ts new file mode 100644 index 000000000..dd3fff83d --- /dev/null +++ b/packages/plugin-utils/src/__tests__/create-compat-types.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test' + +import { createCompatTypes, mergeImportAliases } from '../types' + +describe('createCompatTypes', () => { + it('references an entry for every default alias plus stylex', () => { + expect(createCompatTypes(mergeImportAliases())).toBe( + [ + '/// ', + '/// ', + '/// ', + '/// ', + ].join('\n') + '\n', + ) + }) + + it('omits a disabled alias so its own types keep winning', () => { + expect( + createCompatTypes( + mergeImportAliases({ + '@emotion/react': false, + '@emotion/styled': false, + '@vanilla-extract/css': false, + }), + ), + ).toBe( + [ + '/// ', + '/// ', + ].join('\n') + '\n', + ) + }) + + it('collapses both emotion packages onto one entry', () => { + expect( + createCompatTypes({ + '@emotion/react': null, + '@emotion/styled': 'styled', + }), + ).toBe( + [ + '/// ', + '/// ', + ].join('\n') + '\n', + ) + }) + + it('still emits stylex when nothing is aliased', () => { + expect(createCompatTypes({})).toBe( + '/// \n', + ) + }) + + it('ignores aliases with no compat entry', () => { + expect(createCompatTypes({ 'some-lib': 'styled' })).toBe( + '/// \n', + ) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts b/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts index aff059cb2..286292711 100644 --- a/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts +++ b/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts @@ -11,6 +11,7 @@ describe('mergeImportAliases', () => { const result = mergeImportAliases() expect(result).toEqual({ + '@emotion/react': null, '@emotion/styled': 'styled', 'styled-components': 'styled', '@vanilla-extract/css': null, @@ -23,6 +24,7 @@ describe('mergeImportAliases', () => { }) expect(result).toEqual({ + '@emotion/react': null, '@emotion/styled': 'styled', 'styled-components': 'styled', '@vanilla-extract/css': null, @@ -57,6 +59,7 @@ describe('mergeImportAliases', () => { it('should handle disabling all defaults', () => { const result = mergeImportAliases({ + '@emotion/react': false, '@emotion/styled': false, 'styled-components': false, '@vanilla-extract/css': false, @@ -81,6 +84,7 @@ describe('mergeImportAliases', () => { describe('DEFAULT_IMPORT_ALIASES', () => { it('should have correct default values', () => { expect(DEFAULT_IMPORT_ALIASES).toEqual({ + '@emotion/react': true, '@emotion/styled': 'styled', 'styled-components': 'styled', '@vanilla-extract/css': true, diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 2b9ba3d39..7cece4f19 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -32,4 +32,8 @@ export type { Typography, WasmImportAliases, } from './types' -export { DEFAULT_IMPORT_ALIASES, mergeImportAliases } from './types' +export { + createCompatTypes, + DEFAULT_IMPORT_ALIASES, + mergeImportAliases, +} from './types' diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index b995e9eca..02218de9d 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -96,6 +96,7 @@ export type ImportAliases = Record * Default import aliases for common CSS-in-JS libraries */ export const DEFAULT_IMPORT_ALIASES: ImportAliases = { + '@emotion/react': true, '@emotion/styled': 'styled', 'styled-components': 'styled', '@vanilla-extract/css': true, @@ -130,3 +131,36 @@ export function mergeImportAliases( .map(([pkg, value]) => [pkg, value === true ? null : value]), ) as WasmImportAliases } + +/** + * Which `@devup-ui/react/compat` entry supplies the types for an aliased package. + * Several specifiers can share one entry (both Emotion packages, for instance). + */ +const COMPAT_TYPE_ENTRIES: Record = { + '@emotion/react': 'emotion', + '@emotion/styled': 'emotion', + '@vanilla-extract/css': 'vanilla-extract', + 'styled-components': 'styled-components', +} + +/** + * Build the declaration file that makes aliased packages type-check without + * being installed. + * + * Only enabled aliases are referenced: an ambient declaration wins over a real + * installation, so a package the user opted out of must keep its own types. + * StyleX is always included — the extractor recognises `@stylexjs/stylex` + * directly rather than through the alias table. + */ +export function createCompatTypes(aliases: WasmImportAliases): string { + const entries = new Set(['stylex']) + for (const pkg of Object.keys(aliases)) { + const entry = COMPAT_TYPE_ENTRIES[pkg] + if (entry) entries.add(entry) + } + return [...entries] + .sort() + .map((entry) => `/// `) + .join('\n') + .concat('\n') +} diff --git a/packages/react/package.json b/packages/react/package.json index 262f50515..276bdc11a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,6 +34,31 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./compat": { + "types": "./dist/compat/index.d.ts", + "import": "./dist/compat/index.js", + "require": "./dist/compat/index.cjs" + }, + "./compat/types": { + "types": "./dist/compat/types.d.ts" + }, + "./compat/styled-components": { + "types": "./dist/compat/styled-components.d.ts" + }, + "./compat/emotion": { + "types": "./dist/compat/emotion.d.ts" + }, + "./compat/stylex": { + "types": "./dist/compat/stylex.d.ts" + }, + "./compat/vanilla-extract": { + "types": "./dist/compat/vanilla-extract.d.ts" + }, + "./stylex": { + "types": "./dist/utils/stylex.d.ts", + "import": "./dist/utils/stylex.js", + "require": "./dist/utils/stylex.cjs" } }, "files": [ diff --git a/packages/react/src/compat/__tests__/index.test.ts b/packages/react/src/compat/__tests__/index.test.ts new file mode 100644 index 000000000..ad6e86619 --- /dev/null +++ b/packages/react/src/compat/__tests__/index.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'bun:test' + +describe('compat entry', () => { + it('exports only the absorbed third-party APIs', async () => { + const compat = await import('../index') + + expect({ ...compat }).toEqual({ + Global: expect.any(Function), + ThemeProvider: expect.any(Function), + + createGlobalStyle: expect.any(Function), + useTheme: expect.any(Function), + withTheme: expect.any(Function), + + isStyledComponent: expect.any(Function), + ServerStyleSheet: expect.any(Function), + StyleSheetManager: expect.any(Function), + }) + }) + + it('keeps its useTheme distinct from the devup-ui one', async () => { + const { useTheme: compatUseTheme } = await import('../index') + const { useTheme: devupUseTheme } = await import('../../index') + + expect(compatUseTheme).not.toBe(devupUseTheme) + expect(`${compatUseTheme<{ brand: string }>().brand}`).toBe('var(--brand)') + }) +}) diff --git a/packages/react/src/compat/emotion.d.ts b/packages/react/src/compat/emotion.d.ts new file mode 100644 index 000000000..69bdf9176 --- /dev/null +++ b/packages/react/src/compat/emotion.d.ts @@ -0,0 +1,16 @@ +declare module '@emotion/styled' { + import { styled } from '@devup-ui/react' + + export default styled +} + +declare module '@emotion/react' { + export { css, keyframes } from '@devup-ui/react' + export type { StyledTheme as Theme } from '@devup-ui/react/compat' + export { + Global, + ThemeProvider, + useTheme, + withTheme, + } from '@devup-ui/react/compat' +} diff --git a/packages/react/src/compat/index.ts b/packages/react/src/compat/index.ts new file mode 100644 index 000000000..c22aa8eb4 --- /dev/null +++ b/packages/react/src/compat/index.ts @@ -0,0 +1,20 @@ +/** + * Runtime entry for the third-party CSS-in-JS APIs Devup UI absorbs. + * + * These exist only so a rewritten import resolves — none of them belong to the + * Devup UI API, so they stay out of `@devup-ui/react` and never widen what a + * project using Devup UI directly has to look at. The build plugins point + * rewritten imports here automatically. + */ + +export { Global } from '../components/Global' +export { ThemeProvider } from '../components/ThemeProvider' +export { useStyledTheme as useTheme } from '../hooks/use-styled-theme' +export { createGlobalStyle } from '../utils/create-global-style' +export { + isStyledComponent, + ServerStyleSheet, + StyleSheetManager, +} from '../utils/styled-compat' +export type { StyledTheme } from '../utils/theme-vars' +export { withTheme } from '../utils/with-theme' diff --git a/packages/react/src/compat/styled-components.d.ts b/packages/react/src/compat/styled-components.d.ts new file mode 100644 index 000000000..39658dfcc --- /dev/null +++ b/packages/react/src/compat/styled-components.d.ts @@ -0,0 +1,18 @@ +declare module 'styled-components' { + import { styled } from '@devup-ui/react' + + export { css, keyframes } from '@devup-ui/react' + export type { StyledTheme as DefaultTheme } from '@devup-ui/react/compat' + export { + createGlobalStyle, + isStyledComponent, + ServerStyleSheet, + StyleSheetManager, + ThemeProvider, + useTheme, + withTheme, + } from '@devup-ui/react/compat' + + export { styled } + export default styled +} diff --git a/packages/react/src/compat/stylex.d.ts b/packages/react/src/compat/stylex.d.ts new file mode 100644 index 000000000..955c3c59f --- /dev/null +++ b/packages/react/src/compat/stylex.d.ts @@ -0,0 +1,21 @@ +declare module '@stylexjs/stylex' { + import { stylex } from '@devup-ui/react' + + export { + attrs, + create, + createTheme, + createThemeContract, + defineConsts, + defineVars, + firstThatWorks, + include, + keyframes, + positionTry, + props, + types, + viewTransitionClass, + } from '@devup-ui/react/stylex' + + export default stylex +} diff --git a/packages/react/src/compat/types.d.ts b/packages/react/src/compat/types.d.ts new file mode 100644 index 000000000..2736c2d77 --- /dev/null +++ b/packages/react/src/compat/types.d.ts @@ -0,0 +1,28 @@ +/** + * Ambient declarations for every CSS-in-JS package the Devup UI extractor + * rewrites, typed as the devup-ui equivalents the code compiles to. + * + * The build plugins write these references into `/compat.d.ts` for the + * aliases they have enabled, so nothing has to be configured by hand. Reference + * this aggregate only to opt in manually — from a tsconfig: + * + * ```json + * { "compilerOptions": { "types": ["@devup-ui/react/compat"] } } + * ``` + * + * or from a source file: + * + * ```ts + * /// + * ``` + * + * A declaration takes precedence over an installed package of the same name, + * so pull in only the ones whose imports the build actually rewrites. The + * per-package entries (`@devup-ui/react/compat/styled-components`, `/emotion`, + * `/stylex`, `/vanilla-extract`) exist for exactly that. + */ + +/// +/// +/// +/// diff --git a/packages/react/src/compat/vanilla-extract.d.ts b/packages/react/src/compat/vanilla-extract.d.ts new file mode 100644 index 000000000..13ddd2357 --- /dev/null +++ b/packages/react/src/compat/vanilla-extract.d.ts @@ -0,0 +1,38 @@ +declare module '@vanilla-extract/css' { + export { keyframes, css as style } from '@devup-ui/react' + + type VanillaRule = Record + + /** + * Keeps vanilla-extract's own two-argument shape. The extractor folds the + * selector back into the object `globalCss` takes, so the call compiles even + * though the devup-ui signature it maps onto is single-argument. + */ + export function globalStyle(selector: string, rule: VanillaRule): void + + /** + * Declared but not rewritten: these only run inside `.css.ts` / `.css.js` + * stylesheets, where the extractor evaluates the module and replaces every + * call with its generated output. + */ + export function styleVariants>( + variants: T, + ): Record + export function createVar(): string + export function fallbackVar(...values: string[]): string + export function fontFace(rule: VanillaRule): string + export function globalFontFace(name: string, rule: VanillaRule): void + export function globalKeyframes(name: string, frames: VanillaRule): void + export function createTheme(contract: T, values: unknown): string + export function createTheme(values: T): [string, T] + export function createThemeContract(shape: T): T + export function assignVars( + contract: unknown, + values: unknown, + ): Record + export function composeStyles(...classNames: string[]): string + export function layer(options?: unknown): string + export function globalLayer(options: unknown, name?: string): string + export function createContainer(): string + export function generateIdentifier(debugId?: string): string +} diff --git a/packages/react/src/components/Global.tsx b/packages/react/src/components/Global.tsx new file mode 100644 index 000000000..4327b5358 --- /dev/null +++ b/packages/react/src/components/Global.tsx @@ -0,0 +1,15 @@ +import type { GlobalCssProps } from '../utils/global-css' + +interface GlobalProps { + styles?: GlobalCssProps +} + +/** + * Emotion compatible global stylesheet component. + * + * The extractor lifts `styles` into the stylesheet at build time and strips the + * prop, so the rendered component is inert — it exists only as a render site. + */ +export function Global(_props: GlobalProps) { + return null +} diff --git a/packages/react/src/components/ThemeProvider.tsx b/packages/react/src/components/ThemeProvider.tsx new file mode 100644 index 000000000..9df1ad710 --- /dev/null +++ b/packages/react/src/components/ThemeProvider.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +import type { StyledTheme } from '../utils/theme-vars' +import { themeToCssVariables } from '../utils/theme-vars' + +interface ThemeProviderProps { + theme?: StyledTheme + children?: ReactNode +} + +export function ThemeProvider({ theme, children }: ThemeProviderProps) { + return
{children}
+} diff --git a/packages/react/src/components/__tests__/Global.browser.test.tsx b/packages/react/src/components/__tests__/Global.browser.test.tsx new file mode 100644 index 000000000..b96b8b094 --- /dev/null +++ b/packages/react/src/components/__tests__/Global.browser.test.tsx @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'bun:test' +import { render } from 'bun-test-env-dom' + +import { Global } from '../Global' + +describe('Global', () => { + it('renders nothing because the styles are extracted at build time', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) +}) diff --git a/packages/react/src/components/__tests__/ThemeProvider.browser.test.tsx b/packages/react/src/components/__tests__/ThemeProvider.browser.test.tsx new file mode 100644 index 000000000..4c53b6134 --- /dev/null +++ b/packages/react/src/components/__tests__/ThemeProvider.browser.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test' +import { render } from 'bun-test-env-dom' + +import { ThemeProvider } from '../ThemeProvider' + +describe('ThemeProvider', () => { + it('should declare css variables without affecting layout', () => { + const { container } = render( + + child + , + ) + expect(container).toMatchSnapshot() + }) + + it('should render without a theme', () => { + const { container } = render( + + child + , + ) + expect(container).toMatchSnapshot() + }) +}) diff --git a/packages/react/src/components/__tests__/__snapshots__/ThemeProvider.browser.test.tsx.snap b/packages/react/src/components/__tests__/__snapshots__/ThemeProvider.browser.test.tsx.snap new file mode 100644 index 000000000..db8fcfb56 --- /dev/null +++ b/packages/react/src/components/__tests__/__snapshots__/ThemeProvider.browser.test.tsx.snap @@ -0,0 +1,21 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`ThemeProvider should declare css variables without affecting layout 1`] = ` +"
+
+ + child + +
+
" +`; + +exports[`ThemeProvider should render without a theme 1`] = ` +"
+
+ + child + +
+
" +`; diff --git a/packages/react/src/hooks/__tests__/use-styled-theme.test.ts b/packages/react/src/hooks/__tests__/use-styled-theme.test.ts new file mode 100644 index 000000000..1882a7cc1 --- /dev/null +++ b/packages/react/src/hooks/__tests__/use-styled-theme.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'bun:test' + +import { useStyledTheme } from '../use-styled-theme' + +describe('useStyledTheme', () => { + it('returns css variable references rather than a theme name', () => { + const theme = useStyledTheme<{ colors: { brand: string } }>() + expect(`${theme.colors.brand}`).toBe('var(--colors-brand)') + }) +}) diff --git a/packages/react/src/hooks/use-styled-theme.ts b/packages/react/src/hooks/use-styled-theme.ts new file mode 100644 index 000000000..4088e9215 --- /dev/null +++ b/packages/react/src/hooks/use-styled-theme.ts @@ -0,0 +1,15 @@ +import type { StyledTheme } from '../utils/theme-vars' +import { createThemeAccessor } from '../utils/theme-vars' + +const themeAccessor = createThemeAccessor() + +/** + * styled-components compatible `useTheme`. + * + * Distinct from devup-ui's own `useTheme`, which reports the active theme name. + * This one hands back CSS-variable references, matching what `ThemeProvider` + * declares and what the extractor inlines into styles. + */ +export function useStyledTheme(): T { + return themeAccessor as T +} diff --git a/packages/react/src/types/props/index.ts b/packages/react/src/types/props/index.ts index bb716888c..2972d17aa 100644 --- a/packages/react/src/types/props/index.ts +++ b/packages/react/src/types/props/index.ts @@ -34,6 +34,12 @@ export interface DevupProps extends DevupCommonProps, DevupSelectorProps {} export interface DevupPropsWithTheme extends DevupProps, DevupThemeSelectorProps {} +export type StyledThemeValue = string | number + +export interface StyledTheme { + [key: string]: StyledThemeValue | StyledTheme +} + export interface DevupComponentProps< T extends React.ElementType, > extends DevupPropsWithTheme { diff --git a/packages/react/src/utils/__tests__/create-global-style.test.ts b/packages/react/src/utils/__tests__/create-global-style.test.ts new file mode 100644 index 000000000..9fd27b86e --- /dev/null +++ b/packages/react/src/utils/__tests__/create-global-style.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'bun:test' + +import { createGlobalStyle } from '../create-global-style' + +describe('createGlobalStyle', () => { + it('cannot run on the runtime', () => { + expect(() => createGlobalStyle`body { margin: 0; }`).toThrowError( + 'Cannot run on the runtime', + ) + expect(() => createGlobalStyle({ body: { margin: 0 } })).toThrowError( + 'Cannot run on the runtime', + ) + }) +}) diff --git a/packages/react/src/utils/__tests__/styled-compat.test.ts b/packages/react/src/utils/__tests__/styled-compat.test.ts new file mode 100644 index 000000000..3a7b15091 --- /dev/null +++ b/packages/react/src/utils/__tests__/styled-compat.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test' + +import { + isStyledComponent, + ServerStyleSheet, + StyleSheetManager, +} from '../styled-compat' + +describe('ServerStyleSheet', () => { + it('collects nothing because the stylesheet is already on disk', () => { + const sheet = new ServerStyleSheet() + expect(sheet.collectStyles('children')).toBe('children') + expect(sheet.getStyleTags()).toBe('') + expect(sheet.getStyleElement()).toEqual([]) + expect(sheet.seal()).toBeUndefined() + }) +}) + +describe('StyleSheetManager', () => { + it('renders its children untouched', () => { + expect(StyleSheetManager({ children: 'child' })).toBe('child') + expect(StyleSheetManager({})).toBeUndefined() + }) +}) + +describe('isStyledComponent', () => { + it('always reports false', () => { + expect(isStyledComponent(() => null)).toBe(false) + }) +}) diff --git a/packages/react/src/utils/__tests__/stylex.test.ts b/packages/react/src/utils/__tests__/stylex.test.ts index d2dbb5cb7..6d5b4a953 100644 --- a/packages/react/src/utils/__tests__/stylex.test.ts +++ b/packages/react/src/utils/__tests__/stylex.test.ts @@ -1,14 +1,19 @@ import { describe, expect, it } from 'bun:test' import { + attrs, create, createTheme, + createThemeContract, + defineConsts, defineVars, firstThatWorks, include, keyframes, + positionTry, props, types, + viewTransitionClass, } from '../stylex' describe('stylex', () => { @@ -22,6 +27,25 @@ describe('stylex', () => { expect(() => props()).toThrowError('Cannot run on the runtime') }) + it('attrs should throw at runtime', () => { + expect(() => attrs()).toThrowError('Cannot run on the runtime') + }) + + it('theme and at-rule helpers should throw at runtime', () => { + expect(() => createThemeContract({ p: 'red' })).toThrowError( + 'Cannot run on the runtime', + ) + expect(() => defineConsts({ p: 'red' })).toThrowError( + 'Cannot run on the runtime', + ) + expect(() => positionTry({ top: '0' })).toThrowError( + 'Cannot run on the runtime', + ) + expect(() => viewTransitionClass({ opacity: '0' })).toThrowError( + 'Cannot run on the runtime', + ) + }) + it('keyframes should throw at runtime', () => { expect(() => keyframes({ from: { opacity: '0' }, to: { opacity: '1' } }), diff --git a/packages/react/src/utils/__tests__/theme-vars.test.ts b/packages/react/src/utils/__tests__/theme-vars.test.ts new file mode 100644 index 000000000..7fa4b1c85 --- /dev/null +++ b/packages/react/src/utils/__tests__/theme-vars.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'bun:test' + +import { + createThemeAccessor, + themeToCssVariables, + themeVariableName, +} from '../theme-vars' + +describe('themeVariableName', () => { + it('joins a path into a custom property', () => { + expect(themeVariableName(['colors', 'brand'])).toBe('--colors-brand') + expect(themeVariableName(['brand'])).toBe('--brand') + }) +}) + +describe('themeToCssVariables', () => { + it('keeps the provider out of layout', () => { + expect(themeToCssVariables()).toEqual({ display: 'contents' }) + }) + + it('flattens nested themes and skips undefined values', () => { + const style = themeToCssVariables({ + brand: 'red', + size: 4, + colors: { accent: 'blue', nested: { deep: 'green' } }, + missing: undefined as never, + }) + expect(style as Record).toEqual({ + display: 'contents', + '--brand': 'red', + '--size': 4, + '--colors-accent': 'blue', + '--colors-nested-deep': 'green', + }) + }) + + it('treats null as a value rather than a nested theme', () => { + const style = themeToCssVariables({ + brand: null as never, + }) + expect(style as Record).toEqual({ + display: 'contents', + '--brand': null, + }) + }) +}) + +describe('createThemeAccessor', () => { + it('resolves reads to css variable references', () => { + const theme = createThemeAccessor<{ colors: { brand: string } }>() + expect(`${theme.colors.brand}`).toBe('var(--colors-brand)') + expect(String(theme.colors.brand)).toBe('var(--colors-brand)') + expect(theme.colors.brand.valueOf()).toBe('var(--colors-brand)') + expect(theme.colors.brand.toString()).toBe('var(--colors-brand)') + }) + + it('has no reference at the root and ignores symbol keys', () => { + const theme = createThemeAccessor() + expect(`${theme}`).toBe('') + expect( + (theme as unknown as Record)[Symbol.iterator], + ).toBeUndefined() + }) +}) diff --git a/packages/react/src/utils/__tests__/with-theme.browser.test.tsx b/packages/react/src/utils/__tests__/with-theme.browser.test.tsx new file mode 100644 index 000000000..aa6fea8ac --- /dev/null +++ b/packages/react/src/utils/__tests__/with-theme.browser.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'bun:test' +import { render } from 'bun-test-env-dom' + +import type { StyledTheme } from '../theme-vars' +import { withTheme } from '../with-theme' + +describe('withTheme', () => { + it('injects the css-variable theme accessor', () => { + const Swatch = ({ theme }: { theme?: StyledTheme }) => ( +
{`${(theme as { brand: string }).brand}`}
+ ) + const Themed = withTheme(Swatch) + const { container } = render() + expect(container.textContent).toBe('var(--brand)') + }) +}) diff --git a/packages/react/src/utils/create-global-style.ts b/packages/react/src/utils/create-global-style.ts new file mode 100644 index 000000000..95ebd39d2 --- /dev/null +++ b/packages/react/src/utils/create-global-style.ts @@ -0,0 +1,22 @@ +import type { GlobalCssProps } from './global-css' + +/** + * styled-components compatible global style declaration. + * + * The CSS is extracted at build time exactly like `globalCss`, and the call is + * replaced with a component that renders nothing — so an existing + * `` render site keeps working without a runtime. + */ +export function createGlobalStyle( + strings: TemplateStringsArray, + ...values: (string | number | boolean | null | undefined)[] +): () => null + +export function createGlobalStyle(props: GlobalCssProps): () => null + +export function createGlobalStyle( + _strings?: TemplateStringsArray | GlobalCssProps, + ..._values: (string | number | boolean | null | undefined)[] +): () => null { + throw new Error('Cannot run on the runtime') +} diff --git a/packages/react/src/utils/styled-compat.ts b/packages/react/src/utils/styled-compat.ts new file mode 100644 index 000000000..517ad4d4f --- /dev/null +++ b/packages/react/src/utils/styled-compat.ts @@ -0,0 +1,40 @@ +import type { ReactElement, ReactNode } from 'react' + +/** + * styled-components SSR plumbing, kept as no-ops. + * + * Those APIs exist to collect runtime-injected styles and flush them into the + * server response. Devup UI writes every rule into a real stylesheet at build + * time, so there is nothing to collect — the shims let existing call sites keep + * compiling while producing no markup of their own. + */ +export class ServerStyleSheet { + constructor() {} + + collectStyles(children: ReactNode): ReactNode { + return children + } + + getStyleTags(): string { + return '' + } + + getStyleElement(): ReactElement[] { + return [] + } + + seal(): void {} +} + +export function StyleSheetManager({ + children, +}: { + children?: ReactNode + [key: string]: unknown +}): ReactNode { + return children +} + +export function isStyledComponent(_target: unknown): boolean { + return false +} diff --git a/packages/react/src/utils/styled.ts b/packages/react/src/utils/styled.ts index 077a878f9..66d22c84a 100644 --- a/packages/react/src/utils/styled.ts +++ b/packages/react/src/utils/styled.ts @@ -1,32 +1,40 @@ -import type { DevupPropsWithTheme } from '../types/props' +import type { DevupPropsWithTheme, StyledTheme } from '../types/props' + +/** + * Props an interpolation receives. `theme` is always present: `ThemeProvider` + * publishes it as CSS variables, and the extractor resolves `props.theme.x` + * reads to `var(--x)` at build time. + */ +type InterpolationProps< + P, + T extends React.ElementType | React.ComponentType, +> = P & React.ComponentProps & { theme: StyledTheme } + +type Interpolation = + | ((props: InterpolationProps) => unknown) + | string + | number + | boolean + | null + | undefined interface StyledCreator { + ( + tag: T, + styles: DevupPropsWithTheme, + ): (props: React.ComponentProps) => React.ReactElement ( tag: T, ): ( strings: TemplateStringsArray | DevupPropsWithTheme, - ...values: ( - | ((props: React.ComponentProps) => unknown) - | string - | number - | boolean - | null - | undefined - )[][] + ...values: Interpolation[][] ) => (props: React.ComponentProps) => React.ReactElement } type Styled = StyledCreator & { - [T in keyof React.JSX.IntrinsicElements]:

( + [T in keyof React.JSX.IntrinsicElements]:

>( strings: TemplateStringsArray | DevupPropsWithTheme, - ...values: ( - | ((props: P & React.ComponentProps) => unknown) - | string - | number - | boolean - | null - | undefined - )[] + ...values: Interpolation[] ) => (props: P & React.ComponentProps) => React.ReactElement } diff --git a/packages/react/src/utils/stylex.ts b/packages/react/src/utils/stylex.ts index 373c2b0f0..e46905f6c 100644 --- a/packages/react/src/utils/stylex.ts +++ b/packages/react/src/utils/stylex.ts @@ -30,6 +30,12 @@ export function props( throw new Error('Cannot run on the runtime') } +export function attrs( + ..._styles: ReadonlyArray +): { class?: string; style?: Record } { + throw new Error('Cannot run on the runtime') +} + export function keyframes(_frames: Record): string { throw new Error('Cannot run on the runtime') } @@ -56,6 +62,26 @@ export function createTheme>( throw new Error('Cannot run on the runtime') } +export function createThemeContract>( + _vars: V, +): { readonly [K in keyof V]: string } { + throw new Error('Cannot run on the runtime') +} + +export function defineConsts>( + _consts: V, +): { readonly [K in keyof V]: V[K] } { + throw new Error('Cannot run on the runtime') +} + +export function positionTry(_fallback: StyleProperties): string { + throw new Error('Cannot run on the runtime') +} + +export function viewTransitionClass(_styles: StyleProperties): string { + throw new Error('Cannot run on the runtime') +} + export const types: StyleXTypes = new Proxy({} as StyleXTypes, { get() { return () => { diff --git a/packages/react/src/utils/theme-vars.ts b/packages/react/src/utils/theme-vars.ts new file mode 100644 index 000000000..88f54d7a2 --- /dev/null +++ b/packages/react/src/utils/theme-vars.ts @@ -0,0 +1,59 @@ +import type { CSSProperties } from 'react' + +import type { StyledTheme } from '../types/props' + +export type { StyledTheme, StyledThemeValue } from '../types/props' + +/** + * Join a theme path into the CSS custom property that carries it, so + * `theme.colors.brand` and the `--colors-brand` variable always agree. + */ +export function themeVariableName(path: readonly string[]): string { + return `--${path.join('-')}` +} + +/** + * Flatten a (possibly nested) theme into CSS custom property declarations. + * + * `display: contents` keeps the provider out of layout: the element exists only + * to scope the variables to its subtree, and the cascade handles nesting. + */ +export function themeToCssVariables(theme?: StyledTheme): CSSProperties { + const style: Record = { display: 'contents' } + const walk = (node: StyledTheme, path: string[]) => { + for (const [key, value] of Object.entries(node)) { + const next = [...path, key] + if (value !== null && typeof value === 'object') { + walk(value, next) + } else if (value !== undefined) { + style[themeVariableName(next)] = value + } + } + } + if (theme) walk(theme, []) + return style as CSSProperties +} + +/** + * Theme accessor backed purely by CSS variables. + * + * Reading any path yields the `var(--path)` reference for it, so a value read in + * JS and a value the extractor inlined at build time resolve to the same custom + * property. Nesting works because each read returns another accessor. + */ +export function createThemeAccessor( + path: readonly string[] = [], +): T { + const reference = path.length ? `var(${themeVariableName(path)})` : '' + return new Proxy( + {}, + { + get(_target, key) { + if (key === Symbol.toPrimitive) return () => reference + if (typeof key !== 'string') return undefined + if (key === 'toString' || key === 'valueOf') return () => reference + return createThemeAccessor([...path, key]) + }, + }, + ) as T +} diff --git a/packages/react/src/utils/with-theme.tsx b/packages/react/src/utils/with-theme.tsx new file mode 100644 index 000000000..14f25c2bb --- /dev/null +++ b/packages/react/src/utils/with-theme.tsx @@ -0,0 +1,12 @@ +import type { ComponentType } from 'react' + +import { useStyledTheme } from '../hooks/use-styled-theme' +import type { StyledTheme } from './theme-vars' + +export function withTheme

( + Component: ComponentType

, +): ComponentType> { + return function WithTheme(props: Omit) { + return + } +} diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index c496cdf5b..17167e831 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -49,6 +49,7 @@ export default defineConfig({ formats: ['es', 'cjs'], entry: { index: 'src/index.ts', + 'compat/index': 'src/compat/index.ts', }, }, outDir: 'dist', diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index fedc42e2d..16f618fd2 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -5,6 +5,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, computeFileReach, + createCompatTypes, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, @@ -127,6 +128,11 @@ export const DevupUI = ({ if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(distDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) await writeDataFiles({ package: libPackage, diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index cb6582988..53399fe8d 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -1095,6 +1095,7 @@ describe('devupUIVitePlugin', () => { true, false, { + '@emotion/react': null, '@emotion/styled': 'styled', '@vanilla-extract/css': null, 'styled-components': 'styled', @@ -1112,6 +1113,7 @@ describe('devupUIVitePlugin', () => { true, false, { + '@emotion/react': null, '@emotion/styled': 'styled', '@vanilla-extract/css': null, 'styled-components': 'styled', diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 546dd81bf..4d39cb6a0 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -5,6 +5,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, computeFileReach, + createCompatTypes, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, @@ -287,6 +288,11 @@ export function DevupUI({ } if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(distDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) await writeDataFiles({ package: libPackage, cssDir, diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 1a43af8b6..c5dbc18aa 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -6,6 +6,7 @@ import { dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, computeFileReach, + createCompatTypes, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, @@ -186,6 +187,11 @@ export class DevupUIWebpackPlugin { if (!existsSync(this.options.distDir)) mkdirSync(this.options.distDir, { recursive: true }) writeFileSync(join(this.options.distDir, '.gitignore'), '*', 'utf-8') + writeFileSync( + join(this.options.distDir, 'compat.d.ts'), + createCompatTypes(this.importAliases), + 'utf-8', + ) if (this.options.watch) { try {