From 74a6627bd4a517704784deb6b6d1d62551456651 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 15 Sep 2026 21:08:57 +0900 Subject: [PATCH] Support styled-components, Emotion, vanilla-extract, and StyleX sources Projects migrating to Devup UI had to rewrite every styling call site before they could build. The extractor now recognises the source APIs of those four libraries and rewrites them to Devup equivalents at build time, so an existing codebase compiles as-is and can migrate file by file. - Resolve StyleX computed member access (`styles[variant]`), which previously dropped the whole style. Fixes #664. - Implement the remaining StyleX surface: defineVars, createTheme, createThemeContract, defineConsts, positionTry, viewTransitionClass, attrs, StyleXArray and optional chaining. - Map styled-components and Emotion onto `styled`/`css`, including both the curried and two-argument call forms, and add the Emotion `Global` element. - Resolve `props.theme` interpolation to CSS variables at build time via ThemeProvider, so theme reads cost no runtime work. - Map vanilla-extract `style`/`globalStyle` onto `css`/`globalCss`. Compat-only symbols move to a separate `@devup-ui/react/compat` entry so the main entry keeps exporting Devup's own API, and each plugin emits a `compat.d.ts` alongside the generated CSS to type the source packages. Replace 12 `to_expression()` calls with `as_expression()`; the former panics on non-expression nodes that these code paths can legitimately receive. Two placements are load-bearing rather than cosmetic. `is_vanilla_extract_file` moves to `utils` because import rewriting needs it in every build, while its old module is behind the `vanilla-extract` feature that the lite WASM variant drops. `StyledTheme` is declared in `types/props` so `styled` reaches it over an import edge it already had: pulling it from `utils/theme-vars` instead adds a new edge that destabilises how `DevupProps` and `DevupCustomShorthands` resolve, and drops the selector props. --- .../src/app/(detail)/docs/LeftMenu.tsx | 22 + .../(detail)/docs/migration/overview/page.mdx | 91 +++ .../docs/migration/styled-components/page.mdx | 96 +++ .../(detail)/docs/migration/stylex/page.mdx | 98 +++ .../docs/migration/vanilla-extract/page.mdx | 58 ++ e2e/exported-routes.ts | 4 + libs/extractor/src/css_utils.rs | 67 +- .../extract_global_style_from_expression.rs | 15 +- .../extract_style_from_expression.rs | 45 +- .../extractor/extract_style_from_styled.rs | 86 ++- .../extractor/extract_style_from_stylex.rs | 61 +- libs/extractor/src/import_alias_visit.rs | 305 ++++++-- libs/extractor/src/lib.rs | 652 +++++++++++++++++- libs/extractor/src/prop_modify_utils.rs | 33 +- ...ult_and_named_both_retained_on_source.snap | 5 + ...hout_devup_equivalent_stays_on_source.snap | 5 + ...ecifier_in_vanilla_extract_stylesheet.snap | 5 + ...ts__default_to_named_namespace_import.snap | 4 +- ...it__tests__multiple_imports_same_file.snap | 4 +- ...visit__tests__named_import_with_alias.snap | 4 +- ...s_without_devup_export_stay_on_source.snap | 5 + ...rt_alias_visit__tests__named_to_named.snap | 4 +- ...ests__named_to_named_namespace_import.snap | 4 +- ...named_to_named_with_default_specifier.snap | 4 +- ...space_import_of_a_compat_only_default.snap | 5 + ...ract_names_map_onto_devup_equivalents.snap | 5 + ...l_style_collapses_to_a_null_component.snap | 42 ++ ...ss_and_global_css_typescript_wrappers.snap | 51 ++ ..._tests__devup_props_optional_chaining.snap | 27 + ...ests__devup_props_typescript_wrappers.snap | 48 ++ ...n_global_component_and_import_surface.snap | 36 + ..._emotion_global_with_spread_attribute.snap | 26 + ...member_expression_with_dynamic_values.snap | 27 + ...ctor__tests__minus_zero_is_normalized.snap | 19 + ...t_of_a_default_only_package_redirects.snap | 29 + ..._tests__parenthesized_string_literals.snap | 39 ++ ...__props_prop_becomes_spread_attribute.snap | 19 + ...ests__raw_selector_key_without_parent.snap | 51 ++ ...tests__styled_accepts_both_call_forms.snap | 49 ++ ...onents_import_surface_fully_redirects.snap | 19 + ...nents_theme_resolves_to_css_variables.snap | 103 +++ ...tyled_object_form_theme_interpolation.snap | 19 + ...d_tag_that_is_neither_member_nor_call.snap | 8 + ...or__tests__styled_typescript_wrappers.snap | 79 +++ ...s__stylex_attrs_emits_class_attribute.snap | 19 + ..._tests__stylex_attrs_via_named_import.snap | 19 + ...__stylex_define_vars_and_create_theme.snap | 41 ++ ...osition_try_and_view_transition_class.snap | 21 + ...ts__stylex_props_computed_dynamic_key.snap | 69 ++ ...ex_props_computed_key_empty_namespace.snap | 19 + ...lex_props_computed_key_in_conditional.snap | 29 + ...ps_computed_key_non_identifier_object.snap | 19 + ..._computed_key_only_dynamic_namespaces.snap | 18 + ...tylex_props_computed_key_unresolvable.snap | 19 + ...ts__stylex_props_computed_literal_key.snap | 29 + ...or__tests__stylex_props_style_x_array.snap | 29 + ...x_props_ts_wrapper_and_optional_chain.snap | 29 + ..._theme_apis_ignore_unresolvable_input.snap | 15 + ..._stylex_theme_apis_with_named_imports.snap | 31 + ...__stylex_theme_contract_and_constants.snap | 35 + ..._declarations_skip_unreadable_entries.snap | 41 ++ ...ests__tailwind_conditional_class_name.snap | 39 ++ ...stantiation_expression_as_style_value.snap | 18 + ...mes_extract_without_source_dependency.snap | 36 + libs/extractor/src/stylex.rs | 49 +- libs/extractor/src/util_type.rs | 15 + libs/extractor/src/utils.rs | 48 ++ libs/extractor/src/vanilla_extract.rs | 16 +- libs/extractor/src/visit.rs | 627 +++++++++++++++-- packages/bun-plugin/src/plugin.ts | 6 + .../next-plugin/src/__tests__/plugin.test.ts | 5 + packages/next-plugin/src/plugin.ts | 5 + .../src/__tests__/create-compat-types.test.ts | 59 ++ .../__tests__/merge-import-aliases.test.ts | 4 + packages/plugin-utils/src/index.ts | 6 +- packages/plugin-utils/src/types.ts | 34 + packages/react/package.json | 25 + .../react/src/compat/__tests__/index.test.ts | 28 + packages/react/src/compat/emotion.d.ts | 16 + packages/react/src/compat/index.ts | 20 + .../react/src/compat/styled-components.d.ts | 18 + packages/react/src/compat/stylex.d.ts | 21 + packages/react/src/compat/types.d.ts | 28 + .../react/src/compat/vanilla-extract.d.ts | 38 + packages/react/src/components/Global.tsx | 15 + .../react/src/components/ThemeProvider.tsx | 13 + .../__tests__/Global.browser.test.tsx | 11 + .../__tests__/ThemeProvider.browser.test.tsx | 24 + .../ThemeProvider.browser.test.tsx.snap | 21 + .../hooks/__tests__/use-styled-theme.test.ts | 10 + packages/react/src/hooks/use-styled-theme.ts | 15 + packages/react/src/types/props/index.ts | 6 + .../__tests__/create-global-style.test.ts | 14 + .../src/utils/__tests__/styled-compat.test.ts | 30 + .../react/src/utils/__tests__/stylex.test.ts | 24 + .../src/utils/__tests__/theme-vars.test.ts | 64 ++ .../__tests__/with-theme.browser.test.tsx | 16 + .../react/src/utils/create-global-style.ts | 22 + packages/react/src/utils/styled-compat.ts | 40 ++ packages/react/src/utils/styled.ts | 44 +- packages/react/src/utils/stylex.ts | 26 + packages/react/src/utils/theme-vars.ts | 59 ++ packages/react/src/utils/with-theme.tsx | 12 + packages/react/vite.config.ts | 1 + packages/rsbuild-plugin/src/plugin.ts | 6 + .../vite-plugin/src/__tests__/plugin.test.ts | 2 + packages/vite-plugin/src/plugin.ts | 6 + packages/webpack-plugin/src/plugin.ts | 6 + 108 files changed, 4263 insertions(+), 245 deletions(-) create mode 100644 apps/landing/src/app/(detail)/docs/migration/overview/page.mdx create mode 100644 apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx create mode 100644 apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx create mode 100644 apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx create mode 100644 libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_and_named_both_retained_on_source.snap create mode 100644 libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_export_without_devup_equivalent_stays_on_source.snap create mode 100644 libs/extractor/src/snapshots/extractor__import_alias_visit__tests__default_specifier_in_vanilla_extract_stylesheet.snap create mode 100644 libs/extractor/src/snapshots/extractor__import_alias_visit__tests__named_imports_without_devup_export_stay_on_source.snap create mode 100644 libs/extractor/src/snapshots/extractor__import_alias_visit__tests__namespace_import_of_a_compat_only_default.snap create mode 100644 libs/extractor/src/snapshots/extractor__import_alias_visit__tests__vanilla_extract_names_map_onto_devup_equivalents.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__create_global_style_collapses_to_a_null_component.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__css_and_global_css_typescript_wrappers.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__devup_props_optional_chaining.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__devup_props_typescript_wrappers.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_global_with_spread_attribute.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__member_expression_with_dynamic_values.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__minus_zero_is_normalized.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__namespace_import_of_a_default_only_package_redirects.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__parenthesized_string_literals.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__props_prop_becomes_spread_attribute.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_accepts_both_call_forms.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_components_import_surface_fully_redirects.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_components_theme_resolves_to_css_variables.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_tag_that_is_neither_member_nor_call.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_typescript_wrappers.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_attrs_emits_class_attribute.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_attrs_via_named_import.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_define_vars_and_create_theme.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_position_try_and_view_transition_class.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_dynamic_key.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_empty_namespace.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_in_conditional.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_non_identifier_object.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_only_dynamic_namespaces.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_key_unresolvable.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_props_computed_literal_key.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_props_style_x_array.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_props_ts_wrapper_and_optional_chain.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_theme_apis_ignore_unresolvable_input.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_theme_apis_with_named_imports.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_theme_contract_and_constants.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__stylex_variable_declarations_skip_unreadable_entries.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__type_instantiation_expression_as_style_value.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__vanilla_extract_names_extract_without_source_dependency.snap create mode 100644 packages/plugin-utils/src/__tests__/create-compat-types.test.ts create mode 100644 packages/react/src/compat/__tests__/index.test.ts create mode 100644 packages/react/src/compat/emotion.d.ts create mode 100644 packages/react/src/compat/index.ts create mode 100644 packages/react/src/compat/styled-components.d.ts create mode 100644 packages/react/src/compat/stylex.d.ts create mode 100644 packages/react/src/compat/types.d.ts create mode 100644 packages/react/src/compat/vanilla-extract.d.ts create mode 100644 packages/react/src/components/Global.tsx create mode 100644 packages/react/src/components/ThemeProvider.tsx create mode 100644 packages/react/src/components/__tests__/Global.browser.test.tsx create mode 100644 packages/react/src/components/__tests__/ThemeProvider.browser.test.tsx create mode 100644 packages/react/src/components/__tests__/__snapshots__/ThemeProvider.browser.test.tsx.snap create mode 100644 packages/react/src/hooks/__tests__/use-styled-theme.test.ts create mode 100644 packages/react/src/hooks/use-styled-theme.ts create mode 100644 packages/react/src/utils/__tests__/create-global-style.test.ts create mode 100644 packages/react/src/utils/__tests__/styled-compat.test.ts create mode 100644 packages/react/src/utils/__tests__/theme-vars.test.ts create mode 100644 packages/react/src/utils/__tests__/with-theme.browser.test.tsx create mode 100644 packages/react/src/utils/create-global-style.ts create mode 100644 packages/react/src/utils/styled-compat.ts create mode 100644 packages/react/src/utils/theme-vars.ts create mode 100644 packages/react/src/utils/with-theme.tsx 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 {