)
+
// any other value is read at runtime, default when empty
```
### `props` (Pass-Through to `as` Component)
@@ -325,11 +338,20 @@ globalCss({ body: { margin: 0 }, "*": { boxSizing: "border-box" } });
const spin = keyframes({ from: { transform: "rotate(0)" }, to: { transform: "rotate(360deg)" } });
+
+// A const holding a keyframes name or a css() class is a build-time value
+const card = css({ p: 4 });
+css({ animationName: spin, selectors: { [`.${card}:hover &`]: { m: 1 } } });
```
+- Only a `const` declared in the same file works this way; an imported keyframes/class name is not known at build time.
+- `import * as Devup from "@devup-ui/react"` works (`Devup.css`, `Devup.keyframes`, `Devup.styled.div`, `
`).
+- `styled()` takes any base: tag, Devup component, `motion.div`, `forwardRef(...)`, a variable. `null`/number/boolean/`undefined` bases are build errors.
+- Compiled imports are removed: a top-level alias (`const myCss = css`, `const Row = Flex`) compiles and is removed, but any other runtime read (`export const C = Box`, `[Box]`, `styled('div')` alone, an alias inside a function) is a **build error**.
+
### Dynamic Values with Custom Components
-`css()` only accepts **static values**. For dynamic values on custom components, use `
`:
+`css()`, `globalCss()`, `keyframes()` and `stylex.create()` only accept values known at build time - literals, theme tokens, imported constants and module-level `const`s (templates, arithmetic and `Math.*` calls over them fold). Object, array and enum constants read as if written in place (`css(base)`, `{ ...base, color: 'red' }`, `_hover: hover`, `space[2]`, `Size.M`, ` `), a later property replacing an earlier one. The build only runs code whose result is certain: `const`s, functions and enums this file declares, computing from literals and constants with exact built-ins (`String`, `Number`, `JSON`, `Object`, `Array`, string/array methods, `Math.abs/ceil/floor/round/trunc/sign/max/min/sqrt/fround/imul/clz32` and `Math` constants) - `const double = (n) => n * 2; css({ w: double(SIZE) })` is static. Imports are only read as the literal, object or array their module declares; code of another module never runs (`darken(0.1, PRIMARY)` with an imported `darken` is not computed). Not run: other globals (`window`, `Date`, `Intl`, ...), `Math.random` and approximate `Math` functions (`sin`, `pow`, ...), `**`, `toString(radix)`, `toLocale*`/`localeCompare`/`normalize`, `this`, `new`, classes, regex, `try`, getters, async/generators, JSX, `obj[key]()`, and functions writing module-level bindings - elements and `styled()` keep such values as CSS variables, while `css()`/`globalCss()`/`keyframes()` report a build error (as do `css()`/`styled()` given a whole style object computed that way). An object, array or enum constant that visible code changes (member assignment, `delete`, `++`, `push`/`sort`, `Object.assign`, changing elements in `for...of`/`forEach`, a method using `this`, passing it to an unknown function) is not a constant: elements read its members at runtime, and `css(obj)`, `styled.div(obj)` or `{...obj}` on an element is a build error naming where it changes. JSX props (except `ref`), `export default`, `module.exports` and `Object.freeze` only read it. Never mutate objects styles read; use theme tokens or props for values that change. A value known only at runtime (a prop, state, a parameter), and styles written where the build cannot read an object (a spread of an unknown object, `_hover={x}`, a computed key), are a **build error**. For dynamic values on custom components, use ``:
```tsx
// WRONG - css() cannot handle dynamic values
@@ -563,6 +585,7 @@ One rule explains `Dynamic Values = CSS Variables`, `$token Scope` and
|------|--------|
| ` ` | Static class |
| ` ` | Static class per value - **preferred** |
+| ` ` where `PRIMARY` is a module-level or imported `const` string/number | Static class |
| ` ` where `colors` is declared elsewhere | CSS variable |
| ` ` | CSS variable (genuinely dynamic - correct) |
| `const s = { a: css({ ... }) }` then `className={s[v]}` | Neither - see below |
diff --git a/apps/landing/src/app/(detail)/docs/api/box/page.mdx b/apps/landing/src/app/(detail)/docs/api/box/page.mdx
index 748acafb2..a81f55be1 100644
--- a/apps/landing/src/app/(detail)/docs/api/box/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/box/page.mdx
@@ -65,6 +65,12 @@ However, the following properties are exceptions and **are not multiplied by 4**
- `grid-row`, `grid-row-start`, `grid-row-end`
- `animation-iteration-count`
- `tab-size`, `moz-tab-size`, `-webkit-line-clamp`
+- `grid-area`
+- `column-count`, `columns`, `orphans`, `widows`, `zoom`
+- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width`
+- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width`
+- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold`
+- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
### Rendering as Another Element
@@ -91,3 +97,17 @@ function App() {
background: red;
}
```
+
+`as` also takes a component (`as={Link}`, `as={motion.div}`) or a condition between names (`as={href ? 'a' : 'button'}`), which becomes an element for each name. `undefined`, `null` and `false` give the default element — `div` for `Box`. Any other value, such as a prop or `` `h${level}` ``, is read at runtime, once, and gives the default element when it is empty:
+
+```tsx
+function Card({ as }) {
+ return
+}
+```
+
+```tsx
+function Card({ as }) {
+ return ((DevupAs0) => )(as || 'div')
+}
+```
diff --git a/apps/landing/src/app/(detail)/docs/api/button/page.mdx b/apps/landing/src/app/(detail)/docs/api/button/page.mdx
index 0cbc85daf..a79e01c5f 100644
--- a/apps/landing/src/app/(detail)/docs/api/button/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/button/page.mdx
@@ -68,3 +68,9 @@ However, the following properties are exceptions and **are not multiplied by 4**
- `grid-row`, `grid-row-start`, `grid-row-end`
- `animation-iteration-count`
- `tab-size`, `moz-tab-size`, `-webkit-line-clamp`
+- `grid-area`
+- `column-count`, `columns`, `orphans`, `widows`, `zoom`
+- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width`
+- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width`
+- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold`
+- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/center/page.mdx b/apps/landing/src/app/(detail)/docs/api/center/page.mdx
index 63783ba43..0ac65ae80 100644
--- a/apps/landing/src/app/(detail)/docs/api/center/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/center/page.mdx
@@ -85,6 +85,12 @@ However, the following properties are exceptions and **are not multiplied by 4**
- `grid-row`, `grid-row-start`, `grid-row-end`
- `animation-iteration-count`
- `tab-size`, `moz-tab-size`, `-webkit-line-clamp`
+- `grid-area`
+- `column-count`, `columns`, `orphans`, `widows`, `zoom`
+- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width`
+- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width`
+- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold`
+- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx
index 035921d3c..01c06c307 100644
--- a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx
@@ -77,6 +77,12 @@ However, the following properties are exceptions and **are not multiplied by 4**
- `grid-row`, `grid-row-start`, `grid-row-end`
- `animation-iteration-count`
- `tab-size`, `moz-tab-size`, `-webkit-line-clamp`
+- `grid-area`
+- `column-count`, `columns`, `orphans`, `widows`, `zoom`
+- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width`
+- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width`
+- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold`
+- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/style-props/page.mdx b/apps/landing/src/app/(detail)/docs/api/style-props/page.mdx
index 42b613b78..319509b93 100644
--- a/apps/landing/src/app/(detail)/docs/api/style-props/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/style-props/page.mdx
@@ -10,6 +10,8 @@ export const metadata = {
The attributes defined in Style Property correspond to those defined in Props, allowing them to be used directly as component props.
+A number given to a length is on the spacing scale (`p={4}` is `16px`). Times are milliseconds, never scaled: `transitionDuration={300}`, `transitionDelay`, `animationDuration` and `animationDelay` give `300ms`. Unitless properties such as `lineHeight`, `zIndex`, `opacity` and the counters (`counterReset`, `counterIncrement`, `counterSet`) keep the number as written.
+
## Animation
diff --git a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx
index 9133ae7f7..fb7b8b524 100644
--- a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx
@@ -83,6 +83,12 @@ However, the following properties are exceptions and **are not multiplied by 4**
- `grid-row`, `grid-row-start`, `grid-row-end`
- `animation-iteration-count`
- `tab-size`, `moz-tab-size`, `-webkit-line-clamp`
+- `grid-area`
+- `column-count`, `columns`, `orphans`, `widows`, `zoom`
+- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width`
+- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width`
+- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold`
+- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx
index 9df1f4f6f..376f818a6 100644
--- a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx
@@ -315,7 +315,7 @@ const Title = styled('h1', {
## Combining with Other Styles
-Typography can be combined with other style props:
+Typography can be combined with other style props. A property written next to `typography` wins over the preset at every breakpoint from the one it is set at, so ` ` stays `14px` even where `h1` grows. That holds for a property set only on a condition too (`fontSize={small ? '12px' : undefined}`): preset declarations sit in a cascade layer below the ones written directly, so whichever applies wins:
```tsx
const example = (
diff --git a/apps/landing/src/app/(detail)/docs/features/page.mdx b/apps/landing/src/app/(detail)/docs/features/page.mdx
index ebb85645c..afb7227df 100644
--- a/apps/landing/src/app/(detail)/docs/features/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/features/page.mdx
@@ -62,6 +62,25 @@ const generated =
// .a { background-color: var(--a); }
```
+A value imported from another module stays static when that module declares it with `const` — a string or number literal, a member of an object literal (`as const` included), an alias of another constant, a re-export or a default export:
+
+```tsx
+// tokens.ts
+export const PRIMARY = 'red'
+export const colors = { brand: '#123456' } as const
+
+// App.tsx
+import { colors, PRIMARY } from './tokens'
+const example =
+
+// Devup UI generates:
+const generated =
+```
+
+The same holds for a `const` declared at the top of the file itself, and templates or arithmetic over such constants fold into one value (`` `${SIZE * 2}px` ``).
+
+Imports are resolved like the bundler resolves them — relative paths, `tsconfig` `paths` and packages (`exports`, `module`, `main`) — and a change to the module rebuilds the files reading it. CommonJS modules count too when they assign the export once (`exports.PRIMARY = 'red'`, `module.exports = { ... }`). Anything that can change or is computed — a `let`, a property assigned twice, a function call — stays a CSS variable.
+
## Conditional Expressions
Ternary operators and logical expressions are statically analyzed.
@@ -196,6 +215,8 @@ const Button = styled('button', {
})
```
+`styled()` renders any base: a tag (`styled.div`, `styled('div')`), a Devup UI component (`styled(Box)`), a component or a member naming one (`styled(Link)`, `styled(motion.div)`), or any other value (`styled(forwardRef(...))`, `styled(tag)`), which is read once where the component is declared. A base that can never render — `null`, a number, a boolean, `undefined` — and a `styled` the build cannot compile are build errors.
+
## css() API
Create reusable style objects.
@@ -212,6 +233,25 @@ const cardStyles = css({
const example =
```
+`css()`, `globalCss()` and `keyframes()` have no element to set a CSS variable on, so their values must be known at build time — literals, theme tokens or constants. Constants may be objects, arrays and TypeScript enums, declared in the file or imported: `css(baseStyles)`, `{ ...baseStyles, color: 'red' }`, `_hover: hoverStyles`, `space[2]`, `Size.M`, and ` ` all read as if written in place, a later property replacing an earlier one as in JavaScript. `Math` calls over them (`Math.max(SIZE, 20)`, `Math.round(x)`, `Math.PI`) fold like arithmetic.
+
+The build also runs code, but only code whose result is certain: what this file declares — `const`s, functions and enums — computing from literals and constants with exact built-ins (`String`, `Number`, `JSON`, `Object`, `Array`, string and array methods such as `'a'.toUpperCase()` or `[1, 2].map(double)`, and the exact `Math` functions `abs`, `ceil`, `floor`, `round`, `trunc`, `sign`, `max`, `min`, `sqrt`, `fround`, `imul`, `clz32` and its constants). `const double = (n) => n * 2; css({ w: double(SIZE) })` becomes a static class. An import is only read as the value its module declares — a literal, or an object or array of them — and the code of another module never runs, so `darken(0.1, PRIMARY)` with an imported `darken` is not computed. Anything less certain is not run: other globals (`window`, `Date`, `Intl`, `IntersectionObserver`...), `Math.random` and the approximate `Math` functions (`sin`, `pow`, `exp`...), `**`, `toString` with a radix, locale and Unicode methods (`toLocaleString`, `localeCompare`, `normalize`), `this`, `new`, classes, regular expressions, `try`, getters, `async` code and generators, JSX, a method chosen at runtime (`obj[key]()`), and a function writing a module-level binding. An element or a `styled()` component then keeps such a value as a CSS variable, as it keeps any runtime value; `css()`, `globalCss()` and `keyframes()` have no element for it, so it is a build error naming it — move the value to a style prop, or make it a constant. A whole style object computed that way (`css(makeStyles())`, `styled.div(makeStyles())`) is a build error too.
+
+An object, array or enum constant reads as written only while no code the build sees changes it: the file using it, the module declaring it and the modules re-exporting it, CommonJS modules included. Assigning a member, `delete`, `++`, a method such as `push` or `sort`, `Object.assign`, changing its elements in a `for...of` loop or a `forEach`/`map` callback, calling a method of it that uses `this`, or handing it to a function the build does not know (or to an object that is changed) makes it a value only known at runtime — an element reads its members as CSS variables, and `css()`, `styled()` or a spread taking it whole, directly or through a namespace import or an object holding it, is a build error that names where it is changed or handed on. What only reads it changes nothing: handing on a string or number it holds (`darken(colors.primary)`), keeping it in a `const`, `export default` or `module.exports` nothing changes, `Object.freeze`, `Object.keys` or `Array.from` over its primitives, and element props — React components must not change their props, so `` only reads it; `ref={obj}` is the exception React assigns to. A module the build does not see can still change an imported object, so keep objects styles read unchanged at runtime, and use theme tokens or props for values that change.
+
+A binding that `css()` or `styled()` composes reads the same way: with `const base = makeStyles()`, `css(base)`, `css(...parts)` and `styled.div(base)` apply the rules the module computes, while a class held in a binding (`const base = css({ ... })`, a class imported from a stylesheet) still composes as a class. Elements take such objects too — ` `, ` ` — as do a spread, a selector or a computed key in a `styled` object; values an element holds at runtime stay CSS variables. Source maps point at the code as written, not at the values computed in its place.
+
+A condition the build knows picks its branch (`const DARK = true; css({ color: DARK ? 'white' : 'black' })`). `globalCss()`, `keyframes()` and `` have no class to switch, so a condition known only at runtime is a build error there, where `css()` and style props switch classes.
+
+A value known only at runtime — a prop, a state, a parameter — is a build error naming it, as is styles the build cannot read where an object is expected: a spread of an unknown object, a selector such as `_hover` given something other than an object, a computed key. For a runtime value, use a style prop (` `); any expression works there, `await` and `new` included, and runs as written. A JSX spread of an unknown object (` `) still passes its props at runtime.
+
+Build errors point at the code they are about, and a file with several problems reports them all at once, in source order:
+
+```
+src/App.tsx:6:18: `css()` cannot use `width` at build time: its values must be literals, theme tokens or constants, or be computed from them
+src/App.tsx:9:27: `globalCss()` cannot use `color` at build time: its values must be literals, theme tokens or constants, or be computed from them
+```
+
## globalCss() API
Define global styles that are extracted at build time.
@@ -239,3 +279,7 @@ const spin = keyframes({
const example =
```
+
+A `const` holding what `keyframes()` or `css()` gives is a value the build knows, so the other style functions read it too: `css({ animationName: spin })`, `` globalCss`body { animation: ${spin} 1s; }` ``, `css({ selectors: { ['.' + card + ':hover &']: { m: 1 } } })`. The package imported whole works the same (`import * as Devup from '@devup-ui/react'` with `Devup.keyframes(...)`, `Devup.styled.div(...)`, ` `). A name imported from another module is not read this way, as the class another file generates is only known once that file is built: declare the animation in the file using it, or read it from a style prop.
+
+The build removes the imports it compiles, so their bindings exist only where they are called or rendered. A top-level alias (`const myCss = css`, `const Row = Flex`) compiles like the original and is removed; any other read — exporting it, putting it in an array, `styled('div')` without styles, an alias inside a function — is a build error, instead of code that would throw at runtime.
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
index 52707e389..0cb08360b 100644
--- a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx
@@ -24,8 +24,37 @@ const C = styled.div({ bg: 'red' })
const D = styled('div')({ bg: 'red' })
const E = styled(Component)({ bg: 'red' })
const F = styled('div', { bg: 'red' })
+const G = styled(motion.div)`
+ color: red;
+`
+const H = styled(forwardRef(Field))({ bg: 'red' })
```
+Any base renders: a tag, a component, a member such as `motion.div`, or any other value, read once where the component is declared. `null`, numbers, booleans and `undefined` never render, so they are build errors, as is a `styled` left uncompiled (`styled('div')` never given styles, `styled.div` exported bare): its import is removed, and that code would throw at runtime.
+
+A `keyframes` or `css` result held in a `const` of the same file is a build-time value, so `` css`animation: ${fadeIn} 1s;` `` and `styled.div({ ['.' + card + ':hover &']: { margin: 0 } })` work. In CSS text a class standing where a declaration would is a mixin, composed with the component's class. One imported from another module is not: its name is only known once that file is built, so declare it where it is used.
+
+Object styles follow the libraries: several style arguments, or an array of them, merge into one rule set where a later declaration replaces an earlier one and nested rules merge; Emotion's options argument (`styled('div', { shouldForwardProp })(...)`) is accepted, and every prop is forwarded. Numbers mean pixels as they do in Emotion and styled-components — `padding: 8` is `8px`, unitless properties such as `lineHeight` stay as written — while Devup UI shorthands such as `p` keep the spacing scale.
+
+A style argument can be conditional — `cond && { ... }`, `cond ? { ... } : { ... }`, `value || { ... }` or `value ?? { ... }`, in `styled` as in `css`. Each property it sets becomes a per-property ternary over what came before (`color: cond ? 'blue' : 'red'`), so the result does not depend on the order atomic classes land in the stylesheet; a conditional class joins as `cond ? cls : ''`, and the left side of `||` / `??` joins only when it is a string, as the libraries skip other values. Classes passed beside rule objects compose in `styled` as in `css`.
+
+A style argument that cannot be known at build time — a function call the build cannot run, a spread or a computed key it cannot compute — is a build error naming it, rather than a silently missing style; a spread, a selector value or a computed key the module computes (`styled.div({ ...cardStyles(), _hover: hoverStyles })`) is computed, while the other values stay CSS variables set at runtime. An object constant, declared in the file or imported, reads as if written in place: `styled.div(baseStyles)`, `styled.div({ ...baseStyles, color: 'red' })`. A single argument that is not a rule object composes as a class, as several do; one holding rules the module computes (`const base = makeStyles()`, `styled.div(base)`) applies those rules. In the object form a value may read the theme (`(p) => p.theme.brand`); any other function of the props is a build error — write it in a template (`${(p) => p.color}`), where it becomes a CSS variable.
+
+Inside a template, an interpolation standing where a declaration would is a mixin: `${mixin}` and `${(p) => p.active && mixin}` compose their class with the component's. A literal or constant is written into the CSS wherever it stands — a value, a CSS snippet, a selector. Any other interpolation in a selector or a property name is a build error, as are runtime values in `css`, `keyframes` and global styles, which have no element to set a CSS variable on.
+
+`.attrs()` and `.withConfig()` chain onto any factory. Attrs are merged over the props in order, as styled-components does — an object is spread, a function is called with the props; `.withConfig()` only tunes runtime behavior, so it is dropped:
+
+```tsx
+const Button = styled.button
+ .attrs({ type: 'button' })
+ .attrs((props) => ({ disabled: props.loading }))
+ .withConfig({ displayName: 'Button' })`
+ color: red;
+`
+```
+
+A file may import `styled` from several of these libraries at once; every import compiles.
+
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
@@ -94,3 +123,4 @@ Emotion's ` ` behaves the same way: the `styles` prop is e
| `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 |
+| `.attrs()`, `.withConfig()` | attrs merged over props; `withConfig` dropped |
diff --git a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx
index 2dc60054b..c454cf3fa 100644
--- a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx
@@ -33,6 +33,8 @@ 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`.
+Numbers get the unit StyleX gives them: `fontSize: 16` is `16px`, `transitionDuration: 300` is `300ms`, and unitless properties such as `lineHeight` stay as written. A number passed to a dynamic style gets the same unit at runtime.
+
Accepted argument shapes:
```tsx
@@ -76,6 +78,63 @@ const el =
`createThemeContract()` produces the same references without publishing a `:root` block, and `defineConsts()` inlines its values into the `create()` calls that read them.
+A variable or theme value can depend on at-rules, with an optional `types.*()` wrapper. Each set of conditions becomes one block after the unconditional values, and nested conditions come after the ones they refine, so the more specific value wins:
+
+```tsx
+const DARK = '@media (prefers-color-scheme: dark)'
+
+const colors = stylex.defineVars({
+ text: { default: 'black', [DARK]: 'white' },
+ accent: stylex.types.color({ default: 'blue', [DARK]: 'lightblue' }),
+})
+const dracula = stylex.createTheme(colors, {
+ text: { default: 'purple', [DARK]: 'pink' },
+})
+```
+
+```css
+:root {
+ --a: black;
+ --b: blue;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --a: white;
+ --b: lightblue;
+ }
+}
+.c {
+ --a: purple;
+}
+@media (prefers-color-scheme: dark) {
+ .c {
+ --a: pink;
+ }
+}
+```
+
+Conditions are `default`, `@media`, `@supports` and `@container` keys, and `null` leaves a variable unset under its condition. A value that is not static is a build error naming it; pseudo-class keys count as not static, because a variable is set on `:root` or a theme class, not on the element being styled.
+
+As StyleX requires, variables usually live in a `.stylex.ts` file and are imported where they are used. Imports resolve like the bundler resolves them, and the importing file reads the same custom properties, theme classes and constants that the defining file generates:
+
+```tsx
+// tokens.stylex.ts
+export const colors = stylex.defineVars({ primary: 'blue' })
+export const spacing = stylex.defineConsts({ gap: '8px' })
+
+// Button.tsx
+import { colors, spacing } from './tokens.stylex'
+
+const dark = stylex.createTheme(colors, { primary: 'navy' })
+const styles = stylex.create({
+ box: { color: colors.primary, marginTop: spacing.gap },
+})
+```
+
+A value in `create()` that this file's own functions compute from constants with exact built-ins, such as `double(SIZE)`, is computed at build time; another module's code never runs, and code whose result is not certain (`Date`, `Math.random`, `Math.sin`, locale methods, `new`, `this`...) is not run. A value only known at runtime, such as a prop or a state, is a build error naming it, as is anything `create()`, `defineVars()`, `defineConsts()`, `createTheme()`, `keyframes()`, `positionTry()` or `viewTransitionClass()` cannot read (a spread, a computed key, a non-object argument). Pass runtime values through a dynamic style (`(color) => ({ color })`), which becomes a CSS variable instead.
+
+`stylex.props()` accepts styles compiled elsewhere — a `style` prop a parent passes, a spread, another module's namespaces — and joins their class names at runtime; namespaces of this file resolve at build time.
+
## At-rules
```tsx
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
index bb86dca45..df42ee57e 100644
--- a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx
@@ -9,7 +9,7 @@ export const metadata = {
## 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`.
+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`, `globalFontFace`, `createVar`, `fallbackVar`, `createTheme`, `createGlobalTheme`, `createThemeContract`, `createGlobalThemeContract`, `assignVars`, `layer`, `globalLayer`, `createContainer`. Generated names — custom properties, containers, layers, theme classes, font families — are unique to each stylesheet, and layers keep the order they are declared in.
```ts
// theme.css.ts
@@ -20,9 +20,27 @@ export const light = createTheme(vars, { colors: { bg: 'white' } })
export const box = style({ background: vars.colors.bg })
```
+Numbers keep vanilla-extract's meaning, here and in ordinary modules: `padding: 8` is `8px`, and unitless properties such as `lineHeight` or `zIndex` stay as written. As in vanilla-extract, a number for a time gets `px` too (`transitionDuration: 300` is `300px`, which browsers ignore), so write times with their unit: `transitionDuration: '300ms'`.
+
+A stylesheet can import other stylesheets and ordinary modules — ES modules or CommonJS — resolved like the bundler resolves them (relative paths, `tsconfig` `paths` and packages). An imported stylesheet is extracted the way the bundler extracts it, so the class names and custom properties it exports are the ones its own CSS uses, and the importer keeps importing it so that CSS is loaded:
+
+```ts
+// button.css.ts
+import { style } from '@vanilla-extract/css'
+
+import { base, vars } from './theme.css'
+import { PRIMARY } from './tokens'
+
+export const button = style([base, { color: PRIMARY, margin: vars.space }])
+```
+
+A change to an imported file rebuilds the stylesheets importing it. Import cycles behave as in ES modules: they work as long as neither side reads the other before it has been evaluated, and reading too early is a build error naming the binding and the module.
+
+A stylesheet that throws while it is evaluated — an import that does not resolve, a property read on `undefined` — is a build error carrying that exception, rather than output that still calls `style()` at runtime.
+
## Ordinary modules
-`style` and `globalStyle` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts:
+`style`, `globalStyle` and `keyframes` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts, and `style([base, { ... }])` keeps composing `base`:
```tsx
import { globalStyle, style } from '@vanilla-extract/css'
diff --git a/apps/landing/src/app/(detail)/docs/overview/page.mdx b/apps/landing/src/app/(detail)/docs/overview/page.mdx
index 9c337acd6..b186af1b8 100644
--- a/apps/landing/src/app/(detail)/docs/overview/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/overview/page.mdx
@@ -235,7 +235,7 @@ Turbopack values are medians of six cold builds in alternating order. Build size
-The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median.
+The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one.
### Get Started
diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml
index aecbd6c33..fa769a310 100644
--- a/bindings/devup-ui-wasm/Cargo.toml
+++ b/bindings/devup-ui-wasm/Cargo.toml
@@ -13,18 +13,10 @@ categories = ["development-tools", "wasm", "web-programming"]
[lib]
crate-type = ["cdylib"]
-[features]
-default = ["vanilla-extract"]
-vanilla-extract = [
- "extractor/vanilla-extract",
- "sheet/vanilla-extract",
- "dep:boa_engine",
-]
-
[dependencies]
wasm-bindgen = "0.2.128"
-extractor = { path = "../../libs/extractor", default-features = false }
-sheet = { path = "../../libs/sheet", default-features = false }
+extractor = { path = "../../libs/extractor" }
+sheet = { path = "../../libs/sheet" }
css = { path = "../../libs/css" }
rustc-hash = "2"
@@ -43,10 +35,11 @@ serde-wasm-bindgen = "0.6.5"
# `getrandom_backend="wasm_js"` cfg requires on wasm32 targets.
# Keep this major version in sync with boa_engine's transitive getrandom.
getrandom = { version = "0.4", features = ["wasm_js"] }
-# Not used directly. The extractor evaluates `.css.ts` modules with Boa, whose
-# `Context::default()` reads the clock. Without the `js` feature that clock is
-# `std::time::Instant`, which panics on wasm32-unknown-unknown.
-boa_engine = { version = "0.22", optional = true, features = ["js"] }
+# Not used directly. The extractor evaluates `.css.ts` modules and computed
+# style values with Boa, whose `Context::default()` reads the clock. Without the
+# `js` feature that clock is `std::time::Instant`, which panics on
+# wasm32-unknown-unknown.
+boa_engine = { version = "0.22", features = ["js"] }
[dev-dependencies]
wasm-bindgen-test = "0.3.78"
diff --git a/bindings/devup-ui-wasm/package.json b/bindings/devup-ui-wasm/package.json
index a4b56e0af..c9c61e979 100644
--- a/bindings/devup-ui-wasm/package.json
+++ b/bindings/devup-ui-wasm/package.json
@@ -19,7 +19,7 @@
],
"version": "1.0.81",
"scripts": {
- "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && wasm-pack build --target nodejs --out-dir ./pkg/lite --out-name index --no-default-features && node script.js",
+ "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && node script.js",
"test": "wasm-pack test --node"
},
"publishConfig": {
@@ -33,12 +33,7 @@
"pkg/index.js",
"pkg/package.json",
"pkg/index_bg.wasm",
- "pkg/index_bg.wasm.d.ts",
- "pkg/lite/index.d.ts",
- "pkg/lite/index.js",
- "pkg/lite/package.json",
- "pkg/lite/index_bg.wasm",
- "pkg/lite/index_bg.wasm.d.ts"
+ "pkg/index_bg.wasm.d.ts"
],
"type": "module",
"exports": {
@@ -46,11 +41,6 @@
"types": "./pkg/index.d.ts",
"import": "./pkg/index.js",
"require": "./pkg/index.js"
- },
- "./lite": {
- "types": "./pkg/lite/index.d.ts",
- "import": "./pkg/lite/index.js",
- "require": "./pkg/lite/index.js"
}
},
"types": "./pkg/index.d.ts"
diff --git a/bindings/devup-ui-wasm/script.js b/bindings/devup-ui-wasm/script.js
index 9fafac1be..27b3139cc 100644
--- a/bindings/devup-ui-wasm/script.js
+++ b/bindings/devup-ui-wasm/script.js
@@ -2,4 +2,3 @@ import { writeFileSync } from 'node:fs'
// support mjs config
writeFileSync('pkg/package.json', JSON.stringify({}), 'utf8')
-writeFileSync('pkg/lite/package.json', JSON.stringify({}), 'utf8')
diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs
index 6e7a0b735..4560410f3 100644
--- a/bindings/devup-ui-wasm/src/lib.rs
+++ b/bindings/devup-ui-wasm/src/lib.rs
@@ -3,9 +3,13 @@ use css::file_map::{
canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map,
};
use extractor::extract_style::extract_style_value::ExtractStyleValue;
-use extractor::{ExtractOption, ImportAlias, extract, extract_without_source_map, has_devup_ui};
+use extractor::{
+ ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules,
+ extract_without_source_map, has_devup_ui,
+};
use rustc_hash::FxHashSet;
use sheet::StyleSheet;
+use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::fmt::Display;
use std::sync::{LazyLock, Mutex};
@@ -20,6 +24,10 @@ enum SourceMapMode {
Skip,
}
+thread_local! {
+ static MODULE_RESOLVER: RefCell> = const { RefCell::new(None) };
+}
+
fn with_style_sheet(f: F) -> R
where
F: FnOnce(&StyleSheet) -> R,
@@ -52,9 +60,11 @@ pub struct Output {
css_file: Option,
updated_base_style: bool,
css: Option,
+ dependencies: Vec,
}
#[wasm_bindgen]
impl Output {
+ #[allow(clippy::too_many_arguments)]
fn new(
code: String,
styles: FxHashSet,
@@ -63,6 +73,7 @@ impl Output {
filename: String,
css_file: Option,
import_main_css: bool,
+ dependencies: Vec,
) -> Self {
// Use the bucket identity (single-importer collapse) so the sheet's CSS
// naming + property bucket + emitted chunk match the canonical class names
@@ -83,6 +94,7 @@ impl Output {
code,
map,
css_file,
+ dependencies,
updated_base_style: updated_base_style || default_collected,
css: {
if !collected && !default_collected {
@@ -134,6 +146,13 @@ impl Output {
pub fn css(&self) -> Option {
self.css.clone()
}
+
+ /// Files read through the module resolver, for the bundler to watch
+ #[wasm_bindgen(getter, js_name = "dependencies")]
+ #[must_use]
+ pub fn dependencies(&self) -> Vec {
+ self.dependencies.clone()
+ }
}
#[wasm_bindgen(js_name = "setDebug")]
@@ -337,6 +356,7 @@ pub fn code_extract_internal(
import_main_css_in_css,
import_aliases,
SourceMapMode::Generate,
+ None,
)
}
@@ -361,6 +381,35 @@ pub fn code_extract_without_source_map_internal(
import_main_css_in_css,
import_aliases,
SourceMapMode::Skip,
+ None,
+ )
+}
+
+/// [`code_extract_internal`] reading the modules a file imports through
+/// `resolver`
+#[allow(clippy::too_many_arguments)]
+pub fn code_extract_with_modules_internal(
+ filename: &str,
+ code: &str,
+ package: &str,
+ css_dir: String,
+ single_css: bool,
+ import_main_css_in_code: bool,
+ import_main_css_in_css: bool,
+ import_aliases: HashMap,
+ resolver: &ModuleResolver,
+) -> Result {
+ code_extract_internal_impl(
+ filename,
+ code,
+ package,
+ css_dir,
+ single_css,
+ import_main_css_in_code,
+ import_main_css_in_css,
+ import_aliases,
+ SourceMapMode::Generate,
+ Some(resolver),
)
}
@@ -375,6 +424,7 @@ fn code_extract_internal_impl(
import_main_css_in_css: bool,
import_aliases: HashMap,
source_map: SourceMapMode,
+ resolver: Option<&ModuleResolver>,
) -> Result {
let option = ExtractOption {
package: package.to_string(),
@@ -383,9 +433,16 @@ fn code_extract_internal_impl(
import_main_css: import_main_css_in_code,
import_aliases,
};
- let extracted = match source_map {
- SourceMapMode::Generate => extract(filename, code, option),
- SourceMapMode::Skip => extract_without_source_map(filename, code, option),
+ let extracted = match (resolver, source_map) {
+ (Some(resolver), mode) => extract_with_modules(
+ filename,
+ code,
+ option,
+ matches!(mode, SourceMapMode::Generate),
+ resolver,
+ ),
+ (None, SourceMapMode::Generate) => extract(filename, code, option),
+ (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option),
};
match extracted {
@@ -397,11 +454,77 @@ fn code_extract_internal_impl(
filename.to_string(),
output.css_file,
import_main_css_in_css,
+ output.dependencies,
)),
Err(error) => Err(error.to_string()),
}
}
+/// Set how the imports of the files being extracted are resolved.
+///
+/// `(specifier, importer) => ({ path, code })`, or nothing when they cannot be.
+/// Imported stylesheets are then evaluated, and imported constants inlined.
+#[wasm_bindgen(js_name = "setModuleResolver")]
+#[cfg(not(tarpaulin_include))]
+pub fn set_module_resolver(resolver: Option) {
+ MODULE_RESOLVER.with_borrow_mut(|current| *current = resolver);
+}
+
+#[cfg(not(tarpaulin_include))]
+fn call_module_resolver(
+ resolver: &js_sys::Function,
+ specifier: &str,
+ importer: &str,
+) -> Option {
+ let module = resolver
+ .call2(&JsValue::NULL, &specifier.into(), &importer.into())
+ .ok()?;
+ let field = |name: &str| {
+ js_sys::Reflect::get(&module, &name.into())
+ .ok()?
+ .as_string()
+ };
+ Some(ResolvedModule {
+ path: field("path")?,
+ code: field("code")?,
+ })
+}
+
+/// Extract with the resolver set by `setModuleResolver`, if any
+#[cfg(not(tarpaulin_include))]
+#[allow(clippy::too_many_arguments)]
+fn code_extract_js(
+ filename: &str,
+ code: &str,
+ package: &str,
+ css_dir: String,
+ single_css: bool,
+ import_main_css_in_code: bool,
+ import_main_css_in_css: bool,
+ import_aliases: JsValue,
+ source_map: SourceMapMode,
+) -> Result {
+ let import_aliases = import_aliases_from_js(import_aliases)?;
+ let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| {
+ move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer)
+ });
+ code_extract_internal_impl(
+ filename,
+ code,
+ package,
+ css_dir,
+ single_css,
+ import_main_css_in_code,
+ import_main_css_in_css,
+ import_aliases,
+ source_map,
+ resolver
+ .as_ref()
+ .map(|resolver| resolver as &ModuleResolver),
+ )
+ .map_err(js_error)
+}
+
#[cfg(not(tarpaulin_include))]
fn import_aliases_from_js(
import_aliases: JsValue,
@@ -433,7 +556,7 @@ pub fn code_extract(
import_main_css_in_css: bool,
import_aliases: JsValue,
) -> Result {
- code_extract_internal(
+ code_extract_js(
filename,
code,
package,
@@ -441,9 +564,9 @@ pub fn code_extract(
single_css,
import_main_css_in_code,
import_main_css_in_css,
- import_aliases_from_js(import_aliases)?,
+ import_aliases,
+ SourceMapMode::Generate,
)
- .map_err(js_error)
}
#[cfg(not(tarpaulin_include))]
@@ -459,7 +582,7 @@ pub fn code_extract_without_source_map(
import_main_css_in_css: bool,
import_aliases: JsValue,
) -> Result {
- code_extract_without_source_map_internal(
+ code_extract_js(
filename,
code,
package,
@@ -467,9 +590,9 @@ pub fn code_extract_without_source_map(
single_css,
import_main_css_in_code,
import_main_css_in_css,
- import_aliases_from_js(import_aliases)?,
+ import_aliases,
+ SourceMapMode::Generate,
)
- .map_err(js_error)
}
/// Internal function to register theme (testable without `JsValue`)
@@ -1052,6 +1175,37 @@ mod tests {
assert_debug_snapshot!(get_css(None, false).unwrap().split("*/").nth(1).unwrap());
}
+ #[test]
+ #[serial]
+ fn test_code_extract_with_modules() {
+ {
+ let mut sheet = GLOBAL_STYLE_SHEET.lock().unwrap();
+ *sheet = StyleSheet::default();
+ }
+ let resolver = |specifier: &str, _: &str| {
+ (specifier == "./tokens").then(|| ResolvedModule {
+ path: "/src/tokens.ts".to_string(),
+ code: "export const PRIMARY = 'red'".to_string(),
+ })
+ };
+ {
+ let output = code_extract_with_modules_internal(
+ "/src/App.tsx",
+ "import { Box } from '@devup-ui/react';\nimport { PRIMARY } from './tokens';\nexport const a = ;",
+ "@devup-ui/react",
+ "@devup-ui/react".to_string(),
+ true,
+ false,
+ false,
+ HashMap::new(),
+ &resolver,
+ )
+ .unwrap();
+ assert_eq!(output.dependencies(), ["/src/tokens.ts"]);
+ assert!(!output.code().contains("--"), "{}", output.code());
+ }
+ }
+
#[test]
#[serial]
fn deserialize_theme() {
@@ -1393,6 +1547,7 @@ mod tests {
"test.tsx".to_string(),
Some("devup-ui-0.css".to_string()),
false,
+ Vec::new(),
);
// Test getters
@@ -1419,6 +1574,7 @@ mod tests {
"test.tsx".to_string(),
None,
false,
+ Vec::new(),
);
// Test updated_base_style getter
@@ -1481,6 +1637,7 @@ mod tests {
"test.tsx".to_string(),
Some("devup-ui.css".to_string()),
true, // import_main_css = true
+ Vec::new(),
);
assert!(output.css().is_some());
@@ -1519,6 +1676,7 @@ mod tests {
"test.tsx".to_string(),
None,
false,
+ Vec::new(),
);
// The updated_base_style should be true because global CSS was removed
diff --git a/libs/css/src/optimize_value.rs b/libs/css/src/optimize_value.rs
index 7bf2beac6..1a1175b51 100644
--- a/libs/css/src/optimize_value.rs
+++ b/libs/css/src/optimize_value.rs
@@ -546,6 +546,9 @@ mod tests {
#[case("rgb(2147483648,0,0)", "rgb(2147483648,0,0)")]
// f32 parse failure for alpha (".") → falls back to original
#[case("rgba(255,0,0,.)", "rgba(255,0,0,.)")]
+ // whitespace other than a space between values
+ #[case("10px\t20px", "10px 20px")]
+ #[case("1px\n2px", "1px 2px")]
fn test_optimize_value(#[case] input: &str, #[case] expected: &str) {
assert_eq!(optimize_value(input), expected);
}
diff --git a/libs/extractor/Cargo.toml b/libs/extractor/Cargo.toml
index 4464920b4..33565e1c7 100644
--- a/libs/extractor/Cargo.toml
+++ b/libs/extractor/Cargo.toml
@@ -11,10 +11,6 @@ categories = ["development-tools", "wasm", "web-programming"]
[lints]
workspace = true
-[features]
-default = ["vanilla-extract"]
-vanilla-extract = ["dep:boa_engine"]
-
[dependencies]
oxc_parser = "0.151.0"
oxc_syntax = "0.151.0"
@@ -25,12 +21,13 @@ oxc_ast_visit = "0.151.0"
oxc_codegen = "0.151.0"
oxc_transformer = "0.151.0"
oxc_semantic = "0.151.0"
+oxc_sourcemap = "8"
css = { path = "../css" }
phf = "0.14"
strum = "0.28.0"
strum_macros = "0.28.0"
serde_json = "1.0"
-boa_engine = { version = "0.22", optional = true }
+boa_engine = "0.22"
rustc-hash = "2"
smallvec = "1"
diff --git a/libs/extractor/benches/extract_benchmark.rs b/libs/extractor/benches/extract_benchmark.rs
index b17a0f085..46eadf327 100644
--- a/libs/extractor/benches/extract_benchmark.rs
+++ b/libs/extractor/benches/extract_benchmark.rs
@@ -91,8 +91,8 @@ export const button = style([base, interactive, { background: 'blue', color: 'wh
export const danger = style([button, { background: 'red', color: 'white' }])
export const ghost = style([base, { background: 'transparent', color: 'blue' }])";
-const CSS_TEMPLATE_INPUT: &str = r"import { css } from '@devup-ui/react'
-const cls = css`
+const CSS_TEMPLATE_INPUT: &str = r"import { styled } from '@devup-ui/react'
+const Cls = styled.div`
color: red;
background-color: blue;
padding: 4px;
@@ -108,8 +108,8 @@ const cls = css`
// `found_placeholders` scan, reverse-position decorate-sort, per-placeholder
// `${expr}` rebuild) that the single-interpolation `CSS_TEMPLATE_INPUT` only
// shallowly covers.
-const CSS_MULTI_TEMPLATE_INPUT: &str = r"import { css } from '@devup-ui/react'
-const cls = css`
+const CSS_MULTI_TEMPLATE_INPUT: &str = r"import { styled } from '@devup-ui/react'
+const Cls = styled.div`
margin: ${topGap}px ${sideGap}px ${bottomGap}px ${sideGap}px;
padding: ${padY}px ${padX}px;
color: ${textColor};
diff --git a/libs/extractor/src/as_visit.rs b/libs/extractor/src/as_visit.rs
index e3898d88d..3676c7db3 100644
--- a/libs/extractor/src/as_visit.rs
+++ b/libs/extractor/src/as_visit.rs
@@ -1,62 +1,152 @@
-use crate::utils::get_string_by_literal_expression;
-use oxc_allocator::{Allocator, CloneIn, GetAllocator};
-use oxc_ast::ast::{Expression, JSXElement, JSXElementName, Str};
-use oxc_ast_visit::VisitMut;
-use oxc_ast_visit::walk_mut::walk_expression;
+//! The element ` ` renders: a name the build knows, a
+//! condition choosing between such names, or a type only the runtime gives
+use oxc_allocator::{CloneIn, FromIn, GetAllocator};
+use oxc_ast::ast::{
+ Expression, JSXElement, JSXElementName, JSXIdentifier, JSXMemberExpressionObject, Str,
+};
use oxc_ast::builder::AstBuilder;
use oxc_span::SPAN;
+use oxc_syntax::operator::LogicalOperator;
-pub struct AsVisitor<'a> {
- ast: AstBuilder<'a>,
- element: JSXElement<'a>,
+use crate::utils::unwrap_syntax_only;
+
+/// What an element with an `as` becomes
+pub enum As<'a> {
+ /// The element renamed
+ Name(JSXElementName<'a>),
+ /// A condition choosing between the element renamed for each name
+ Choice(Expression<'a>),
+ /// A type only the runtime gives, or the default tag when it gives
+ /// nothing (`undefined`, `null`, `false`)
+ Runtime(Expression<'a>),
}
-impl<'a> AsVisitor<'a> {
- pub fn new(allocator: &'a Allocator, element: JSXElement<'a>) -> Self {
- Self {
- ast: AstBuilder::new(allocator),
- element,
- }
+/// What an element whose `as` is `value` becomes; `default` is the tag it
+/// renders without one
+pub fn resolve<'a>(
+ ast: &AstBuilder<'a>,
+ element: &JSXElement<'a>,
+ value: Expression<'a>,
+ default: &str,
+) -> As<'a> {
+ if let Some(name) = element_name(ast, &value, default) {
+ return As::Name(name);
+ }
+ if let Some(choice) = choice(ast, element, &value, default) {
+ return As::Choice(choice);
}
+ As::Runtime(Expression::new_logical_expression(
+ SPAN,
+ Expression::new_parenthesized_expression(SPAN, value, ast),
+ LogicalOperator::Or,
+ Expression::new_string_literal(SPAN, Str::from_in(default, ast.allocator()), None, ast),
+ ast,
+ ))
}
-fn change_element_name<'a>(ast: &AstBuilder<'a>, element: &mut JSXElement<'a>, element_name: &str) {
- let element_name = JSXElementName::new_identifier(SPAN, Str::from(element_name), ast);
- element.opening_element.name = element_name.clone_in(ast.allocator());
- if let Some(el) = &mut element.closing_element {
- el.name = element_name.clone_in(ast.allocator());
+/// Name `element` `name`
+pub fn rename<'a>(ast: &AstBuilder<'a>, element: &mut JSXElement<'a>, name: JSXElementName<'a>) {
+ if let Some(closing) = &mut element.closing_element {
+ closing.name = name.clone_in(ast.allocator());
}
+ element.opening_element.name = name;
}
-impl<'a> VisitMut<'a> for AsVisitor<'a> {
- fn visit_expression(&mut self, it: &mut oxc_ast::ast::Expression<'a>) {
- if let Some(element_name) = get_string_by_literal_expression(it) {
- let mut element = self.element.clone_in(self.ast.allocator());
- change_element_name(&self.ast, &mut element, &element_name);
- *it = Expression::JSXElement(oxc_allocator::Box::new_in(element, &self.ast));
- } else if let Expression::Identifier(ident) = it {
- let element_name = ident.name.to_string();
- if element_name != "undefined" {
- let mut element = self.element.clone_in(self.ast.allocator());
- change_element_name(&self.ast, &mut element, &element_name);
- *it = Expression::JSXElement(oxc_allocator::Box::new_in(element, &self.ast));
- }
- } else if let Expression::ConditionalExpression(conditional) = it {
- self.visit_expression(&mut conditional.consequent);
- self.visit_expression(&mut conditional.alternate);
- } else if let Expression::ComputedMemberExpression(member) = it {
- self.visit_expression(&mut member.object);
- } else {
- walk_expression(self, it);
+/// The name JSX gives `value`: a tag, a component, a member such as
+/// `motion.div`, or the default tag for nothing
+fn element_name<'a>(
+ ast: &AstBuilder<'a>,
+ value: &Expression<'a>,
+ default: &str,
+) -> Option> {
+ let tag = |name: &str| {
+ JSXElementName::new_identifier(
+ SPAN,
+ Str::from_in(
+ if name.is_empty() { default } else { name },
+ ast.allocator(),
+ ),
+ ast,
+ )
+ };
+ match unwrap_syntax_only(value) {
+ Expression::StringLiteral(literal) => Some(tag(&literal.value)),
+ Expression::TemplateLiteral(template) if template.expressions.is_empty() => template.quasis
+ [0]
+ .value
+ .cooked
+ .as_ref()
+ .map(|text| tag(text)),
+ Expression::NullLiteral(_) => Some(tag("")),
+ Expression::BooleanLiteral(literal) if !literal.value => Some(tag("")),
+ Expression::Identifier(identifier) if identifier.name == "undefined" => Some(tag("")),
+ // A name JSX reads as a binding rather than a tag
+ Expression::Identifier(identifier)
+ if !identifier
+ .name
+ .starts_with(|c: char| c.is_ascii_lowercase()) =>
+ {
+ Some(tag(&identifier.name))
}
+ Expression::StaticMemberExpression(member) => Some(JSXElementName::new_member_expression(
+ SPAN,
+ member_object(ast, &member.object)?,
+ JSXIdentifier::new(
+ SPAN,
+ Str::from_in(member.property.name.as_str(), ast.allocator()),
+ ast,
+ ),
+ ast,
+ )),
+ _ => None,
}
+}
- fn visit_object_property(&mut self, it: &mut oxc_ast::ast::ObjectProperty<'a>) {
- self.visit_expression(&mut it.value);
+fn member_object<'a>(
+ ast: &AstBuilder<'a>,
+ object: &Expression<'a>,
+) -> Option> {
+ match unwrap_syntax_only(object) {
+ Expression::Identifier(identifier) => Some(
+ JSXMemberExpressionObject::new_identifier_reference(SPAN, identifier.name, ast),
+ ),
+ Expression::StaticMemberExpression(member) => {
+ Some(JSXMemberExpressionObject::new_member_expression(
+ SPAN,
+ member_object(ast, &member.object)?,
+ JSXIdentifier::new(
+ SPAN,
+ Str::from_in(member.property.name.as_str(), ast.allocator()),
+ ast,
+ ),
+ ast,
+ ))
+ }
+ _ => None,
}
+}
- fn visit_spread_element(&mut self, _: &mut oxc_ast::ast::SpreadElement<'a>) {
- // spread be mantained
+/// `value` as `element` renamed for each name a condition chooses, when every
+/// choice is a name
+fn choice<'a>(
+ ast: &AstBuilder<'a>,
+ element: &JSXElement<'a>,
+ value: &Expression<'a>,
+ default: &str,
+) -> Option> {
+ if let Expression::ConditionalExpression(conditional) = unwrap_syntax_only(value) {
+ return Some(Expression::new_conditional_expression(
+ SPAN,
+ conditional.test.clone_in(ast.allocator()),
+ choice(ast, element, &conditional.consequent, default)?,
+ choice(ast, element, &conditional.alternate, default)?,
+ ast,
+ ));
}
+ let mut renamed = element.clone_in(ast.allocator());
+ rename(ast, &mut renamed, element_name(ast, value, default)?);
+ Some(Expression::JSXElement(oxc_allocator::Box::new_in(
+ renamed, ast,
+ )))
}
diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs
new file mode 100644
index 000000000..1e8bdd7e3
--- /dev/null
+++ b/libs/extractor/src/build_time_values.rs
@@ -0,0 +1,1094 @@
+//! Values the styles of a file compute from constants with code the file
+//! declares, such as `css({ w: double(SIZE) })`: the APIs taking them have no
+//! element to set a runtime value on, so the build runs that code and writes
+//! what it gives in their place. Only code whose every step gives the same
+//! value on every page and every build runs; anything else is left to the
+//! runtime.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use oxc_allocator::Allocator;
+use oxc_ast::ast::{
+ ArrayExpressionElement, Declaration, ExportDefaultDeclarationKind, Expression,
+ ImportDeclarationSpecifier, ObjectPropertyKind, Program, Statement, VariableDeclarationKind,
+};
+use oxc_ast_visit::{Visit, walk};
+use oxc_parser::Parser;
+use oxc_semantic::{Scoping, SemanticBuilder};
+use oxc_span::{GetSpan, SourceType, Span};
+use oxc_syntax::operator::LogicalOperator;
+use oxc_syntax::reference::ReferenceId;
+use oxc_syntax::symbol::SymbolId;
+use rustc_hash::{FxHashMap, FxHashSet};
+
+use crate::stylex::StylexFunction;
+use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only};
+use crate::{ExtractOption, ModuleResolver};
+
+/// What code reads outside itself
+#[derive(Clone, Default)]
+struct Closure {
+ /// The top-level statements it reads, directly or through each other
+ statements: BTreeSet,
+ /// The imports it reads, by local name, each as the value the build knows
+ imports: BTreeMap,
+}
+
+/// An expression whose reads the build can run
+struct Found {
+ span: Span,
+ closure: Closure,
+ /// The key of the shorthand property it is written as, which the value
+ /// written in its place needs
+ shorthand: Option,
+ /// A part `css()` or `styled()` composes, which a class computed in its
+ /// place would turn into CSS text
+ rules_only: bool,
+}
+
+const UTILS: [&str; 4] = ["css", "globalCss", "keyframes", "createGlobalStyle"];
+
+/// Whether `code` has a style value that only running the module computes:
+/// one that the constants it reads do not give once inlined
+#[cfg(test)]
+pub(crate) fn has_build_time_values(
+ filename: &str,
+ code: &str,
+ option: &ExtractOption,
+ resolver: Option<&ModuleResolver>,
+) -> bool {
+ let (code, _) = crate::import_alias_visit::transform_import_aliases_with_edits(
+ code,
+ filename,
+ &option.package,
+ &option.import_aliases,
+ );
+ let allocator = Allocator::default();
+ let Some(mut program) = parse(&allocator, filename, &code) else {
+ return false;
+ };
+ let inlined = crate::imported_constants::inline_constants(
+ &oxc_ast::builder::AstBuilder::new(&allocator),
+ &mut program,
+ filename,
+ option,
+ resolver,
+ );
+ let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver);
+ !find(
+ &program,
+ &|source| source.starts_with(option.package.as_str()),
+ &inlined.unknown,
+ &|name| changes.is_changed(name),
+ &|name| changes.known(name),
+ )
+ .is_empty()
+}
+
+/// The globals the build runs: plain data and the functions over it that every
+/// engine computes alike
+const GLOBALS: [&str; 18] = [
+ "undefined",
+ "NaN",
+ "Infinity",
+ "Math",
+ "String",
+ "Number",
+ "Boolean",
+ "Array",
+ "Object",
+ "JSON",
+ "parseInt",
+ "parseFloat",
+ "isNaN",
+ "isFinite",
+ "encodeURIComponent",
+ "decodeURIComponent",
+ "encodeURI",
+ "decodeURI",
+];
+
+/// The members of `Math` every engine gives exactly; the others are
+/// approximations that may differ in their last digits
+const EXACT_MATH: [&str; 20] = [
+ "abs", "ceil", "floor", "round", "trunc", "sign", "max", "min", "sqrt", "fround", "imul",
+ "clz32", "PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2",
+];
+
+/// Members giving what the locale, the Unicode data of the engine or chance
+/// make them, and `toString`, which engines only approximate with a radix:
+/// it runs only called at once without one
+const UNCERTAIN_MEMBERS: [&str; 9] = [
+ "toLocaleString",
+ "toLocaleDateString",
+ "toLocaleTimeString",
+ "toLocaleUpperCase",
+ "toLocaleLowerCase",
+ "localeCompare",
+ "normalize",
+ "random",
+ "toString",
+];
+
+fn parse<'a>(allocator: &'a Allocator, filename: &str, code: &'a str) -> Option> {
+ let source_type = SourceType::from_path(filename).ok()?;
+ let parsed = Parser::new(allocator, code, source_type).parse();
+ (!parsed.fatal_error).then_some(parsed.program)
+}
+
+fn find(
+ program: &Program<'_>,
+ is_style: &dyn Fn(&str) -> bool,
+ unknown: &crate::imported_constants::Unknown,
+ is_changed: &dyn Fn(&str) -> bool,
+ known: &dyn Fn(&str) -> Option,
+) -> Vec {
+ let scoping = SemanticBuilder::new()
+ .build(program)
+ .semantic
+ .into_scoping();
+ let mut finder = Finder::new(program, &scoping, is_style, unknown, is_changed, known);
+ finder.visit_program(program);
+ finder.found
+}
+
+/// A top-level binding: the statement declaring it, and whether the module
+/// can run it without the style packages
+struct Binding {
+ statement: usize,
+ usable: bool,
+}
+
+struct Finder<'s, 'a> {
+ scoping: &'s Scoping,
+ statements: &'s [Statement<'a>],
+ bindings: FxHashMap,
+ /// Style APIs imported by name, and the namespaces holding them
+ apis: FxHashSet,
+ namespaces: FxHashSet,
+ stylex_namespaces: FxHashSet,
+ /// `css` and `styled` imported by name, which compose their arguments
+ css: FxHashSet,
+ styled: FxHashSet,
+ /// What the style packages other than `StyleX` are imported as by name
+ components: FxHashSet,
+ /// Bindings whose value only running the module gives
+ unknown: &'s crate::imported_constants::Unknown,
+ /// Whether a binding holds an object or array code changes, which the
+ /// module would compute from as declared
+ is_changed: &'s dyn Fn(&str) -> bool,
+ /// The value of an import as JavaScript source, when the build knows all
+ /// of it; the module it comes from never runs
+ known: &'s dyn Fn(&str) -> Option,
+ closures: FxHashMap>,
+ found: Vec,
+}
+
+fn is_evaluated_stylex(name: &str) -> bool {
+ StylexFunction::from_export_name(name).is_some_and(|function| function.requirement().is_some())
+}
+
+impl<'s, 'a> Finder<'s, 'a> {
+ fn new(
+ program: &'s Program<'a>,
+ scoping: &'s Scoping,
+ is_style: &dyn Fn(&str) -> bool,
+ unknown: &'s crate::imported_constants::Unknown,
+ is_changed: &'s dyn Fn(&str) -> bool,
+ known: &'s dyn Fn(&str) -> Option,
+ ) -> Self {
+ let mut finder = Self {
+ scoping,
+ statements: &program.body,
+ bindings: FxHashMap::default(),
+ apis: FxHashSet::default(),
+ namespaces: FxHashSet::default(),
+ stylex_namespaces: FxHashSet::default(),
+ css: FxHashSet::default(),
+ styled: FxHashSet::default(),
+ components: FxHashSet::default(),
+ unknown,
+ is_changed,
+ known,
+ closures: FxHashMap::default(),
+ found: Vec::new(),
+ };
+ for (index, statement) in program.body.iter().enumerate() {
+ finder.bind(index, statement, is_style);
+ }
+ finder
+ }
+
+ fn bind(&mut self, statement: usize, node: &Statement<'a>, is_style: &dyn Fn(&str) -> bool) {
+ let mut declare = |symbol: Option, usable: bool| {
+ if let Some(symbol) = symbol {
+ self.bindings.insert(symbol, Binding { statement, usable });
+ }
+ };
+ let declaration = match node {
+ Statement::ImportDeclaration(import) => {
+ let source = import.source.value.as_str();
+ let stylex = source == crate::STYLEX_PACKAGE;
+ let style = stylex || is_style(source);
+ for specifier in import.specifiers.iter().flatten() {
+ let symbol = specifier.local().symbol_id.get();
+ let (usable, api, namespace) = match specifier {
+ ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
+ let name = specifier.imported.name();
+ (
+ !specifier.import_kind.is_type(),
+ if stylex {
+ is_evaluated_stylex(&name)
+ } else {
+ UTILS.contains(&name.as_str())
+ },
+ false,
+ )
+ }
+ _ => (true, false, true),
+ };
+ declare(symbol, usable && !style && !import.import_kind.is_type());
+ if let Some(symbol) = symbol.filter(|_| style) {
+ if api {
+ self.apis.insert(symbol);
+ } else if namespace && stylex {
+ self.stylex_namespaces.insert(symbol);
+ } else if namespace {
+ self.namespaces.insert(symbol);
+ }
+ if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier
+ && !stylex
+ {
+ self.components.insert(symbol);
+ match specifier.imported.name().as_str() {
+ "css" => {
+ self.css.insert(symbol);
+ }
+ "styled" => {
+ self.styled.insert(symbol);
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+ return;
+ }
+ Statement::ExportDeclaration(export) => &export.declaration,
+ Statement::ExportDefaultDeclaration(export) => {
+ match &export.declaration {
+ ExportDefaultDeclarationKind::FunctionDeclaration(function) => {
+ declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true);
+ }
+ ExportDefaultDeclarationKind::ClassDeclaration(class) => {
+ declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false);
+ }
+ _ => {}
+ }
+ return;
+ }
+ node => match node.as_declaration() {
+ Some(declaration) => declaration,
+ None => return,
+ },
+ };
+ match declaration {
+ Declaration::VariableDeclaration(declaration) => {
+ for declarator in &declaration.declarations {
+ for identifier in declarator.id.get_binding_identifiers() {
+ declare(
+ identifier.symbol_id.get(),
+ declaration.kind == VariableDeclarationKind::Const,
+ );
+ }
+ }
+ }
+ Declaration::FunctionDeclaration(function) => {
+ declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true);
+ }
+ Declaration::ClassDeclaration(class) => {
+ declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false);
+ }
+ Declaration::TSEnumDeclaration(declaration) => {
+ declare(declaration.id.symbol_id.get(), !declaration.declare);
+ }
+ _ => {}
+ }
+ }
+
+ fn symbol(&self, expression: &Expression<'_>) -> Option {
+ let Expression::Identifier(identifier) = expression else {
+ return None;
+ };
+ self.scoping
+ .get_reference(identifier.reference_id.get()?)
+ .symbol_id()
+ }
+
+ /// Whether `callee` is a style API with no element to set a runtime value on
+ fn is_api(&self, callee: &Expression<'_>) -> bool {
+ match callee {
+ Expression::Identifier(_) => {
+ self.symbol(callee).is_some_and(|s| self.apis.contains(&s))
+ }
+ Expression::StaticMemberExpression(member) => {
+ self.symbol(&member.object).is_some_and(|symbol| {
+ let name = member.property.name.as_str();
+ (self.namespaces.contains(&symbol) && UTILS.contains(&name))
+ || (self.stylex_namespaces.contains(&symbol) && is_evaluated_stylex(name))
+ })
+ }
+ _ => false,
+ }
+ }
+
+ fn is_css(&self, callee: &Expression<'_>) -> bool {
+ if let Expression::StaticMemberExpression(member) = callee {
+ return member.property.name == "css";
+ }
+ self.symbol(callee)
+ .is_some_and(|symbol| self.css.contains(&symbol))
+ }
+
+ /// Whether calling `callee` gives a `styled` component its styles:
+ /// `styled.div(...)`, `styled(Link)(...)`, `styled.div.attrs({})(...)`
+ fn is_styled(&self, callee: &Expression<'_>) -> bool {
+ match unwrap_syntax_only(callee) {
+ Expression::StaticMemberExpression(member) => self.is_styled_function(&member.object),
+ Expression::CallExpression(call) => match unwrap_syntax_only(&call.callee) {
+ Expression::StaticMemberExpression(member)
+ if matches!(member.property.name.as_str(), "attrs" | "withConfig") =>
+ {
+ self.is_styled(&member.object)
+ }
+ callee => self.is_styled_function(callee),
+ },
+ _ => false,
+ }
+ }
+
+ /// Whether `name` is a component of the style packages: ``, ``
+ fn is_component(&self, name: &oxc_ast::ast::JSXElementName<'_>) -> bool {
+ let mut object = match name {
+ oxc_ast::ast::JSXElementName::IdentifierReference(identifier) => {
+ return self
+ .symbol_of(identifier)
+ .is_some_and(|symbol| self.components.contains(&symbol));
+ }
+ oxc_ast::ast::JSXElementName::MemberExpression(member) => &member.object,
+ _ => return false,
+ };
+ loop {
+ match object {
+ oxc_ast::ast::JSXMemberExpressionObject::MemberExpression(member) => {
+ object = &member.object;
+ }
+ oxc_ast::ast::JSXMemberExpressionObject::IdentifierReference(identifier) => {
+ return self
+ .symbol_of(identifier)
+ .is_some_and(|symbol| self.namespaces.contains(&symbol));
+ }
+ oxc_ast::ast::JSXMemberExpressionObject::ThisExpression(_) => return false,
+ }
+ }
+ }
+
+ fn symbol_of(&self, identifier: &oxc_ast::ast::IdentifierReference<'_>) -> Option {
+ self.scoping
+ .get_reference(identifier.reference_id.get()?)
+ .symbol_id()
+ }
+
+ fn is_styled_function(&self, expression: &Expression<'_>) -> bool {
+ if let Expression::StaticMemberExpression(member) = unwrap_syntax_only(expression) {
+ return member.property.name == "styled"
+ && self
+ .symbol(&member.object)
+ .is_some_and(|symbol| self.namespaces.contains(&symbol));
+ }
+ self.symbol(expression)
+ .is_some_and(|symbol| self.styled.contains(&symbol))
+ }
+
+ /// The parts `css()` and `styled()` compose: rule objects, whose values
+ /// are read when `rules` is set, and classes, which only rules the module
+ /// computes replace
+ fn parts(&mut self, expression: &Expression<'a>, rules: bool) {
+ match unwrap_syntax_only(expression) {
+ Expression::ObjectExpression(object) => {
+ if rules {
+ self.values(expression, None);
+ return;
+ }
+ // Values an element holds at runtime stay; what gives it styles
+ // is computed: spreads, selectors and computed keys
+ for property in &object.properties {
+ match property {
+ ObjectPropertyKind::SpreadProperty(spread) => {
+ self.parts(&spread.argument, false);
+ }
+ ObjectPropertyKind::ObjectProperty(property) => {
+ if property.computed
+ && let Some(key) = property.key.as_expression()
+ {
+ self.values(key, None);
+ }
+ if property
+ .key
+ .static_name()
+ .is_some_and(|key| key.starts_with('_'))
+ {
+ self.parts(&property.value, false);
+ }
+ }
+ }
+ }
+ }
+ Expression::ArrayExpression(array) => {
+ for element in &array.elements {
+ if let Some(element) = element.as_expression() {
+ self.parts(element, rules);
+ }
+ }
+ }
+ Expression::ConditionalExpression(conditional) => {
+ self.parts(&conditional.consequent, rules);
+ self.parts(&conditional.alternate, rules);
+ }
+ Expression::LogicalExpression(logical) => {
+ if logical.operator != LogicalOperator::And {
+ self.parts(&logical.left, rules);
+ }
+ self.parts(&logical.right, rules);
+ }
+ // CSS text, whose values are read as `values` reads them
+ Expression::TemplateLiteral(_) => {
+ if rules {
+ self.values(expression, None);
+ }
+ }
+ Expression::NullLiteral(_)
+ | Expression::BooleanLiteral(_)
+ | Expression::StringLiteral(_) => {}
+ inner => {
+ if binding_root(inner).is_none() || self.unknown.read_by(inner) {
+ self.candidate(expression, None, true);
+ }
+ }
+ }
+ }
+
+ /// The values `expression` holds: itself, or those of the object, array or
+ /// condition it writes
+ fn values(&mut self, expression: &Expression<'a>, shorthand: Option<&str>) {
+ match unwrap_syntax_only(expression) {
+ Expression::ObjectExpression(object) => {
+ for property in &object.properties {
+ match property {
+ ObjectPropertyKind::ObjectProperty(property) => {
+ if property.computed
+ && let Some(key) = property.key.as_expression()
+ {
+ self.values(key, None);
+ }
+ let key = property.key.static_name();
+ self.values(
+ &property.value,
+ key.as_deref().filter(|_| property.shorthand),
+ );
+ }
+ ObjectPropertyKind::SpreadProperty(spread) => {
+ self.values(&spread.argument, None);
+ }
+ }
+ }
+ }
+ Expression::ArrayExpression(array) => {
+ for element in &array.elements {
+ match element {
+ ArrayExpressionElement::SpreadElement(spread) => {
+ self.values(&spread.argument, None);
+ }
+ element => {
+ if let Some(element) = element.as_expression() {
+ self.values(element, None);
+ }
+ }
+ }
+ }
+ }
+ Expression::NullLiteral(_)
+ | Expression::BooleanLiteral(_)
+ | Expression::ArrowFunctionExpression(_)
+ | Expression::FunctionExpression(_) => {}
+ inner if get_string_by_literal_expression(inner).is_some() => {}
+ inner => {
+ if self.candidate(expression, shorthand, false) {
+ return;
+ }
+ // A condition only known at runtime still chooses between
+ // values the module computes
+ match inner {
+ Expression::ConditionalExpression(conditional) => {
+ self.values(&conditional.consequent, None);
+ self.values(&conditional.alternate, None);
+ }
+ Expression::LogicalExpression(logical) => {
+ self.values(&logical.left, None);
+ self.values(&logical.right, None);
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+
+ fn candidate(
+ &mut self,
+ expression: &Expression<'a>,
+ shorthand: Option<&str>,
+ rules_only: bool,
+ ) -> bool {
+ let span = expression.span();
+ let mut reads = Reads::new(self.scoping);
+ reads.visit_expression(expression);
+ if reads.impure {
+ return false;
+ }
+ let Some(closure) = self.closure(span, &reads.references) else {
+ return false;
+ };
+ self.found.push(Found {
+ span,
+ closure,
+ shorthand: shorthand.map(str::to_string),
+ rules_only,
+ });
+ true
+ }
+
+ /// What code at `span` reads outside itself, `None` when it reads a
+ /// binding the build does not run: one declared other than as a `const`,
+ /// a function or an enum, code it does not run, an object code changes,
+ /// or an import whose value it does not know whole
+ fn closure(&mut self, span: Span, references: &[ReferenceId]) -> Option {
+ let mut closure = Closure::default();
+ for reference in references {
+ let Some(symbol) = self.scoping.get_reference(*reference).symbol_id() else {
+ continue;
+ };
+ if span.contains_inclusive(self.scoping.symbol_span(symbol)) {
+ continue;
+ }
+ let name = self.scoping.symbol_name(symbol);
+ let statement = match self.bindings.get(&symbol) {
+ Some(Binding {
+ statement,
+ usable: true,
+ }) if !(self.is_changed)(name) => *statement,
+ _ => return None,
+ };
+ if matches!(self.statements[statement], Statement::ImportDeclaration(_)) {
+ closure
+ .imports
+ .insert(name.to_string(), (self.known)(name)?);
+ continue;
+ }
+ closure.statements.insert(statement);
+ let inner = self.statement_closure(statement)?;
+ closure.statements.extend(inner.statements);
+ closure.imports.extend(inner.imports);
+ }
+ Some(closure)
+ }
+
+ fn statement_closure(&mut self, index: usize) -> Option {
+ if let Some(closure) = self.closures.get(&index) {
+ return closure.clone();
+ }
+ // Read while it is computed, so statements reading each other stop
+ self.closures.insert(index, Some(Closure::default()));
+ let statement = &self.statements[index];
+ let mut reads = Reads::new(self.scoping);
+ reads.visit_statement(statement);
+ let closure = if reads.impure {
+ None
+ } else {
+ self.closure(statement.span(), &reads.references)
+ };
+ self.closures.insert(index, closure.clone());
+ closure
+ }
+}
+
+impl<'a> Visit<'a> for Finder<'_, 'a> {
+ fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
+ let (api, styled) = (self.is_api(&call.callee), self.is_styled(&call.callee));
+ if api || styled {
+ let composes = styled || self.is_css(&call.callee);
+ for argument in &call.arguments {
+ let argument = match argument {
+ oxc_ast::ast::Argument::SpreadElement(spread) => &spread.argument,
+ argument => argument.to_expression(),
+ };
+ if composes {
+ self.parts(argument, api);
+ } else {
+ self.values(argument, None);
+ }
+ }
+ }
+ walk::walk_call_expression(self, call);
+ }
+
+ fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) {
+ if self.is_component(&element.name) {
+ for attribute in &element.attributes {
+ match attribute {
+ oxc_ast::ast::JSXAttributeItem::SpreadAttribute(spread) => {
+ self.parts(&spread.argument, false);
+ }
+ oxc_ast::ast::JSXAttributeItem::Attribute(attribute) => {
+ if let oxc_ast::ast::JSXAttributeName::Identifier(name) = &attribute.name
+ && name.name.starts_with('_')
+ && let Some(oxc_ast::ast::JSXAttributeValue::ExpressionContainer(
+ container,
+ )) = &attribute.value
+ && let Some(value) = container.expression.as_expression()
+ {
+ self.parts(value, false);
+ }
+ }
+ }
+ }
+ }
+ walk::walk_jsx_opening_element(self, element);
+ }
+
+ fn visit_tagged_template_expression(
+ &mut self,
+ tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>,
+ ) {
+ if self.is_api(&tagged.tag) {
+ for expression in &tagged.quasi.expressions {
+ self.values(expression, None);
+ }
+ }
+ walk::walk_tagged_template_expression(self, tagged);
+ }
+}
+
+/// The bindings code reads, and whether the build runs it
+struct Reads<'s> {
+ scoping: &'s Scoping,
+ references: Vec,
+ /// Does what the build does not run: reads what only the code around it,
+ /// the page or the process knows (`this`, globals such as `window` or
+ /// `Date`), chance, the locale or results engines only approximate; uses
+ /// more than plain data and functions (`new`, classes, regular
+ /// expressions, `try`, getters, async code, JSX); or writes a binding
+ /// outside itself
+ impure: bool,
+}
+
+impl<'s> Reads<'s> {
+ const fn new(scoping: &'s Scoping) -> Self {
+ Self {
+ scoping,
+ references: Vec::new(),
+ impure: false,
+ }
+ }
+
+ fn symbol(&self, identifier: &oxc_ast::ast::IdentifierReference<'_>) -> Option {
+ self.scoping
+ .get_reference(identifier.reference_id.get()?)
+ .symbol_id()
+ }
+
+ fn is_global(&self, expression: &Expression<'_>, name: &str) -> bool {
+ matches!(expression, Expression::Identifier(identifier)
+ if identifier.name == name && self.symbol(identifier).is_none())
+ }
+
+ fn member(&mut self, object: &Expression<'_>, name: &str) {
+ self.impure |= UNCERTAIN_MEMBERS.contains(&name)
+ || (self.is_global(object, "Math") && !EXACT_MATH.contains(&name));
+ }
+
+ /// Whether `expression`, the object of a member written, is a top-level
+ /// binding or a member of one
+ fn is_outside(&self, expression: &Expression<'_>) -> bool {
+ let mut expression = expression;
+ loop {
+ match unwrap_syntax_only(expression) {
+ Expression::StaticMemberExpression(member) => expression = &member.object,
+ Expression::ComputedMemberExpression(member) => expression = &member.object,
+ Expression::Identifier(identifier) => {
+ return self.symbol(identifier).is_some_and(|symbol| {
+ self.scoping.symbol_scope_id(symbol) == self.scoping.root_scope_id()
+ });
+ }
+ _ => return false,
+ }
+ }
+ }
+}
+
+impl<'a> Visit<'a> for Reads<'_> {
+ fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) {
+ if let Some(reference) = identifier.reference_id.get() {
+ let reference_data = self.scoping.get_reference(reference);
+ self.impure |= reference_data.symbol_id().map_or_else(
+ // `Math` runs only through the members `member` checks
+ || !GLOBALS.contains(&identifier.name.as_str()) || identifier.name == "Math",
+ |symbol| {
+ reference_data.is_write()
+ && self.scoping.symbol_scope_id(symbol) == self.scoping.root_scope_id()
+ },
+ );
+ self.references.push(reference);
+ }
+ }
+
+ fn visit_static_member_expression(
+ &mut self,
+ member: &oxc_ast::ast::StaticMemberExpression<'a>,
+ ) {
+ self.member(&member.object, member.property.name.as_str());
+ if !self.is_global(&member.object, "Math") {
+ walk::walk_static_member_expression(self, member);
+ }
+ }
+
+ fn visit_computed_member_expression(
+ &mut self,
+ member: &oxc_ast::ast::ComputedMemberExpression<'a>,
+ ) {
+ if let Some(key) = get_string_by_literal_expression(&member.expression) {
+ self.member(&member.object, &key);
+ if self.is_global(&member.object, "Math") {
+ return;
+ }
+ }
+ walk::walk_computed_member_expression(self, member);
+ }
+
+ fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
+ match unwrap_syntax_only(&call.callee) {
+ // A method chosen at runtime may be any
+ Expression::ComputedMemberExpression(member) => {
+ self.impure |= get_string_by_literal_expression(&member.expression).is_none();
+ }
+ // Exact only without a radix
+ Expression::StaticMemberExpression(member) if member.property.name == "toString" => {
+ self.impure |= !call.arguments.is_empty();
+ self.visit_expression(&member.object);
+ return;
+ }
+ _ => {}
+ }
+ walk::walk_call_expression(self, call);
+ }
+
+ fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) {
+ self.impure |= assignment.operator == oxc_syntax::operator::AssignmentOperator::Exponential
+ || assignment
+ .left
+ .as_simple_assignment_target()
+ .and_then(|target| target.as_member_expression())
+ .is_some_and(|member| self.is_outside(member.object()));
+ walk::walk_assignment_expression(self, assignment);
+ }
+
+ fn visit_update_expression(&mut self, update: &oxc_ast::ast::UpdateExpression<'a>) {
+ self.impure |= update
+ .argument
+ .as_member_expression()
+ .is_some_and(|member| self.is_outside(member.object()));
+ walk::walk_update_expression(self, update);
+ }
+
+ fn visit_unary_expression(&mut self, unary: &oxc_ast::ast::UnaryExpression<'a>) {
+ self.impure |= unary.operator == oxc_syntax::operator::UnaryOperator::Delete
+ && self.is_outside(&unary.argument);
+ walk::walk_unary_expression(self, unary);
+ }
+
+ fn visit_binary_expression(&mut self, binary: &oxc_ast::ast::BinaryExpression<'a>) {
+ self.impure |= binary.operator == oxc_syntax::operator::BinaryOperator::Exponential;
+ walk::walk_binary_expression(self, binary);
+ }
+
+ fn visit_object_property(&mut self, property: &oxc_ast::ast::ObjectProperty<'a>) {
+ self.impure |= property.kind != oxc_ast::ast::PropertyKind::Init;
+ walk::walk_object_property(self, property);
+ }
+
+ fn visit_binding_property(&mut self, property: &oxc_ast::ast::BindingProperty<'a>) {
+ self.impure |= property
+ .key
+ .static_name()
+ .is_some_and(|key| UNCERTAIN_MEMBERS.contains(&key.as_ref()));
+ walk::walk_binding_property(self, property);
+ }
+
+ fn visit_function(
+ &mut self,
+ function: &oxc_ast::ast::Function<'a>,
+ flags: oxc_syntax::scope::ScopeFlags,
+ ) {
+ self.impure |= function.r#async || function.generator;
+ walk::walk_function(self, function, flags);
+ }
+
+ fn visit_arrow_function_expression(
+ &mut self,
+ arrow: &oxc_ast::ast::ArrowFunctionExpression<'a>,
+ ) {
+ self.impure |= arrow.r#async;
+ walk::walk_arrow_function_expression(self, arrow);
+ }
+
+ fn visit_this_expression(&mut self, _: &oxc_ast::ast::ThisExpression) {
+ self.impure = true;
+ }
+
+ fn visit_super(&mut self, _: &oxc_ast::ast::Super) {
+ self.impure = true;
+ }
+
+ fn visit_import_meta(&mut self, _: &oxc_ast::ast::ImportMeta) {
+ self.impure = true;
+ }
+
+ fn visit_new_target(&mut self, _: &oxc_ast::ast::NewTarget) {
+ self.impure = true;
+ }
+
+ fn visit_new_expression(&mut self, _: &oxc_ast::ast::NewExpression<'a>) {
+ self.impure = true;
+ }
+
+ fn visit_class(&mut self, _: &oxc_ast::ast::Class<'a>) {
+ self.impure = true;
+ }
+
+ fn visit_reg_exp_literal(&mut self, _: &oxc_ast::ast::RegExpLiteral<'a>) {
+ self.impure = true;
+ }
+
+ fn visit_try_statement(&mut self, _: &oxc_ast::ast::TryStatement<'a>) {
+ self.impure = true;
+ }
+
+ fn visit_await_expression(&mut self, _: &oxc_ast::ast::AwaitExpression<'a>) {
+ self.impure = true;
+ }
+
+ fn visit_yield_expression(&mut self, _: &oxc_ast::ast::YieldExpression<'a>) {
+ self.impure = true;
+ }
+
+ fn visit_import_expression(&mut self, _: &oxc_ast::ast::ImportExpression<'a>) {
+ self.impure = true;
+ }
+
+ fn visit_jsx_element(&mut self, _: &oxc_ast::ast::JSXElement<'a>) {
+ self.impure = true;
+ }
+
+ fn visit_jsx_fragment(&mut self, _: &oxc_ast::ast::JSXFragment<'a>) {
+ self.impure = true;
+ }
+
+ // Types are erased before the code runs, so what they name is not read
+ fn visit_ts_type(&mut self, _: &oxc_ast::ast::TSType<'a>) {}
+}
+
+/// `code` with what its style values compute written in their place, the
+/// replacements made, and the files read; `None` when running the code it
+/// reads computes none of them as a string, a finite number, or a plain
+/// object or array of those
+pub(crate) fn evaluate(
+ code: &str,
+ filename: &str,
+ option: &ExtractOption,
+ resolver: Option<&ModuleResolver>,
+ unknown: &crate::imported_constants::Unknown,
+) -> Option<(
+ String,
+ Vec,
+ BTreeSet,
+)> {
+ let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?;
+ values.sort_unstable_by_key(|(span, _)| span.start);
+ let mut result = String::with_capacity(code.len());
+ let mut edits = Vec::with_capacity(values.len());
+ let mut copied = 0;
+ for (span, literal) in values {
+ let (start, end) = (span.start as usize, span.end as usize);
+ result.push_str(&code[copied..start]);
+ result.push_str(&literal);
+ edits.push((start, end, literal.len()));
+ copied = end;
+ }
+ result.push_str(&code[copied..]);
+ Some((result, edits, dependencies))
+}
+
+/// A value's source text by the span of the code computing it
+type Replacement = (Span, String);
+
+/// Run before the values: the code checked to read nothing that differs
+/// between builds or pages, nothing does even if the check missed it
+/// (reading `Date`, `Math.random`, the environment or the locale throws), and
+/// the source text of a value the build can read. A statement that throws is
+/// recorded, and a value reading it is not computed
+const PRELUDE: &str = r#"delete globalThis.Date;
+Math.random = undefined;
+for (const name of ["window", "self", "document", "navigator", "location", "history", "localStorage", "sessionStorage", "matchMedia", "screen", "innerWidth", "innerHeight", "devicePixelRatio", "process", "global", "Deno", "Bun", "Intl"]) Object.defineProperty(globalThis, name, { get() { throw new ReferenceError(`${name} is only known at runtime`); }, configurable: true });
+for (const [prototype, names] of [[Object.prototype, ["toLocaleString"]], [Number.prototype, ["toLocaleString"]], [BigInt.prototype, ["toLocaleString"]], [Array.prototype, ["toLocaleString"]], [String.prototype, ["localeCompare", "toLocaleUpperCase", "toLocaleLowerCase", "normalize"]]]) for (const name of names) Object.defineProperty(prototype, name, { value() { throw new ReferenceError(`${name} depends on the locale`); }, configurable: true, writable: true });
+Object.setPrototypeOf(globalThis, new Proxy(Object.getPrototypeOf(globalThis), { get(target, key, receiver) { if (typeof key === "string" && !(key in target)) throw new ReferenceError(`${key} is only known at runtime`); return Reflect.get(target, key, receiver); } }));
+const __failed__ = (() => { const fail = () => { throw new ReferenceError("its value threw"); }; return new Proxy(function () {}, { get: fail, apply: fail, construct: fail, getPrototypeOf: fail }); })();
+const __failed_statements__ = new Set();
+const __try__ = (compute, statement) => { try { return compute(); } catch { __failed_statements__.add(statement); return __failed__; } };
+const __literal__ = (value) => {
+ const plain = (item) => item === undefined || item === null || typeof item === "string" || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item))
+ || (Array.isArray(item) && item.every(plain))
+ || (typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype && Object.getOwnPropertySymbols(item).length === 0 && !Object.prototype.hasOwnProperty.call(item, "__proto__") && Object.values(item).every(plain));
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined;
+ return value !== undefined && plain(value) ? JSON.stringify(value) : undefined;
+};
+"#;
+
+fn compute(
+ code: &str,
+ filename: &str,
+ option: &ExtractOption,
+ resolver: Option<&ModuleResolver>,
+ unknown: &crate::imported_constants::Unknown,
+) -> Option<(Vec, BTreeSet)> {
+ use std::fmt::Write;
+
+ use boa_engine::{Context, JsObject, Source};
+
+ let allocator = Allocator::default();
+ let program = parse(&allocator, filename, code)?;
+ let is_style = |source: &str| {
+ source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source)
+ };
+ let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver);
+ let found = find(
+ &program,
+ &is_style,
+ unknown,
+ &|name| changes.is_changed(name),
+ &|name| changes.known(name),
+ );
+ if found.is_empty() {
+ return None;
+ }
+ let statements: BTreeSet = found
+ .iter()
+ .flat_map(|found| found.closure.statements.iter().copied())
+ .collect();
+ let imports: BTreeMap<&str, &str> = found
+ .iter()
+ .flat_map(|found| &found.closure.imports)
+ .map(|(name, value)| (name.as_str(), value.as_str()))
+ .collect();
+ // The modules imports come from never run: their values are written as
+ // the build knows them
+ let mut module = String::new();
+ for (name, value) in imports {
+ let _ = writeln!(module, "const {name} = {value};");
+ }
+ for index in statements {
+ let statement = &program.body[index];
+ let declaration = match statement {
+ Statement::VariableDeclaration(declaration) => Some(declaration),
+ Statement::ExportDeclaration(export) => match &export.declaration {
+ Declaration::VariableDeclaration(declaration) => Some(declaration),
+ _ => None,
+ },
+ _ => None,
+ };
+ if let Some(declaration) = declaration {
+ // A binding whose value throws is one reading it throws for,
+ // leaving the others
+ for declarator in &declaration.declarations {
+ let Some(init) = &declarator.init else {
+ continue;
+ };
+ let _ = writeln!(
+ module,
+ "{} {}__try__(() => ({}), {index});",
+ declaration.kind.as_str(),
+ &code[declarator.span.start as usize..init.span().start as usize],
+ &code[init.span().start as usize..init.span().end as usize],
+ );
+ }
+ continue;
+ }
+ let span = match statement {
+ Statement::ExportDeclaration(export) => export.declaration.span(),
+ Statement::ExportDefaultDeclaration(export) => export.declaration.span(),
+ statement => statement.span(),
+ };
+ module.push_str(&code[span.start as usize..span.end as usize]);
+ module.push('\n');
+ }
+ // Each runs apart, so one that throws leaves the others
+ for (index, found) in found.iter().enumerate() {
+ let statements: Vec = found
+ .closure
+ .statements
+ .iter()
+ .map(ToString::to_string)
+ .collect();
+ let _ = writeln!(
+ module,
+ "const __value_{index}__ = (() => {{ if ([{}].some((statement) => __failed_statements__.has(statement))) return undefined; try {{ return __literal__(({})); }} catch {{ return undefined; }} }})();",
+ statements.join(", "),
+ &code[found.span.start as usize..found.span.end as usize]
+ );
+ }
+ let names: Vec = (0..found.len())
+ .map(|index| format!("__value_{index}__"))
+ .collect();
+ let _ = writeln!(module, "[{}];", names.join(", "));
+
+ let script = crate::vanilla_extract::strip_typescript(&module, filename);
+ let mut context = Context::default();
+ context
+ .runtime_limits_mut()
+ .set_loop_iteration_limit(crate::module_loader::LOOP_ITERATION_LIMIT);
+ let values = context
+ .eval(Source::from_bytes(format!("{PRELUDE}{script}").as_bytes()))
+ .ok()?;
+ let values = values.as_object().filter(JsObject::is_array)?;
+ let mut computed = Vec::new();
+ for (index, found) in found.into_iter().enumerate() {
+ let Some(literal) = values
+ .get(index, &mut context)
+ .ok()?
+ .as_string()
+ .map(|literal| literal.to_std_string_escaped())
+ else {
+ continue;
+ };
+ // A part joined gives rules, or a class as a string
+ if found.rules_only && !literal.starts_with(['{', '[', '"']) {
+ continue;
+ }
+ computed.push((
+ found.span,
+ match found.shorthand {
+ Some(key) => format!("{key}: {literal}"),
+ None => literal,
+ },
+ ));
+ }
+ (!computed.is_empty()).then_some((computed, changes.dependencies()))
+}
diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs
index b594560af..f0d67200b 100644
--- a/libs/extractor/src/css_utils.rs
+++ b/libs/extractor/src/css_utils.rs
@@ -2,7 +2,10 @@ use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt::Write as _;
-use crate::utils::{get_str_by_property_key, get_string_by_literal_expression, wrap_direct_call};
+use crate::utils::{
+ get_str_by_property_key, get_string_by_literal_expression, readable_code, runtime_value_error,
+ wrap_direct_call,
+};
use css::{
at_rule::split_at_rule_key,
optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value},
@@ -10,12 +13,12 @@ use css::{
style_selector::StyleSelector,
};
use oxc_allocator::Allocator;
-use oxc_span::SPAN;
+use oxc_span::{GetSpan, 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::ast::{TemplateElement, TemplateLiteral};
use oxc_ast::builder::AstBuilder;
use crate::extract_style::{
@@ -128,6 +131,80 @@ pub fn css_to_style_literal(
level: u8,
selector: &Option,
) -> Vec {
+ css_to_style_template(css, level, selector).styles
+}
+
+/// The declarations of a CSS template, and the interpolations that are not the
+/// value of one
+#[derive(Debug, Default)]
+pub struct TemplateStyles {
+ pub styles: Vec,
+ /// Indexes of the interpolations standing where a declaration would, such
+ /// as a mixin
+ pub statements: Vec,
+ /// Indexes of the interpolations in a selector or a property name
+ pub unplaced: Vec,
+}
+
+enum Place {
+ Value,
+ Statement,
+ Other,
+}
+
+/// Where an interpolation stands, from the CSS written before and after it
+fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place {
+ let head = &before[before.rfind([';', '{', '}']).map_or(0, |index| index + 1)..];
+ let rest: String = after.iter().map(|quasi| quasi.value.raw.as_str()).collect();
+ let end = rest.find([';', '{', '}']);
+ if end.is_some_and(|end| rest.as_bytes()[end] == b'{') {
+ return Place::Other;
+ }
+ if head.contains(':') {
+ Place::Value
+ } else if head.trim().is_empty()
+ && rest
+ .chars()
+ .next()
+ .is_none_or(|c| c.is_whitespace() || c == ';')
+ {
+ Place::Statement
+ } else {
+ Place::Other
+ }
+}
+
+/// The CSS text of a template whose interpolations must all be known at build
+/// time, as `api` has no element to set a runtime value on; the error is at
+/// the offset of the first that is not
+pub(crate) fn template_css_text(
+ css: &TemplateLiteral<'_>,
+ api: &str,
+) -> Result {
+ let mut text = String::new();
+ for (index, quasi) in css.quasis.iter().enumerate() {
+ text.push_str(&quasi.value.raw);
+ if let Some(expression) = css.expressions.get(index) {
+ let value = get_string_by_literal_expression(expression)
+ .map(Cow::into_owned)
+ .or_else(|| theme_var_reference(expression))
+ .ok_or_else(|| {
+ (
+ expression.span().start,
+ runtime_value_error(api, &readable_code(expression)),
+ )
+ })?;
+ text.push_str(&value);
+ }
+ }
+ Ok(text)
+}
+
+pub fn css_to_style_template(
+ css: &TemplateLiteral<'_>,
+ level: u8,
+ selector: &Option,
+) -> TemplateStyles {
let mut styles = vec![];
// If there are no expressions, just process quasis as static CSS
@@ -139,8 +216,13 @@ pub fn css_to_style_literal(
.map(CssToStyleResult::Static),
);
}
- return styles;
+ return TemplateStyles {
+ styles,
+ ..TemplateStyles::default()
+ };
}
+ let mut statements = vec![];
+ let mut unplaced = vec![];
// Process template literal with expressions
// Template literal format: `text ${expr1} text ${expr2} text`
@@ -170,8 +252,20 @@ pub fn css_to_style_literal(
for (i, quasi) in css.quasis.iter().enumerate() {
combined_css.push_str(&quasi.value.raw);
- // Add expression placeholder if not the last quasi
+ // Add expression placeholder if not the last quasi. Only a value keeps a
+ // placeholder: anything else is written in when known, or reported
if i < css.expressions.len() {
+ let place = interpolation_place(&combined_css, &css.quasis[i + 1..]);
+ if !matches!(place, Place::Value) {
+ if let Some(text) = get_string_by_literal_expression(&css.expressions[i]) {
+ combined_css.push_str(&text);
+ } else if matches!(place, Place::Statement) {
+ statements.push(i);
+ } else {
+ unplaced.push(i);
+ }
+ continue;
+ }
// Use a unique placeholder format that CSS parser won't modify.
// Build the placeholder once into the reused scratch buffer, push it
// into `combined_css`, then clone it as the owned map key.
@@ -419,7 +513,11 @@ pub fn css_to_style_literal(
}
}
- styles
+ TemplateStyles {
+ styles,
+ statements,
+ unplaced,
+ }
}
pub fn css_to_style(
diff --git a/libs/extractor/src/extract_style/constant.rs b/libs/extractor/src/extract_style/constant.rs
index a1e1821c4..3388d2198 100644
--- a/libs/extractor/src/extract_style/constant.rs
+++ b/libs/extractor/src/extract_style/constant.rs
@@ -1,6 +1,16 @@
use phf::phf_set;
-pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! {
+/// Properties whose numbers are unitless in CSS, so they are kept as written
+/// instead of being scaled to `px`. Includes every property vanilla-extract
+/// leaves unitless, so numbers from `.css.ts` files keep their meaning,
+/// `content`, which never takes a length, and the counters.
+pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! {
+ "content",
+ "counter-increment",
+ "counter-reset",
+ "counter-set",
+ "font-size-adjust",
+ "math-depth",
"opacity",
"flex",
"z-index",
@@ -12,6 +22,7 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! {
"flex-grow",
"flex-shrink",
"order",
+ "grid-area",
"grid-column",
"grid-column-start",
"grid-column-end",
@@ -21,5 +32,40 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! {
"animation-iteration-count",
"tab-size",
"moz-tab-size",
- "-webkit-line-clamp"
+ "-webkit-line-clamp",
+ "webkit-line-clamp",
+ "border-image",
+ "border-image-outset",
+ "border-image-slice",
+ "border-image-width",
+ "box-flex",
+ "box-flex-group",
+ "box-ordinal-group",
+ "column-count",
+ "columns",
+ "initial-letter",
+ "max-lines",
+ "orphans",
+ "widows",
+ "zoom",
+ "fill-opacity",
+ "flood-opacity",
+ "mask-border",
+ "mask-border-outset",
+ "mask-border-slice",
+ "mask-border-width",
+ "shape-image-threshold",
+ "stop-opacity",
+ "stroke-dashoffset",
+ "stroke-miterlimit",
+ "stroke-opacity",
+ "stroke-width"
+};
+
+/// Properties taking a time, whose numbers are milliseconds
+pub(crate) static TIME_PROPERTIES: phf::Set<&str> = phf_set! {
+ "transition-duration",
+ "transition-delay",
+ "animation-duration",
+ "animation-delay",
};
diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs
index 414aae1d2..f09b03b07 100644
--- a/libs/extractor/src/extract_style/extract_dynamic_style.rs
+++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs
@@ -1,7 +1,6 @@
use std::fmt::{Debug, Formatter};
use css::{
- optimize_value::optimize_value,
sheet_to_classname, sheet_to_variable_name,
style_selector::{StyleSelector, optimize_selector},
};
@@ -23,6 +22,8 @@ pub struct ExtractDynamicStyle {
/// Whether the value had `!important` that was stripped from the identifier
important: bool,
+
+ pub(crate) layer: Option,
}
impl Debug for ExtractDynamicStyle {
@@ -36,6 +37,9 @@ impl Debug for ExtractDynamicStyle {
if self.important {
s.field("important", &self.important);
}
+ if let Some(layer) = &self.layer {
+ s.field("layer", layer);
+ }
s.finish()
}
}
@@ -79,6 +83,22 @@ fn strip_important(identifier: String) -> (String, bool) {
(identifier, false)
}
+/// `identifier`, the code the element runs, without the statement's semicolon;
+/// only a template holding the whole CSS value loses the `;` ending it. The
+/// code is not optimized as CSS, as that would rewrite the literals it passes on
+fn runtime_code(identifier: &str) -> String {
+ let code = identifier.trim();
+ let code = code.strip_suffix(';').unwrap_or(code);
+ match code
+ .strip_prefix('`')
+ .and_then(|code| code.strip_suffix('`'))
+ .filter(|value| value.ends_with(';'))
+ {
+ Some(value) => format!("`{}`", value.trim_end_matches(';')),
+ None => code.to_string(),
+ }
+}
+
impl ExtractDynamicStyle {
/// create a new `ExtractDynamicStyle`
pub fn new(
@@ -89,8 +109,7 @@ impl ExtractDynamicStyle {
) -> Self {
// `optimize_value` returns `Cow`; `strip_important` takes ownership (and
// the struct stores the `String`), so materialize the owned form here.
- let optimized = optimize_value(identifier).into_owned();
- let (identifier, important) = strip_important(optimized);
+ let (identifier, important) = strip_important(runtime_code(identifier));
Self {
property: property.to_string(),
level,
@@ -98,6 +117,7 @@ impl ExtractDynamicStyle {
selector: selector.map(optimize_selector),
style_order: None,
important,
+ layer: None,
}
}
@@ -124,11 +144,15 @@ impl ExtractDynamicStyle {
pub const fn important(&self) -> bool {
self.important
}
+
+ pub fn layer(&self) -> Option<&str> {
+ self.layer.as_deref()
+ }
}
impl ExtractStyleProperty for ExtractDynamicStyle {
fn extract(&self, filename: Option<&str>) -> StyleProperty {
- let selector = self.selector.as_ref().map(StyleSelector::as_class_str);
+ let selector = super::class_selector(self.selector.as_ref(), self.layer());
StyleProperty::Variable {
class_name: sheet_to_classname(
self.property.as_str(),
diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs
index 91ad74822..1adc78be6 100644
--- a/libs/extractor/src/extract_style/extract_static_style.rs
+++ b/libs/extractor/src/extract_style/extract_static_style.rs
@@ -10,7 +10,9 @@ use css::{
use crate::{
extract_style::{
- ExtractStyleProperty, constant::MAINTAIN_VALUE_PROPERTIES, style_property::StyleProperty,
+ ExtractStyleProperty,
+ constant::{MAINTAIN_VALUE_PROPERTIES, TIME_PROPERTIES},
+ style_property::StyleProperty,
},
utils::{convert_value, gcd},
};
@@ -65,22 +67,28 @@ impl ExtractStaticStyle {
// immediately re-copied by `optimize_value` (which takes `&str` and owns its
// own result). Only the aspect-ratio reduction and the `convert_value` branch
// must own; both already produce owned `String`s. Byte-identical output.
- let normalized: Cow = if MAINTAIN_VALUE_PROPERTIES.contains(property) {
- if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') {
- if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| {
- Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?))
- }) {
- let gcd = gcd(a, b);
- Cow::Owned(format!("{}/{}", a / gcd, b / gcd))
+ let normalized: Cow =
+ if MAINTAIN_VALUE_PROPERTIES.contains(property) || property.starts_with("--") {
+ if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') {
+ if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| {
+ Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?))
+ }) {
+ let gcd = gcd(a, b);
+ Cow::Owned(format!("{}/{}", a / gcd, b / gcd))
+ } else {
+ Cow::Borrowed(value)
+ }
} else {
Cow::Borrowed(value)
}
+ } else if TIME_PROPERTIES.contains(property) {
+ // A time is written in `ms`, never on the spacing scale
+ value.parse::().map_or(Cow::Borrowed(value), |number| {
+ Cow::Owned(format!("{}ms", crate::utils::js_number_string(number)))
+ })
} else {
- Cow::Borrowed(value)
- }
- } else {
- convert_value(value)
- };
+ convert_value(value)
+ };
// `optimize_value` returns `Cow` (borrowed when no optimization pass
// fires); this constructor stores an owned `String`, so materialize it
// here — a borrowed result costs exactly the one copy it always did.
@@ -144,6 +152,13 @@ impl ExtractStaticStyle {
self.layer.as_deref()
}
+ /// The selector part of the class name key, holding the layer so a layered
+ /// declaration never shares a class with an unlayered one
+ #[must_use]
+ pub fn class_selector(&self) -> Option> {
+ super::class_selector(self.selector.as_ref(), self.layer.as_deref())
+ }
+
#[must_use]
pub const fn property(&self) -> &str {
self.property.as_str()
@@ -177,7 +192,7 @@ impl ExtractStaticStyle {
impl ExtractStyleProperty for ExtractStaticStyle {
fn extract(&self, filename: Option<&str>) -> StyleProperty {
- let s = self.selector.as_ref().map(StyleSelector::as_class_str);
+ let s = self.class_selector();
// `self.value` is already the result of `optimize_value(convert_value(..))`
// (computed in the constructors), so re-running convert_value + optimize_value
// here is redundant. Only the multi-css optimization is not applied at construction.
@@ -210,6 +225,14 @@ mod tests {
assert_eq!(style.selector(), None);
assert_eq!(style.style_order(), None);
assert_eq!(style.layer(), None);
+ assert_eq!(
+ ExtractStaticStyle::new("--columns", "4", 0, None).value(),
+ "4"
+ );
+ assert_eq!(
+ ExtractStaticStyle::new("padding", "4", 0, None).value(),
+ "16px"
+ );
}
#[test]
diff --git a/libs/extractor/src/extract_style/mod.rs b/libs/extractor/src/extract_style/mod.rs
index 05d4646de..e101f8bbb 100644
--- a/libs/extractor/src/extract_style/mod.rs
+++ b/libs/extractor/src/extract_style/mod.rs
@@ -9,6 +9,22 @@ pub mod extract_style_value;
pub mod style_property;
use crate::extract_style::style_property::StyleProperty;
+use css::style_selector::StyleSelector;
+use std::borrow::Cow;
+
+fn class_selector<'a>(
+ selector: Option<&'a StyleSelector>,
+ layer: Option<&str>,
+) -> Option> {
+ let selector = selector.map(StyleSelector::as_class_str);
+ match layer {
+ Some(layer) => Some(Cow::Owned(format!(
+ "{}@layer {layer}",
+ selector.as_deref().unwrap_or_default()
+ ))),
+ None => selector,
+ }
+}
pub trait ExtractStyleProperty {
/// extract style properties
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 fd8a70208..e82d4a73a 100644
--- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs
@@ -10,7 +10,8 @@ use crate::{
extractor::{
GlobalExtractResult,
extract_style_from_expression::{
- LiteralHandling, at_rule_record_kind, extract_style_from_expression,
+ LiteralHandling, at_rule_record_kind, extract_style_from_expression, place_in_layer,
+ unreadable, unreadable_key, yield_typography,
},
},
utils::{
@@ -248,24 +249,20 @@ fn collect_global_styles<'a>(
})
.unwrap_or_default();
- // Filter out @layer property from styles and set layer on remaining styles
- for mut style in extracted.styles {
- if let ExtractStyleProp::Static(ExtractStyleValue::Static(
- ref mut st,
- )) = style
- {
- // Skip @layer property - it's not a CSS property, set layer on other styles
- if st.property() != "@layer" {
- if let Some(ref layer) = layer_name {
- st.layer = Some(layer.to_string());
- }
- styles.push(style);
- }
- } else {
- styles.push(style);
- }
+ // `@layer` names the layer of every other declaration,
+ // responsive and nested ones included
+ let mut extracted = extracted.styles;
+ extracted.retain(|style| {
+ !matches!(style, ExtractStyleProp::Static(ExtractStyleValue::Static(st)) if st.property() == "@layer")
+ });
+ if let Some(layer) = layer_name {
+ place_in_layer(&mut extracted, &layer);
}
+ yield_typography(&mut extracted);
+ styles.extend(extracted);
}
+ } else {
+ styles.push(unreadable_key(&o.key, false));
}
}
ObjectPropertyKind::SpreadProperty(o) => {
@@ -279,5 +276,7 @@ fn collect_global_styles<'a>(
}
}
}
+ } else {
+ styles.extend(unreadable(expression).styles);
}
}
diff --git a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs
index 7d3d19b1c..ae468ce3d 100644
--- a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs
@@ -3,9 +3,14 @@ use crate::{
extract_style::{extract_keyframes::ExtractKeyframes, extract_style_value::ExtractStyleValue},
extractor::{
ExtractResult, KeyframesExtractResult,
- extract_style_from_expression::{LiteralHandling, extract_style_from_expression},
+ extract_style_from_expression::{
+ LiteralHandling, extract_style_from_expression, unreadable,
+ },
+ },
+ utils::{
+ fixed_value, get_string_by_property_key, readable_code, runtime_value,
+ unwrap_syntax_only_mut,
},
- utils::get_string_by_property_key,
};
use oxc_ast::{
ast::{Expression, ObjectPropertyKind},
@@ -17,35 +22,58 @@ pub fn extract_keyframes_from_expression<'a>(
expression: &mut Expression<'a>,
) -> KeyframesExtractResult {
let mut keyframes = ExtractKeyframes::default();
-
- if let Expression::ObjectExpression(obj) = expression {
- for p in &mut obj.properties {
- if let ObjectPropertyKind::ObjectProperty(o) = p
- && let Some(name) = get_string_by_property_key(&o.key)
- {
- let ExtractResult { styles, .. } = extract_style_from_expression(
- ast_builder,
- None,
- &mut o.value,
- 0,
- &None,
- LiteralHandling::ExpandResponsiveThemeToken,
- );
-
- let mut styles = styles
- .into_iter()
- .filter_map(|s| match s {
- ExtractStyleProp::Static(ExtractStyleValue::Static(s)) => Some(s),
- _ => None,
- })
- .collect::>();
- styles.sort_by(|a, b| a.property().cmp(b.property()));
- keyframes.keyframes.insert(
- name.parse::().map(|v| format!("{v}%")).unwrap_or(name),
- styles,
- );
+ let Expression::ObjectExpression(obj) = unwrap_syntax_only_mut(expression) else {
+ return KeyframesExtractResult {
+ keyframes,
+ runtime_value: runtime_value(&unreadable(expression).styles),
+ };
+ };
+ let mut runtime = None;
+ for p in &mut obj.properties {
+ let o = match p {
+ ObjectPropertyKind::ObjectProperty(o) => o,
+ ObjectPropertyKind::SpreadProperty(spread) => {
+ runtime =
+ runtime.or_else(|| Some(format!("...{}", readable_code(&spread.argument))));
+ continue;
}
- }
+ };
+ let Some(name) = get_string_by_property_key(&o.key) else {
+ runtime = runtime.or_else(|| {
+ Some(format!(
+ "[{}]",
+ o.key
+ .as_expression()
+ .map_or_else(String::new, readable_code)
+ ))
+ });
+ continue;
+ };
+ let ExtractResult { styles, .. } = extract_style_from_expression(
+ ast_builder,
+ None,
+ &mut o.value,
+ 0,
+ &None,
+ LiteralHandling::ExpandResponsiveThemeToken,
+ );
+ runtime = runtime.or_else(|| fixed_value(&styles));
+
+ let mut styles = styles
+ .into_iter()
+ .filter_map(|s| match s {
+ ExtractStyleProp::Static(ExtractStyleValue::Static(s)) => Some(s),
+ _ => None,
+ })
+ .collect::>();
+ styles.sort_by(|a, b| a.property().cmp(b.property()));
+ keyframes.keyframes.insert(
+ name.parse::().map(|v| format!("{v}%")).unwrap_or(name),
+ styles,
+ );
+ }
+ KeyframesExtractResult {
+ keyframes,
+ runtime_value: runtime,
}
- KeyframesExtractResult { keyframes }
}
diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs
index 2d98df84d..4b8f7f27b 100644
--- a/libs/extractor/src/extractor/extract_style_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_style_from_expression.rs
@@ -12,7 +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,
+ readable_code, unwrap_syntax_only, unwrap_syntax_only_mut,
},
};
use css::{
@@ -27,12 +27,13 @@ use css::{
use oxc_allocator::{CloneIn, GetAllocator};
use oxc_ast::{
ast::{
- BinaryOperator, Expression, LogicalOperator, ObjectPropertyKind, Str, TemplateElement,
- TemplateElementValue, UnaryOperator,
+ BinaryOperator, Expression, LogicalOperator, ObjectExpression, ObjectPropertyKind,
+ PropertyKey, Str, TemplateElement, TemplateElementValue, UnaryOperator,
},
builder::AstBuilder,
};
-use oxc_span::SPAN;
+use oxc_span::{GetSpan, SPAN};
+use std::collections::BTreeMap;
const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"];
@@ -42,6 +43,112 @@ pub enum LiteralHandling {
KeepSingleClass,
}
+/// `expression` where styles are expected but the build cannot read them:
+/// nothing for a value that holds no styles (`null`, `false`, `undefined`)
+pub(crate) fn unreadable<'a>(expression: &Expression<'a>) -> ExtractResult<'a> {
+ let holds_nothing = match unwrap_syntax_only(expression) {
+ Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => true,
+ Expression::Identifier(identifier) => {
+ IGNORED_IDENTIFIERS.contains(&identifier.name.as_str())
+ }
+ Expression::UnaryExpression(unary) => unary.operator == UnaryOperator::Void,
+ _ => false,
+ };
+ ExtractResult {
+ styles: if holds_nothing {
+ vec![]
+ } else {
+ vec![ExtractStyleProp::Unreadable {
+ offset: expression.span().start,
+ code: readable_code(expression),
+ prop: false,
+ }]
+ },
+ ..ExtractResult::default()
+ }
+}
+
+/// Whether `expression` is truthy, when it is a literal: a condition the
+/// build knows picks its branch there, as once constants are inlined
+fn literal_truthiness(expression: &Expression<'_>) -> Option {
+ match unwrap_syntax_only(expression) {
+ Expression::BooleanLiteral(literal) => Some(literal.value),
+ Expression::NullLiteral(_) => Some(false),
+ Expression::NumericLiteral(literal) => {
+ Some(literal.value != 0.0 && !literal.value.is_nan())
+ }
+ Expression::StringLiteral(literal) => Some(!literal.value.is_empty()),
+ Expression::Identifier(identifier) if identifier.name == "undefined" => Some(false),
+ _ => None,
+ }
+}
+
+/// Merge the object literals spread into `object` in place, a later property
+/// replacing an earlier one of the same key as in the object JavaScript builds
+pub(crate) fn flatten_spreads<'a>(ast_builder: &AstBuilder<'a>, object: &mut ObjectExpression<'a>) {
+ if !object.properties.iter().any(|property| {
+ matches!(property, ObjectPropertyKind::SpreadProperty(spread)
+ if matches!(unwrap_syntax_only(&spread.argument), Expression::ObjectExpression(_)))
+ }) {
+ return;
+ }
+ let properties = std::mem::replace(
+ &mut object.properties,
+ oxc_allocator::Vec::new_in(ast_builder),
+ );
+ for property in properties {
+ match property {
+ ObjectPropertyKind::SpreadProperty(mut spread) => {
+ if let Expression::ObjectExpression(inner) =
+ unwrap_syntax_only_mut(&mut spread.argument)
+ {
+ flatten_spreads(ast_builder, inner);
+ let spread_properties = std::mem::replace(
+ &mut inner.properties,
+ oxc_allocator::Vec::new_in(ast_builder),
+ );
+ for property in spread_properties {
+ push_replacing(&mut object.properties, property);
+ }
+ } else {
+ object
+ .properties
+ .push(ObjectPropertyKind::SpreadProperty(spread));
+ }
+ }
+ property @ ObjectPropertyKind::ObjectProperty(_) => {
+ push_replacing(&mut object.properties, property);
+ }
+ }
+ }
+}
+
+fn push_replacing<'a>(
+ properties: &mut oxc_allocator::Vec<'a, ObjectPropertyKind<'a>>,
+ property: ObjectPropertyKind<'a>,
+) {
+ if let ObjectPropertyKind::ObjectProperty(added) = &property
+ && let Some(key) = get_str_by_property_key(&added.key)
+ {
+ properties.retain(|existing| {
+ !matches!(existing, ObjectPropertyKind::ObjectProperty(existing)
+ if get_str_by_property_key(&existing.key).as_deref() == Some(key.as_ref()))
+ });
+ }
+ properties.push(property);
+}
+
+pub(crate) fn unreadable_key<'a>(key: &PropertyKey<'_>, prop: bool) -> ExtractStyleProp<'a> {
+ ExtractStyleProp::Unreadable {
+ offset: key.span().start,
+ code: format!(
+ "[{}]",
+ key.as_expression().map_or_else(String::new, readable_code)
+ ),
+ prop,
+ }
+}
+
fn create_static_styles<'a>(
name: &str,
value: &str,
@@ -113,11 +220,18 @@ pub fn extract_style_from_expression<'a>(
let mut props = None;
return match expression {
Expression::ObjectExpression(obj) => {
+ flatten_spreads(ast_builder, obj);
let mut props_styles: Vec> = vec![];
let mut tag = None;
for idx in (0..obj.properties.len()).rev() {
let mut prop = obj.properties.remove(idx);
if !match &mut prop {
+ ObjectPropertyKind::ObjectProperty(prop)
+ if get_str_by_property_key(&prop.key).is_none() =>
+ {
+ props_styles.push(unreadable_key(&prop.key, true));
+ false
+ }
ObjectPropertyKind::ObjectProperty(prop) => {
if let Some(name) = get_str_by_property_key(&prop.key)
&& !is_special_property(&name)
@@ -217,7 +331,14 @@ pub fn extract_style_from_expression<'a>(
.collect(),
..ExtractResult::default()
},
- _ => ExtractResult::default(),
+ Expression::StringLiteral(text) => ExtractResult {
+ styles: css_to_style(&text.value, level, selector)
+ .into_iter()
+ .map(|ex| ExtractStyleProp::Static(ExtractStyleValue::Static(ex)))
+ .collect(),
+ ..ExtractResult::default()
+ },
+ _ => unreadable(expression),
};
}
@@ -268,9 +389,11 @@ pub fn extract_style_from_expression<'a>(
{
let mut props = vec![];
for p in &mut obj.properties {
- if let ObjectPropertyKind::ObjectProperty(o) = p
- && let Some(key_name) = o.key.name()
- {
+ if let ObjectPropertyKind::ObjectProperty(o) = p {
+ let Some(key_name) = get_string_by_property_key(&o.key) else {
+ props.push(unreadable_key(&o.key, false));
+ continue;
+ };
for part in split_selector_list(key_name.trim()) {
if let Some(child) = nest_selectors_key(selector.as_ref(), part) {
props.extend(
@@ -294,6 +417,34 @@ pub fn extract_style_from_expression<'a>(
};
}
+ // vanilla-extract's `'@layer': { [layerName]: rules }`
+ if name == "@layer"
+ && let Expression::ObjectExpression(obj) = expression
+ {
+ let mut props = vec![];
+ for p in &mut obj.properties {
+ if let ObjectPropertyKind::ObjectProperty(o) = p
+ && let Some(layer) = get_string_by_property_key(&o.key)
+ {
+ let mut styles = extract_style_from_expression(
+ ast_builder,
+ None,
+ &mut o.value,
+ level,
+ selector,
+ literal_handling,
+ )
+ .styles;
+ place_in_layer(&mut styles, &layer);
+ props.extend(styles);
+ }
+ }
+ return ExtractResult {
+ styles: props,
+ ..ExtractResult::default()
+ };
+ }
+
if let Some(kind) = at_rule_record_kind(name)
&& let Expression::ObjectExpression(obj) = expression
{
@@ -398,36 +549,12 @@ pub fn extract_style_from_expression<'a>(
}
}
} else {
+ // `name` is `None` only under a selector (`_hover: value`), which takes
+ // styles rather than a value a CSS variable could hold
match expression {
- Expression::UnaryExpression(un) => ExtractResult {
- // `name` is None only when this was reached through the `_xxx`
- // selector recursion (see line 324). In that case the value
- // cannot be statically extracted as a dynamic style because
- // the pseudo-selector has no CSS property slot to bind a
- // CSS variable to, so we return an empty result and let the
- // caller drop the attribute (see issue with `_hover={var}`).
- styles: if un.operator == UnaryOperator::Void {
- vec![]
- } else if let Some(name) = name {
- vec![dynamic_style(
- ast_builder,
- name,
- expression,
- level,
- selector,
- )]
- } else {
- vec![]
- },
- ..ExtractResult::default()
- },
- // Each variant is kept on its own line so per-line coverage
- // tools (tarpaulin on CI) can attribute the hit to the exact
- // pattern being exercised. The body is flattened to a single
- // `Option::map().unwrap_or_default()` chain to avoid an extra
- // 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.
+ Expression::UnaryExpression(un) if un.operator == UnaryOperator::Void => {
+ ExtractResult::default()
+ }
// `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))
@@ -442,13 +569,17 @@ pub fn extract_style_from_expression<'a>(
),
..ExtractResult::default()
},
- _ => ExtractResult::default(),
+ _ => unreadable(expression),
},
- Expression::BinaryExpression(_)
+ // Each variant is kept on its own line so per-line coverage
+ // tools (tarpaulin on CI) can attribute the hit to the exact
+ // pattern being exercised.
+ Expression::UnaryExpression(_)
+ | Expression::BinaryExpression(_)
| Expression::StaticMemberExpression(_)
| Expression::ChainExpression(_)
- | Expression::CallExpression(_) => name
- .map(|name| ExtractResult {
+ | Expression::CallExpression(_) => match name {
+ Some(name) => ExtractResult {
styles: vec![dynamic_style(
ast_builder,
name,
@@ -457,78 +588,73 @@ pub fn extract_style_from_expression<'a>(
selector,
)],
..ExtractResult::default()
- })
- .unwrap_or_default(),
+ },
+ None => unreadable(expression),
+ },
Expression::ComputedMemberExpression(mem) => {
extract_style_from_member_expression(ast_builder, name, mem, level, selector)
}
- Expression::TemplateLiteral(_) => ExtractResult {
- // `typo == true` implies `name == Some("typography")` (set at
- // line 337 inside an `if let Some(name) = name` block), so the
- // typo branch is safe. The non-typo branch must handle the
- // `name.is_none()` case (pseudo-selector recursion) by
- // returning empty styles.
- styles: if typo
- && let Some(style) =
- conditional_typography(ast_builder, expression, level, selector)
- {
- vec![style]
- } else if typo {
- vec![ExtractStyleProp::Expression {
- expression: Expression::new_template_literal(
- SPAN,
- oxc_allocator::Vec::from_array_in(
- [
- TemplateElement::new(
- SPAN,
- TemplateElementValue {
- raw: Str::from("typo-"),
- cooked: None,
- },
- false,
- ast_builder,
- ),
- TemplateElement::new(
- SPAN,
- TemplateElementValue {
- raw: Str::from(""),
- cooked: None,
- },
- true,
- ast_builder,
- ),
- ],
- ast_builder,
- ),
- oxc_allocator::Vec::from_array_in(
- [expression.clone_in(ast_builder.allocator())],
+ Expression::TemplateLiteral(_) => {
+ let Some(name) = name else {
+ return unreadable(expression);
+ };
+ ExtractResult {
+ styles: if typo
+ && let Some(style) =
+ conditional_typography(ast_builder, expression, level, selector)
+ {
+ vec![style]
+ } else if typo {
+ vec![ExtractStyleProp::Expression {
+ expression: Expression::new_template_literal(
+ SPAN,
+ oxc_allocator::Vec::from_array_in(
+ [
+ TemplateElement::new(
+ SPAN,
+ TemplateElementValue {
+ raw: Str::from("typo-"),
+ cooked: None,
+ },
+ false,
+ ast_builder,
+ ),
+ TemplateElement::new(
+ SPAN,
+ TemplateElementValue {
+ raw: Str::from(""),
+ cooked: None,
+ },
+ true,
+ ast_builder,
+ ),
+ ],
+ ast_builder,
+ ),
+ oxc_allocator::Vec::from_array_in(
+ [expression.clone_in(ast_builder.allocator())],
+ ast_builder,
+ ),
ast_builder,
),
+ styles: vec![],
+ }]
+ } else {
+ vec![dynamic_style(
ast_builder,
- ),
- styles: vec![],
- }]
- } else if let Some(name) = name {
- vec![dynamic_style(
- ast_builder,
- name,
- expression,
- level,
- selector,
- )]
- } else {
- vec![]
- },
- ..ExtractResult::default()
- },
+ name,
+ expression,
+ level,
+ selector,
+ )]
+ },
+ ..ExtractResult::default()
+ }
+ }
Expression::Identifier(identifier) => {
- // When `name` is `None` we are inside a pseudo-selector
- // recursion (e.g. `_hover={someIdentifier}`). In that case
- // the identifier is a black box (it may come from another
- // module) and we cannot statically extract a style from it,
- // so we skip extraction gracefully instead of panicking. The
- // pseudo-selector attribute will be stripped by the visitor
- // like any other non-extracted style prop.
+ let Some(name) = name else {
+ return unreadable(expression);
+ };
if IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) {
ExtractResult::default()
} else if typo
@@ -543,71 +669,109 @@ pub fn extract_style_from_expression<'a>(
styles: vec![style],
..ExtractResult::default()
}
- } else if let Some(name) = name {
- if typo {
- ExtractResult {
- styles: vec![ExtractStyleProp::Expression {
- expression: Expression::new_conditional_expression(
+ } else if typo {
+ ExtractResult {
+ styles: vec![ExtractStyleProp::Expression {
+ expression: Expression::new_conditional_expression(
+ SPAN,
+ Expression::new_identifier(
+ SPAN,
+ identifier.name.as_str(),
+ ast_builder,
+ ),
+ Expression::new_template_literal(
SPAN,
- Expression::new_identifier(
- SPAN,
- identifier.name.as_str(),
+ oxc_allocator::Vec::from_array_in(
+ [
+ TemplateElement::new(
+ SPAN,
+ TemplateElementValue {
+ raw: Str::from("typo-"),
+ cooked: None,
+ },
+ false,
+ ast_builder,
+ ),
+ TemplateElement::new(
+ SPAN,
+ TemplateElementValue {
+ raw: Str::from(""),
+ cooked: None,
+ },
+ true,
+ ast_builder,
+ ),
+ ],
ast_builder,
),
- Expression::new_template_literal(
- SPAN,
- oxc_allocator::Vec::from_array_in(
- [
- TemplateElement::new(
- SPAN,
- TemplateElementValue {
- raw: Str::from("typo-"),
- cooked: None,
- },
- false,
- ast_builder,
- ),
- TemplateElement::new(
- SPAN,
- TemplateElementValue {
- raw: Str::from(""),
- cooked: None,
- },
- true,
- ast_builder,
- ),
- ],
- ast_builder,
- ),
- oxc_allocator::Vec::from_array_in(
- [expression.clone_in(ast_builder.allocator())],
- ast_builder,
- ),
+ oxc_allocator::Vec::from_array_in(
+ [expression.clone_in(ast_builder.allocator())],
ast_builder,
),
- Expression::new_string_literal(SPAN, "", None, ast_builder),
ast_builder,
),
- styles: vec![],
- }],
- ..ExtractResult::default()
- }
- } else {
- ExtractResult {
- styles: vec![dynamic_style(
+ Expression::new_string_literal(SPAN, "", None, ast_builder),
ast_builder,
- name,
- expression,
- level,
- selector,
- )],
- ..ExtractResult::default()
- }
+ ),
+ styles: vec![],
+ }],
+ ..ExtractResult::default()
}
} else {
- ExtractResult::default()
+ ExtractResult {
+ styles: vec![dynamic_style(
+ ast_builder,
+ name,
+ expression,
+ level,
+ selector,
+ )],
+ ..ExtractResult::default()
+ }
}
}
+ Expression::LogicalExpression(logical)
+ if literal_truthiness(&logical.left).is_some() =>
+ {
+ let nullish = matches!(
+ unwrap_syntax_only(&logical.left),
+ Expression::NullLiteral(_)
+ ) || matches!(unwrap_syntax_only(&logical.left), Expression::Identifier(identifier) if identifier.name == "undefined");
+ let takes_right = match logical.operator {
+ LogicalOperator::And => literal_truthiness(&logical.left) == Some(true),
+ LogicalOperator::Or => literal_truthiness(&logical.left) == Some(false),
+ LogicalOperator::Coalesce => nullish,
+ };
+ extract_style_from_expression(
+ ast_builder,
+ name,
+ if takes_right {
+ &mut logical.right
+ } else {
+ &mut logical.left
+ },
+ level,
+ selector,
+ literal_handling,
+ )
+ }
+ Expression::ConditionalExpression(conditional)
+ if literal_truthiness(&conditional.test).is_some() =>
+ {
+ let consequent = literal_truthiness(&conditional.test) == Some(true);
+ extract_style_from_expression(
+ ast_builder,
+ name,
+ if consequent {
+ &mut conditional.consequent
+ } else {
+ &mut conditional.alternate
+ },
+ level,
+ selector,
+ literal_handling,
+ )
+ }
Expression::LogicalExpression(logical) => {
let res = Some(Box::new(ExtractStyleProp::StaticArray(
extract_style_from_expression(
@@ -743,6 +907,7 @@ pub fn extract_style_from_expression<'a>(
}
}
Expression::ObjectExpression(obj) => {
+ flatten_spreads(ast_builder, obj);
let mut props = vec![];
let params = obj.properties.iter().find_map(|p| {
if let ObjectPropertyKind::ObjectProperty(o) = p
@@ -788,24 +953,43 @@ pub fn extract_style_from_expression<'a>(
});
for p in &mut obj.properties {
- if let ObjectPropertyKind::ObjectProperty(o) = p
- && let Some(key_name) = o.key.name()
- && key_name != "params"
- {
- for name in disassemble_property(&key_name) {
- let name: &str = &name;
+ let o = match p {
+ ObjectPropertyKind::ObjectProperty(o) => o,
+ ObjectPropertyKind::SpreadProperty(spread) => {
props.extend(
extract_style_from_expression(
ast_builder,
- Some(name),
- &mut o.value,
+ None,
+ &mut spread.argument,
level,
&selector,
literal_handling,
)
.styles,
);
+ continue;
}
+ };
+ let Some(key_name) = o.key.name() else {
+ props.push(unreadable_key(&o.key, false));
+ continue;
+ };
+ if key_name == "params" {
+ continue;
+ }
+ for name in disassemble_property(&key_name) {
+ let name: &str = &name;
+ props.extend(
+ extract_style_from_expression(
+ ast_builder,
+ Some(name),
+ &mut o.value,
+ level,
+ &selector,
+ literal_handling,
+ )
+ .styles,
+ );
}
}
ExtractResult {
@@ -813,7 +997,217 @@ pub fn extract_style_from_expression<'a>(
..ExtractResult::default()
}
}
- _ => ExtractResult::default(),
+ _ => match name {
+ None => unreadable(expression),
+ Some(_) if matches!(expression, Expression::NullLiteral(_)) => {
+ ExtractResult::default()
+ }
+ Some(_)
+ if matches!(
+ expression,
+ Expression::FunctionExpression(_) | Expression::ClassExpression(_)
+ ) =>
+ {
+ unreadable(expression)
+ }
+ // A sequence keeps its parentheses, as its code is written into
+ // the element's style object, where a comma ends the property
+ Some(name) if matches!(expression, Expression::SequenceExpression(_)) => {
+ let code = expression_to_code(expression);
+ ExtractResult {
+ styles: vec![ExtractStyleProp::Static(ExtractStyleValue::Dynamic(
+ ExtractDynamicStyle::new(
+ name,
+ level,
+ &format!("({})", code.trim_end().trim_end_matches(';')),
+ selector.clone(),
+ ),
+ ))],
+ ..ExtractResult::default()
+ }
+ }
+ // Any other value is only known at runtime: `await`, `new`, an
+ // assignment, a tagged template
+ Some(name) => ExtractResult {
+ styles: vec![dynamic_style(
+ ast_builder,
+ name,
+ expression,
+ level,
+ selector,
+ )],
+ ..ExtractResult::default()
+ },
+ },
+ }
+ }
+}
+
+/// Put every static declaration of `props` in `layer`, nesting the layer one
+/// already sits in
+pub(crate) fn place_in_layer(props: &mut [ExtractStyleProp<'_>], layer: &str) {
+ let nest = |inner: &mut Option| {
+ *inner = Some(match inner.take() {
+ Some(inner) => format!("{layer}.{inner}"),
+ None => layer.to_string(),
+ });
+ };
+ for prop in props {
+ match prop {
+ ExtractStyleProp::Static(ExtractStyleValue::Static(style)) => nest(&mut style.layer),
+ ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) => nest(&mut style.layer),
+ ExtractStyleProp::StaticArray(props) => place_in_layer(props, layer),
+ ExtractStyleProp::Conditional {
+ consequent,
+ alternate,
+ ..
+ } => {
+ for branch in [consequent, alternate].into_iter().flatten() {
+ place_in_layer(std::slice::from_mut(branch.as_mut()), layer);
+ }
+ }
+ ExtractStyleProp::Enum { map, .. } => {
+ for props in map.values_mut() {
+ place_in_layer(props, layer);
+ }
+ }
+ ExtractStyleProp::MemberExpression { map, .. } => {
+ for prop in map.values_mut() {
+ place_in_layer(std::slice::from_mut(prop.as_mut()), layer);
+ }
+ }
+ // A `typography` preset is shared by every use, so it stays out of layers
+ ExtractStyleProp::Static(_)
+ | ExtractStyleProp::Expression { .. }
+ | ExtractStyleProp::Unreadable { .. } => {}
+ }
+ }
+}
+
+const TYPOGRAPHY_PROPERTIES: [&str; 5] = [
+ "font-family",
+ "font-size",
+ "font-weight",
+ "line-height",
+ "letter-spacing",
+];
+
+/// `(selector, typography property)` -> breakpoint its declaration starts at
+type Declared = BTreeMap<(Option, &'static str), u8>;
+
+/// Typography properties `props` always declare, per selector
+fn declared_from(props: &[ExtractStyleProp<'_>]) -> Declared {
+ let mut declared = Declared::new();
+ for prop in props {
+ let found = match prop {
+ ExtractStyleProp::Static(value) => {
+ let (property, level, selector) = match value {
+ ExtractStyleValue::Static(style) => {
+ (style.property(), style.level(), style.selector())
+ }
+ ExtractStyleValue::Dynamic(style) => {
+ (style.property(), style.level(), style.selector())
+ }
+ _ => continue,
+ };
+ let Some(property) = TYPOGRAPHY_PROPERTIES.into_iter().find(|p| *p == property)
+ else {
+ continue;
+ };
+ Declared::from([((selector.cloned(), property), level)])
+ }
+ ExtractStyleProp::StaticArray(props) => declared_from(props),
+ ExtractStyleProp::Conditional {
+ consequent,
+ alternate,
+ ..
+ } => declared_by_every([consequent, alternate].map(|branch| {
+ branch.as_deref().map_or_else(Declared::new, |branch| {
+ declared_from(std::slice::from_ref(branch))
+ })
+ })),
+ ExtractStyleProp::Enum { map, .. } => {
+ declared_by_every(map.values().map(|props| declared_from(props)))
+ }
+ ExtractStyleProp::MemberExpression { map, .. } => declared_by_every(
+ map.values()
+ .map(|prop| declared_from(std::slice::from_ref(prop.as_ref()))),
+ ),
+ ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => continue,
+ };
+ for (key, level) in found {
+ declared
+ .entry(key)
+ .and_modify(|from| *from = (*from).min(level))
+ .or_insert(level);
+ }
+ }
+ declared
+}
+
+/// What every alternative declares, from the widest breakpoint one starts at
+fn declared_by_every(alternatives: impl IntoIterator- ) -> Declared {
+ let mut alternatives = alternatives.into_iter();
+ let Some(mut common) = alternatives.next() else {
+ return Declared::new();
+ };
+ for alternative in alternatives {
+ common = common
+ .into_iter()
+ .filter_map(|(key, level)| alternative.get(&key).map(|other| (key, level.max(*other))))
+ .collect();
+ }
+ common
+}
+
+/// A `typography` preset gives way to the declarations written beside it: under
+/// the same selector it skips each of their properties from the breakpoint the
+/// written one starts at, so a wider breakpoint of the preset cannot win.
+pub(crate) fn yield_typography(props: &mut [ExtractStyleProp<'_>]) {
+ let declared = declared_from(props);
+ if !declared.is_empty() {
+ skip_declared(props, &declared);
+ }
+}
+
+fn skip_declared(props: &mut [ExtractStyleProp<'_>], declared: &Declared) {
+ for prop in props {
+ match prop {
+ ExtractStyleProp::Static(ExtractStyleValue::Static(style))
+ if style.property() == "typography" =>
+ {
+ let skipped: Vec
= declared
+ .iter()
+ .filter(|((selector, _), _)| selector.as_ref() == style.selector())
+ .map(|((_, property), level)| format!("{property}:{level}"))
+ .collect();
+ if !skipped.is_empty() {
+ style.value = format!("{}|{}", style.value, skipped.join(","));
+ }
+ }
+ ExtractStyleProp::StaticArray(props) => skip_declared(props, declared),
+ ExtractStyleProp::Conditional {
+ consequent,
+ alternate,
+ ..
+ } => {
+ for branch in [consequent, alternate].into_iter().flatten() {
+ skip_declared(std::slice::from_mut(branch.as_mut()), declared);
+ }
+ }
+ ExtractStyleProp::Enum { map, .. } => {
+ for props in map.values_mut() {
+ skip_declared(props, declared);
+ }
+ }
+ ExtractStyleProp::MemberExpression { map, .. } => {
+ for prop in map.values_mut() {
+ skip_declared(std::slice::from_mut(prop.as_mut()), declared);
+ }
+ }
+ ExtractStyleProp::Static(_)
+ | ExtractStyleProp::Expression { .. }
+ | ExtractStyleProp::Unreadable { .. } => {}
}
}
}
@@ -977,6 +1371,16 @@ mod tests {
use oxc_parser::Parser;
use oxc_span::SourceType;
+ #[test]
+ fn test_declared_by_every() {
+ assert!(declared_by_every(std::iter::empty()).is_empty());
+ let declared = declared_by_every([
+ Declared::from([((None, "font-size"), 0), ((None, "line-height"), 1)]),
+ Declared::from([((None, "font-size"), 2)]),
+ ]);
+ assert_eq!(declared, Declared::from([((None, "font-size"), 2)]));
+ }
+
#[test]
fn test_extract_selector_object_styles() {
let allocator = Allocator::default();
diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs
index c5c494d63..b52e3169e 100644
--- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs
+++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs
@@ -8,7 +8,7 @@ use crate::{
},
utils::{
get_number_by_literal_expression, get_str_by_property_key,
- get_string_by_literal_expression, get_string_by_property_key,
+ get_string_by_literal_expression, get_string_by_property_key, readable_code,
},
};
use css::style_selector::StyleSelector;
@@ -43,6 +43,32 @@ pub(super) fn extract_style_from_member_expression<'a>(
mem.object = inner;
}
+ // With a spread in it, the literal as written gives what a position it
+ // does not spell out holds
+ let spread = match &mem.object {
+ Expression::ArrayExpression(array) => array
+ .elements
+ .iter()
+ .any(|element| matches!(element, ArrayExpressionElement::SpreadElement(_))),
+ Expression::ObjectExpression(object) => object
+ .properties
+ .iter()
+ .any(|property| matches!(property, ObjectPropertyKind::SpreadProperty(_))),
+ _ => false,
+ }
+ .then(|| mem.object.clone_in(ast_builder.allocator()));
+ let runtime = |whole: &Expression<'a>, offset: u32| {
+ runtime_member(
+ ast_builder,
+ name,
+ whole.clone_in(ast_builder.allocator()),
+ mem_expression,
+ offset,
+ level,
+ selector,
+ )
+ };
+
if let Expression::ArrayExpression(array) = &mut mem.object
&& !array.elements.is_empty()
{
@@ -50,12 +76,13 @@ pub(super) fn extract_style_from_member_expression<'a>(
if num < 0f64 {
return ExtractResult::default();
}
- let mut etc = None;
+ // Only the elements before the first spread sit at a fixed index
let selected_index = (num.fract() == 0.0).then_some(num as usize);
for (idx, p) in array.elements.iter_mut().enumerate() {
- if let ArrayExpressionElement::SpreadElement(sp) = p {
- etc = Some(sp.argument.clone_in(ast_builder.allocator()));
- } else if Some(idx) == selected_index
+ if matches!(p, ArrayExpressionElement::SpreadElement(_)) {
+ break;
+ }
+ if Some(idx) == selected_index
&& let Some(p) = p.as_expression_mut()
{
return extract_style_from_expression(
@@ -68,58 +95,21 @@ pub(super) fn extract_style_from_member_expression<'a>(
);
}
}
- // If `name` is None (pseudo-selector recursion) we cannot emit a
- // dynamic_style because there is no CSS property slot to bind to.
- // Fall back to an empty result in that case.
return ExtractResult {
- props: None,
- styles: etc
- .zip(name)
- .map(|(etc, name)| {
- vec![dynamic_style(
- ast_builder,
- name,
- &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed(
- SPAN,
- etc,
- mem_expression.clone_in(ast_builder.allocator()),
- false,
- ast_builder,
- )),
- level,
- selector,
- )]
- })
- .unwrap_or_default(),
- tag: None,
- style_order: None,
- style_vars: None,
+ styles: spread
+ .iter()
+ .map(|whole| runtime(whole, mem.span.start))
+ .collect(),
+ ..ExtractResult::default()
};
}
let mut map = BTreeMap::new();
for (idx, p) in array.elements.iter_mut().enumerate() {
- if let ArrayExpressionElement::SpreadElement(sp) = p {
- // Skip spread elements entirely when `name` is None — we
- // can't synthesize a dynamic style without a prop name.
- if let Some(name) = name {
- map.insert(
- idx.to_string(),
- Box::new(dynamic_style(
- ast_builder,
- name,
- &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed(
- SPAN,
- sp.argument.clone_in(ast_builder.allocator()),
- mem_expression.clone_in(ast_builder.allocator()),
- false,
- ast_builder,
- )),
- level,
- selector,
- )),
- );
- }
+ if let ArrayExpressionElement::SpreadElement(sp) = p
+ && let Some(whole) = &spread
+ {
+ map.insert(idx.to_string(), Box::new(runtime(whole, sp.span.start)));
} else if let Some(p) = p.as_expression_mut() {
map.insert(
idx.to_string(),
@@ -147,48 +137,40 @@ pub(super) fn extract_style_from_member_expression<'a>(
{
let mut map = BTreeMap::new();
if let Some(k) = get_string_by_literal_expression(mem_expression) {
- let mut etc = None;
- for p in &mut obj.properties {
- if let ObjectPropertyKind::ObjectProperty(o) = p {
- if let Some(property_name) = get_str_by_property_key(&o.key)
- && property_name == k
- {
- return ExtractResult {
- styles: extract_style_from_expression(
- ast_builder,
- name,
- &mut o.value,
- level,
- selector,
- LiteralHandling::ExpandResponsiveThemeToken,
- )
- .styles,
- ..ExtractResult::default()
- };
- }
- } else if let ObjectPropertyKind::SpreadProperty(sp) = p {
- etc = Some(sp.argument.clone_in(ast_builder.allocator()));
- }
+ // The last property written for the key gives it, unless a spread
+ // after it may replace it
+ let written = obj.properties.iter().rposition(|p| {
+ matches!(p, ObjectPropertyKind::ObjectProperty(o)
+ if get_str_by_property_key(&o.key).as_deref() == Some(k.as_ref()))
+ });
+ let replaced = obj.properties[written.map_or(0, |index| index + 1)..]
+ .iter()
+ .any(|p| matches!(p, ObjectPropertyKind::SpreadProperty(_)));
+ if replaced && let Some(whole) = &spread {
+ return ExtractResult {
+ styles: vec![runtime(whole, mem.span.start)],
+ ..ExtractResult::default()
+ };
}
-
- match (etc, name) {
- (Some(etc), Some(name)) => ret.push(dynamic_style(
- ast_builder,
- name,
- &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed(
- SPAN,
- etc,
- mem_expression.clone_in(ast_builder.allocator()),
- false,
- ast_builder,
- )),
- level,
- selector,
- )),
- // No spread fallback, or no prop name (pseudo-selector
- // recursion): return empty instead of panicking.
- _ => return ExtractResult::default(),
+ for p in obj.properties.iter_mut().rev() {
+ if let ObjectPropertyKind::ObjectProperty(o) = p
+ && get_str_by_property_key(&o.key).as_deref() == Some(k.as_ref())
+ {
+ return ExtractResult {
+ styles: extract_style_from_expression(
+ ast_builder,
+ name,
+ &mut o.value,
+ level,
+ selector,
+ LiteralHandling::ExpandResponsiveThemeToken,
+ )
+ .styles,
+ ..ExtractResult::default()
+ };
+ }
}
+ return ExtractResult::default();
}
for p in &mut obj.properties {
@@ -215,21 +197,16 @@ pub(super) fn extract_style_from_member_expression<'a>(
expression: mem_expression.clone_in(ast_builder.allocator()),
map,
});
- } else if let Expression::Identifier(_) = &mut mem.object
- && let Some(name) = name
- {
- // When `name` is None we are in a pseudo-selector recursion and
- // cannot emit a dynamic_style — skip gracefully.
- ret.push(dynamic_style(
+ } else if !matches!(
+ &mem.object,
+ Expression::ArrayExpression(_) | Expression::ObjectExpression(_)
+ ) {
+ ret.push(runtime_member(
ast_builder,
name,
- &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed(
- SPAN,
- mem.object.clone_in(ast_builder.allocator()),
- mem_expression.clone_in(ast_builder.allocator()),
- false,
- ast_builder,
- )),
+ mem.object.clone_in(ast_builder.allocator()),
+ mem_expression,
+ mem.span.start,
level,
selector,
));
@@ -240,3 +217,31 @@ pub(super) fn extract_style_from_member_expression<'a>(
..ExtractResult::default()
}
}
+
+/// `object[key]` known only at runtime: a CSS variable for a property, and
+/// under a selector, which takes styles, what the build cannot read
+fn runtime_member<'a>(
+ ast_builder: &AstBuilder<'a>,
+ name: Option<&str>,
+ object: Expression<'a>,
+ key: &Expression<'a>,
+ offset: u32,
+ level: u8,
+ selector: &Option,
+) -> ExtractStyleProp<'a> {
+ let member = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed(
+ SPAN,
+ object,
+ key.clone_in(ast_builder.allocator()),
+ false,
+ ast_builder,
+ ));
+ match name {
+ Some(name) => dynamic_style(ast_builder, name, &member, level, selector),
+ None => ExtractStyleProp::Unreadable {
+ offset,
+ code: readable_code(&member),
+ prop: false,
+ },
+ }
+}
diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs
index 492264a49..425ed0245 100644
--- a/libs/extractor/src/extractor/extract_style_from_styled.rs
+++ b/libs/extractor/src/extractor/extract_style_from_styled.rs
@@ -3,79 +3,158 @@ use rustc_hash::FxHashMap;
use crate::{
ExtractStyleProp,
component::ExportVariableKind,
- css_utils::css_to_style_literal,
+ css_utils::{TemplateStyles, css_to_style_template},
extract_style::extract_style_value::ExtractStyleValue,
extractor::{
ExtractResult,
extract_style_from_expression::{LiteralHandling, extract_style_from_expression},
},
- gen_class_name::gen_class_names,
+ gen_class_name::{gen_class_names, merge_expression_for_class_name},
gen_style::gen_styles,
- utils::{merge_object_expressions, unwrap_syntax_only, wrap_array_filter},
+ utils::{
+ STYLE_OBJECT, StyleArguments, build_time_error, call_with_values, merge_object_expressions,
+ readable_code, reads_directly, style_arguments, uncomposable_error, unplaced_error,
+ unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, wrap_array_filter,
+ wrap_direct_call,
+ },
};
use oxc_allocator::{CloneIn, FromIn, GetAllocator};
use oxc_ast::{
ast::{
Argument, BindingPattern, BindingProperty, BindingRestElement, CallExpression, Expression,
FormalParameter, FormalParameterKind, FormalParameters, JSXAttributeItem, JSXAttributeName,
- JSXAttributeValue, JSXElementName, JSXOpeningElement, PropertyKey, Str,
+ JSXAttributeValue, JSXElementName, JSXOpeningElement, ObjectPropertyKind, PropertyKey, Str,
},
builder::AstBuilder,
};
-use oxc_span::SPAN;
+use oxc_span::{GetSpan, SPAN};
+use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator};
+
+/// The binding a styled component reads the component it renders through, when
+/// only the runtime gives that component
+const STYLED_BASE: &str = "DevupStyled";
+
+const STYLED_FACTORY: &str =
+ "it renders a tag, a component or a value naming one, with rule objects or CSS text";
+
+/// What a styled component renders: a tag or a component JSX names, with the
+/// styles a Devup UI component brings, and the value `name` is bound to when
+/// only the runtime gives the component
+struct Base<'a> {
+ name: String,
+ styles: Option>,
+ bound: Option>,
+}
+
+impl<'a> Base<'a> {
+ const fn named(name: String) -> Self {
+ Self {
+ name,
+ styles: None,
+ bound: None,
+ }
+ }
-fn extract_base_tag_and_class_name(
- input: &Expression<'_>,
+ /// `component` with the base bound to the name it renders, when it is a
+ /// runtime value
+ fn render(self, ast_builder: &AstBuilder<'a>, component: Expression<'a>) -> Expression<'a> {
+ match self.bound {
+ Some(bound) => call_with_values(ast_builder, vec![(self.name, bound)], component),
+ None => component,
+ }
+ }
+}
+
+fn extract_base_tag_and_class_name<'a>(
+ ast_builder: &AstBuilder<'a>,
+ input: &Expression<'a>,
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)
- {
+) -> Option > {
+ match unwrap_syntax_only(input) {
+ Expression::StaticMemberExpression(member) => {
+ Some(Base::named(member.property.name.to_string()))
+ }
// styled("div") or styled(Component)
- (Some(tag_name), default_class_name)
- } else {
- (None, None)
+ Expression::CallExpression(call) if call.arguments.len() == 1 => {
+ tag_from_argument(ast_builder, &call.arguments[0], imports)
+ }
+ _ => 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<'_>,
+/// Read the base out of a `styled(...)` argument: a devup-ui component gives its HTML
+/// tag and the styles it contributes by default, a component or a member such as
+/// `motion.div` is rendered as named, and any other value (a lowercase variable, a
+/// call such as `forwardRef(...)`) is read at runtime.
+fn tag_from_argument<'a>(
+ ast_builder: &AstBuilder<'a>,
+ argument: &Argument<'a>,
imports: &FxHashMap,
-) -> Option<(String, Option>)> {
+) -> Option > {
+ let argument = unwrap_syntax_only(argument.as_expression()?);
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),
- }),
+ Expression::StringLiteral(lit) => return Some(Base::named(lit.value.to_string())),
+ // Never a component
+ Expression::NullLiteral(_)
+ | Expression::BooleanLiteral(_)
+ | Expression::NumericLiteral(_) => return None,
+ Expression::Identifier(ident) if ident.name == "undefined" => return None,
+ _ => {}
+ }
+ if let Expression::Identifier(ident) = argument
+ && let Some(kind) = imports.get(ident.name.as_str())
+ {
+ return Some(Base {
+ styles: Some(kind.extract()),
+ ..Base::named(kind.to_tag().to_string())
+ });
+ }
+ Some(jsx_name(argument).map_or_else(
+ || Base {
+ bound: Some(argument.clone_in(ast_builder.allocator())),
+ ..Base::named(STYLED_BASE.to_string())
+ },
+ Base::named,
+ ))
+}
+
+/// How JSX names `expression` as a component: a binding that is not a tag name,
+/// or a member of a binding such as `motion.div`
+fn jsx_name(expression: &Expression<'_>) -> Option {
+ match expression {
+ Expression::Identifier(ident)
+ if !ident.name.starts_with(|c: char| c.is_ascii_lowercase()) =>
+ {
+ Some(ident.name.to_string())
+ }
+ Expression::StaticMemberExpression(member) => {
+ let mut name = match unwrap_syntax_only(&member.object) {
+ Expression::Identifier(ident) => ident.name.to_string(),
+ object => jsx_name(object)?,
+ };
+ name.push('.');
+ name.push_str(&member.property.name);
+ Some(name)
+ }
_ => None,
}
}
-/// Resolve a `styled(...)` call to its base tag, default styles, and the index of the
-/// argument holding the style object.
+/// Resolve a `styled(...)` call to its base, 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<'_>,
+fn resolve_styled_call_target<'a>(
+ ast_builder: &AstBuilder<'a>,
+ call: &CallExpression<'a>,
imports: &FxHashMap,
-) -> Option<(String, Option>, usize)> {
+) -> Option<(Base<'a>, usize)> {
if call.arguments.len() == 1
- && let (Some(tag_name), default_class_name) =
- extract_base_tag_and_class_name(&call.callee, imports)
+ && let Some(base) = extract_base_tag_and_class_name(ast_builder, &call.callee, imports)
{
- return Some((tag_name, default_class_name, 0));
+ return Some((base, 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.
@@ -84,9 +163,9 @@ fn resolve_styled_call_target(
&& 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)
+ && let Some(base) = tag_from_argument(ast_builder, &call.arguments[0], imports)
{
- return Some((tag_name, default_class_name, 1));
+ return Some((base, 1));
}
None
}
@@ -103,41 +182,109 @@ pub fn extract_style_from_styled<'a>(
expression: &mut Expression<'a>,
split_filename: Option<&str>,
imports: &FxHashMap,
-) -> (ExtractResult<'a>, Expression<'a>) {
+ attrs: &[Expression<'a>],
+) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) {
+ let mut composed_classes = Vec::new();
+ let mut errors = Vec::new();
+ if let Expression::CallExpression(call) = expression
+ && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some()
+ {
+ match style_arguments(ast_builder, &call.arguments) {
+ Some(StyleArguments { classes, rules }) => {
+ call.arguments =
+ oxc_allocator::Vec::from_array_in([Argument::from(rules)], ast_builder);
+ composed_classes = classes;
+ }
+ None if !reads_directly(&call.arguments) => {
+ errors.push((call.span.start, uncomposable_error(&call.arguments)));
+ call.arguments = oxc_allocator::Vec::from_array_in(
+ [Argument::from(Expression::new_object_expression(
+ SPAN,
+ oxc_allocator::Vec::new_in(ast_builder),
+ ast_builder,
+ ))],
+ ast_builder,
+ );
+ }
+ None => {}
+ }
+ }
let (result, new_expr) = if let Expression::TaggedTemplateExpression(tag) = expression
- && let (Some(tag_name), default_class_name) =
- extract_base_tag_and_class_name(&tag.tag, imports)
+ && let Some(mut base) = extract_base_tag_and_class_name(ast_builder, &tag.tag, imports)
{
// Case 1: styled.div`css` or styled("div")`css`
// Check if tag is styled.div or styled(...)
// Extract CSS from template literal
- let styles = css_to_style_literal(&tag.quasi, 0, &None);
+ let TemplateStyles {
+ styles,
+ statements,
+ unplaced,
+ } = css_to_style_template(&tag.quasi, 0, &None);
+ for index in unplaced {
+ let expression = &tag.quasi.expressions[index];
+ errors.push((expression.span().start, unplaced_error(expression)));
+ }
let mut props_styles: Vec> = styles
.into_iter()
.map(|ex| ExtractStyleProp::Static(ex.into()))
.collect();
- if let Some(default_class_name) = default_class_name {
+ if let Some(default_class_name) = base.styles.take() {
props_styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static));
}
- let class_name = gen_class_names(ast_builder, &mut props_styles, None, split_filename);
- let styled_component = create_styled_component(
+ let mixins = statements.into_iter().map(|index| {
+ let mixin = &tag.quasi.expressions[index];
+ if matches!(
+ unwrap_syntax_only(mixin),
+ Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
+ ) {
+ // A mixin returns a class, or `false` when its condition fails
+ Expression::new_logical_expression(
+ SPAN,
+ wrap_direct_call(
+ ast_builder,
+ mixin,
+ &[Expression::new_identifier(SPAN, "rest", ast_builder)],
+ ),
+ LogicalOperator::Or,
+ Expression::new_string_literal(SPAN, "", None, ast_builder),
+ ast_builder,
+ )
+ } else {
+ mixin.clone_in(ast_builder.allocator())
+ }
+ });
+ let class_name = merge_expression_for_class_name(
+ ast_builder,
+ mixins
+ .collect::>()
+ .into_iter()
+ .chain(gen_class_names(
+ ast_builder,
+ &mut props_styles,
+ None,
+ split_filename,
+ )),
+ );
+ let tag = Some(Expression::new_string_literal(
+ SPAN,
+ Str::from_in(&base.name, ast_builder.allocator()),
+ None,
ast_builder,
- &tag_name,
+ ));
+ let component = create_styled_component(
+ ast_builder,
+ &base.name,
&class_name,
&gen_styles(ast_builder, &props_styles, None),
);
+ let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs));
let result = ExtractResult {
styles: props_styles,
- tag: Some(Expression::new_string_literal(
- SPAN,
- Str::from_in(&tag_name, ast_builder.allocator()),
- None,
- ast_builder,
- )),
+ tag,
style_order: None,
style_vars: None,
props: None,
@@ -145,8 +292,8 @@ 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, style_index)) =
- resolve_styled_call_target(call, imports)
+ && let Some((mut base, style_index)) =
+ resolve_styled_call_target(ast_builder, call, imports)
{
// Case 2: styled.div({ bg: "red" }), styled("div")({ bg: "red" }),
// or styled("div", { bg: "red" })
@@ -170,17 +317,33 @@ pub fn extract_style_from_styled<'a>(
&None,
LiteralHandling::ExpandResponsiveThemeToken,
);
- if let Some(default_class_name) = default_class_name {
+ let mut unreadable = Vec::new();
+ unreadable_styles(&styles, true, &mut unreadable);
+ errors.extend(
+ unreadable
+ .into_iter()
+ .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))),
+ );
+ if let Some(default_class_name) = base.styles.take() {
styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static));
}
- let class_name = gen_class_names(ast_builder, &mut styles, style_order, split_filename);
- let styled_component = create_styled_component(
+ let class_name = merge_expression_for_class_name(
ast_builder,
- &tag_name,
+ composed_classes.into_iter().chain(gen_class_names(
+ ast_builder,
+ &mut styles,
+ style_order,
+ split_filename,
+ )),
+ );
+ let component = create_styled_component(
+ ast_builder,
+ &base.name,
&class_name,
&gen_styles(ast_builder, &styles, None),
);
+ let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs));
let result = ExtractResult {
styles,
@@ -192,11 +355,188 @@ pub fn extract_style_from_styled<'a>(
(Some(result), Some(styled_component))
} else {
+ // Left as written it would call `styled` at runtime, which only the build runs
+ let code = match &*expression {
+ Expression::TaggedTemplateExpression(tag) => readable_code(&tag.tag),
+ expression => readable_code(expression),
+ };
+ errors.push((
+ expression.span().start,
+ build_time_error("styled", &code, STYLED_FACTORY),
+ ));
(None, None)
};
(
result.unwrap_or_else(ExtractResult::default),
new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())),
+ errors,
+ )
+}
+
+/// The name the attrs wrapper binds props to, chosen not to shadow what the
+/// attrs expressions read
+const ATTRS_PROPS: &str = "__devupProps";
+
+/// Strip styled-components' `.attrs()` / `.withConfig()` off a styled factory
+/// such as `styled.div.attrs(a).withConfig(c)`, returning the attrs in the
+/// order they apply. `withConfig` only tunes runtime behavior, so it is dropped.
+pub fn take_styled_modifiers<'a>(
+ ast_builder: &AstBuilder<'a>,
+ factory: &mut Expression<'a>,
+ is_styled: impl Fn(&str) -> bool,
+) -> Vec> {
+ let mut attrs = Vec::new();
+ if !is_modified_styled(factory, is_styled) {
+ return attrs;
+ }
+ while let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory)
+ && let CallExpression {
+ callee, arguments, ..
+ } = &mut **call
+ && let Expression::StaticMemberExpression(member) = unwrap_syntax_only_mut(callee)
+ && matches!(member.property.name.as_str(), "attrs" | "withConfig")
+ {
+ let placeholder = || Expression::new_null_literal(SPAN, ast_builder);
+ if member.property.name == "attrs"
+ && let Some(argument) = arguments[0].as_expression_mut()
+ {
+ attrs.push(std::mem::replace(argument, placeholder()));
+ }
+ let object = std::mem::replace(&mut member.object, placeholder());
+ *factory = object;
+ }
+ attrs.reverse();
+ attrs
+}
+
+fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool {
+ let mut expression = unwrap_syntax_only(expression);
+ let mut modified = false;
+ while let Some(object) = modifier_object(expression) {
+ expression = unwrap_syntax_only(object);
+ modified = true;
+ }
+ modified
+ && match expression {
+ Expression::StaticMemberExpression(member) => {
+ matches!(&member.object, Expression::Identifier(ident) if is_styled(&ident.name))
+ }
+ Expression::CallExpression(call) => {
+ matches!(&call.callee, Expression::Identifier(ident) if is_styled(&ident.name))
+ }
+ _ => false,
+ }
+}
+
+fn modifier_object<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression<'a>> {
+ if let Expression::CallExpression(call) = expression
+ && let [argument] = call.arguments.as_slice()
+ && argument.is_expression()
+ && let Expression::StaticMemberExpression(member) = unwrap_syntax_only(&call.callee)
+ && matches!(member.property.name.as_str(), "attrs" | "withConfig")
+ {
+ Some(&member.object)
+ } else {
+ None
+ }
+}
+
+/// Wrap `component` so the attrs are merged over its props first: an object is
+/// spread, anything else is called with the props when it is a function, as
+/// styled-components does
+fn apply_attrs<'a>(
+ ast_builder: &AstBuilder<'a>,
+ component: Expression<'a>,
+ attrs: &[Expression<'a>],
+) -> Expression<'a> {
+ if attrs.is_empty() {
+ return component;
+ }
+ let props = || Expression::new_identifier(SPAN, ATTRS_PROPS, ast_builder);
+ let mut merged = props();
+ for attr in attrs {
+ let attr = attr.clone_in(ast_builder.allocator());
+ if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) {
+ merged = spread_objects(ast_builder, merged, attr);
+ continue;
+ }
+ let called = wrap_direct_call(ast_builder, &attr, &[props()]);
+ let resolved = if matches!(
+ unwrap_syntax_only(&attr),
+ Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
+ ) {
+ called
+ } else {
+ let is_function = Expression::new_binary_expression(
+ SPAN,
+ Expression::new_unary_expression(
+ SPAN,
+ UnaryOperator::Typeof,
+ attr.clone_in(ast_builder.allocator()),
+ ast_builder,
+ ),
+ BinaryOperator::StrictEquality,
+ Expression::new_string_literal(SPAN, "function", None, ast_builder),
+ ast_builder,
+ );
+ Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder)
+ };
+ let step = props_arrow(ast_builder, spread_objects(ast_builder, props(), resolved));
+ merged = wrap_direct_call(ast_builder, &step, &[merged]);
+ }
+ props_arrow(
+ ast_builder,
+ wrap_direct_call(ast_builder, &component, &[merged]),
+ )
+}
+
+fn spread_objects<'a>(
+ ast_builder: &AstBuilder<'a>,
+ first: Expression<'a>,
+ second: Expression<'a>,
+) -> Expression<'a> {
+ let mut properties = oxc_allocator::Vec::with_capacity_in(2, ast_builder);
+ properties.push(ObjectPropertyKind::new_spread_property(
+ SPAN,
+ first,
+ ast_builder,
+ ));
+ properties.push(ObjectPropertyKind::new_spread_property(
+ SPAN,
+ second,
+ ast_builder,
+ ));
+ Expression::new_object_expression(SPAN, properties, ast_builder)
+}
+
+fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expression<'a> {
+ let parameter = FormalParameter::new(
+ SPAN,
+ oxc_allocator::Vec::new_in(ast_builder),
+ BindingPattern::new_binding_identifier(SPAN, ATTRS_PROPS, ast_builder),
+ None::>>,
+ None::>>,
+ false,
+ None,
+ false,
+ false,
+ ast_builder,
+ );
+ let params = FormalParameters::boxed(
+ SPAN,
+ FormalParameterKind::ArrowFormalParameters,
+ oxc_allocator::Vec::from_iter_in([parameter], ast_builder),
+ None::>>,
+ ast_builder,
+ );
+ Expression::new_arrow_function_expression(
+ SPAN,
+ false,
+ None::>>,
+ params,
+ None::>>,
+ body.into(),
+ ast_builder,
)
}
diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs
index 568ec9b42..69ff713a8 100644
--- a/libs/extractor/src/extractor/extract_style_from_stylex.rs
+++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs
@@ -3,17 +3,23 @@ use crate::extract_style::extract_dynamic_style::ExtractDynamicStyle;
use crate::extract_style::extract_static_style::ExtractStaticStyle;
use crate::extract_style::extract_style_value::ExtractStyleValue;
use crate::stylex::{
- SelectorPart, StylexIncludeRef, decompose_value_conditions, is_first_that_works_call,
- is_include_call_static, is_types_call, normalize_stylex_property,
+ DecomposedStyle, SelectorPart, StylexIncludeRef, decompose_value_conditions,
+ dynamic_number_suffix, is_include_call_static, normalize_stylex_property, stylex_value,
};
-use crate::utils::get_string_by_literal_expression;
use css::optimize_value::optimize_value;
use css::sheet_to_variable_name;
use css::style_selector::StyleSelector;
-use oxc_ast::ast::{BindingPattern, Expression, ObjectPropertyKind};
+use oxc_ast::ast::{
+ Argument, ArrowFunctionExpression, BindingPattern, Expression, ObjectExpression,
+ ObjectPropertyKind, SpreadElement,
+};
+use oxc_span::GetSpan;
use rustc_hash::FxHashMap;
-use crate::utils::{get_str_by_property_key, get_string_by_property_key};
+use crate::utils::{
+ build_time_error, get_str_by_property_key, get_string_by_property_key, key_error,
+ readable_code, runtime_value_error, spread_error,
+};
/// Construct a static style directly — bypass `convert_value()` to avoid devup-ui
/// spacing transformations. `StyleX` values are raw CSS, only `optimize_value()`.
@@ -35,24 +41,34 @@ 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()
+pub fn extract_stylex_declarations(
+ api: &str,
+ object: &ObjectExpression<'_>,
+ errors: &mut Vec<(u32, String)>,
+) -> Vec<(String, String)> {
+ let mut declarations = vec![];
+ for property in &object.properties {
+ let property = match property {
+ ObjectPropertyKind::ObjectProperty(property) => property,
+ ObjectPropertyKind::SpreadProperty(spread) => {
+ errors.push(spread_error(api, spread));
+ continue;
+ }
+ };
+ let Some(name) = get_str_by_property_key(&property.key) else {
+ errors.push(key_error(api, &property.key));
+ continue;
+ };
+ let name = normalize_stylex_property(name.as_ref());
+ match stylex_value(&name, &property.value) {
+ Some(value) => declarations.push((name, optimize_value(&value).into_owned())),
+ None => errors.push((
+ property.value.span().start,
+ runtime_value_error(api, &readable_code(&property.value)),
+ )),
+ }
+ }
+ declarations
}
/// Resolve a `vars.key` member access against the contracts `stylex.defineVars()`
@@ -96,253 +112,228 @@ static SHORTHAND_PROPERTIES: phf::Set<&'static str> = phf::phf_set! {
"padding-block",
};
+type Leaf<'l> = dyn Fn(&str, &Expression<'_>) -> Option + 'l;
+
/// Extract styles from a `stylex.create()` call's argument (`ObjectExpression`).
///
-/// Handles static string/number values (Phase 1) and value-level conditions (Phase 2).
+/// Handles static string/number values (Phase 1) and value-level conditions (Phase 2);
+/// what cannot be read at build time is reported in `errors`.
///
/// Returns a Vec of `(namespace_name, style_props, css_vars, include_refs)` tuples. Each namespace
/// corresponds to a top-level key in the `stylex.create({...})` argument.
#[allow(clippy::type_complexity)]
pub fn extract_stylex_namespace_styles<'a>(
- expression: &mut Expression<'a>,
+ obj: &ObjectExpression<'_>,
keyframe_names: &FxHashMap,
var_refs: &FxHashMap,
+ errors: &mut Vec<(u32, String)>,
) -> Vec<(
String,
Vec>,
- Option>,
+ Option>,
Vec,
)> {
- let Expression::ObjectExpression(obj) = expression else {
- return vec![];
+ // A keyframes name or a `defineVars` member reads as the value it stands for
+ let leaf = |property: &str, value: &Expression<'_>| {
+ if let Expression::Identifier(ident) = value
+ && let Some(name) = keyframe_names.get(ident.name.as_str())
+ {
+ return Some(name.clone());
+ }
+ var_reference(value, var_refs)
+ .map(str::to_string)
+ .or_else(|| stylex_value(property, value).map(std::borrow::Cow::into_owned))
};
let mut result = vec![];
-
for prop in &obj.properties {
- let ObjectPropertyKind::ObjectProperty(prop) = prop else {
- // Phase 4c: Spread not supported at namespace level
- if matches!(prop, ObjectPropertyKind::SpreadProperty(_)) {
- eprintln!(
- "[stylex] ERROR: Object spread is not allowed at the namespace level of stylex.create()."
- );
+ let prop = match prop {
+ ObjectPropertyKind::ObjectProperty(prop) => prop,
+ ObjectPropertyKind::SpreadProperty(spread) => {
+ errors.push(spread_error("stylex.create", spread));
+ continue;
}
- continue;
};
-
let Some(ns_name) = get_string_by_property_key(&prop.key) else {
- // Phase 4c: Computed namespace keys not supported
- if prop.computed {
- eprintln!(
- "[stylex] ERROR: Computed namespace keys are not allowed in stylex.create()."
- );
- }
+ errors.push(key_error("stylex.create", &prop.key));
continue;
};
-
- // Phase 4b: Arrow function (dynamic namespace)
- if let Expression::ArrowFunctionExpression(arrow) = &prop.value {
- if let Some((styles, css_vars)) =
- extract_stylex_dynamic_namespace(arrow, keyframe_names)
- {
+ match &prop.value {
+ Expression::ArrowFunctionExpression(arrow) => {
+ let Some((styles, css_vars)) =
+ extract_stylex_dynamic_namespace(arrow, &leaf, errors)
+ else {
+ errors.push((
+ prop.value.span().start,
+ build_time_error(
+ "stylex.create",
+ &readable_code(&prop.value),
+ "a dynamic style is an arrow function with plain parameters returning an object literal",
+ ),
+ ));
+ continue;
+ };
result.push((ns_name, styles, Some(css_vars), vec![]));
- } else {
- result.push((ns_name, vec![], None, vec![]));
}
- continue;
+ Expression::ObjectExpression(ns_obj) => {
+ let (styles, include_refs) = extract_stylex_namespace(ns_obj, &leaf, errors);
+ result.push((ns_name, styles, None, include_refs));
+ }
+ Expression::NullLiteral(_) => result.push((ns_name, vec![], None, vec![])),
+ value => errors.push((
+ value.span().start,
+ build_time_error(
+ "stylex.create",
+ &readable_code(value),
+ "a namespace is an object of styles or an arrow function returning one",
+ ),
+ )),
}
+ }
+ result
+}
- let Expression::ObjectExpression(ns_obj) = &prop.value else {
- // Non-object namespace value (e.g., null): push empty styles
- result.push((ns_name, vec![], None, vec![]));
- continue;
- };
-
- let mut styles = vec![];
- let mut include_refs = vec![];
-
- for style_prop in &ns_obj.properties {
- let ObjectPropertyKind::ObjectProperty(style_prop) = style_prop else {
- // Check for stylex.include() spread
- if let ObjectPropertyKind::SpreadProperty(spread) = style_prop
- && let Expression::CallExpression(call) = &spread.argument
- && is_include_call_static(&call.callee)
- {
- // Parse include(base.member)
- 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 {
- var_name: ident.name.to_string(),
- member_name: member.property.name.to_string(),
- });
- }
- } else if matches!(style_prop, ObjectPropertyKind::SpreadProperty(_)) {
- eprintln!(
- "[stylex] ERROR: Object spread is not allowed in stylex.create() namespaces. Define all properties explicitly."
- );
+/// The styles and `include()` references of one static namespace
+fn extract_stylex_namespace<'a>(
+ namespace: &ObjectExpression<'_>,
+ leaf: &Leaf<'_>,
+ errors: &mut Vec<(u32, String)>,
+) -> (Vec>, Vec) {
+ let mut styles = vec![];
+ let mut include_refs = vec![];
+ for style_prop in &namespace.properties {
+ let style_prop = match style_prop {
+ ObjectPropertyKind::ObjectProperty(style_prop) => style_prop,
+ ObjectPropertyKind::SpreadProperty(spread) => {
+ match include(spread) {
+ Some(Ok(include_ref)) => include_refs.push(include_ref),
+ Some(Err(error)) => errors.push(error),
+ None => errors.push(spread_error("stylex.create", spread)),
}
continue;
- };
+ }
+ };
+ let Some(prop_name) = get_str_by_property_key(&style_prop.key) else {
+ errors.push(key_error("stylex.create", &style_prop.key));
+ continue;
+ };
- let Some(prop_name) = get_str_by_property_key(&style_prop.key) else {
- // Phase 4c: Computed property keys not supported
- if style_prop.computed {
- eprintln!(
- "[stylex] ERROR: Computed property keys are not allowed in stylex.create(). Use static string keys instead."
- );
- }
+ // Phase 2: pseudo-element / pseudo-class top-level keys
+ if prop_name.starts_with(':') {
+ let Expression::ObjectExpression(inner_obj) = &style_prop.value else {
+ errors.push((
+ style_prop.value.span().start,
+ build_time_error(
+ "stylex.create",
+ &readable_code(&style_prop.value),
+ "a pseudo-class or pseudo-element key takes an object of styles",
+ ),
+ ));
continue;
};
-
- // Phase 2: pseudo-element / pseudo-class top-level keys
- if prop_name.starts_with("::") || prop_name.starts_with(':') {
- let Expression::ObjectExpression(inner_obj) = &style_prop.value else {
+ let parent_selectors = [SelectorPart::Pseudo(prop_name.to_string())];
+ for inner_prop in &inner_obj.properties {
+ let inner_prop = match inner_prop {
+ ObjectPropertyKind::ObjectProperty(inner_prop) => inner_prop,
+ ObjectPropertyKind::SpreadProperty(spread) => {
+ errors.push(spread_error("stylex.create", spread));
+ continue;
+ }
+ };
+ let Some(inner_name) = get_str_by_property_key(&inner_prop.key) else {
+ errors.push(key_error("stylex.create", &inner_prop.key));
continue;
};
- for inner_prop in &inner_obj.properties {
- let ObjectPropertyKind::ObjectProperty(inner_prop) = inner_prop else {
- continue;
- };
- let Some(inner_name) = get_str_by_property_key(&inner_prop.key) else {
- continue;
- };
- let inner_css_property = normalize_stylex_property(inner_name.as_ref());
- let parent_selectors = vec![SelectorPart::Pseudo(prop_name.to_string())];
- for decomposed in decompose_value_conditions(
- &inner_css_property,
+ push_decomposed(
+ &mut styles,
+ decompose_value_conditions(
+ &normalize_stylex_property(inner_name.as_ref()),
&inner_prop.value,
&parent_selectors,
- ) {
- if let Some(css_value) = decomposed.value {
- styles.push(raw_static_style(
- decomposed.property,
- &css_value,
- decomposed.selector,
- ));
- }
- }
- }
- continue;
- }
-
- let css_property = normalize_stylex_property(prop_name.as_ref());
-
- if SHORTHAND_PROPERTIES.contains(css_property.as_str()) {
- eprintln!(
- "[stylex] WARNING: Shorthand property '{css_property}' may cause unexpected specificity issues. Consider using longhand properties (e.g., 'marginTop', 'paddingLeft')."
+ leaf,
+ errors,
+ ),
);
}
+ continue;
+ }
- // Phase 4a: Resolve keyframe variable references (e.g., animationName: fadeIn)
- if let Expression::Identifier(ident) = &style_prop.value
- && let Some(anim_name) = keyframe_names.get(ident.name.as_str())
- {
- styles.push(raw_static_style(css_property, anim_name, None));
- 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
- } else if matches!(&style_prop.value, Expression::ObjectExpression(_)) {
- // Phase 2: value-level conditions
- for decomposed in decompose_value_conditions(&css_property, &style_prop.value, &[])
- {
- if let Some(css_value) = decomposed.value {
- styles.push(raw_static_style(
- decomposed.property,
- &css_value,
- decomposed.selector,
- ));
- }
- }
- continue;
- } else if let Expression::CallExpression(call) = &style_prop.value
- && is_first_that_works_call(&call.callee)
- {
- // 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() {
- 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)
- // stylex.types.length('100px') → extract inner value '100px'
- && 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 {
- continue; // Can't resolve inner value
- };
- styles.push(raw_static_style(css_property, &css_value, None));
- continue;
- } else {
- // Phase 4c: Non-static values in create() are not supported
- if !matches!(&style_prop.value, Expression::NullLiteral(_)) {
- eprintln!(
- "[stylex] ERROR: Non-static value for property '{prop_name}' in stylex.create(). Only string literals, numbers, null, objects (conditions), firstThatWorks(), types.*(), and arrow functions are allowed."
- );
- }
- continue;
- };
-
- styles.push(raw_static_style(css_property, &css_value, None));
+ let css_property = normalize_stylex_property(prop_name.as_ref());
+ if SHORTHAND_PROPERTIES.contains(css_property.as_str()) {
+ eprintln!(
+ "[stylex] WARNING: Shorthand property '{css_property}' may cause unexpected specificity issues. Consider using longhand properties (e.g., 'marginTop', 'paddingLeft')."
+ );
}
+ push_decomposed(
+ &mut styles,
+ decompose_value_conditions(&css_property, &style_prop.value, &[], leaf, errors),
+ );
+ }
+ (styles, include_refs)
+}
- result.push((ns_name, styles, None, include_refs));
+fn push_decomposed(styles: &mut Vec>, decomposed: Vec) {
+ for style in decomposed {
+ if let Some(value) = style.value {
+ styles.push(raw_static_style(style.property, &value, style.selector));
+ }
}
+}
- result
+/// `...stylex.include(base.member)`: `None` when the spread is not an
+/// `include()` call
+fn include(spread: &SpreadElement<'_>) -> Option> {
+ let Expression::CallExpression(call) = &spread.argument else {
+ return None;
+ };
+ if !is_include_call_static(&call.callee) {
+ return None;
+ }
+ if let Some(Expression::StaticMemberExpression(member)) =
+ call.arguments.first().and_then(Argument::as_expression)
+ && let Expression::Identifier(ident) = &member.object
+ {
+ return Some(Ok(StylexIncludeRef {
+ var_name: ident.name.to_string(),
+ member_name: member.property.name.to_string(),
+ offset: spread.span.start,
+ }));
+ }
+ Some(Err((
+ spread.span.start,
+ build_time_error(
+ "stylex.include",
+ &readable_code(&spread.argument),
+ "it takes a namespace such as `styles.base`",
+ ),
+ )))
}
/// Extract styles from a dynamic `StyleX` namespace (arrow function).
-/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to CSS variable name.
+/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to a CSS variable
+/// name and the unit a number passed for it gets; `None` when the function is
+/// not an arrow with plain parameters returning an object literal.
#[allow(clippy::type_complexity)]
fn extract_stylex_dynamic_namespace<'a>(
- arrow: &oxc_ast::ast::ArrowFunctionExpression<'a>,
- keyframe_names: &FxHashMap,
-) -> Option<(Vec>, Vec<(usize, String)>)> {
+ arrow: &ArrowFunctionExpression<'_>,
+ leaf: &Leaf<'_>,
+ errors: &mut Vec<(u32, String)>,
+) -> Option<(
+ Vec>,
+ Vec<(usize, String, &'static str)>,
+)> {
// 1. Extract parameter names
- let param_names: Vec = arrow
- .params
- .items
- .iter()
- .filter_map(|param| {
- if let BindingPattern::BindingIdentifier(ident) = ¶m.pattern {
- Some(ident.name.to_string())
- } else {
- None
- }
- })
- .collect();
-
- if param_names.is_empty() {
- return None;
+ let mut param_names = vec![];
+ for param in &arrow.params.items {
+ let BindingPattern::BindingIdentifier(ident) = ¶m.pattern else {
+ return None;
+ };
+ param_names.push(ident.name.to_string());
}
// 2. Get body ObjectExpression from expression body: (x) => ({ ... })
- // Unwrap ParenthesizedExpression since Oxc preserves parens for `(x) => ({...})`.
- let body_expr = arrow.body.as_expression()?;
- let inner = if let Expression::ParenthesizedExpression(paren) = body_expr {
- &paren.expression
- } else {
- body_expr
- };
- let Expression::ObjectExpression(body_obj) = inner else {
+ let Expression::ObjectExpression(body_obj) = arrow.body.as_expression()?.without_parentheses()
+ else {
return None;
};
@@ -351,11 +342,15 @@ fn extract_stylex_dynamic_namespace<'a>(
let mut css_vars = vec![];
for prop in &body_obj.properties {
- let ObjectPropertyKind::ObjectProperty(prop) = prop else {
- continue;
+ let prop = match prop {
+ ObjectPropertyKind::ObjectProperty(prop) => prop,
+ ObjectPropertyKind::SpreadProperty(spread) => {
+ errors.push(spread_error("stylex.create", spread));
+ continue;
+ }
};
-
let Some(prop_name) = get_string_by_property_key(&prop.key) else {
+ errors.push(key_error("stylex.create", &prop.key));
continue;
};
let css_property = normalize_stylex_property(&prop_name);
@@ -373,25 +368,23 @@ fn extract_stylex_dynamic_namespace<'a>(
if let Some(param_idx) = is_dynamic {
// Dynamic property: generate CSS variable
let var_name = sheet_to_variable_name(&css_property, 0, None);
- css_vars.push((param_idx, var_name));
+ css_vars.push((param_idx, var_name, dynamic_number_suffix(&css_property)));
let param_name = ¶m_names[param_idx];
styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic(
ExtractDynamicStyle::new(&css_property, 0, param_name, None),
)));
- } else {
- // Static property: resolve keyframe references or literal values
- if let Expression::Identifier(ident) = &prop.value
- && let Some(anim_name) = keyframe_names.get(ident.name.as_str())
- {
- styles.push(raw_static_style(css_property, anim_name, None));
- continue;
- }
- let css_value = if let Some(s) = get_string_by_literal_expression(&prop.value) {
- s
- } else {
- continue;
- };
- styles.push(raw_static_style(css_property, &css_value, None));
+ continue;
+ }
+ match leaf(&css_property, &prop.value) {
+ Some(value) => styles.push(raw_static_style(css_property, &value, None)),
+ None => errors.push((
+ prop.value.span().start,
+ build_time_error(
+ "stylex.create",
+ &readable_code(&prop.value),
+ "a dynamic style's value is one of its parameters or a static value; compute it before passing it",
+ ),
+ )),
}
}
diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs
index 45e32e199..1b47b9788 100644
--- a/libs/extractor/src/extractor/mod.rs
+++ b/libs/extractor/src/extractor/mod.rs
@@ -36,4 +36,6 @@ pub struct GlobalExtractResult<'a> {
#[derive(Debug)]
pub struct KeyframesExtractResult {
pub keyframes: ExtractKeyframes,
+ /// A value only known at runtime, which keyframes cannot hold
+ pub runtime_value: Option,
}
diff --git a/libs/extractor/src/gen_class_name.rs b/libs/extractor/src/gen_class_name.rs
index 3101e5667..3b274b7fd 100644
--- a/libs/extractor/src/gen_class_name.rs
+++ b/libs/extractor/src/gen_class_name.rs
@@ -1,5 +1,6 @@
use crate::ExtractStyleProp;
use crate::extract_style::style_property::StyleProperty;
+use crate::extractor::extract_style_from_expression::yield_typography;
use crate::prop_modify_utils::convert_class_name;
use crate::utils::is_same_expression;
use oxc_allocator::{CloneIn, FromIn, GetAllocator};
@@ -16,6 +17,7 @@ pub fn gen_class_names<'a>(
style_order: Option,
filename: Option<&str>,
) -> Option> {
+ yield_typography(style_props);
merge_expression_for_class_name(
ast_builder,
style_props
@@ -126,6 +128,7 @@ fn gen_class_name<'a>(
ExtractStyleProp::Expression { expression, .. } => {
Some(expression.clone_in(ast_builder.allocator()))
}
+ ExtractStyleProp::Unreadable { .. } => None,
// direct select
ExtractStyleProp::MemberExpression { map, expression } => {
let exp = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed(
diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs
index 259bf357f..158d0f420 100644
--- a/libs/extractor/src/import_alias_visit.rs
+++ b/libs/extractor/src/import_alias_visit.rs
@@ -8,14 +8,178 @@
//! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'`
use crate::ImportAlias;
-use crate::utils::is_vanilla_extract_file;
+use crate::utils::{
+ get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number,
+};
use oxc_allocator::Allocator;
-use oxc_ast::ast::{ImportDeclarationSpecifier, ModuleExportName};
+use oxc_ast::ast::{
+ Argument, CallExpression, Expression, ImportDeclarationSpecifier, JSXAttributeItem,
+ JSXAttributeValue, JSXElementName, JSXOpeningElement, ModuleExportName, ObjectPropertyKind,
+ Statement,
+};
+use oxc_ast_visit::{
+ Visit,
+ walk::{walk_call_expression, walk_jsx_opening_element},
+};
use oxc_parser::Parser;
-use oxc_span::SourceType;
+use oxc_span::{GetSpan, SourceType};
use std::borrow::Cow;
use std::collections::HashMap;
+/// Where a style function takes its rules
+#[derive(Clone, Copy)]
+enum RulesAt {
+ Argument(usize),
+ EveryArgument,
+}
+
+/// Numbers in rules written against vanilla-extract, Emotion or styled-components
+/// outside a stylesheet. Those calls become Devup UI's, which read a number as its
+/// spacing scale, so it is rewritten to the `px` string the library makes of it.
+#[derive(Default)]
+struct LibraryNumbers<'n> {
+ /// Local name of each style function and where its rules are
+ calls: Vec<(&'n str, RulesAt)>,
+ /// Local names that build styled components
+ styled: Vec<&'n str>,
+ /// Local names of components taking rules in `styles` (Emotion's `Global`)
+ components: Vec<&'n str>,
+ replacements: Vec<(usize, usize, String)>,
+}
+
+impl LibraryNumbers<'_> {
+ /// `styled.div`, `styled(tag)`, `styled(tag, options)` and their
+ /// `.attrs()` / `.withConfig()`: whatever a call of it passes are rules
+ fn is_styled_factory(&self, callee: &Expression) -> bool {
+ match callee {
+ Expression::StaticMemberExpression(member) => {
+ matches!(&member.object, Expression::Identifier(root) if self.styled.contains(&root.name.as_str()))
+ }
+ Expression::CallExpression(call) => match &call.callee {
+ Expression::Identifier(root) => self.styled.contains(&root.name.as_str()),
+ Expression::StaticMemberExpression(member) => {
+ matches!(member.property.name.as_str(), "attrs" | "withConfig")
+ && self.is_styled_factory(&member.object)
+ }
+ _ => false,
+ },
+ _ => false,
+ }
+ }
+
+ fn pixelify(&mut self, rules: &Expression) {
+ match rules {
+ Expression::ArrayExpression(array) => {
+ for element in &array.elements {
+ if let Some(element) = element.as_expression() {
+ self.pixelify(element);
+ }
+ }
+ }
+ Expression::ObjectExpression(object) => {
+ for property in &object.properties {
+ if let ObjectPropertyKind::ObjectProperty(property) = property
+ && let Some(key) = get_str_by_property_key(&property.key)
+ {
+ self.pixelify_value(&key, &property.value);
+ }
+ }
+ }
+ Expression::ConditionalExpression(conditional) => {
+ self.pixelify(&conditional.consequent);
+ self.pixelify(&conditional.alternate);
+ }
+ Expression::LogicalExpression(logical) => self.pixelify(&logical.right),
+ Expression::ParenthesizedExpression(inner) => self.pixelify(&inner.expression),
+ _ => {}
+ }
+ }
+
+ fn pixelify_value(&mut self, key: &str, value: &Expression) {
+ if let Some(number) = js_number_literal(value) {
+ if number != 0.0 && !keeps_bare_number(key) {
+ let span = value.span();
+ self.replacements.push((
+ span.start as usize,
+ span.end as usize,
+ format!("\"{number}px\""),
+ ));
+ }
+ return;
+ }
+ match value {
+ Expression::ObjectExpression(_) => {
+ if key != "vars" {
+ self.pixelify(value);
+ }
+ }
+ Expression::ConditionalExpression(conditional) => {
+ self.pixelify_value(key, &conditional.consequent);
+ self.pixelify_value(key, &conditional.alternate);
+ }
+ Expression::LogicalExpression(logical) => self.pixelify_value(key, &logical.right),
+ Expression::ParenthesizedExpression(inner) => {
+ self.pixelify_value(key, &inner.expression);
+ }
+ _ => {}
+ }
+ }
+}
+
+impl<'a> Visit<'a> for LibraryNumbers<'_> {
+ fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
+ let rules_at = match &call.callee {
+ Expression::Identifier(callee) => self
+ .calls
+ .iter()
+ .find(|(name, _)| *name == callee.name.as_str())
+ .map(|(_, rules_at)| *rules_at),
+ callee => self
+ .is_styled_factory(callee)
+ .then_some(RulesAt::EveryArgument),
+ };
+ let rules: Vec<&Expression<'a>> = match rules_at {
+ Some(RulesAt::Argument(index)) => call
+ .arguments
+ .get(index)
+ .and_then(Argument::as_expression)
+ .into_iter()
+ .collect(),
+ Some(RulesAt::EveryArgument) => call
+ .arguments
+ .iter()
+ .filter_map(Argument::as_expression)
+ .collect(),
+ None => vec![],
+ };
+ for rules in rules {
+ self.pixelify(rules);
+ }
+ walk_call_expression(self, call);
+ }
+
+ fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) {
+ if let JSXElementName::IdentifierReference(name) = &element.name
+ && self.components.contains(&name.name.as_str())
+ {
+ for attribute in &element.attributes {
+ if let JSXAttributeItem::Attribute(attribute) = attribute
+ && attribute
+ .name
+ .as_identifier()
+ .is_some_and(|name| name.name == "styles")
+ && let Some(JSXAttributeValue::ExpressionContainer(container)) =
+ &attribute.value
+ && let Some(rules) = container.expression.as_expression()
+ {
+ self.pixelify(rules);
+ }
+ }
+ }
+ walk_jsx_opening_element(self, element);
+ }
+}
+
/// 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.
@@ -55,6 +219,19 @@ fn devup_equivalent(source: &str, imported: &str) -> Option
}
}
+#[cfg(test)]
+pub fn transform_import_aliases<'a>(
+ code: &'a str,
+ filename: &str,
+ package: &str,
+ import_aliases: &HashMap,
+) -> Cow<'a, str> {
+ transform_import_aliases_with_edits(code, filename, package, import_aliases).0
+}
+
+/// A replacement of `code[start..end]` by text of `length` bytes
+pub type Edit = (usize, usize, usize);
+
/// Transform source code by rewriting aliased imports to the target package
///
/// # Arguments
@@ -64,16 +241,18 @@ fn devup_equivalent(source: &str, imported: &str) -> Option
/// * `import_aliases` - Map of source package → alias configuration
///
/// # Returns
-/// The transformed source code, or the original code if no transformations were needed
-pub fn transform_import_aliases<'a>(
+/// The transformed source code, or the original code if no transformations were
+/// needed, and the replacements made in order, so a position in the result maps
+/// back to the source
+pub fn transform_import_aliases_with_edits<'a>(
code: &'a str,
filename: &str,
package: &str,
import_aliases: &HashMap,
-) -> Cow<'a, str> {
+) -> (Cow<'a, str>, Vec) {
// Quick check: if no aliases match, return original code
if import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)) {
- return Cow::Borrowed(code);
+ return (Cow::Borrowed(code), Vec::new());
}
let allocator = Allocator::default();
@@ -87,9 +266,10 @@ pub fn transform_import_aliases<'a>(
// Collect import transformations
let mut transformations: Vec<(usize, usize, String)> = Vec::new();
+ let mut numbers = LibraryNumbers::default();
for stmt in &program.body {
- if let oxc_ast::ast::Statement::ImportDeclaration(import_decl) = stmt {
+ if let Statement::ImportDeclaration(import_decl) = stmt {
let source_value = import_decl.source.value.as_str();
if let Some(alias) = import_aliases.get(source_value) {
@@ -97,21 +277,89 @@ pub fn transform_import_aliases<'a>(
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));
+ if !redirect_every_name {
+ for specifier in import_decl.specifiers.iter().flatten() {
+ match specifier {
+ ImportDeclarationSpecifier::ImportSpecifier(spec) => {
+ let local = spec.local.name.as_str();
+ match (source_value, imported_name(&spec.imported).as_ref()) {
+ ("@vanilla-extract/css", "style" | "keyframes") => {
+ numbers.calls.push((local, RulesAt::Argument(0)));
+ }
+ ("@vanilla-extract/css", "globalStyle") => {
+ numbers.calls.push((local, RulesAt::Argument(1)));
+ }
+ (
+ "@emotion/react" | "styled-components",
+ "css" | "keyframes",
+ ) => numbers.calls.push((local, RulesAt::EveryArgument)),
+ ("@emotion/react", "Global") => numbers.components.push(local),
+ _ => {}
+ }
+ }
+ ImportDeclarationSpecifier::ImportDefaultSpecifier(spec)
+ if matches!(
+ source_value,
+ "@emotion/styled" | "styled-components"
+ ) =>
+ {
+ numbers.styled.push(spec.local.name.as_str());
+ }
+ ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec)
+ if matches!(
+ source_value,
+ "@emotion/styled" | "styled-components"
+ ) =>
+ {
+ numbers.styled.push(spec.local.name.as_str());
+ }
+ _ => {}
+ }
+ }
+ }
}
}
}
+ if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) {
+ numbers.visit_program(&program);
+ transformations.append(&mut numbers.replacements);
+ transformations.sort_unstable_by_key(|(start, ..)| *start);
+ }
// Apply transformations in reverse order to preserve positions
if transformations.is_empty() {
- return Cow::Borrowed(code);
+ return (Cow::Borrowed(code), Vec::new());
}
+ let edits = transformations
+ .iter()
+ .map(|(start, end, replacement)| (*start, *end, replacement.len()))
+ .collect();
let mut result = code.to_string();
for (start, end, replacement) in transformations.into_iter().rev() {
result.replace_range(start..end, &replacement);
}
- Cow::Owned(result)
+ (Cow::Owned(result), edits)
+}
+
+/// The source offset of `offset` in code `edits` made; an offset inside a
+/// replacement maps to where the replaced text began
+#[must_use]
+pub fn source_offset(edits: &[Edit], offset: usize) -> usize {
+ let (mut added, mut removed) = (0, 0);
+ for &(start, end, length) in edits {
+ let replaced_at = start + added - removed;
+ if offset < replaced_at {
+ break;
+ }
+ if offset < replaced_at + length {
+ return start;
+ }
+ added += length;
+ removed += end - start;
+ }
+ offset + removed - added
}
/// Pick the name a specifier should import from the target package, or `None` to leave it
@@ -338,6 +586,26 @@ mod tests {
aliases
}
+ #[test]
+ fn test_source_offset() {
+ // `ab` at 2 became 5 bytes and `cdef` at 10 became 1 byte
+ let edits = [(2, 4, 5), (10, 14, 1)];
+ for (offset, source) in [
+ (0, 0),
+ (1, 1),
+ (2, 2),
+ (6, 2),
+ (7, 4),
+ (12, 9),
+ (13, 10),
+ (14, 14),
+ (20, 20),
+ ] {
+ assert_eq!(source_offset(&edits, offset), source, "{offset}");
+ }
+ assert_eq!(source_offset(&[], 7), 7);
+ }
+
fn styled_components_alias() -> HashMap {
let mut aliases = HashMap::new();
aliases.insert(
@@ -501,6 +769,70 @@ export const container = style({ background: 'red' })",
));
}
+ #[test]
+ fn test_emotion_numbers_become_px() {
+ let code = r"import styled from '@emotion/styled'
+import { Global, ThemeProvider } from '@emotion/react'
+foo.bar.baz({ top: 1 })
+make()()({ top: 1 })
+;(() => 1)()
+styled.div({ top: 1, p: 2 })
+styled.div(cond && { top: 1 }, (flag ? { left: 2 } : { right: 3 }), { bottom: cond ? 4 : (5), left: (cond ? 7 : 8), vars: flag && { x: 6 } })
+export const A = () => <>
>";
+ let mut aliases = combined_aliases();
+ aliases.insert("@emotion/react".to_string(), ImportAlias::NamedToNamed);
+ assert_eq!(
+ transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases),
+ r#"import { styled } from '@devup-ui/react';
+import { Global, ThemeProvider } from '@devup-ui/react/compat';
+foo.bar.baz({ top: 1 })
+make()()({ top: 1 })
+;(() => 1)()
+styled.div({ top: "1px", p: 2 })
+styled.div(cond && { top: "1px" }, (flag ? { left: "2px" } : { right: "3px" }), { bottom: cond ? "4px" : "5px", left: (cond ? "7px" : "8px"), vars: flag && { x: 6 } })
+export const A = () => <>
>"#
+ );
+ }
+
+ #[test]
+ fn test_vanilla_extract_numbers_become_px_outside_stylesheets() {
+ let code = r"import { style as s, globalStyle, keyframes, styleVariants } from '@vanilla-extract/css'
+export const a = s({ padding: 8, top: -2, left: (1.5), width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: 4 } } })
+export const b = s([a, { right: 3 }], 'debug')
+globalStyle('body', { margin: 2 })
+keyframes({ from: { width: 10, opacity: 0 }, '50%': { width: 20 } })
+styleVariants({ small: { padding: 1 } })
+other({ padding: 8 })
+s()";
+ assert_eq!(
+ transform_import_aliases(
+ code,
+ "test.tsx",
+ "@devup-ui/react",
+ &vanilla_extract_alias()
+ ),
+ r#"import { css as s, globalCss as globalStyle, keyframes } from '@devup-ui/react'; import { styleVariants } from '@vanilla-extract/css';
+export const a = s({ padding: "8px", top: "-2px", left: "1.5px", width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: "4px" } } })
+export const b = s([a, { right: "3px" }], 'debug')
+globalStyle('body', { margin: "2px" })
+keyframes({ from: { width: "10px", opacity: 0 }, '50%': { width: "20px" } })
+styleVariants({ small: { padding: 1 } })
+other({ padding: 8 })
+s()"#
+ );
+ let stylesheet =
+ "import { style } from '@vanilla-extract/css'\nexport const a = style({ padding: 8 })";
+ assert!(
+ transform_import_aliases(
+ stylesheet,
+ "a.css.ts",
+ "@devup-ui/react",
+ &vanilla_extract_alias()
+ )
+ .ends_with("style({ padding: 8 })")
+ );
+ }
+
#[test]
fn test_multiple_imports_same_file() {
assert_snapshot!(transform_import_aliases(
diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs
new file mode 100644
index 000000000..a600d1deb
--- /dev/null
+++ b/libs/extractor/src/imported_constants.rs
@@ -0,0 +1,1981 @@
+//! Imported constants inlined where styles read them, so a value another
+//! module declares with `const` becomes a static class instead of a CSS
+//! variable set at runtime.
+
+use std::cell::Cell;
+use std::collections::BTreeSet;
+use std::rc::Rc;
+
+use oxc_allocator::{Allocator, FromIn, GetAllocator};
+use oxc_ast::ast::{
+ Argument, ArrayExpressionElement, Expression, ImportDeclarationSpecifier, JSXAttributeItem,
+ JSXElementName, ObjectPropertyKind, Program, Statement, Str, VariableDeclarationKind,
+};
+use oxc_ast::builder::AstBuilder;
+use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut};
+use oxc_parser::Parser;
+use oxc_semantic::{Scoping, SemanticBuilder};
+use oxc_span::{SPAN, SourceType};
+use oxc_syntax::number::NumberBase;
+use oxc_syntax::operator::BinaryOperator;
+use oxc_syntax::symbol::SymbolId;
+use rustc_hash::{FxHashMap, FxHashSet};
+
+use crate::stylex::StylexFunction;
+use crate::{ExtractOption, ModuleResolver};
+
+#[derive(Clone, Debug)]
+enum Constant {
+ String(String),
+ Number(f64),
+ Null,
+ Bool(bool),
+ Undefined,
+ /// A function an object literal holds: styles only call it, which only
+ /// running the module does
+ Function,
+ /// The properties known of an object, a module namespace or an enum
+ Object(Rc>),
+ /// An object literal every property of which is known, in source order
+ Record(Rc>),
+ Array(Rc>),
+ /// `StyleX` custom properties by key, which read as their `var()`
+ Vars(Rc>),
+ /// The class a `StyleX` theme applies
+ Theme(String),
+ /// What another style API gives: a class, a component or a keyframes
+ /// name, never rules
+ Style,
+ /// An object or array code changes, or a value read from one
+ Changed(Rc),
+}
+
+impl Constant {
+ /// Whether code can change what it holds
+ const fn is_mutable(&self) -> bool {
+ matches!(self, Self::Object(_) | Self::Record(_) | Self::Array(_))
+ }
+
+ /// Whether this value or one it holds is a function
+ fn has_function(&self) -> bool {
+ match self {
+ Self::Function => true,
+ Self::Object(object) => object.values().any(Self::has_function),
+ Self::Record(entries) => entries.iter().any(|(_, value)| value.has_function()),
+ Self::Array(values) => values.iter().any(Self::has_function),
+ _ => false,
+ }
+ }
+
+ /// This value as JavaScript source, when every part of it is known
+ fn js_literal(&self) -> Option {
+ match self {
+ Self::String(text) => serde_json::to_string(text).ok(),
+ Self::Number(number) => Some(crate::utils::js_number_string(*number)),
+ Self::Null => Some("null".to_string()),
+ Self::Bool(value) => Some(value.to_string()),
+ Self::Undefined => Some("undefined".to_string()),
+ Self::Record(entries) => {
+ let mut properties = Vec::with_capacity(entries.len());
+ for (key, value) in entries.iter() {
+ properties.push(format!(
+ "{}: {}",
+ serde_json::to_string(key).ok()?,
+ value.js_literal()?
+ ));
+ }
+ Some(format!("({{ {} }})", properties.join(", ")))
+ }
+ Self::Array(values) => {
+ let values: Option> = values.iter().map(Self::js_literal).collect();
+ Some(format!("[{}]", values?.join(", ")))
+ }
+ _ => None,
+ }
+ }
+
+ /// Where code changes this value or one it holds
+ fn change(&self) -> Option> {
+ match self {
+ Self::Changed(change) => Some(change.clone()),
+ Self::Object(object) => object.values().find_map(Self::change),
+ Self::Record(entries) => entries.iter().find_map(|(_, value)| value.change()),
+ Self::Array(values) => values.iter().find_map(Self::change),
+ _ => None,
+ }
+ }
+
+ /// Whether what `path` leads to (`None` for any key) holds nothing code can
+ /// change
+ fn reaches_only_primitives(&self, path: &[Option]) -> bool {
+ match path.split_first() {
+ None => !self.is_mutable(),
+ Some((Some(key), rest)) => match member_of(self, key) {
+ Some(member) => member.reaches_only_primitives(rest),
+ // A key the build knows is missing reads `undefined`
+ None => !matches!(self, Self::Object(_)),
+ },
+ Some((None, rest)) => match self {
+ Self::Record(entries) => entries
+ .iter()
+ .all(|(_, value)| value.reaches_only_primitives(rest)),
+ Self::Array(values) => values
+ .iter()
+ .all(|value| value.reaches_only_primitives(rest)),
+ value => !value.is_mutable(),
+ },
+ }
+ }
+}
+
+/// Where code changes the object or array a binding holds, or hands it to code
+/// that may change it
+#[derive(Debug)]
+pub(crate) struct Change {
+ /// The binding, as the module changing it names it
+ pub name: String,
+ pub site: ChangeSite,
+ /// Handed on rather than changed there
+ pub handed: bool,
+}
+
+#[derive(Debug)]
+pub(crate) enum ChangeSite {
+ /// An offset in the file extracted
+ Here(u32),
+ /// `file:line:column` in a module it imports
+ In(String),
+}
+
+/// What inlining found: the files read, and the `StyleX` values imported from
+/// other modules, by the name the program binds them to
+#[derive(Default)]
+pub(crate) struct Inlined {
+ pub dependencies: BTreeSet,
+ pub stylex_vars: FxHashMap>,
+ pub stylex_themes: FxHashMap,
+ pub unknown: Unknown,
+ pub changed: Changed,
+}
+
+/// Bindings styles read that hold an object or array code changes, whole or
+/// in some member
+#[derive(Default, Clone)]
+pub(crate) struct Changed {
+ whole: FxHashMap>,
+ holding: FxHashMap,
+}
+
+impl Changed {
+ pub(crate) fn is_empty(&self) -> bool {
+ self.whole.is_empty() && self.holding.is_empty()
+ }
+
+ /// Whether `expression` (`x`, `x.y` or `x[y]`) reads, whole or in part, an
+ /// object or array code changes
+ pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool {
+ let mut path = Vec::new();
+ let mut expression = expression;
+ let name = loop {
+ match expression {
+ Expression::StaticMemberExpression(member) => {
+ path.push(Some(member.property.name.as_str()));
+ expression = &member.object;
+ }
+ Expression::ComputedMemberExpression(member) => {
+ path.push(None);
+ expression = &member.object;
+ }
+ Expression::Identifier(identifier) => break identifier.name.as_str(),
+ _ => return false,
+ }
+ };
+ if self.whole.contains_key(name) {
+ return true;
+ }
+ let Some(mut value) = self.holding.get(name) else {
+ return false;
+ };
+ for key in path.iter().rev() {
+ let Some(key) = key else {
+ return true;
+ };
+ match member_value(value, key) {
+ Some(member) => value = member,
+ None => return false,
+ }
+ }
+ value.change().is_some()
+ }
+
+ /// The changes of the bindings `message` names, by the name the module
+ /// changing them uses
+ pub(crate) fn named_in(&self, message: &str) -> Vec> {
+ let is_part = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
+ let mut changes: Vec> = self
+ .whole
+ .iter()
+ .map(|(name, change)| (name, Some(change.clone())))
+ .chain(
+ self.holding
+ .iter()
+ .map(|(name, value)| (name, value.change())),
+ )
+ .filter(|(name, _)| {
+ message.match_indices(name.as_str()).any(|(index, _)| {
+ !message[..index].ends_with(is_part)
+ && !message[index + name.len()..].starts_with(is_part)
+ })
+ })
+ .filter_map(|(_, change)| change)
+ .collect();
+ changes.sort_unstable_by(|a, b| a.name.cmp(&b.name));
+ changes.dedup_by(|a, b| Rc::ptr_eq(a, b));
+ changes
+ }
+}
+
+/// Module-level bindings styles read whose value only running the module
+/// gives, whole or in part
+#[derive(Default, Clone)]
+pub(crate) struct Unknown {
+ names: FxHashSet,
+ /// Objects and namespaces the build knows some members of
+ partial: FxHashMap,
+}
+
+impl Unknown {
+ pub(crate) fn is_empty(&self) -> bool {
+ self.names.is_empty() && self.partial.is_empty()
+ }
+
+ /// Whether `expression` (`x`, `x.y.z`, `x[y]` or a call of one) reads what
+ /// only running the module gives
+ pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool {
+ let mut path = Vec::new();
+ let mut expression = expression;
+ loop {
+ match expression {
+ Expression::StaticMemberExpression(member) => {
+ path.push(member.property.name.as_str());
+ expression = &member.object;
+ }
+ Expression::ComputedMemberExpression(member) => {
+ return crate::utils::binding_root(&member.object).is_some_and(|name| {
+ self.names.contains(name) || self.partial.contains_key(name)
+ });
+ }
+ Expression::CallExpression(call) => return self.read_by(&call.callee),
+ Expression::Identifier(identifier) => {
+ let name = identifier.name.as_str();
+ if self.names.contains(name) {
+ return true;
+ }
+ let Some(mut value) = self.partial.get(name) else {
+ return false;
+ };
+ for key in path.iter().rev() {
+ match member_value(value, key) {
+ Some(member) => value = member,
+ None => break,
+ }
+ }
+ return matches!(value, Constant::Object(_)) || value.has_function();
+ }
+ _ => return false,
+ }
+ }
+ }
+}
+
+fn member_value<'c>(value: &'c Constant, key: &str) -> Option<&'c Constant> {
+ match value {
+ Constant::Object(object) => object.get(key),
+ Constant::Record(entries) => entries
+ .iter()
+ .find(|(name, _)| name == key)
+ .map(|(_, value)| value),
+ _ => None,
+ }
+}
+
+enum Imported {
+ Named(String),
+ Namespace,
+}
+
+/// Inline the primitive constants `program` reads in styles, its own
+/// module-level `const`s and those it imports, returning the files read
+pub(crate) fn inline_constants<'a>(
+ ast_builder: &AstBuilder<'a>,
+ program: &mut Program<'a>,
+ filename: &str,
+ option: &ExtractOption,
+ resolver: Option<&ModuleResolver>,
+) -> Inlined {
+ let is_style_package =
+ |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE;
+ let mut style_roots = FxHashSet::default();
+ let mut apis = StyleApis::default();
+ for statement in &program.body {
+ if let Statement::ImportDeclaration(import) = statement
+ && is_style_package(&import.source.value)
+ {
+ let stylex = import.source.value == crate::STYLEX_PACKAGE;
+ for specifier in import.specifiers.iter().flatten() {
+ let local = specifier.local().name.as_str();
+ style_roots.insert(local);
+ match specifier {
+ ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
+ if takes_style_objects(stylex, &specifier.imported.name()) {
+ apis.functions.insert(local);
+ }
+ }
+ _ => {
+ apis.namespaces.insert(local, stylex);
+ }
+ }
+ }
+ }
+ }
+ if style_roots.is_empty() {
+ return Inlined::default();
+ }
+ let mut read = StyleReads {
+ style_roots: &style_roots,
+ names: FxHashSet::default(),
+ depth: 0,
+ };
+ read.visit_program(program);
+ if read.names.is_empty() {
+ return Inlined::default();
+ }
+ let mut modules = Modules {
+ resolver,
+ option,
+ exports: FxHashMap::default(),
+ loading: Vec::new(),
+ };
+ let mut symbols: FxHashMap = FxHashMap::default();
+ let mut inlined = Inlined::default();
+ let (scoping, reads_math) = {
+ let mut scope = ModuleScope::new(filename, program, None);
+ scope
+ .style_names
+ .extend(style_roots.iter().map(ToString::to_string));
+ let mut bindings: FxHashMap<&str, Vec<&Cell>>> = FxHashMap::default();
+ for statement in &program.body {
+ let declaration = match statement {
+ Statement::ImportDeclaration(import) => {
+ if !is_style_package(&import.source.value) {
+ scope.import(import);
+ for specifier in import.specifiers.iter().flatten() {
+ let local = specifier.local();
+ bindings
+ .entry(local.name.as_str())
+ .or_default()
+ .push(&local.symbol_id);
+ }
+ } else if import.source.value != crate::STYLEX_PACKAGE {
+ scope.style_imports.extend(
+ import
+ .specifiers
+ .iter()
+ .flatten()
+ .map(|specifier| specifier.local().name.to_string()),
+ );
+ }
+ continue;
+ }
+ Statement::VariableDeclaration(declaration) => declaration,
+ Statement::ExportDeclaration(export) => match &export.declaration {
+ oxc_ast::ast::Declaration::VariableDeclaration(declaration) => declaration,
+ oxc_ast::ast::Declaration::TSEnumDeclaration(declaration) => {
+ scope.declare_enum(declaration);
+ bindings
+ .entry(declaration.id.name.as_str())
+ .or_default()
+ .push(&declaration.id.symbol_id);
+ continue;
+ }
+ _ => continue,
+ },
+ Statement::TSEnumDeclaration(declaration) => {
+ scope.declare_enum(declaration);
+ bindings
+ .entry(declaration.id.name.as_str())
+ .or_default()
+ .push(&declaration.id.symbol_id);
+ continue;
+ }
+ _ => continue,
+ };
+ scope.declare(declaration);
+ for declarator in &declaration.declarations {
+ if let oxc_ast::ast::BindingPattern::BindingIdentifier(identifier) = &declarator.id
+ {
+ bindings
+ .entry(identifier.name.as_str())
+ .or_default()
+ .push(&identifier.symbol_id);
+ }
+ }
+ }
+ // Scoping is only worth building when a style reads a name that may
+ // hold a constant
+ let reads_math = read.names.contains("Math") && !scope.binds("Math");
+ if !reads_math && !read.names.iter().any(|name| scope.binds(name)) {
+ return Inlined::default();
+ }
+ let scoping = SemanticBuilder::new()
+ .build(program)
+ .semantic
+ .into_scoping();
+ for name in &read.names {
+ let bound = scope.binds(name);
+ let constant = scope.lookup(&mut modules, name);
+ match &constant {
+ Some(Constant::Changed(change)) => {
+ inlined.changed.whole.insert(name.clone(), change.clone());
+ continue;
+ }
+ Some(constant) if constant.change().is_some() => {
+ inlined
+ .changed
+ .holding
+ .insert(name.clone(), constant.clone());
+ }
+ _ => {}
+ }
+ match &constant {
+ None if bound => {
+ inlined.unknown.names.insert(name.clone());
+ }
+ Some(object)
+ if bound
+ && (matches!(object, Constant::Object(_)) || object.has_function()) =>
+ {
+ inlined.unknown.partial.insert(name.clone(), object.clone());
+ }
+ _ => {}
+ }
+ let Some(constant) = constant else {
+ continue;
+ };
+ match &constant {
+ Constant::Vars(vars) => {
+ inlined
+ .stylex_vars
+ .insert(name.clone(), vars.as_ref().clone());
+ }
+ Constant::Theme(class) => {
+ inlined.stylex_themes.insert(name.clone(), class.clone());
+ }
+ _ => {}
+ }
+ for symbol in bindings.get(name.as_str()).into_iter().flatten() {
+ symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone())));
+ }
+ }
+ (scoping, reads_math)
+ };
+ inlined.dependencies = modules.exports.into_keys().collect();
+ if !symbols.is_empty() || reads_math {
+ Inline {
+ ast_builder,
+ scoping: &scoping,
+ symbols: &symbols,
+ style_roots: &style_roots,
+ apis: &apis,
+ objects: false,
+ styles: false,
+ }
+ .visit_program(program);
+ }
+ inlined
+}
+
+/// Tells which top-level bindings of a program hold, whole or in some member,
+/// an object or array code changes, reading only the modules they come from
+pub(crate) struct ChangeCheck<'p, 'a, 'r> {
+ scope: std::cell::RefCell>,
+ modules: std::cell::RefCell>,
+}
+
+impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> {
+ pub(crate) fn new(
+ program: &'p Program<'a>,
+ filename: &'p str,
+ option: &'r ExtractOption,
+ resolver: Option<&'r ModuleResolver>,
+ ) -> Self {
+ let mut scope = ModuleScope::new(filename, program, None);
+ for statement in &program.body {
+ match statement {
+ Statement::ImportDeclaration(import) => scope.import(import),
+ Statement::VariableDeclaration(declaration) => {
+ scope.declare(declaration);
+ }
+ Statement::ExportDeclaration(export) => match &export.declaration {
+ oxc_ast::ast::Declaration::VariableDeclaration(declaration) => {
+ scope.declare(declaration);
+ }
+ oxc_ast::ast::Declaration::TSEnumDeclaration(declaration) => {
+ scope.declare_enum(declaration);
+ }
+ _ => {}
+ },
+ Statement::TSEnumDeclaration(declaration) => {
+ scope.declare_enum(declaration);
+ }
+ _ => {}
+ }
+ }
+ Self {
+ scope: std::cell::RefCell::new(scope),
+ modules: std::cell::RefCell::new(Modules {
+ resolver,
+ option,
+ exports: FxHashMap::default(),
+ loading: Vec::new(),
+ }),
+ }
+ }
+
+ /// The value of `name` as JavaScript source, when the build knows all of
+ /// it and no code changes it
+ pub(crate) fn known(&self, name: &str) -> Option {
+ let mut modules = self.modules.borrow_mut();
+ self.scope
+ .borrow_mut()
+ .lookup(&mut modules, name)?
+ .js_literal()
+ }
+
+ /// The modules read for the values of imports
+ pub(crate) fn dependencies(&self) -> BTreeSet {
+ self.modules.borrow().exports.keys().cloned().collect()
+ }
+
+ pub(crate) fn is_changed(&self, name: &str) -> bool {
+ let mut modules = self.modules.borrow_mut();
+ let mut scope = self.scope.borrow_mut();
+ scope.change(&mut modules, name).is_some()
+ || scope
+ .lookup(&mut modules, name)
+ .is_some_and(|value| value.change().is_some())
+ }
+}
+
+/// The local names of the style APIs that read style objects at build time
+#[derive(Default)]
+struct StyleApis<'s> {
+ functions: FxHashSet<&'s str>,
+ /// Namespace and default imports, `true` for `StyleX`
+ namespaces: FxHashMap<&'s str, bool>,
+}
+
+fn takes_style_objects(stylex: bool, export: &str) -> bool {
+ if stylex {
+ StylexFunction::from_export_name(export)
+ .is_some_and(|function| function.requirement().is_some())
+ } else {
+ matches!(
+ export,
+ "css" | "globalCss" | "keyframes" | "createGlobalStyle" | "styled"
+ )
+ }
+}
+
+impl StyleApis<'_> {
+ /// Whether calling `callee` reads its arguments as style objects:
+ /// `css(...)`, `styled.div(...)`, `styled(Link).attrs(...)`,
+ /// `stylex.create(...)`
+ fn reads(&self, callee: &Expression<'_>) -> bool {
+ let mut expression = callee;
+ let mut member = None;
+ loop {
+ match crate::utils::unwrap_syntax_only(expression) {
+ Expression::Identifier(identifier) => {
+ let name = identifier.name.as_str();
+ return self.functions.contains(name)
+ || self.namespaces.get(name).is_some_and(|stylex| {
+ member.is_some_and(|member| takes_style_objects(*stylex, member))
+ });
+ }
+ Expression::StaticMemberExpression(inner) => {
+ member = Some(inner.property.name.as_str());
+ expression = &inner.object;
+ }
+ Expression::CallExpression(call) => {
+ member = None;
+ expression = &call.callee;
+ }
+ _ => return false,
+ }
+ }
+ }
+}
+/// Names read inside the props of the package's components and the arguments
+/// of its functions
+struct StyleReads<'s> {
+ style_roots: &'s FxHashSet<&'s str>,
+ names: FxHashSet,
+ depth: usize,
+}
+
+/// Whether `expression` is a style API: a root the package gives, or a member
+/// or call of one
+fn is_style_root(style_roots: &FxHashSet<&str>, expression: &Expression<'_>) -> bool {
+ match expression {
+ Expression::Identifier(identifier) => style_roots.contains(identifier.name.as_str()),
+ Expression::StaticMemberExpression(member) => is_style_root(style_roots, &member.object),
+ Expression::CallExpression(call) => is_style_root(style_roots, &call.callee),
+ _ => false,
+ }
+}
+
+impl StyleReads<'_> {
+ fn is_style_root(&self, expression: &Expression<'_>) -> bool {
+ is_style_root(self.style_roots, expression)
+ }
+
+ fn reading(&mut self, style: bool, walk: impl FnOnce(&mut Self) -> T) -> T {
+ self.depth += usize::from(style);
+ let result = walk(self);
+ self.depth -= usize::from(style);
+ result
+ }
+}
+
+impl<'a> Visit<'a> for StyleReads<'_> {
+ fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) {
+ if self.depth > 0 {
+ self.names.insert(identifier.name.to_string());
+ }
+ }
+
+ fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
+ self.visit_expression(&call.callee);
+ let style = self.is_style_root(&call.callee);
+ self.reading(style, |reads| {
+ for argument in &call.arguments {
+ reads.visit_argument(argument);
+ }
+ });
+ }
+
+ fn visit_tagged_template_expression(
+ &mut self,
+ tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>,
+ ) {
+ self.visit_expression(&tagged.tag);
+ let style = self.is_style_root(&tagged.tag);
+ self.reading(style, |reads| reads.visit_template_literal(&tagged.quasi));
+ }
+
+ fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) {
+ let style = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root));
+ self.reading(style, |reads| {
+ for attribute in &element.attributes {
+ match attribute {
+ JSXAttributeItem::Attribute(attribute) => {
+ if let Some(value) = &attribute.value {
+ reads.visit_jsx_attribute_value(value);
+ }
+ }
+ JSXAttributeItem::SpreadAttribute(spread) => {
+ reads.visit_expression(&spread.argument);
+ }
+ }
+ }
+ });
+ }
+}
+
+/// The name `` or `` starts with
+pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> {
+ match name {
+ JSXElementName::IdentifierReference(identifier) => Some(identifier.name.as_str()),
+ JSXElementName::MemberExpression(member) => {
+ let mut object = &member.object;
+ while let oxc_ast::ast::JSXMemberExpressionObject::MemberExpression(inner) = object {
+ object = &inner.object;
+ }
+ match object {
+ oxc_ast::ast::JSXMemberExpressionObject::IdentifierReference(identifier) => {
+ Some(identifier.name.as_str())
+ }
+ _ => None,
+ }
+ }
+ _ => None,
+ }
+}
+
+/// The constant exports of the modules read, by path
+struct Modules<'r> {
+ resolver: Option<&'r ModuleResolver>,
+ option: &'r ExtractOption,
+ exports: FxHashMap>>,
+ loading: Vec,
+}
+
+impl Modules<'_> {
+ fn exports(
+ &mut self,
+ specifier: &str,
+ importer: &str,
+ ) -> Option>> {
+ let module = (self.resolver?)(specifier, importer)?;
+ if let Some(exports) = self.exports.get(&module.path) {
+ return Some(exports.clone());
+ }
+ if self.loading.contains(&module.path) {
+ return None;
+ }
+ self.loading.push(module.path.clone());
+ let exports = Rc::new(self.read(&module.path, &module.code));
+ self.loading.pop();
+ self.exports.insert(module.path, exports.clone());
+ Some(exports)
+ }
+
+ fn read(&mut self, path: &str, code: &str) -> FxHashMap {
+ let allocator = Allocator::default();
+ let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts());
+ let program = Parser::new(&allocator, code, source_type).parse().program;
+ let mut scope = ModuleScope::new(path, &program, Some(code));
+ let mut exports = FxHashMap::default();
+ let mut exported: Vec<(String, String)> = Vec::new();
+ let mut commonjs = CommonJs::new(&program);
+ for statement in &program.body {
+ match statement {
+ Statement::ImportDeclaration(import) => scope.import(import),
+ Statement::VariableDeclaration(declaration) => {
+ scope.require(declaration);
+ scope.declare(declaration);
+ }
+ Statement::ExpressionStatement(statement) => {
+ commonjs.assign(&mut scope, self, &statement.expression);
+ }
+ Statement::ExportDeclaration(export) => match &export.declaration {
+ oxc_ast::ast::Declaration::VariableDeclaration(declaration) => {
+ for name in scope.declare(declaration) {
+ exported.push((name.clone(), name));
+ }
+ }
+ oxc_ast::ast::Declaration::TSEnumDeclaration(declaration) => {
+ let name = scope.declare_enum(declaration);
+ exported.push((name.clone(), name));
+ }
+ _ => {}
+ },
+ Statement::TSEnumDeclaration(declaration) => {
+ scope.declare_enum(declaration);
+ }
+ Statement::ExportNamedDeclaration(export) => {
+ for specifier in &export.specifiers {
+ exported.push((
+ specifier.exported.name().to_string(),
+ specifier.local.name().to_string(),
+ ));
+ }
+ }
+ Statement::ExportFromDeclaration(export) => {
+ if let Some(from) = self.exports(&export.source.value, path) {
+ for specifier in &export.specifiers {
+ if let Some(constant) = from.get(specifier.local.name().as_str()) {
+ exports.insert(
+ specifier.exported.name().to_string(),
+ constant.clone(),
+ );
+ }
+ }
+ }
+ }
+ Statement::ExportAllDeclaration(export) => {
+ if let Some(from) = self.exports(&export.source.value, path) {
+ match &export.exported {
+ Some(exported) => {
+ exports.insert(exported.name().to_string(), Constant::Object(from));
+ }
+ None => {
+ for (name, constant) in from.iter() {
+ if name != "default" {
+ exports
+ .entry(name.clone())
+ .or_insert_with(|| constant.clone());
+ }
+ }
+ }
+ }
+ }
+ }
+ Statement::ExportDefaultDeclaration(export) => {
+ if let Some(expression) = export.declaration.as_expression()
+ && let Some(constant) = scope.evaluate(self, expression)
+ {
+ exports.insert("default".to_string(), constant);
+ }
+ }
+ _ => {}
+ }
+ }
+ for (exported, local) in exported {
+ if let Some(constant) = scope.lookup(self, &local) {
+ exports.insert(exported, constant);
+ }
+ }
+ commonjs.finish(&mut exports);
+ exports
+ }
+}
+/// The exports of a `CommonJS` module. A property counts only when the module
+/// assigns it once, as anything assigned again may change after it is read.
+#[derive(Default)]
+struct CommonJs {
+ /// Assignments to each exported property anywhere in the module, `*` for
+ /// `module.exports` itself; `void 0` placeholders do not count
+ writes: FxHashMap,
+ exports: FxHashMap,
+ whole: Option,
+ es_module: bool,
+ seen: bool,
+}
+
+impl CommonJs {
+ fn new(program: &Program<'_>) -> Self {
+ let mut commonjs = Self::default();
+ commonjs.visit_program(program);
+ commonjs
+ }
+
+ fn assign(
+ &mut self,
+ scope: &mut ModuleScope<'_, '_>,
+ modules: &mut Modules<'_>,
+ expression: &Expression<'_>,
+ ) {
+ if let Expression::CallExpression(call) = expression
+ && is_define_es_module(call)
+ {
+ self.es_module = true;
+ return;
+ }
+ let Expression::AssignmentExpression(assignment) = expression else {
+ return;
+ };
+ let Some(key) = assignment
+ .left
+ .as_simple_assignment_target()
+ .and_then(|target| target.as_member_expression())
+ .and_then(commonjs_target)
+ else {
+ return;
+ };
+ self.seen = true;
+ if key == "__esModule" {
+ self.es_module = true;
+ return;
+ }
+ if self.writes.get(&key) != Some(&1) {
+ return;
+ }
+ let Some(value) = scope.evaluate(modules, &assignment.right) else {
+ return;
+ };
+ if key == "*" {
+ // What `module.exports` holds becomes the module's exports
+ self.whole = Some(match value {
+ Constant::Record(entries) => {
+ Constant::Object(Rc::new(entries.iter().cloned().collect()))
+ }
+ value => value,
+ });
+ } else {
+ self.exports.insert(key, value);
+ }
+ }
+
+ fn finish(self, exports: &mut FxHashMap) {
+ if !self.seen {
+ return;
+ }
+ let mut properties = FxHashMap::default();
+ if let Some(Constant::Object(object)) = &self.whole {
+ for (key, value) in object.iter() {
+ if !self.writes.contains_key(key) {
+ properties.insert(key.clone(), value.clone());
+ }
+ }
+ }
+ properties.extend(self.exports);
+ let default = if self.es_module {
+ properties.get("default").cloned()
+ } else {
+ match self.whole {
+ Some(Constant::Object(_)) | None => Some(Constant::Object(Rc::new(
+ properties
+ .clone()
+ .into_iter()
+ .filter(|(key, _)| key != "default")
+ .collect(),
+ ))),
+ primitive => primitive,
+ }
+ };
+ exports.extend(properties);
+ if let Some(default) = default {
+ exports.insert("default".to_string(), default);
+ }
+ }
+}
+
+impl<'a> Visit<'a> for CommonJs {
+ fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) {
+ let mut value = &assignment.right;
+ while let Expression::AssignmentExpression(inner) = value {
+ value = &inner.right;
+ }
+ let placeholder = matches!(value, Expression::UnaryExpression(unary)
+ if unary.operator == oxc_syntax::operator::UnaryOperator::Void);
+ if !placeholder
+ && let Some(key) = assignment
+ .left
+ .as_simple_assignment_target()
+ .and_then(|target| target.as_member_expression())
+ .and_then(commonjs_target)
+ {
+ *self.writes.entry(key).or_default() += 1;
+ }
+ walk::walk_assignment_expression(self, assignment);
+ }
+}
+
+/// `exports.x` / `module.exports.x` -> `x`, `module.exports` -> `*`
+fn commonjs_target(member: &oxc_ast::ast::MemberExpression<'_>) -> Option {
+ let key = member.static_property_name()?;
+ let object = member.object();
+ if matches!(object, Expression::Identifier(identifier) if identifier.name == "module") {
+ return (key == "exports").then(|| "*".to_string());
+ }
+ let is_exports = match object {
+ Expression::Identifier(identifier) => identifier.name == "exports",
+ Expression::StaticMemberExpression(inner) => {
+ inner.property.name == "exports"
+ && matches!(&inner.object, Expression::Identifier(identifier) if identifier.name == "module")
+ }
+ _ => false,
+ };
+ is_exports.then(|| key.to_string())
+}
+
+/// `Object.defineProperty(exports, '__esModule', ...)`
+fn is_define_es_module(call: &oxc_ast::ast::CallExpression<'_>) -> bool {
+ matches!(&call.callee, Expression::StaticMemberExpression(callee)
+ if callee.property.name == "defineProperty"
+ && matches!(&callee.object, Expression::Identifier(object) if object.name == "Object"))
+ && matches!(call.arguments.first(), Some(Argument::Identifier(target)) if target.name == "exports")
+ && matches!(call.arguments.get(1), Some(Argument::StringLiteral(key)) if key.value == "__esModule")
+}
+
+/// The top-level constants and imports of a module, each constant evaluated
+/// when first read
+struct ModuleScope<'p, 'a> {
+ path: &'p str,
+ program: &'p Program<'a>,
+ /// The code of an imported module, where changes are located as
+ /// `file:line:column`; the file extracted keeps offsets
+ source: Option<&'p str>,
+ locals: FxHashMap,
+ declarations: FxHashMap>,
+ imports: FxHashMap,
+ style_imports: FxHashSet,
+ /// Style APIs besides the imports, which never run what they are given
+ style_names: FxHashSet,
+ uses: Option>>>,
+ changes: FxHashMap>>,
+}
+
+impl<'p, 'a> ModuleScope<'p, 'a> {
+ fn new(path: &'p str, program: &'p Program<'a>, source: Option<&'p str>) -> Self {
+ Self {
+ path,
+ program,
+ source,
+ locals: FxHashMap::default(),
+ declarations: FxHashMap::default(),
+ imports: FxHashMap::default(),
+ style_imports: FxHashSet::default(),
+ style_names: FxHashSet::default(),
+ uses: None,
+ changes: FxHashMap::default(),
+ }
+ }
+
+ fn is_style_api(&self, modules: &Modules<'_>, callee: &Expression<'_>) -> bool {
+ let mut expression = callee;
+ let name = loop {
+ match expression {
+ Expression::Identifier(identifier) => break identifier.name.as_str(),
+ Expression::StaticMemberExpression(member) => expression = &member.object,
+ Expression::CallExpression(call) => expression = &call.callee,
+ _ => return false,
+ }
+ };
+ self.style_imports.contains(name)
+ || self.imports.get(name).is_some_and(|(source, _)| {
+ source != crate::STYLEX_PACKAGE
+ && (source.starts_with(modules.option.package.as_str())
+ || modules.option.import_aliases.contains_key(source))
+ })
+ }
+
+ fn is_style_import(&self, option: &ExtractOption, name: &str) -> bool {
+ self.style_imports.contains(name)
+ || self.style_names.contains(name)
+ || self.imports.get(name).is_some_and(|(source, _)| {
+ source == crate::STYLEX_PACKAGE
+ || source.starts_with(option.package.as_str())
+ || option.import_aliases.contains_key(source)
+ })
+ }
+
+ fn site(&self, name: &str, at: u32, handed: bool) -> Rc {
+ Rc::new(Change {
+ name: name.to_string(),
+ site: match self.source {
+ Some(code) => ChangeSite::In(crate::locate(self.path, code, at as usize)),
+ None => ChangeSite::Here(at),
+ },
+ handed,
+ })
+ }
+
+ /// Where code changes the object or array `name` holds, directly or
+ /// through the `const` it is put in; handing on a value the build does
+ /// not know, such as a function, does not count. A namespace import can
+ /// only have its members changed
+ fn change(&mut self, modules: &mut Modules<'_>, name: &str) -> Option> {
+ if let Some(change) = self.changes.get(name) {
+ return change.clone();
+ }
+ self.changes.insert(name.to_string(), None);
+ let uses = if let Some(uses) = &self.uses {
+ uses.clone()
+ } else {
+ let option = modules.option;
+ let uses = Rc::new(crate::mutations::uses(self.program, &|name| {
+ self.is_style_import(option, name)
+ }));
+ self.uses = Some(uses.clone());
+ uses
+ };
+ let namespace = matches!(self.imports.get(name), Some((_, Imported::Namespace)));
+ let value = self.lookup_raw(modules, name);
+ let mut change = None;
+ for found in uses.get(name).into_iter().flatten() {
+ change = match found {
+ crate::mutations::Use::Changes { at, depth } => {
+ (!namespace || *depth > 1).then(|| self.site(name, *at, false))
+ }
+ crate::mutations::Use::Calls { at, path } => (!namespace
+ && value
+ .as_ref()
+ .is_some_and(|value| !value.reaches_only_primitives(path)))
+ .then(|| self.site(name, *at, true)),
+ crate::mutations::Use::Escapes { at, path, into } => {
+ let mut path = path.clone();
+ if namespace && path.is_empty() {
+ path.push(None);
+ }
+ if value
+ .as_ref()
+ .is_none_or(|value| value.reaches_only_primitives(&path))
+ {
+ continue;
+ }
+ match into {
+ Some(into) => self.change(modules, into),
+ None => Some(self.site(name, *at, true)),
+ }
+ }
+ };
+ if change.is_some() {
+ break;
+ }
+ }
+ self.changes.insert(name.to_string(), change.clone());
+ change
+ }
+
+ fn binds(&self, name: &str) -> bool {
+ self.declarations.contains_key(name)
+ || self.imports.contains_key(name)
+ || self.locals.contains_key(name)
+ }
+
+ fn is_global_math(&self, expression: &Expression<'_>) -> bool {
+ matches!(expression, Expression::Identifier(identifier)
+ if identifier.name == "Math" && !self.binds("Math"))
+ }
+
+ /// Record the members of an enum whose values are literals, up to the
+ /// first that is computed, returning its name
+ fn declare_enum(&mut self, declaration: &oxc_ast::ast::TSEnumDeclaration<'_>) -> String {
+ let name = declaration.id.name.to_string();
+ if declaration.declare {
+ return name;
+ }
+ let mut members = FxHashMap::default();
+ let mut next = Some(0.0);
+ for member in &declaration.body.members {
+ let value = match &member.initializer {
+ None => next.map(Constant::Number),
+ Some(Expression::StringLiteral(literal)) => {
+ Some(Constant::String(literal.value.to_string()))
+ }
+ Some(initializer) => {
+ crate::utils::js_number_literal(initializer).map(Constant::Number)
+ }
+ };
+ let Some(value) = value else {
+ break;
+ };
+ next = match &value {
+ Constant::Number(number) => Some(number + 1.0),
+ _ => None,
+ };
+ members.insert(member.id.static_name().to_string(), value);
+ }
+ self.locals
+ .insert(name.clone(), Constant::Object(Rc::new(members)));
+ name
+ }
+
+ fn import(&mut self, import: &oxc_ast::ast::ImportDeclaration<'_>) {
+ for specifier in import.specifiers.iter().flatten() {
+ let imported = match specifier {
+ ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
+ Imported::Named(specifier.imported.name().to_string())
+ }
+ ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {
+ Imported::Named("default".to_string())
+ }
+ ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => Imported::Namespace,
+ };
+ self.imports.insert(
+ specifier.local().name.to_string(),
+ (import.source.value.to_string(), imported),
+ );
+ }
+ }
+ /// `const x = require('m')` and `const { a, b: c } = require('m')`
+ fn require(&mut self, declaration: &oxc_ast::ast::VariableDeclaration<'_>) {
+ for declarator in &declaration.declarations {
+ let Some(Expression::CallExpression(call)) = &declarator.init else {
+ continue;
+ };
+ let (Expression::Identifier(callee), [Argument::StringLiteral(source)]) =
+ (&call.callee, call.arguments.as_slice())
+ else {
+ continue;
+ };
+ if callee.name != "require" {
+ continue;
+ }
+ let source = source.value.to_string();
+ match &declarator.id {
+ oxc_ast::ast::BindingPattern::BindingIdentifier(identifier) => {
+ self.imports
+ .insert(identifier.name.to_string(), (source, Imported::Namespace));
+ }
+ oxc_ast::ast::BindingPattern::ObjectPattern(pattern) => {
+ for property in &pattern.properties {
+ if let Some(key) = property.key.static_name()
+ && let Some(local) = property.value.get_identifier_name()
+ {
+ self.imports.insert(
+ local.to_string(),
+ (source.clone(), Imported::Named(key.to_string())),
+ );
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+
+ /// Record the `const`s of `declaration`, returning the names it binds
+ fn declare(&mut self, declaration: &'p oxc_ast::ast::VariableDeclaration<'a>) -> Vec {
+ let mut names = Vec::new();
+ if declaration.kind != VariableDeclarationKind::Const {
+ return names;
+ }
+ for declarator in &declaration.declarations {
+ if let Some(name) = declarator.id.get_identifier_name()
+ && let Some(init) = &declarator.init
+ {
+ self.declarations.insert(name.to_string(), init);
+ names.push(name.to_string());
+ }
+ }
+ names
+ }
+
+ /// What `name` holds, or where code changes it when it is an object or
+ /// array the module changes
+ fn lookup(&mut self, modules: &mut Modules<'_>, name: &str) -> Option {
+ let value = self.lookup_raw(modules, name)?;
+ if value.is_mutable()
+ && let Some(change) = self.change(modules, name)
+ {
+ return Some(Constant::Changed(change));
+ }
+ Some(value)
+ }
+
+ fn lookup_raw(&mut self, modules: &mut Modules<'_>, name: &str) -> Option {
+ if let Some(constant) = self.locals.get(name) {
+ return Some(constant.clone());
+ }
+ // Taken out while it is evaluated, so a constant reading itself stops
+ if let Some(init) = self.declarations.remove(name)
+ && let Some(constant) = self.evaluate(modules, init)
+ {
+ self.locals.insert(name.to_string(), constant.clone());
+ return Some(constant);
+ }
+ let (source, imported) = self.imports.get(name)?;
+ let exports = modules.exports(source, self.path)?;
+ match imported {
+ Imported::Named(export) => exports.get(export).cloned(),
+ Imported::Namespace => Some(Constant::Object(exports)),
+ }
+ }
+
+ fn stylex_function(&self, callee: &Expression<'_>) -> Option {
+ let (name, member) = match callee {
+ Expression::Identifier(identifier) => (identifier.name.as_str(), None),
+ Expression::StaticMemberExpression(member) => match &member.object {
+ Expression::Identifier(object) => {
+ (object.name.as_str(), Some(member.property.name.as_str()))
+ }
+ _ => return None,
+ },
+ _ => return None,
+ };
+ let export = match (self.imports.get(name)?, member) {
+ ((source, _), _) if source != crate::STYLEX_PACKAGE => return None,
+ ((_, Imported::Named(export)), None) => export.as_str(),
+ ((_, Imported::Namespace), Some(export)) => export,
+ ((_, Imported::Named(export)), Some(member)) if export == "default" => member,
+ _ => return None,
+ };
+ StylexFunction::from_export_name(export)
+ }
+
+ /// A value `StyleX` gives when this module's own extraction reads it, with
+ /// the names that extraction generates
+ fn evaluate_stylex(
+ &mut self,
+ modules: &mut Modules<'_>,
+ call: &oxc_ast::ast::CallExpression<'_>,
+ ) -> Option {
+ let function = self.stylex_function(&call.callee)?;
+ let split_filename = crate::css_bucket(self.path, modules.option);
+ match (function, call.arguments.as_slice()) {
+ (
+ function @ (StylexFunction::DefineVars | StylexFunction::CreateThemeContract),
+ [Argument::ObjectExpression(object)],
+ ) => {
+ let mut vars = FxHashMap::default();
+ for property in &object.properties {
+ if let ObjectPropertyKind::ObjectProperty(property) = property
+ && let Some(key) = self.property_key(modules, &property.key)
+ && (function == StylexFunction::CreateThemeContract
+ || self.variable_value(modules, &property.value))
+ {
+ let variable = crate::stylex::define_vars_variable(
+ self.path,
+ &key,
+ split_filename.as_deref(),
+ );
+ vars.insert(key, variable);
+ }
+ }
+ Some(Constant::Vars(Rc::new(vars)))
+ }
+ (StylexFunction::DefineConsts, [Argument::ObjectExpression(object)]) => {
+ let mut values = FxHashMap::default();
+ for property in &object.properties {
+ if let ObjectPropertyKind::ObjectProperty(property) = property
+ && let Some(key) = self.property_key(modules, &property.key)
+ && let Some(value) = self.literal_text(modules, &property.value)
+ {
+ values.insert(key, Constant::String(value));
+ }
+ }
+ Some(Constant::Object(Rc::new(values)))
+ }
+ (
+ StylexFunction::CreateTheme,
+ [
+ Argument::Identifier(contract),
+ Argument::ObjectExpression(_),
+ ],
+ ) => matches!(self.lookup(modules, &contract.name)?, Constant::Vars(_)).then(|| {
+ Constant::Theme(crate::stylex::create_theme_class(
+ self.path,
+ &contract.name,
+ split_filename.as_deref(),
+ ))
+ }),
+ _ => None,
+ }
+ }
+
+ fn property_key(
+ &mut self,
+ modules: &mut Modules<'_>,
+ key: &oxc_ast::ast::PropertyKey<'_>,
+ ) -> Option {
+ match crate::utils::get_string_by_property_key(key) {
+ Some(key) => Some(key),
+ None => self.literal_text(modules, key.as_expression()?),
+ }
+ }
+
+ /// Whether the module's own extraction reads `value` as the value of a
+ /// `StyleX` variable, as [`crate::stylex::variable_values`] reads it once
+ /// constants are inlined
+ fn variable_value(&mut self, modules: &mut Modules<'_>, value: &Expression<'_>) -> bool {
+ let value = crate::stylex::unwrap_types_call(value);
+ if matches!(value, Expression::NullLiteral(_))
+ || self.literal_text(modules, value).is_some()
+ {
+ return true;
+ }
+ let Expression::ObjectExpression(object) = value else {
+ return false;
+ };
+ for property in &object.properties {
+ let ObjectPropertyKind::ObjectProperty(property) = property else {
+ return false;
+ };
+ let Some(key) = self.property_key(modules, &property.key) else {
+ return false;
+ };
+ if (key != "default" && css::at_rule::split_at_rule_key(&key).is_none())
+ || !self.variable_value(modules, &property.value)
+ {
+ return false;
+ }
+ }
+ true
+ }
+
+ /// `value` as the text the module's own extraction reads it as, when that
+ /// extraction knows it
+ fn literal_text(
+ &mut self,
+ modules: &mut Modules<'_>,
+ value: &Expression<'_>,
+ ) -> Option {
+ match crate::utils::get_string_by_literal_expression(value) {
+ Some(text) => Some(text.into_owned()),
+ None => js_string(&self.evaluate(modules, value)?),
+ }
+ }
+
+ fn evaluate(
+ &mut self,
+ modules: &mut Modules<'_>,
+ expression: &Expression<'_>,
+ ) -> Option {
+ match expression {
+ Expression::StringLiteral(literal) => Some(Constant::String(literal.value.to_string())),
+ Expression::NumericLiteral(literal) => Some(Constant::Number(literal.value)),
+ Expression::UnaryExpression(unary)
+ if unary.operator == oxc_syntax::operator::UnaryOperator::UnaryNegation =>
+ {
+ match self.evaluate(modules, &unary.argument)? {
+ Constant::Number(number) => Some(Constant::Number(-number)),
+ _ => None,
+ }
+ }
+ Expression::TemplateLiteral(template) => {
+ let mut values = Vec::with_capacity(template.expressions.len());
+ for expression in &template.expressions {
+ values.push(self.evaluate(modules, expression)?);
+ }
+ fold_template(template, &values)
+ }
+ Expression::BinaryExpression(binary) => {
+ let left = self.evaluate(modules, &binary.left)?;
+ let right = self.evaluate(modules, &binary.right)?;
+ fold_binary(binary.operator, &left, &right)
+ }
+ Expression::NullLiteral(_) => Some(Constant::Null),
+ Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)),
+ Expression::Identifier(identifier)
+ if identifier.name == "undefined" && !self.binds("undefined") =>
+ {
+ Some(Constant::Undefined)
+ }
+ Expression::ObjectExpression(object) => Some(self.object(modules, object)),
+ Expression::ArrayExpression(array) => {
+ let mut values = Vec::with_capacity(array.elements.len());
+ for element in &array.elements {
+ match element {
+ ArrayExpressionElement::SpreadElement(spread) => {
+ let Constant::Array(spread) =
+ self.evaluate(modules, &spread.argument)?
+ else {
+ return None;
+ };
+ values.extend(spread.iter().cloned());
+ }
+ element => values.push(self.evaluate(modules, element.as_expression()?)?),
+ }
+ }
+ Some(Constant::Array(Rc::new(values)))
+ }
+ Expression::ComputedMemberExpression(member) => {
+ let key = js_string(&self.evaluate(modules, &member.expression)?)?;
+ member_of(&self.evaluate(modules, &member.object)?, &key)
+ }
+ Expression::Identifier(identifier) => self.lookup(modules, &identifier.name),
+ Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => {
+ math_constant(member.property.name.as_str())
+ }
+ Expression::StaticMemberExpression(member) => member_of(
+ &self.evaluate(modules, &member.object)?,
+ member.property.name.as_str(),
+ ),
+ Expression::CallExpression(call) => match &call.callee {
+ Expression::StaticMemberExpression(callee)
+ if self.is_global_math(&callee.object) =>
+ {
+ let mut arguments = Vec::with_capacity(call.arguments.len());
+ for argument in &call.arguments {
+ arguments.push(self.evaluate(modules, argument.as_expression()?)?);
+ }
+ fold_math(callee.property.name.as_str(), &arguments)
+ }
+ callee if self.is_style_api(modules, callee) => Some(Constant::Style),
+ _ => self.evaluate_stylex(modules, call),
+ },
+ Expression::TaggedTemplateExpression(tagged)
+ if self.is_style_api(modules, &tagged.tag) =>
+ {
+ Some(Constant::Style)
+ }
+ Expression::TSAsExpression(inner) => self.evaluate(modules, &inner.expression),
+ Expression::TSSatisfiesExpression(inner) => self.evaluate(modules, &inner.expression),
+ Expression::ParenthesizedExpression(inner) => self.evaluate(modules, &inner.expression),
+ _ => None,
+ }
+ }
+
+ /// Every property of `object` when all are known, the known ones otherwise;
+ /// a key written twice holds its last value at its first place, as in
+ /// JavaScript, and what an unknown spread or key may replace is unknown
+ fn object(
+ &mut self,
+ modules: &mut Modules<'_>,
+ object: &oxc_ast::ast::ObjectExpression<'_>,
+ ) -> Constant {
+ let mut entries: Vec<(String, Constant)> = Vec::with_capacity(object.properties.len());
+ let mut complete = true;
+ for property in &object.properties {
+ let (key, value) = match property {
+ ObjectPropertyKind::ObjectProperty(property)
+ if property.kind == oxc_ast::ast::PropertyKind::Init =>
+ {
+ let key = self.property_key(modules, &property.key);
+ let value = if property.method
+ || matches!(
+ crate::utils::unwrap_syntax_only(&property.value),
+ Expression::ArrowFunctionExpression(_)
+ | Expression::FunctionExpression(_)
+ ) {
+ Some(Constant::Function)
+ } else {
+ self.evaluate(modules, &property.value)
+ };
+ (key, value)
+ }
+ ObjectPropertyKind::SpreadProperty(spread) => {
+ if let Some(Constant::Record(spread)) = self.evaluate(modules, &spread.argument)
+ {
+ for (key, value) in spread.iter() {
+ set_entry(&mut entries, key.clone(), value.clone());
+ }
+ continue;
+ }
+ (None, None)
+ }
+ ObjectPropertyKind::ObjectProperty(property) => {
+ (self.property_key(modules, &property.key), None)
+ }
+ };
+ match (key, value) {
+ (Some(key), Some(value)) if key != "__proto__" => {
+ set_entry(&mut entries, key, value);
+ }
+ (Some(key), _) => {
+ complete = false;
+ entries.retain(|(existing, _)| *existing != key);
+ }
+ (None, _) => {
+ complete = false;
+ entries.clear();
+ }
+ }
+ }
+ if complete {
+ Constant::Record(Rc::new(entries))
+ } else {
+ Constant::Object(Rc::new(entries.into_iter().collect()))
+ }
+ }
+}
+
+fn set_entry(entries: &mut Vec<(String, Constant)>, key: String, value: Constant) {
+ match entries.iter_mut().find(|(existing, _)| *existing == key) {
+ Some(entry) => entry.1 = value,
+ None => entries.push((key, value)),
+ }
+}
+
+/// `value` as JavaScript turns it into a string
+fn js_string(value: &Constant) -> Option {
+ match value {
+ Constant::String(text) => Some(text.clone()),
+ Constant::Number(number) => Some(crate::utils::js_number_string(*number)),
+ Constant::Null => Some("null".to_string()),
+ Constant::Bool(value) => Some(value.to_string()),
+ Constant::Undefined => Some("undefined".to_string()),
+ _ => None,
+ }
+}
+
+fn member_of(object: &Constant, key: &str) -> Option {
+ match object {
+ Constant::Object(object) => object.get(key).cloned(),
+ Constant::Record(entries) => entries
+ .iter()
+ .find(|(name, _)| name == key)
+ .map(|(_, value)| value.clone()),
+ Constant::Array(values) => key
+ .parse::()
+ .ok()
+ .filter(|index| index.to_string() == key)
+ .and_then(|index| values.get(index).cloned()),
+ Constant::Vars(vars) => vars
+ .get(key)
+ .map(|variable| Constant::String(format!("var({variable})"))),
+ _ => None,
+ }
+}
+
+fn fold_template(
+ template: &oxc_ast::ast::TemplateLiteral<'_>,
+ values: &[Constant],
+) -> Option {
+ let mut text = String::new();
+ for (index, quasi) in template.quasis.iter().enumerate() {
+ text.push_str(quasi.value.cooked.as_deref()?);
+ if let Some(value) = values.get(index) {
+ text.push_str(&js_string(value)?);
+ }
+ }
+ Some(Constant::String(text))
+}
+
+fn math_constant(name: &str) -> Option {
+ use std::f64::consts;
+ let value = match name {
+ "PI" => consts::PI,
+ "E" => consts::E,
+ "LN2" => consts::LN_2,
+ "LN10" => consts::LN_10,
+ "LOG2E" => consts::LOG2_E,
+ "LOG10E" => consts::LOG10_E,
+ "SQRT2" => consts::SQRT_2,
+ "SQRT1_2" => consts::FRAC_1_SQRT_2,
+ _ => return None,
+ };
+ Some(Constant::Number(value))
+}
+
+/// `Math.{name}(...arguments)`, folded only where every engine computes the
+/// same result, so the CSS never depends on the platform that builds it
+fn fold_math(name: &str, arguments: &[Constant]) -> Option {
+ let mut numbers = Vec::with_capacity(arguments.len());
+ for argument in arguments {
+ let Constant::Number(number) = argument else {
+ return None;
+ };
+ numbers.push(*number);
+ }
+ let first = numbers.first().copied();
+ let value = match name {
+ "abs" => first?.abs(),
+ "ceil" => first?.ceil(),
+ "floor" => first?.floor(),
+ "trunc" => first?.trunc(),
+ "sqrt" => first?.sqrt(),
+ "sign" => {
+ let x = first?;
+ if x > 0.0 {
+ 1.0
+ } else if x < 0.0 {
+ -1.0
+ } else {
+ x
+ }
+ }
+ // JavaScript rounds a half up, toward +Infinity, where Rust rounds it
+ // away from zero; `x - floor(x)` is exact for every double
+ "round" => {
+ let x = first?;
+ let floor = x.floor();
+ if x - floor >= 0.5 { floor + 1.0 } else { floor }
+ }
+ "max" => numbers.iter().copied().fold(f64::NEG_INFINITY, f64::max),
+ "min" => numbers.iter().copied().fold(f64::INFINITY, f64::min),
+ "pow" => exact_power(first?, *numbers.get(1)?)?,
+ _ => return None,
+ };
+ value.is_finite().then_some(Constant::Number(value))
+}
+
+/// An integer raised to a whole power, when the result is an exact integer
+fn exact_power(base: f64, exponent: f64) -> Option {
+ const EXACT: f64 = 9_007_199_254_740_992.0;
+ if base.fract() != 0.0 || exponent.fract() != 0.0 || !(0.0..=64.0).contains(&exponent) {
+ return None;
+ }
+ let mut result = 1.0_f64;
+ for _ in 0..exponent as u8 {
+ result *= base;
+ if result.abs() > EXACT {
+ return None;
+ }
+ }
+ Some(result)
+}
+
+fn fold_binary(operator: BinaryOperator, left: &Constant, right: &Constant) -> Option {
+ let number = match (operator, left, right) {
+ (BinaryOperator::Addition, Constant::Number(a), Constant::Number(b)) => a + b,
+ (BinaryOperator::Subtraction, Constant::Number(a), Constant::Number(b)) => a - b,
+ (BinaryOperator::Multiplication, Constant::Number(a), Constant::Number(b)) => a * b,
+ (BinaryOperator::Division, Constant::Number(a), Constant::Number(b)) => a / b,
+ (BinaryOperator::Remainder, Constant::Number(a), Constant::Number(b)) => a % b,
+ (BinaryOperator::Addition, Constant::String(_), _)
+ | (BinaryOperator::Addition, _, Constant::String(_)) => {
+ return Some(Constant::String(js_string(left)? + &js_string(right)?));
+ }
+ _ => return None,
+ };
+ number.is_finite().then_some(Constant::Number(number))
+}
+/// Replaces reads of constants where styles read them: primitives in the
+/// arguments of style APIs and in style props, objects and arrays where they
+/// are read as style objects, as a copy elsewhere would change their identity.
+/// Other code keeps reading the binding itself
+struct Inline<'s, 'a> {
+ ast_builder: &'s AstBuilder<'a>,
+ scoping: &'s Scoping,
+ symbols: &'s FxHashMap,
+ style_roots: &'s FxHashSet<&'s str>,
+ apis: &'s StyleApis<'s>,
+ /// Inside what the build reads as style objects
+ objects: bool,
+ /// Inside the arguments of a style API or a style prop
+ styles: bool,
+}
+
+impl<'a> Inline<'_, 'a> {
+ fn constant(&self, expression: &Expression<'a>) -> Option {
+ match expression {
+ Expression::Identifier(identifier) => {
+ let reference = identifier.reference_id.get()?;
+ let symbol = self.scoping.get_reference(reference).symbol_id()?;
+ self.symbols.get(&symbol).cloned()
+ }
+ Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => {
+ math_constant(member.property.name.as_str())
+ }
+ Expression::StaticMemberExpression(member) => member_of(
+ &self.constant(&member.object)?,
+ member.property.name.as_str(),
+ ),
+ Expression::CallExpression(call) => {
+ let Expression::StaticMemberExpression(callee) = &call.callee else {
+ return None;
+ };
+ if !self.is_global_math(&callee.object) {
+ return None;
+ }
+ let arguments: Option> = call
+ .arguments
+ .iter()
+ .map(|argument| self.operand(argument.as_expression()?))
+ .collect();
+ fold_math(callee.property.name.as_str(), &arguments?)
+ }
+ Expression::ComputedMemberExpression(member) => {
+ let key = js_string(&self.operand(&member.expression)?)?;
+ member_of(&self.constant(&member.object)?, &key)
+ }
+ // Folded only when they read a constant, leaving other code as written
+ Expression::TemplateLiteral(template)
+ if template
+ .expressions
+ .iter()
+ .any(|e| self.constant(e).is_some()) =>
+ {
+ let values: Option> = template
+ .expressions
+ .iter()
+ .map(|expression| self.operand(expression))
+ .collect();
+ fold_template(template, &values?)
+ }
+ Expression::BinaryExpression(binary)
+ if self.constant(&binary.left).is_some()
+ || self.constant(&binary.right).is_some() =>
+ {
+ fold_binary(
+ binary.operator,
+ &self.operand(&binary.left)?,
+ &self.operand(&binary.right)?,
+ )
+ }
+ Expression::UnaryExpression(unary)
+ if unary.operator == oxc_syntax::operator::UnaryOperator::UnaryNegation =>
+ {
+ match self.constant(&unary.argument)? {
+ Constant::Number(number) => Some(Constant::Number(-number)),
+ _ => None,
+ }
+ }
+ Expression::ParenthesizedExpression(inner) => self.constant(&inner.expression),
+ _ => None,
+ }
+ }
+
+ /// A constant read or a literal
+ fn operand(&self, expression: &Expression<'a>) -> Option {
+ self.constant(expression).or_else(|| match expression {
+ Expression::StringLiteral(literal) => Some(Constant::String(literal.value.to_string())),
+ Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)),
+ Expression::NullLiteral(_) => Some(Constant::Null),
+ _ => crate::utils::js_number_literal(expression).map(Constant::Number),
+ })
+ }
+
+ /// Whether a condition reading `expression` holds, when it is a constant
+ /// read or a literal
+ fn holds(&self, expression: &Expression<'a>) -> Option {
+ Some(match self.operand(expression)? {
+ Constant::String(text) => !text.is_empty(),
+ Constant::Number(number) => number != 0.0 && !number.is_nan(),
+ Constant::Null | Constant::Undefined => false,
+ Constant::Bool(value) => value,
+ _ => true,
+ })
+ }
+
+ /// The side of a condition or of `&&`, `||` or `??` a constant or literal
+ /// chooses, taken out of `expression`
+ fn chosen(&self, expression: &mut Expression<'a>) -> Option> {
+ use oxc_allocator::TakeIn;
+
+ let allocator = self.ast_builder;
+ match expression {
+ Expression::ConditionalExpression(conditional) => {
+ Some(if self.holds(&conditional.test)? {
+ conditional.consequent.take_in(allocator)
+ } else {
+ conditional.alternate.take_in(allocator)
+ })
+ }
+ Expression::LogicalExpression(logical) => {
+ let left = self.operand(&logical.left)?;
+ let keep_left = match logical.operator {
+ oxc_syntax::operator::LogicalOperator::And => !self.holds(&logical.left)?,
+ oxc_syntax::operator::LogicalOperator::Or => self.holds(&logical.left)?,
+ oxc_syntax::operator::LogicalOperator::Coalesce => {
+ !matches!(left, Constant::Null | Constant::Undefined)
+ }
+ };
+ Some(if keep_left {
+ logical.left.take_in(allocator)
+ } else {
+ logical.right.take_in(allocator)
+ })
+ }
+ _ => None,
+ }
+ }
+
+ fn is_global_math(&self, expression: &Expression<'a>) -> bool {
+ matches!(expression, Expression::Identifier(identifier)
+ if identifier.name == "Math"
+ && identifier
+ .reference_id
+ .get()
+ .and_then(|reference| self.scoping.get_reference(reference).symbol_id())
+ .is_none())
+ }
+
+ fn literal(&self, constant: &Constant) -> Option> {
+ let builder = self.ast_builder;
+ match constant {
+ Constant::String(value) => Some(Expression::new_string_literal(
+ SPAN,
+ Str::from_in(value.as_str(), builder.allocator()),
+ None,
+ builder,
+ )),
+ Constant::Number(value) => Some(Expression::new_numeric_literal(
+ SPAN,
+ *value,
+ None,
+ NumberBase::Decimal,
+ builder,
+ )),
+ Constant::Null => Some(Expression::new_null_literal(SPAN, builder)),
+ Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)),
+ Constant::Record(entries) if self.objects => {
+ let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder);
+ for (key, value) in entries.iter() {
+ properties.push(ObjectPropertyKind::new_object_property(
+ SPAN,
+ oxc_ast::ast::PropertyKind::Init,
+ oxc_ast::ast::PropertyKey::StringLiteral(
+ oxc_ast::ast::StringLiteral::boxed(
+ SPAN,
+ Str::from_in(key.as_str(), builder.allocator()),
+ None,
+ builder,
+ ),
+ ),
+ self.literal(value)?,
+ false,
+ false,
+ false,
+ builder,
+ ));
+ }
+ Some(Expression::new_object_expression(SPAN, properties, builder))
+ }
+ Constant::Array(values) if self.objects => {
+ let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder);
+ for value in values.iter() {
+ elements.push(self.literal(value)?.into());
+ }
+ Some(Expression::new_array_expression(SPAN, elements, builder))
+ }
+ _ => None,
+ }
+ }
+
+ fn reading_objects(&mut self, objects: bool, visit: impl FnOnce(&mut Self) -> T) -> T {
+ let outer = std::mem::replace(&mut self.objects, objects);
+ let result = visit(self);
+ self.objects = outer;
+ result
+ }
+
+ fn reading_styles(&mut self, styles: bool, visit: impl FnOnce(&mut Self) -> T) -> T {
+ let outer = self.styles;
+ self.styles |= styles;
+ let result = visit(self);
+ self.styles = outer;
+ result
+ }
+}
+
+impl<'a> VisitMut<'a> for Inline<'_, 'a> {
+ fn visit_expression(&mut self, expression: &mut Expression<'a>) {
+ if self.styles {
+ if let Some(literal) = self
+ .constant(expression)
+ .and_then(|constant| self.literal(&constant))
+ {
+ *expression = literal;
+ return;
+ }
+ if let Some(chosen) = self.chosen(expression) {
+ *expression = chosen;
+ self.visit_expression(expression);
+ return;
+ }
+ }
+ walk_mut::walk_expression(self, expression);
+ }
+
+ fn visit_tagged_template_expression(
+ &mut self,
+ tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>,
+ ) {
+ self.visit_expression(&mut tagged.tag);
+ let styles = is_style_root(self.style_roots, &tagged.tag);
+ self.reading_styles(styles, |inline| {
+ inline.visit_template_literal(&mut tagged.quasi);
+ });
+ }
+
+ fn visit_member_expression(&mut self, member: &mut oxc_ast::ast::MemberExpression<'a>) {
+ self.reading_objects(false, |inline| {
+ walk_mut::walk_member_expression(inline, member);
+ });
+ }
+
+ fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) {
+ self.visit_expression(&mut call.callee);
+ let objects = self.apis.reads(&call.callee);
+ let styles = is_style_root(self.style_roots, &call.callee);
+ self.reading_styles(styles, |inline| {
+ inline.reading_objects(objects, |inline| {
+ for argument in &mut call.arguments {
+ inline.visit_argument(argument);
+ }
+ });
+ });
+ }
+
+ fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) {
+ let styled = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root));
+ for attribute in &mut element.attributes {
+ let objects = styled
+ && match attribute {
+ JSXAttributeItem::Attribute(attribute) => {
+ matches!(&attribute.name, oxc_ast::ast::JSXAttributeName::Identifier(name)
+ if !css::is_special_property::is_special_property(&name.name)
+ && !matches!(name.name.as_str(), "as" | "props" | "styleVars"))
+ }
+ JSXAttributeItem::SpreadAttribute(_) => true,
+ };
+ self.reading_styles(objects, |inline| {
+ inline
+ .reading_objects(objects, |inline| inline.visit_jsx_attribute_item(attribute));
+ });
+ }
+ }
+
+ fn visit_object_property(&mut self, property: &mut oxc_ast::ast::ObjectProperty<'a>) {
+ walk_mut::walk_object_property(self, property);
+ if property.shorthand && !matches!(property.value, Expression::Identifier(_)) {
+ property.shorthand = false;
+ }
+ }
+}
diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs
index 9b3759ae4..62018c30f 100644
--- a/libs/extractor/src/lib.rs
+++ b/libs/extractor/src/lib.rs
@@ -1,4 +1,5 @@
mod as_visit;
+mod build_time_values;
mod component;
mod css_utils;
pub mod extract_style;
@@ -6,12 +7,16 @@ mod extractor;
mod gen_class_name;
mod gen_style;
mod import_alias_visit;
+mod imported_constants;
+mod module_loader;
+mod mutations;
mod prop_modify_utils;
+mod source_map;
+mod style_values;
mod stylex;
mod tailwind;
mod util_type;
mod utils;
-#[cfg(feature = "vanilla-extract")]
mod vanilla_extract;
mod visit;
use crate::extract_style::extract_style_value::ExtractStyleValue;
@@ -23,7 +28,6 @@ use oxc_ast_visit::VisitMut;
use oxc_codegen::{Codegen, CodegenOptions};
use oxc_parser::{Parser, ParserReturn};
use oxc_span::SourceType;
-#[cfg(feature = "vanilla-extract")]
use rustc_hash::FxHashMap;
use rustc_hash::FxHashSet;
use std::collections::{BTreeMap, HashMap};
@@ -63,6 +67,14 @@ pub enum ExtractStyleProp<'a> {
map: BTreeMap>>,
expression: Expression<'a>,
},
+ /// Styles written where the build cannot read them, reported as an error;
+ /// `prop` for a computed key among an element's props, which the element
+ /// takes as it is at runtime
+ Unreadable {
+ offset: u32,
+ code: String,
+ prop: bool,
+ },
}
impl<'a> ExtractStyleProp<'a> {
@@ -101,6 +113,11 @@ impl<'a> ExtractStyleProp<'a> {
expression: expression.clone_in(alloc),
}
}
+ ExtractStyleProp::Unreadable { offset, code, prop } => ExtractStyleProp::Unreadable {
+ offset: *offset,
+ code: code.clone(),
+ prop: *prop,
+ },
}
}
@@ -137,6 +154,7 @@ impl<'a> ExtractStyleProp<'a> {
.values()
.flat_map(|s| s.iter().flat_map(ExtractStyleProp::extract))
.collect(),
+ ExtractStyleProp::Unreadable { .. } => vec![],
}
}
@@ -177,6 +195,7 @@ impl<'a> ExtractStyleProp<'a> {
.into_values()
.flat_map(|s| s.into_iter().flat_map(ExtractStyleProp::into_extract))
.collect(),
+ ExtractStyleProp::Unreadable { .. } => vec![],
}
}
}
@@ -191,8 +210,22 @@ pub struct ExtractOutput {
pub map: Option,
pub css_file: Option,
+
+ /// Files read through the module resolver, for the bundler to watch
+ pub dependencies: Vec,
+}
+
+/// A module the bundler resolved an import to
+pub struct ResolvedModule {
+ pub path: String,
+ pub code: String,
}
+/// Resolves `(specifier, importer)` the way the bundler does; `None` when it
+/// cannot
+pub type ModuleResolver = dyn Fn(&str, &str) -> Option;
+
+#[derive(Clone)]
pub struct ExtractOption {
pub package: String,
pub css_dir: String,
@@ -219,7 +252,7 @@ pub fn extract(
code: &str,
option: ExtractOption,
) -> Result> {
- extract_with_source_map(filename, code, option, true)
+ extract_with_source_map(filename, code, option, true, None)
}
pub fn extract_without_source_map(
@@ -227,7 +260,18 @@ pub fn extract_without_source_map(
code: &str,
option: ExtractOption,
) -> Result> {
- extract_with_source_map(filename, code, option, false)
+ extract_with_source_map(filename, code, option, false, None)
+}
+
+/// [`extract`] that reads the modules a file imports through `resolver`
+pub fn extract_with_modules(
+ filename: &str,
+ code: &str,
+ option: ExtractOption,
+ source_map: bool,
+ resolver: &ModuleResolver,
+) -> Result> {
+ extract_with_source_map(filename, code, option, source_map, Some(resolver))
}
fn extract_with_source_map(
@@ -235,11 +279,25 @@ fn extract_with_source_map(
code: &str,
option: ExtractOption,
source_map: bool,
+ resolver: Option<&ModuleResolver>,
+) -> Result> {
+ extract_source(filename, code, None, option, source_map, resolver)
+}
+
+/// `evaluated` is the source `code` was computed from, with the layers of
+/// edits, last made first, that map `code` back to it
+fn extract_source(
+ filename: &str,
+ code: &str,
+ evaluated: Option<(&str, &[&[import_alias_visit::Edit]])>,
+ option: ExtractOption,
+ source_map: bool,
+ resolver: Option<&ModuleResolver>,
) -> Result> {
// Step 1: Transform import aliases
// e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'`
// e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'`
- let transformed_code = import_alias_visit::transform_import_aliases(
+ let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits(
code,
filename,
&option.package,
@@ -257,18 +315,20 @@ fn extract_with_source_map(
code: code.to_string(),
map: None,
css_file: None,
+ dependencies: Vec::new(),
});
}
+ let mut dependencies = std::collections::BTreeSet::new();
+ let mut evaluation_error = None;
// Step 3: Handle vanilla-extract style files (.css.ts, .css.js)
// `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 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)
- {
- Ok(collected) => {
+ match vanilla_extract::execute_stylesheet(&transformed_code, filename, &option, resolver) {
+ Ok((collected, imports)) => {
+ dependencies = imports.dependencies;
// Keyframes names are generated, so extract the referenced ones
// first and substitute their names into the styles using them.
let referenced = vanilla_extract::referenced_keyframes(&collected);
@@ -286,20 +346,43 @@ fn extract_with_source_map(
&referenced,
)?
};
- Some(vanilla_extract::collected_styles_to_code_with_keyframes(
+ let code = vanilla_extract::collected_styles_to_code_with_keyframes(
&collected,
&option.package,
&keyframes_names,
- ))
+ );
+ Some(if code.is_empty() {
+ code
+ } else {
+ imports
+ .kept_imports
+ .iter()
+ .map(|specifier| {
+ format!("import {};\n", vanilla_extract::json_string(specifier))
+ })
+ .chain([code])
+ .collect()
+ })
+ }
+ // A stylesheet another one imports must give its own values, and an
+ // import cycle read too early fails as it does in ES modules, so both
+ // are reported rather than hidden behind plain extraction
+ Err(error)
+ if module_loader::loading_for_stylesheet()
+ || error.contains(module_loader::IMPORT_CYCLE) =>
+ {
+ return Err(error.into());
+ }
+ // Plain extraction still compiles Devup UI's own APIs; the error is
+ // reported when calls it cannot compile remain
+ Err(error) => {
+ evaluation_error = Some(error);
+ None
}
- // Fall back to treating as regular file if execution fails
- Err(_) => None,
}
} else {
None
};
- #[cfg(not(feature = "vanilla-extract"))]
- let processed_code: Option = None;
// For vanilla-extract files, if no styles were collected, return early
if processed_code.as_deref() == Some("") {
return Ok(ExtractOutput {
@@ -307,6 +390,7 @@ fn extract_with_source_map(
code: code.to_string(),
map: None,
css_file: None,
+ dependencies: dependencies.into_iter().collect(),
});
}
@@ -333,6 +417,18 @@ fn extract_with_source_map(
if fatal_error {
return Err("Parser panicked".into());
}
+ let inlined = if processed_code.is_none() {
+ imported_constants::inline_constants(
+ &oxc_ast::builder::AstBuilder::new(&allocator),
+ &mut program,
+ filename,
+ &option,
+ resolver,
+ )
+ } else {
+ imported_constants::Inlined::default()
+ };
+ dependencies.extend(inlined.dependencies);
let mut visitor = DevupVisitor::new(
&allocator,
filename,
@@ -340,7 +436,52 @@ fn extract_with_source_map(
css_files,
if global { None } else { Some(bucket) },
);
+ visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes);
+ visitor.unknown_bindings(&inlined.unknown);
+ visitor.changed_bindings(inlined.changed.clone());
visitor.visit_program(&mut program);
+ if let Some(error) = evaluation_error
+ && imports_uncompiled(&program, &option.package)
+ {
+ return Err(error.into());
+ }
+ // Run the code a value computes, or tell rules the module computes from a
+ // class it composes
+ if (!visitor.errors.is_empty() || visitor.composes_unknown)
+ && evaluated.is_none()
+ && !utils::is_vanilla_extract_file(filename)
+ && let Some((computed, value_edits, read)) = build_time_values::evaluate(
+ &transformed_code,
+ filename,
+ &option,
+ resolver,
+ &inlined.unknown,
+ )
+ {
+ let mut output = extract_source(
+ filename,
+ &computed,
+ Some((code, &[value_edits.as_slice(), alias_edits.as_slice()])),
+ option,
+ source_map,
+ resolver,
+ )?;
+ let mut files: std::collections::BTreeSet =
+ output.dependencies.into_iter().collect();
+ files.extend(read);
+ output.dependencies = files.into_iter().collect();
+ return Ok(output);
+ }
+ let (source, earlier_edits) = evaluated.unwrap_or((code, &[]));
+ let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice())
+ .chain(earlier_edits.iter().copied())
+ .collect();
+ visitor.errors.append(&mut visitor.unknown_parts);
+ if !visitor.errors.is_empty() {
+ let mut message = located_errors(filename, source, &edits, visitor.errors);
+ message += &changed_notes(&message, filename, source, &edits, &inlined.changed);
+ return Err(message.into());
+ }
let codegen_options = if source_map {
CodegenOptions {
source_map_path: Some(PathBuf::from(filename)),
@@ -350,12 +491,22 @@ fn extract_with_source_map(
CodegenOptions::default()
};
let result = Codegen::new().with_options(codegen_options).build(&program);
+ // A stylesheet's output is generated, so its map stays on that code
+ let map = result.map.map(|map| {
+ if processed_code.is_some() || edits.iter().all(|edits| edits.is_empty()) {
+ map
+ } else {
+ source_map::remap(map, code_to_parse, source, &edits)
+ }
+ .to_json_string()
+ });
Ok(ExtractOutput {
styles: visitor.styles,
code: result.code,
- map: result.map.map(|m| m.to_json_string()),
+ map,
css_file: Some(css_file),
+ dependencies: dependencies.into_iter().collect(),
})
}
@@ -377,6 +528,96 @@ fn main_css_path(css_dir: &str) -> String {
/// kept for parse/sourcemap. Global (shared-chunk) files are emitted like
/// single-css: into devup-ui.css with prefix-less global naming, so styles
/// shared across routes ship once.
+/// Whether `program` still imports a value from `package` that extraction did
+/// not compile away
+fn imports_uncompiled(program: &oxc_ast::ast::Program<'_>, package: &str) -> bool {
+ program.body.iter().any(|statement| {
+ matches!(statement, oxc_ast::ast::Statement::ImportDeclaration(import)
+ if import.source.value == package
+ && !import.import_kind.is_type()
+ && import.specifiers.iter().flatten().any(|specifier| matches!(
+ specifier,
+ oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named)
+ if !named.import_kind.is_type()
+ )))
+ })
+}
+
+/// `errors` in source order, one per line, each led by `filename:line:column`
+/// of the code it is about; each layer of `edits`, last made first, maps the
+/// offsets back to `source`
+fn located_errors(
+ filename: &str,
+ source: &str,
+ edits: &[&[import_alias_visit::Edit]],
+ mut errors: Vec<(u32, String)>,
+) -> String {
+ errors.sort_unstable();
+ errors.dedup();
+ errors
+ .into_iter()
+ .map(|(offset, message)| {
+ let offset = edits.iter().fold(offset as usize, |offset, edits| {
+ import_alias_visit::source_offset(edits, offset)
+ });
+ format!("{}: {message}", locate(filename, source, offset))
+ })
+ .collect::>()
+ .join("\n")
+}
+
+/// `filename:line:column` of `offset` in `source`
+fn locate(filename: &str, source: &str, offset: usize) -> String {
+ let before = source.get(..offset).unwrap_or(source);
+ let line_start = before.rfind('\n').map_or(0, |index| index + 1);
+ format!(
+ "{filename}:{}:{}",
+ before.matches('\n').count() + 1,
+ before[line_start..].chars().count() + 1
+ )
+}
+
+/// A line for each binding `message` names that code changes, telling where
+fn changed_notes(
+ message: &str,
+ filename: &str,
+ source: &str,
+ edits: &[&[import_alias_visit::Edit]],
+ changed: &imported_constants::Changed,
+) -> String {
+ let mut notes = String::new();
+ for change in changed.named_in(message) {
+ let location = match &change.site {
+ imported_constants::ChangeSite::Here(offset) => {
+ let offset = edits.iter().fold(*offset as usize, |offset, edits| {
+ import_alias_visit::source_offset(edits, offset)
+ });
+ locate(filename, source, offset)
+ }
+ imported_constants::ChangeSite::In(location) => location.clone(),
+ };
+ let what = if change.handed {
+ "is handed here to code that may change it"
+ } else {
+ "is changed here"
+ };
+ let _ = std::fmt::Write::write_fmt(
+ &mut notes,
+ format_args!(
+ "\n{location}: `{}` {what}, so the build cannot read it as a constant",
+ change.name
+ ),
+ );
+ }
+ notes
+}
+
+/// The file name generated names of `filename` are scoped by, `None` when its
+/// CSS goes to the shared sheet
+fn css_bucket(filename: &str, option: &ExtractOption) -> Option {
+ (!(option.single_css || is_global(filename))).then(|| canonical(filename))
+}
+
fn resolve_css_target(filename: &str, option: &ExtractOption) -> (String, bool, String) {
let bucket = canonical(filename);
let global = option.single_css || is_global(filename);
@@ -394,7 +635,6 @@ fn resolve_css_target(filename: &str, option: &ExtractOption) -> (String, bool,
/// Extract class names from generated code for specific style names
/// Used for two-pass vanilla-extract processing to resolve selector references
-#[cfg(feature = "vanilla-extract")]
fn extract_class_map_from_code(
filename: &str,
partial_code: &str,
@@ -500,6 +740,7 @@ mod tests {
use std::collections::BTreeSet;
use super::*;
+ use crate::build_time_values::has_build_time_values;
use css::class_map::reset_class_map;
use css::file_map::reset_file_map;
use insta::assert_debug_snapshot;
@@ -6484,26 +6725,29 @@ import {Button} from '@devup/ui'
.unwrap()
));
- // Computed selector key whose `key.name()` is None must be skipped.
- // Mixed with a valid selector to ensure extraction still proceeds.
+ // A selector only the runtime knows is reported, not dropped
reset_class_map();
reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.jsx",
- r#"import {Box} from '@devup-ui/core'
+ let message = extract(
+ "test.jsx",
+ r#"import {Box} from '@devup-ui/core'
"#,
- ExtractOption {
- package: "@devup-ui/core".to_string(),
- css_dir: "@devup-ui/core".to_string(),
- single_css: true,
- import_main_css: false,
- import_aliases: HashMap::new()
- }
- )
- .unwrap()
- ));
+ ExtractOption {
+ package: "@devup-ui/core".to_string(),
+ css_dir: "@devup-ui/core".to_string(),
+ single_css: true,
+ import_main_css: false,
+ import_aliases: HashMap::new(),
+ },
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(
+ message.contains("`` cannot use `[dynamic]`"),
+ "{message}"
+ );
}
#[test]
@@ -7738,12 +7982,10 @@ globalCss()
reset_class_map();
reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
+ assert_eq!(
extract(
"test.tsx",
- r#"import { globalCss } from "@devup-ui/core";
-globalCss(1)
-"#,
+ "import { globalCss } from \"@devup-ui/core\";\nglobalCss(1)\n",
ExtractOption {
package: "@devup-ui/core".to_string(),
css_dir: "@devup-ui/core".to_string(),
@@ -7752,8 +7994,10 @@ globalCss(1)
import_aliases: HashMap::new()
}
)
- .unwrap()
- ));
+ .unwrap_err()
+ .to_string(),
+ "test.tsx:2:1: `globalCss()` cannot use `1` at build time: its values must be literals, theme tokens or constants, or be computed from them"
+ );
}
#[test]
@@ -8870,7 +9114,7 @@ keyframes(...{
keyframes({
from: { opacity: 0 },
[true]: { opacity: 0.5 },
- to: { opacity: 1, color: dy }
+ to: { opacity: 1 }
})
"#,
ExtractOption {
@@ -8885,6 +9129,206 @@ keyframes({
));
}
+ #[test]
+ #[serial]
+ fn test_runtime_values_where_no_element_holds_them() {
+ for (code, error) in [
+ (
+ "import { css } from '@devup-ui/react';\nexport const a = css({ color: x });",
+ "`css()` cannot use `x`",
+ ),
+ (
+ "import { globalCss } from '@devup-ui/react';\nglobalCss({ body: { color: x } });",
+ "`globalCss()` cannot use `x`",
+ ),
+ (
+ "import { globalCss } from '@devup-ui/react';\nglobalCss('body', { color: x });",
+ "`globalCss()` cannot use `x`",
+ ),
+ (
+ "import { keyframes } from '@devup-ui/react';\nkeyframes({ from: { opacity: 0 }, to: { color: dy } });",
+ "`keyframes()` cannot use `dy`",
+ ),
+ (
+ "import * as stylex from '@stylexjs/stylex';\nexport const spin = stylex.keyframes({ to: { color: dy } });",
+ "`stylex.keyframes()` cannot use `dy`",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract("test.tsx", code, ExtractOption::default())
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(
+ message.starts_with("test.tsx:2:") && message.contains(error),
+ "{message}"
+ );
+ assert!(
+ message.ends_with(
+ "at build time: its values must be literals, theme tokens or constants, or be computed from them"
+ ),
+ "{message}"
+ );
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_template_interpolations() {
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "test.tsx",
+ r"import { css, globalCss, keyframes, styled } from '@devup-ui/react';
+const C = 'red';
+const O = 0.5;
+const SEL = '.x';
+const SNIPPET = 'display: flex;';
+const mixin = css`display: grid;`;
+globalCss`body { color: ${C}; padding: ${4}px; }`;
+globalCss`a { color: ${(p) => p.theme.colors.link}; }`;
+export const k = keyframes`from { opacity: ${O} } to { opacity: 1 }`;
+export const c = css`${SNIPPET} color: ${C};`;
+export const d = css`${mixin}; margin: 0;`;
+export const A = styled.div`${mixin} color: red; ${(p) => p.active && mixin}`;
+export const B = styled.div`${SEL} & { color: ${C}; }`;",
+ ExtractOption::default(),
+ )
+ .unwrap()
+ ));
+ }
+
+ #[test]
+ #[serial]
+ fn test_template_interpolations_known_only_at_runtime() {
+ for (code, error) in [
+ (
+ "import { globalCss } from '@devup-ui/react';\nglobalCss`body { color: ${x}; }`;",
+ "`globalCss()` cannot use `x` at build time",
+ ),
+ (
+ "import { keyframes } from '@devup-ui/react';\nexport const k = keyframes`from { opacity: ${x} }`;",
+ "`keyframes()` cannot use `x` at build time",
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nexport const c = css`color: ${x};`;",
+ "`css()` cannot use `x` at build time",
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nexport const c = css`${x}: red;`;",
+ "Cannot place `x` at build time",
+ ),
+ (
+ "import { styled } from '@devup-ui/react';\nexport const A = styled.div`${Other}:hover & { color: red; }`;",
+ "Cannot place `Other` at build time",
+ ),
+ (
+ "import { styled } from '@devup-ui/react';\nexport const A = styled.div`& ${x} { color: red; }`;",
+ "Cannot place `x` at build time",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract("test.tsx", code, ExtractOption::default())
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(
+ message.starts_with("test.tsx:2:") && message.contains(error),
+ "{message}"
+ );
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_errors_are_located_and_all_reported() {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
+ "src/App.tsx",
+ "import {\n css,\n keyframes,\n} from '@emotion/react';\nimport { globalCss } from '@devup-ui/react';\nexport const a = css({ padding: 8 }); export const b = css({ color: x });\nglobalCss`body { color: ${y}; }`;\nexport const k = keyframes({ to: { opacity: z } });\nexport const c = css({ color: x });",
+ ExtractOption {
+ import_aliases: HashMap::from([(
+ "@emotion/react".to_string(),
+ ImportAlias::NamedToNamed,
+ )]),
+ ..ExtractOption::default()
+ },
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert_eq!(
+ message.lines().collect::>(),
+ [
+ "src/App.tsx:6:56: `css()` cannot use `x` at build time: its values must be literals, theme tokens or constants, or be computed from them",
+ "src/App.tsx:7:27: `globalCss()` cannot use `y` at build time: its values must be literals, theme tokens or constants, or be computed from them",
+ "src/App.tsx:8:18: `keyframes()` cannot use `z` at build time: its values must be literals, theme tokens or constants, or be computed from them",
+ "src/App.tsx:9:18: `css()` cannot use `x` at build time: its values must be literals, theme tokens or constants, or be computed from them",
+ ]
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn test_inline_local_constants_edges() {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ r"import { Box } from '@devup-ui/react';
+export function helper() {}
+const OBJ = { a: 1 };
+export const a = ;",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ let mut values: Vec = output
+ .styles
+ .iter()
+ .map(|style| format!("{style:?}"))
+ .collect();
+ values.sort();
+ let values = values.join("\n");
+ assert!(values.contains("\"Infinitypx\""), "{values}");
+ assert!(values.contains("\"-Infinitypx\""), "{values}");
+ assert!(output.code.contains("OBJ"), "{}", output.code);
+ }
+
+ #[test]
+ #[serial]
+ fn test_inline_local_constants() {
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "test.tsx",
+ r"import { Box, css, keyframes, globalCss } from '@devup-ui/react';
+const SIZE = 4;
+const UNIT = `${SIZE}px`;
+const DOUBLE = SIZE * 2;
+const HALF = SIZE / 2 - 1;
+const LABEL = 'a' + SIZE;
+const TINY = 0.0000001;
+const HUGE = 1e21;
+const ZERO = -0;
+const PX = 'px';
+const SELF = SELF;
+let mutable = 1;
+export const a = ;
+export const b = css({ padding: `${SIZE * 2}px`, margin: SIZE - 1, width: SIZE * SIZE, height: (SIZE) });
+export const k = keyframes({ from: { opacity: SIZE / 8 } });
+globalCss({ body: { padding: UNIT } });
+export const c = ;",
+ ExtractOption::default(),
+ )
+ .unwrap()
+ ));
+ }
+
#[test]
#[serial]
fn extract_keyframs_literal() {
@@ -9781,7 +10225,7 @@ keyframes({
extract(
"test.tsx",
r"import {styled} from '@devup-ui/core'
- const color = 'red';
+ let color = 'red';
const StyledDiv = styled.div`
color: ${color};
background: blue;
@@ -9806,8 +10250,8 @@ keyframes({
extract(
"test.tsx",
r#"import {styled} from '@devup-ui/core'
- const primaryColor = 'blue';
- const padding = '16px';
+ let primaryColor = 'blue';
+ let padding = '16px';
const StyledDiv = styled("div")`
color: ${primaryColor};
padding: ${padding};
@@ -9831,8 +10275,8 @@ keyframes({
extract(
"test.tsx",
r#"import {styled} from '@devup-ui/core'
- const primaryColor = 'blue';
- const padding = '16px';
+ let primaryColor = 'blue';
+ let padding = '16px';
const StyledDiv = styled("div")({ bg: primaryColor, padding })
"#,
ExtractOption {
@@ -9853,8 +10297,8 @@ keyframes({
extract(
"test.tsx",
r"import {styled} from '@devup-ui/core'
- const primaryColor = 'blue';
- const padding = '16px';
+ let primaryColor = 'blue';
+ let padding = '16px';
const StyledDiv = styled.div({ bg: primaryColor, padding })
",
ExtractOption {
@@ -9968,7 +10412,7 @@ keyframes({
extract(
"test.tsx",
r"import {styled, Box} from '@devup-ui/core'
- const fontSize = '18px';
+ let fontSize = '18px';
const StyledComponent = styled(Box)`
font-size: ${fontSize};
color: ${props => props.color || 'black'};
@@ -9993,8 +10437,8 @@ keyframes({
extract(
"test.tsx",
r"import {styled} from '@devup-ui/core'
- const margin = '10px';
- const padding = '20px';
+ let margin = '10px';
+ let padding = '20px';
const StyledDiv = styled.div`
margin: ${margin};
padding: ${padding};
@@ -10041,71 +10485,40 @@ keyframes({
#[test]
#[serial]
fn test_wrong_styled_with_variable_like_emotion_props() {
- reset_class_map();
- reset_file_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r"import {styled} from '@devup-ui/core'
- const StyledDiv = styled(null)`
- background: ${props => props.bg};
- color: red;
- `
- ",
- ExtractOption {
- package: "@devup-ui/core".to_string(),
- css_dir: "@devup-ui/core".to_string(),
- single_css: false,
- import_main_css: false,
- import_aliases: HashMap::new()
- }
- )
- .unwrap()
- ));
-
- reset_class_map();
- reset_file_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r#"import {styled} from '@devup-ui/core'
- const StyledDiv = styled("div", "span")`
- background: ${props => props.bg};
- color: red;
- `
- "#,
- ExtractOption {
- package: "@devup-ui/core".to_string(),
- css_dir: "@devup-ui/core".to_string(),
- single_css: false,
- import_main_css: false,
- import_aliases: HashMap::new()
- }
- )
- .unwrap()
- ));
-
- reset_class_map();
- reset_file_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
+ // Left as written these would call `styled` at runtime, whose import
+ // the build removes
+ for (code, error) in [
+ (
+ "const StyledDiv = styled(null)`\n background: ${props => props.bg};\n color: red;\n`",
+ "`styled()` cannot use `styled(null)`",
+ ),
+ (
+ "const StyledDiv = styled(\"div\", \"span\")`\n background: ${props => props.bg};\n color: red;\n`",
+ "`styled()` cannot use `styled(\"div\", \"span\")`",
+ ),
+ (
+ "const StyledDiv = styled(\"div\", \"span\").filter(Boolean)",
+ "`styled()` cannot use `styled(\"div\", \"span\")`",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
"test.tsx",
- r#"import {styled} from '@devup-ui/core'
- const StyledDiv = styled("div", "span").filter(Boolean)
- "#,
+ &format!("import {{styled}} from '@devup-ui/core'\n{code}"),
ExtractOption {
package: "@devup-ui/core".to_string(),
css_dir: "@devup-ui/core".to_string(),
single_css: false,
import_main_css: false,
- import_aliases: HashMap::new()
- }
+ import_aliases: HashMap::new(),
+ },
)
- .unwrap()
- ));
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains(error), "{code}\n{message}");
+ }
reset_class_map();
reset_file_map();
@@ -10559,8 +10972,8 @@ globalCss({
extract(
"test.tsx",
r#"import {Box} from '@devup-ui/core'
-const colors = { primary: "blue", secondary: "red" };
-const key = "primary";
+let colors = { primary: "blue", secondary: "red" };
+let key = "primary";
"#,
ExtractOption {
@@ -10737,7 +11150,7 @@ const b = false;
extract(
"test.tsx",
r#"import {Box} from '@devup-ui/core'
-const color = "red";
+let color = "red";
"#,
ExtractOption {
@@ -10882,8 +11295,8 @@ const className = DevUI.css({ bg: "red" })
extract(
"test.tsx",
r#"import {Box} from '@devup-ui/core'
-const variant = "primary";
-const colors = { primary: "blue", secondary: "red" };
+let variant = "primary";
+let colors = { primary: "blue", secondary: "red" };
"#,
ExtractOption {
@@ -10908,7 +11321,7 @@ const colors = { primary: "blue", secondary: "red" };
extract(
"test.tsx",
r#"import {Box} from '@devup-ui/core'
-const dynamicColor = "blue";
+let dynamicColor = "blue";
"#,
ExtractOption {
@@ -10982,9 +11395,9 @@ const extraProps = { onClick: () => {} };
extract(
"test.tsx",
r#"import {Box} from '@devup-ui/core'
-const color = "red";
-const padding = 10;
-const margin = 5;
+let color = "red";
+let padding = 10;
+let margin = 5;
"#,
ExtractOption {
@@ -11010,7 +11423,7 @@ const margin = 5;
extract(
"test.tsx",
r#"import {Box} from '@devup-ui/core'
-const color = "red";
+let color = "red";
"#,
ExtractOption {
@@ -11978,19 +12391,79 @@ export const darkTheme = createTheme(vars, {
#[test]
#[serial]
- fn test_vanilla_extract_layer() {
+ fn test_layer_record_places_every_declaration() {
reset_class_map();
reset_file_map();
- reset_file_map();
- // layer - CSS cascade layers
assert_debug_snapshot!(ToBTreeSet::from(
extract(
- "layers.css.ts",
- r"import { layer, style, globalStyle } from '@devup-ui/react'
-export const reset = layer('reset')
-export const base = layer('base')
-export const components = layer('components')
-globalStyle('*', {
+ "test.tsx",
+ r"import { styled } from '@devup-ui/react'
+const A = styled.div({ '@layer': { base: {
+ color: cond ? 'red' : 'blue',
+ positioning: pos,
+ bg: { a: 'red', b: 'blue' }[key],
+ typography: typo,
+ width: w,
+ p: [1, 2],
+ '@layer': { inner: { m: 1 } },
+} } })
+",
+ 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_vanilla_extract_layer_records() {
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "layers.css.ts",
+ r"import { layer, style, globalStyle } from '@vanilla-extract/css'
+const reset = layer('reset')
+export const components = layer('components')
+globalStyle('a', { color: 'blue', '@layer': { [reset]: { color: 'inherit', padding: 0 } } })
+export const button = style({ color: 'red', '@layer': { [components]: { color: 'green', padding: 4 } } })
+",
+ 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_vanilla_extract_layer() {
+ reset_class_map();
+ reset_file_map();
+ reset_file_map();
+ // layer - CSS cascade layers
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "layers.css.ts",
+ r"import { layer, style, globalStyle } from '@devup-ui/react'
+export const reset = layer('reset')
+export const base = layer('base')
+export const components = layer('components')
+globalStyle('*', {
'@layer': reset,
margin: 0,
padding: 0
@@ -13092,6 +13565,36 @@ globalCss({
assert!(!output.code.is_empty());
}
+ #[test]
+ #[serial]
+ fn test_stylesheet_evaluation_errors() {
+ reset_class_map();
+ reset_file_map();
+ let error = extract(
+ "broken.css.ts",
+ "import { type StyleRule, style } from '@devup-ui/react';\nconst tokens = { brand: 'red' };\nexport const a = style({ color: tokens.accent.toUpperCase() });",
+ ExtractOption::default(),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(
+ error.starts_with("JS execution error: TypeError"),
+ "{error}"
+ );
+
+ // Devup UI's own APIs still compile when evaluation fails
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "own.css.ts",
+ "import type { DevupProps } from '@devup-ui/react';\nimport { css } from '@devup-ui/react';\nexport const a = css({ color: 'red' });",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ assert!(!output.code.contains("css("), "{}", output.code);
+ }
+
#[test]
#[serial]
fn test_vanilla_extract_execution_fallback() {
@@ -14746,6 +15249,132 @@ const styles = stylex.create({
));
}
+ fn static_values(output: &ExtractOutput) -> BTreeSet<(String, String)> {
+ output
+ .styles
+ .iter()
+ .filter_map(|style| match style {
+ ExtractStyleValue::Static(st) => {
+ Some((st.property().to_string(), st.value().to_string()))
+ }
+ ExtractStyleValue::Keyframes(keyframes) => keyframes
+ .keyframes
+ .values()
+ .flatten()
+ .next()
+ .map(|st| (st.property().to_string(), st.value().to_string())),
+ _ => None,
+ })
+ .collect()
+ }
+
+ #[test]
+ #[serial]
+ fn test_vanilla_extract_numbers_follow_vanilla_extract_units() {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "numbers.css.ts",
+ r"import { style, globalStyle, keyframes, styleVariants } from '@devup-ui/react'
+export const box = style({
+ fontSize: 16,
+ top: -2,
+ width: 0,
+ lineHeight: 1.5,
+ zIndex: 2,
+ columnCount: 3,
+ strokeWidth: 2,
+ aspectRatio: 1.5,
+ selectors: { '&:hover': { marginTop: 4 } },
+ '@media': { print: { left: 1.5 } },
+})
+export const spin = keyframes({ to: { height: 10 } })
+export const size = styleVariants({ small: { maxWidth: 20 } })
+globalStyle('body', { margin: 2 })
+",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ let values = static_values(&output);
+ for (property, value) in [
+ ("font-size", "16px"),
+ ("top", "-2px"),
+ ("width", "0"),
+ ("line-height", "1.5"),
+ ("z-index", "2"),
+ ("column-count", "3"),
+ ("stroke-width", "2"),
+ ("aspect-ratio", "1.5"),
+ ("margin-top", "4px"),
+ ("left", "1.5px"),
+ ("height", "10px"),
+ ("max-width", "20px"),
+ ("margin", "2px"),
+ ] {
+ assert!(
+ values.contains(&(property.to_string(), value.to_string())),
+ "{property}: {value} not in {values:?}"
+ );
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_stylex_numbers_follow_stylex_units() {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ r"import stylex from '@stylexjs/stylex';
+const styles = stylex.create({
+ base: {
+ fontSize: 16,
+ marginTop: -8,
+ width: -0,
+ height: '16',
+ lineHeight: 1.5,
+ transitionDuration: 300,
+ '--size': 4,
+ left: 1.33333,
+ top: { default: 2, ':hover': 4 },
+ right: stylex.firstThatWorks(6, 'auto'),
+ },
+ dynamic: (opacity, delay) => ({ opacity, transitionDelay: delay, bottom: 3 }),
+});
+const result = stylex.props(styles.dynamic(o, d));",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ let values = static_values(&output);
+ for (property, value) in [
+ ("font-size", "16px"),
+ ("margin-top", "-8px"),
+ ("width", "0"),
+ ("height", "16"),
+ ("line-height", "1.5"),
+ ("transition-duration", "300ms"),
+ ("--size", "4"),
+ ("left", "1.3333px"),
+ ("top", "2px"),
+ ("top", "4px"),
+ ("right", "6px"),
+ ("bottom", "3px"),
+ ] {
+ assert!(
+ values.contains(&(property.to_string(), value.to_string())),
+ "{property}: {value} not in {values:?}"
+ );
+ }
+ assert!(output.code.contains(r#""--a": o"#), "{}", output.code);
+ assert!(
+ output
+ .code
+ .contains(r#"((v) => typeof v === "number" ? v + "ms" : v)(d)"#),
+ "{}",
+ output.code
+ );
+ }
+
#[test]
#[serial]
fn test_stylex_create_numeric_values() {
@@ -15718,86 +16347,33 @@ const result = stylex.props(styles.bar(myH, myW));",
// StyleX Phase 4c: Diagnostic error/warning tests
// ==========================================
- #[test]
- #[serial]
- fn test_stylex_error_computed_key() {
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const key = 'color';
-const styles = stylex.create({
- base: {
- [key]: '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::new()
- },
- )
- .unwrap()
- ));
- }
-
- #[test]
- #[serial]
- fn test_stylex_error_spread_in_namespace() {
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const shared = { color: 'red' };
-const styles = stylex.create({
- base: {
- ...shared,
- fontSize: '16px',
- },
-});",
- 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_error_non_static_value() {
- 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: someVariable,
- fontSize: '16px',
- },
-});",
- 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()
- ));
+ for (value, shown) in [
+ ("someVariable", "someVariable"),
+ ("someFunc()", "someFunc()"),
+ ("theme.colors.primary", "theme.colors.primary"),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
+ "test.tsx",
+ &format!(
+ "import stylex from '@stylexjs/stylex';\nconst styles = stylex.create({{ base: {{ color: {value}, fontSize: '16px' }} }});"
+ ),
+ ExtractOption::default(),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert_eq!(
+ message,
+ format!(
+ "test.tsx:2:47: `stylex.create()` cannot use `{shown}` at build time: its values must be literals, theme tokens or constants, or be computed from them"
+ )
+ );
+ }
}
#[test]
@@ -15827,32 +16403,6 @@ const styles = stylex.create({
));
}
- #[test]
- #[serial]
- fn test_stylex_error_destructuring_create() {
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const { base } = stylex.create({
- base: {
- 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::new()
- },
- )
- .unwrap()
- ));
- }
-
#[test]
#[serial]
fn test_stylex_coexist_with_box() {
@@ -16146,14 +16696,14 @@ const styles = stylex.create({ base: { fontSize: stylex.types.length(16) } });",
#[test]
#[serial]
- fn test_stylex_types_unresolvable() {
+ fn test_stylex_dynamic_empty_params() {
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: { fontSize: stylex.types.length(someVar) } });",
+const styles = stylex.create({ base: () => ({ color: 'red' }) });",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16168,14 +16718,14 @@ const styles = stylex.create({ base: { fontSize: stylex.types.length(someVar) }
#[test]
#[serial]
- fn test_stylex_pseudo_non_object_value() {
+ fn test_stylex_dynamic_numeric_value() {
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: { ':hover': 'invalid' } });",
+const styles = stylex.create({ base: (x) => ({ fontSize: 16, height: x }) });",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16190,14 +16740,32 @@ const styles = stylex.create({ base: { ':hover': 'invalid' } });",
#[test]
#[serial]
- fn test_stylex_non_object_namespace_value() {
+ fn test_stylex_dynamic_keyframe_ref() {
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "test.tsx",
+ r"import stylex from '@stylexjs/stylex';
+const fadeIn = stylex.keyframes({ from: { opacity: '0' }, to: { opacity: '1' } });
+const styles = stylex.create({ base: (dur) => ({ animationName: fadeIn, animationDuration: dur }) });",
+ 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_consequent_only() {
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: 'not-an-object', active: { color: 'blue' } });",
+const styles = stylex.create({ active: { color: 'red' } });
+const el =
;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16212,15 +16780,15 @@ const styles = stylex.create({ base: 'not-an-object', active: { color: 'blue' }
#[test]
#[serial]
- fn test_stylex_spread_at_namespace_level() {
+ fn test_stylex_props_conditional_alternate_only() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
r"import stylex from '@stylexjs/stylex';
-const other = {};
-const styles = stylex.create({ ...other, base: { color: 'red' } });",
+const styles = stylex.create({ fallback: { color: 'gray' } });
+const el =
;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16235,15 +16803,15 @@ const styles = stylex.create({ ...other, base: { color: 'red' } });",
#[test]
#[serial]
- fn test_stylex_computed_namespace_key() {
+ fn test_stylex_props_conditional_none_none() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
r"import stylex from '@stylexjs/stylex';
-const key = 'base';
-const styles = stylex.create({ [key]: { color: 'red' } });",
+const styles = stylex.create({ base: { color: 'red' } });
+const el =
;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16258,14 +16826,15 @@ const styles = stylex.create({ [key]: { color: 'red' } });",
#[test]
#[serial]
- fn test_stylex_dynamic_empty_params() {
+ fn test_stylex_props_falsy_literals() {
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 styles = stylex.create({ base: { color: 'red' } });
+const el =
;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16280,14 +16849,15 @@ const styles = stylex.create({ base: () => ({ color: 'red' }) });",
#[test]
#[serial]
- fn test_stylex_dynamic_block_body() {
+ fn test_stylex_props_unresolvable_arg() {
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: (x) => { return { color: x }; } });",
+const styles = stylex.create({ base: { color: 'red' } });
+const el =
;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16302,14 +16872,15 @@ const styles = stylex.create({ base: (x) => { return { color: x }; } });",
#[test]
#[serial]
- fn test_stylex_dynamic_numeric_value() {
+ fn test_stylex_props_dynamic_as_non_call() {
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: (x) => ({ fontSize: 16, height: x }) });",
+const styles = stylex.create({ base: (x) => ({ color: x }) });
+const el =
;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16324,15 +16895,15 @@ const styles = stylex.create({ base: (x) => ({ fontSize: 16, height: x }) });",
#[test]
#[serial]
- fn test_stylex_dynamic_keyframe_ref() {
+ fn test_stylex_include_dynamic_target() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
r"import stylex from '@stylexjs/stylex';
-const fadeIn = stylex.keyframes({ from: { opacity: '0' }, to: { opacity: '1' } });
-const styles = stylex.create({ base: (dur) => ({ animationName: fadeIn, animationDuration: dur }) });",
+const base = stylex.create({ dynamic: (x) => ({ color: x, fontSize: '14px' }) });
+const composed = stylex.create({ fancy: { ...stylex.include(base.dynamic), backgroundColor: 'blue' } });",
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()
@@ -16341,15 +16912,14 @@ const styles = stylex.create({ base: (dur) => ({ animationName: fadeIn, animatio
#[test]
#[serial]
- fn test_stylex_props_conditional_consequent_only() {
+ fn test_stylex_import_unknown_named() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const styles = stylex.create({ active: { color: 'red' } });
-const el =
;",
+ r"import { create, unknownFunction } from '@stylexjs/stylex';
+const styles = create({ base: { color: 'red' } });",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16362,17 +16932,25 @@ const el =
;",
));
}
+ // ==========================================
+ // Coverage tests: extract_style_from_stylex.rs
+ // ==========================================
+
+ // ==========================================
+ // Coverage tests: stylex.rs
+ // ==========================================
+
+ /// Lines 66-67: Named import `types.X()`
#[test]
#[serial]
- fn test_stylex_props_conditional_alternate_only() {
+ fn test_stylex_named_import_types() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const styles = stylex.create({ fallback: { color: 'gray' } });
-const el =
;",
+ r"import { create, types } from '@stylexjs/stylex';
+const styles = create({ base: { width: types.length('100px') } });",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16385,17 +16963,22 @@ const el =
;",
));
}
+ // ==========================================
+ // Coverage tests: visit.rs
+ // ==========================================
+
+ /// Line 188: Empty className in static namespace — styles.empty where empty has no properties
#[test]
#[serial]
- fn test_stylex_props_conditional_none_none() {
+ fn test_stylex_props_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({ base: { color: 'red' } });
-const el =
;",
+const styles = stylex.create({ empty: {} });
+const el =
;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16408,9 +16991,10 @@ const el =
;",
));
}
+ /// Line 198: styles.nonexistent — member not found in namespace map
#[test]
#[serial]
- fn test_stylex_props_falsy_literals() {
+ fn test_stylex_props_nonexistent_member() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
@@ -16418,7 +17002,7 @@ const el =
;",
"test.tsx",
r"import stylex from '@stylexjs/stylex';
const styles = stylex.create({ base: { color: 'red' } });
-const el =
;",
+const el =
;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16431,9 +17015,10 @@ const el =
;",
));
}
+ /// Line 208: None from `LogicalExpression` when right side can't resolve
#[test]
#[serial]
- fn test_stylex_props_unresolvable_arg() {
+ fn test_stylex_props_logical_unresolvable_right() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
@@ -16441,7 +17026,7 @@ const el =
;",
"test.tsx",
r"import stylex from '@stylexjs/stylex';
const styles = stylex.create({ base: { color: 'red' } });
-const el =
;",
+const el =
;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16454,17 +17039,18 @@ const el =
;",
));
}
+ /// Line 332: include with empty `class_name_str` — include a namespace that has no properties
#[test]
#[serial]
- fn test_stylex_props_dynamic_as_non_call() {
+ fn test_stylex_include_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({ base: (x) => ({ color: x }) });
-const el =
;",
+const base = stylex.create({ empty: {} });
+const composed = stylex.create({ test: { ...stylex.include(base.empty), color: 'red' } });",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16477,33 +17063,19 @@ const el =
;",
));
}
+ /// Include-only namespace with no own styles — `class_name_str` starts empty
+ /// Covers: visit.rs line 332 (`class_name_str` = `included_class` when empty)
#[test]
#[serial]
- fn test_stylex_include_dynamic_target() {
+ fn test_stylex_include_only_no_own_styles() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
r"import stylex from '@stylexjs/stylex';
-const base = stylex.create({ dynamic: (x) => ({ color: x, fontSize: '14px' }) });
-const composed = stylex.create({ fancy: { ...stylex.include(base.dynamic), backgroundColor: 'blue' } });",
- 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_import_unknown_named() {
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r"import { create, unknownFunction } from '@stylexjs/stylex';
-const styles = create({ base: { color: 'red' } });",
+const base = stylex.create({ root: { color: 'red' } });
+const composed = stylex.create({ combined: { ...stylex.include(base.root) } });",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16516,405 +17088,417 @@ const styles = create({ base: { color: 'red' } });",
));
}
- // ==========================================
- // Coverage tests: extract_style_from_stylex.rs
- // ==========================================
-
- /// Line 24: Non-object argument to `stylex.create()`
#[test]
#[serial]
- fn test_stylex_create_non_object_arg() {
+ fn test_length_token_extraction() {
+ // Test $token on gap prop
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r#"import stylex from '@stylexjs/stylex';
-const styles = stylex.create("not-object");"#,
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
- ));
- }
-
- /// Lines 100, 103: Non-ObjectProperty / no string key inside pseudo inner properties
- #[test]
- #[serial]
- fn test_stylex_pseudo_inner_spread_and_computed() {
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const other = { fontSize: '14px' };
-const styles = stylex.create({ base: { ':hover': { ...other, [Symbol()]: 'val', 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::new() },
+ }
)
.unwrap()
));
- }
- /// Lines 232, 236: Non-ObjectProperty / no key in dynamic body (spread and computed)
- #[test]
- #[serial]
- fn test_stylex_dynamic_body_spread_and_computed() {
+ // Test $token on padding/margin shortcuts
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const other = { fontSize: '14px' };
-const styles = stylex.create({ base: (x) => ({ ...other, [Symbol()]: x, color: 'red' }) });",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
- }
- /// Line 269: Unresolvable value in dynamic namespace (not a param, not string, not number)
- #[test]
- #[serial]
- fn test_stylex_dynamic_unresolvable_value() {
+ // Test $token on multiple spacing props
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const someVar = getSomething();
-const styles = stylex.create({ base: (x) => ({ color: x, fontSize: someVar }) });",
+ r#"import {Flex} from '@devup-ui/react'
+
+ "#,
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()
));
}
- // ==========================================
- // Coverage tests: stylex.rs
- // ==========================================
-
- /// Line 48: false from `is_include_call_static` — spread with non-include call
#[test]
#[serial]
- fn test_stylex_spread_non_include_call() {
+ fn test_shadow_token_extraction() {
+ // Test $token on boxShadow prop
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-function notInclude() { return {}; }
-const styles = stylex.create({ base: { ...notInclude(), color: 'red' } });",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
- }
- /// Lines 66-67: Named import `types.X()`
- #[test]
- #[serial]
- fn test_stylex_named_import_types() {
+ // Test shadow token with other props
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import { create, types } from '@stylexjs/stylex';
-const styles = create({ base: { width: types.length('100px') } });",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
}
- /// Line 70: false from `is_types_call` — call expression value that's not types or firstThatWorks
#[test]
#[serial]
- fn test_stylex_non_types_non_ftw_call() {
+ fn test_responsive_length_token_literal_vs_array() {
+ use css::theme_tokens::set_theme_token_levels;
+
+ let mut length = BTreeMap::new();
+ length.insert("containerX".to_string(), vec![0, 2]);
+ set_theme_token_levels(length, BTreeMap::new());
+
+ // String literal: w="$containerX" → expands to multiple breakpoint classes
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-function someFunc() { return 'red'; }
-const styles = stylex.create({ base: { color: someFunc() } });",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
- }
- /// Line 183: types with unresolvable inside condition object
- #[test]
- #[serial]
- fn test_stylex_types_unresolvable_in_condition() {
+ // Expression: w={"$containerX"} → also expands (same as string literal)
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const someVar = getSomething();
-const styles = stylex.create({ base: { fontSize: { default: stylex.types.length(someVar) } } });",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
- }
- /// Line 188: Non-object/non-string/non-number/non-null/non-call value in decompose (array expression)
- #[test]
- #[serial]
- fn test_stylex_array_in_condition_object() {
+ // Array with single element: w={["$containerX"]} → single class, base value only
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: { opacity: { default: [1, 2] } } });",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
- }
- /// Line 195: Non-ObjectProperty inside condition object (spread)
- #[test]
- #[serial]
- fn test_stylex_spread_in_condition_object() {
+ // Mixed array: w={["1px", null, "$containerX"]} → token inside array stays single per slot
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const spreadObj = { ':hover': 'blue' };
-const styles = stylex.create({ base: { color: { ...spreadObj, default: 'red' } } });",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
}
- /// Line 198: No string key in condition object (computed key)
#[test]
#[serial]
- fn test_stylex_computed_key_in_condition_object() {
+ fn test_responsive_shadow_token_literal_vs_array() {
+ use css::theme_tokens::set_theme_token_levels;
+
+ let mut shadow = BTreeMap::new();
+ shadow.insert("card".to_string(), vec![0, 3]);
+ set_theme_token_levels(BTreeMap::new(), shadow);
+
+ // String literal: boxShadow="$card" → expands to multiple breakpoint classes
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const key = ':hover';
-const styles = stylex.create({ base: { color: { [key]: 'blue', default: 'red' } } });",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
- }
-
- // ==========================================
- // Coverage tests: visit.rs
- // ==========================================
- /// Line 188: Empty className in static namespace — styles.empty where empty has no properties
- #[test]
- #[serial]
- fn test_stylex_props_empty_namespace() {
+ // Expression: boxShadow={"$card"} → also expands (same as string literal)
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: {} });
-const el =
;",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
- }
- /// Line 198: styles.nonexistent — member not found in namespace map
- #[test]
- #[serial]
- fn test_stylex_props_nonexistent_member() {
+ // Array with single element: boxShadow={["$card"]} → single class, base value only
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 =
;",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
- }
- /// Line 208: None from `LogicalExpression` when right side can't resolve
- #[test]
- #[serial]
- fn test_stylex_props_logical_unresolvable_right() {
+ // Mixed array: boxShadow={["none", null, null, "$card"]} → token inside array stays single per slot
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 =
;",
+ r#"import {Box} from '@devup-ui/react'
+
+ "#,
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()
));
}
- /// Line 332: include with empty `class_name_str` — include a namespace that has no properties
#[test]
#[serial]
- fn test_stylex_include_empty_namespace() {
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const base = stylex.create({ empty: {} });
-const composed = stylex.create({ test: { ...stylex.include(base.empty), 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::new()
- },
- )
- .unwrap()
- ));
- }
+ fn extract_pseudo_selector_with_non_literal_value_graceful() {
+ // Minimal regression tests for the panic that ERROR.tsx surfaced.
+ //
+ // When a pseudo-selector prop (`_hover`, `_active`, `_focus`, ...)
+ // receives anything other than an inline object literal, the
+ // extractor used to `unwrap()` a None prop-name inside the
+ // `_xxx` selector recursion path and panic. The fix is to return
+ // an empty ExtractResult from the non-literal branches of
+ // `extract_style_from_expression` and
+ // `extract_style_from_member_expression` when `name` is `None`.
+ //
+ // Each case below must NOT panic (used to panic before the fix).
+ // Formerly the un-extractable pseudo-selector attribute was dropped
+ // from the generated code (no class, no runtime style — devup-ui is
+ // fully static, so there is no runtime fallback to fall back
+ // to)
+ let cases: &[(&str, &str)] = &[
+ (
+ "identifier",
+ r"import {Box} from '@devup-ui/react'
+const hoverStyle = { opacity: 1 };
+export const A = () => ;
+",
+ ),
+ (
+ "call expression",
+ r"import {Box} from '@devup-ui/react'
+declare const getHover: () => object;
+export const A = () => ;
+",
+ ),
+ (
+ "member expression",
+ r"import {Box} from '@devup-ui/react'
+declare const styles: { hover: object };
+export const A = () => ;
+",
+ ),
+ (
+ "binary expression",
+ r"import {Box} from '@devup-ui/react'
+declare const a: any; declare const b: any;
+export const A = () => ;
+",
+ ),
+ (
+ "template literal",
+ r"import {Box} from '@devup-ui/react'
+declare const x: string;
+export const A = () => ;
+",
+ ),
+ (
+ "unary expression",
+ r"import {Box} from '@devup-ui/react'
+declare const v: any;
+export const A = () => ;
+",
+ ),
+ (
+ "computed member expression (array index)",
+ r"import {Box} from '@devup-ui/react'
+declare const arr: any[];
+export const A = () => ;
+",
+ ),
+ ];
- /// Dynamic namespace with bare expression body (not object): (x) => x
- /// Covers: `extract_style_from_stylex.rs` `ObjectExpression` else branch
- #[test]
- #[serial]
- fn test_stylex_dynamic_bare_expression_body() {
- 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: (x) => x });",
- 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()
- ));
+ for (label, src) in cases {
+ reset_class_map();
+ reset_file_map();
+ let result = std::panic::catch_unwind(|| {
+ extract(
+ "test.tsx",
+ src,
+ 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(),
+ },
+ )
+ });
+ // A constant object is inlined; anything else is a located error
+ match result {
+ Ok(Ok(output)) => {
+ assert_eq!(*label, "identifier");
+ assert_eq!(output.styles.len(), 1);
+ }
+ Ok(Err(e)) => {
+ assert!(e.to_string().contains("`` cannot use"), "{label}: {e}");
+ }
+ Err(panic_payload) => {
+ let msg = panic_payload
+ .downcast_ref::<&'static str>()
+ .map(|s| (*s).to_string())
+ .or_else(|| panic_payload.downcast_ref::().cloned())
+ .unwrap_or_else(|| "".to_string());
+ panic!("[FAIL] {label}: extract panicked: {msg}");
+ }
+ }
+ }
}
- /// Dynamic namespace with parenthesized non-object body: (x) => (x)
- /// Covers: `extract_style_from_stylex.rs` `ParenthesizedExpression` unwrap + `ObjectExpression` else
#[test]
#[serial]
- fn test_stylex_dynamic_paren_non_object_body() {
+ fn extract_pseudo_selector_with_identifier_snapshot() {
+ // Snapshot-locks the current behavior for the minimal ERROR.tsx
+ // reduction so future refactors can't silently regress either
+ // direction (re-introducing the panic, or over-eagerly turning the
+ // `_hover={ident}` attribute into something surprising).
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: (x) => (x) });",
+ r#"import {Box} from '@devup-ui/react'
+const hoverStyle = { opacity: 1 };
+export const A = () => ;
+"#,
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
@@ -16927,46 +17511,54 @@ const styles = stylex.create({ base: (x) => (x) });",
));
}
- /// Include-only namespace with no own styles — `class_name_str` starts empty
- /// Covers: visit.rs line 332 (`class_name_str` = `included_class` when empty)
+ // Coverage for extract_style_from_expression.rs:206 — the
+ // `Expression::BinaryExpression | StaticMemberExpression | CallExpression`
+ // arm. Both internal branches must be exercised in a single, dedicated
+ // test so coverage tooling can attribute hits to this exact line:
+ //
+ // 207: `if let Some(name) = name { dynamic_style(...) }` (Some path)
+ // 211: `else { ExtractResult::default() }` (None path,
+ // reached only via `_xxx={...}` selector recursion)
+ //
+ // Each variant (binary / static-member / call) is asserted on its own so
+ // a regression in any single pattern fails loudly instead of being hidden
+ // behind a multi-snapshot test.
#[test]
#[serial]
- fn test_stylex_include_only_no_own_styles() {
+ fn extract_dynamic_style_props_binary_member_call_arm() {
+ // ── Some(name) branch — line 207 ────────────────────────────────
+ // BinaryExpression on a real prop name
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r"import stylex from '@stylexjs/stylex';
-const base = stylex.create({ root: { color: 'red' } });
-const composed = stylex.create({ combined: { ...stylex.include(base.root) } });",
+ r#"import { Box } from "@devup-ui/core";
+ ;
+"#,
ExtractOption {
- package: "@devup-ui/react".to_string(),
- css_dir: "@devup-ui/react".to_string(),
+ package: "@devup-ui/core".to_string(),
+ css_dir: "@devup-ui/core".to_string(),
single_css: true,
import_main_css: false,
import_aliases: HashMap::new()
- },
+ }
)
.unwrap()
));
- }
- #[test]
- #[serial]
- fn test_length_token_extraction() {
- // Test $token on gap prop
+ // StaticMemberExpression on a real prop name
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
+ r#"import { Box } from "@devup-ui/core";
+ ;
+"#,
ExtractOption {
- package: "@devup-ui/react".to_string(),
- css_dir: "@devup-ui/react".to_string(),
+ package: "@devup-ui/core".to_string(),
+ css_dir: "@devup-ui/core".to_string(),
single_css: true,
import_main_css: false,
import_aliases: HashMap::new()
@@ -16975,18 +17567,18 @@ const composed = stylex.create({ combined: { ...stylex.include(base.root) } });"
.unwrap()
));
- // Test $token on padding/margin shortcuts
+ // CallExpression on a real prop name
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
+ r#"import { Box } from "@devup-ui/core";
+ ;
+"#,
ExtractOption {
- package: "@devup-ui/react".to_string(),
- css_dir: "@devup-ui/react".to_string(),
+ package: "@devup-ui/core".to_string(),
+ css_dir: "@devup-ui/core".to_string(),
single_css: true,
import_main_css: false,
import_aliases: HashMap::new()
@@ -16995,679 +17587,2800 @@ const composed = stylex.create({ combined: { ...stylex.include(base.root) } });"
.unwrap()
));
- // Test $token on multiple spacing props
+ // A selector takes styles, so a value it cannot read is an error
+ for src in [
+ // BinaryExpression
+ r"import {Box} from '@devup-ui/react'
+declare const a: any; declare const b: any;
+export const A = () => ;
+",
+ // StaticMemberExpression
+ r"import {Box} from '@devup-ui/react'
+declare const t: { hover: object };
+export const A = () => ;
+",
+ // CallExpression
+ r"import {Box} from '@devup-ui/react'
+declare const fn: () => object;
+export const A = () => ;
+",
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let error = extract("test.tsx", src, ExtractOption::default())
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(
+ error.contains("`` cannot use") && error.contains(utils::STYLE_OBJECT),
+ "{src}: {error}"
+ );
+ }
+ }
+
+ fn extract_tsx(code: &str) -> ExtractOutput {
reset_class_map();
reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r#"import {Flex} from '@devup-ui/react'
-
- "#,
- 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()
- ));
+ 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_shadow_token_extraction() {
- // Test $token on boxShadow prop
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
- 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()
- ));
+ 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()] {
+ reset_class_map();
+ reset_file_map();
+ let _ = extract("test.tsx", &source, ExtractOption::default());
+ }
+ }
- // Test shadow token with other props
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
- 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_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_responsive_length_token_literal_vs_array() {
- use css::theme_tokens::set_theme_token_levels;
+ 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 =
;"
+ )));
+ }
- let mut length = BTreeMap::new();
- length.insert("containerX".to_string(), vec![0, 2]);
- set_theme_token_levels(length, BTreeMap::new());
+ #[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 =
;"
+ )));
+ }
- // String literal: w="$containerX" → expands to multiple breakpoint classes
+ #[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_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' });"
+ )));
+ }
+
+ #[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 });"
+ )));
reset_class_map();
reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
+ assert_eq!(
extract(
"test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
- 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()
- }
+ "import { styled } from '@devup-ui/react';\nconst Plain = styled('span')({ color: (p) => p.color });",
+ ExtractOption::default(),
)
- .unwrap()
- ));
+ .unwrap_err()
+ .to_string(),
+ "test.tsx:2:39: `styled()` cannot use `(p) => p.color` at build time: its styles must be an object literal or a constant object, or be computed from constants"
+ );
+ }
- // Expression: w={"$containerX"} → also expands (same as string literal)
+ #[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 {Box} from '@devup-ui/react'
-
- "#,
+ 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::new()
- }
+ import_aliases: HashMap::from([
+ ("@emotion/react".to_string(), ImportAlias::NamedToNamed),
+ (
+ "@emotion/styled".to_string(),
+ ImportAlias::DefaultToNamed("styled".to_string())
+ )
+ ])
+ },
)
.unwrap()
));
+ }
- // Array with single element: w={["$containerX"]} → single class, base value only
+ #[test]
+ #[serial]
+ fn test_emotion_and_styled_components_object_styles() {
+ let aliases = HashMap::from([
+ ("@emotion/react".to_string(), ImportAlias::NamedToNamed),
+ (
+ "@emotion/styled".to_string(),
+ ImportAlias::DefaultToNamed("styled".to_string()),
+ ),
+ (
+ "styled-components".to_string(),
+ ImportAlias::DefaultToNamed("styled".to_string()),
+ ),
+ ]);
+ for code in [
+ r"import styled from '@emotion/styled';
+import { css, keyframes, Global } from '@emotion/react';
+export const a = css({ padding: 8, lineHeight: 1.5, m: 2 }, [{ margin: 1 }, null, undefined, false]);
+export const B = styled('div', { shouldForwardProp: (p) => p !== 'x' })({ left: 3 });
+export const C = styled('span', { label: 'c' })`color: red;`;
+export const D = styled.div([{ top: 1, color: 'red', '&:hover': { margin: 5 } }, { top: 3, '&:hover': { padding: 6 } }]);
+export const E = styled.div({ bottom: 2 }, cond && { right: 4 });
+export const F = styled('p', { color: 'blue' });
+export const spin = keyframes({ to: { width: 10 } });
+export const App = () => <> >;",
+ r"import * as Styled from 'styled-components';
+import { css } from 'styled-components';
+export const G = Styled.div.attrs({ type: 'button' }).withConfig({ displayName: 'g' })({ right: 7 });
+export const H = Styled(Base).withConfig({ displayName: 'h' })({ left: 9 });
+export const I = Styled.div({ width: 1 });
+export const j = css({ height: 2 });",
+ ] {
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ 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: aliases.clone(),
+ },
+ )
+ .unwrap()
+ ));
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_styled_components_attrs_and_every_styled_import() {
+ let aliases = HashMap::from([
+ (
+ "@emotion/styled".to_string(),
+ ImportAlias::DefaultToNamed("styled".to_string()),
+ ),
+ (
+ "styled-components".to_string(),
+ ImportAlias::DefaultToNamed("styled".to_string()),
+ ),
+ ]);
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
+ r"import emotion from '@emotion/styled';
+import sc from 'styled-components';
+export const A = emotion.div({ top: 1 });
+export const B = sc.span({ left: 2 });
+export const C = sc.button.attrs({ type: 'button' }).withConfig({ displayName: 'c' })({ right: 3 });
+export const D = sc(Base).withConfig({ shouldForwardProp: () => true })`color: red;`;
+export const E = (sc.input.attrs((props) => ({ size: props.small ? 5 : 10 })) as any).attrs(extra)({ bottom: 4 });
+export const G = other.div.attrs({ role: 'note' })({ margin: 1 });
+export const I = factory.attrs({ id: 'i' })({ margin: 3 });
+export const J = other(Base).attrs({ id: 'j' })({ margin: 4 });",
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()
- }
+ import_aliases: aliases.clone(),
+ },
)
.unwrap()
));
-
- // Mixed array: w={["1px", null, "$containerX"]} → token inside array stays single per slot
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
+ // Never called for styles, so they would call `styled` at runtime
+ for code in [
+ "export const F = sc.div.attrs({ role: 'note' });",
+ "export const H = sc.div.attrs(...rest)({ margin: 2 });",
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
"test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
+ &format!("import sc from 'styled-components';\n{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()
- }
+ import_aliases: aliases.clone(),
+ ..ExtractOption::default()
+ },
)
- .unwrap()
- ));
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(
+ message.contains("is read at runtime, where it does not exist"),
+ "{code}\n{message}"
+ );
+ }
}
#[test]
#[serial]
- fn test_responsive_shadow_token_literal_vs_array() {
- use css::theme_tokens::set_theme_token_levels;
-
- let mut shadow = BTreeMap::new();
- shadow.insert("card".to_string(), vec![0, 3]);
- set_theme_token_levels(BTreeMap::new(), shadow);
-
- // String literal: boxShadow="$card" → expands to multiple breakpoint classes
+ fn test_selector_keys_computed_from_constants() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
+ "import { css } from '@devup-ui/react';\nconst card = css({ p: 1 });\nconst HOVER = ':hover';\nexport const a = css({ selectors: { [`.${card}${HOVER} &`]: { m: 1 } }, [`.${card} &`]: { m: 2 } });",
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()
- }
+ ..ExtractOption::default()
+ },
)
.unwrap()
));
+ for (code, error) in [
+ (
+ "import { css } from '@devup-ui/react';\nlet X = 'x';\nexport const a = css({ selectors: { [X]: { m: 1 } } });",
+ "`css()` cannot use `[X]`",
+ ),
+ (
+ "import { Box } from '@devup-ui/react';\nlet X = 'x';\nexport const a = ;",
+ "`` cannot use `[X]`",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract("test.tsx", code, ExtractOption::default())
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains(error), "{code}\n{message}");
+ }
+ }
- // Expression: boxShadow={"$card"} → also expands (same as string literal)
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
+ #[test]
+ #[serial]
+ fn test_compiled_bindings_never_dangle() {
+ let outputs: Vec<(&str, String)> = [
+ "import { css, styled, Box } from '@devup-ui/react';\nconst myCss = css;\nconst again = myCss;\nconst S = styled;\nconst B = Box;\nexport const a = again({ color: 'red' });\nexport const s = S.div({ color: 'blue' });\nexport const b = ;",
+ "import { css } from '@devup-ui/react';\nexport function f(css) { return css; }\nexport const a = css({ color: 'red' });",
+ "import { css, Box } from '@devup-ui/react';\ntype T = typeof css;\nexport type P = React.ComponentProps;\nexport const a = css({ color: 'red' });",
+ "import { css } from '@devup-ui/react';\nconst kept = 1, myCss = css;\nexport const a = myCss({ color: 'red' }) + kept;",
+ ]
+ .into_iter()
+ .map(|code| {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
"test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
+ 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()
- }
+ ..ExtractOption::default()
+ },
)
- .unwrap()
- ));
-
- // Array with single element: boxShadow={["$card"]} → single class, base value only
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
- 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();
+ (
+ code,
+ output
+ .code
+ .lines()
+ .filter(|line| !line.starts_with("import \""))
+ .collect::>()
+ .join("\n"),
)
- .unwrap()
- ));
+ })
+ .collect();
+ assert_debug_snapshot!(outputs);
- // Mixed array: boxShadow={["none", null, null, "$card"]} → token inside array stays single per slot
+ for (code, name) in [
+ (
+ "import { css } from '@devup-ui/react';\nexport const myCss = css;",
+ "css",
+ ),
+ (
+ "import { Box } from '@devup-ui/react';\nexport const C = Box;\nexport const d = ;",
+ "Box",
+ ),
+ (
+ "import { Box } from '@devup-ui/react';\nexport const list = [Box].map((B) => );",
+ "Box",
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nexport function f() { const inner = css; return inner({ color: 'red' }); }",
+ "css",
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst myCss = css;\nexport const v = myCss;",
+ "myCss",
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nlet myCss = css;\nmyCss = null;",
+ "myCss",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract("test.tsx", code, ExtractOption::default())
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(
+ message.contains(&format!(
+ "`{name}` is read at runtime, where it does not exist"
+ )),
+ "{code}\n{message}"
+ );
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_composed_css_keeps_composed_classes() {
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
- r#"import {Box} from '@devup-ui/react'
-
- "#,
+ r"import { style } from '@vanilla-extract/css';
+import { css } from '@emotion/react';
+const base = style({ color: 'red' });
+export const a = style([base, { margin: 2 }]);
+export const b = css(base, 'extra', { padding: 1 });
+export const c = css([base]);
+export const d = style([cond && base]);",
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()
- }
+ import_aliases: HashMap::from([
+ ("@emotion/react".to_string(), ImportAlias::NamedToNamed),
+ (
+ "@vanilla-extract/css".to_string(),
+ ImportAlias::NamedToNamed
+ )
+ ])
+ },
)
.unwrap()
));
}
- #[test]
- #[serial]
- fn extract_pseudo_selector_with_non_literal_value_graceful() {
- // Minimal regression tests for the panic that ERROR.tsx surfaced.
- //
- // When a pseudo-selector prop (`_hover`, `_active`, `_focus`, ...)
- // receives anything other than an inline object literal, the
- // extractor used to `unwrap()` a None prop-name inside the
- // `_xxx` selector recursion path and panic. The fix is to return
- // an empty ExtractResult from the non-literal branches of
- // `extract_style_from_expression` and
- // `extract_style_from_member_expression` when `name` is `None`.
- //
- // Each case below must:
- // 1. NOT panic (used to panic before the fix)
- // 2. Return Ok from `extract()`
- // 3. Drop the un-extractable pseudo-selector attribute from the
- // generated code (no class, no runtime style — devup-ui is
- // fully static, so there is no runtime fallback to fall back
- // to)
- let cases: &[(&str, &str)] = &[
+ fn memory_resolver(
+ files: &'static [(&'static str, &'static str)],
+ ) -> impl Fn(&str, &str) -> Option {
+ move |specifier, importer| {
+ let directory = importer
+ .rsplit_once('/')
+ .map_or("", |(directory, _)| directory);
+ let path = format!("{directory}/{}", specifier.trim_start_matches("./"));
+ files
+ .iter()
+ .find(|(file, _)| {
+ [".ts", ".css.ts", ".js", ""]
+ .iter()
+ .any(|extension| format!("{path}{extension}") == *file)
+ })
+ .map(|(file, code)| ResolvedModule {
+ path: (*file).to_string(),
+ code: (*code).to_string(),
+ })
+ }
+ }
+
+ const STYLESHEET_MODULES: &[(&str, &str)] = &[
+ (
+ "/src/theme.css.ts",
+ r"import { createTheme, style } from '@vanilla-extract/css';
+export const [themeClass, vars] = createTheme({ color: { brand: 'red' }, space: '4px' });
+export const base = style({ padding: vars.space });",
+ ),
+ (
+ "/src/tokens.ts",
+ r"import { extra } from './more';
+export const brand: string = 'blue';
+export default 8;
+const hidden = 1;
+export { hidden as shown };
+export * from './more';
+export * as more from './more';
+export { extra as renamed } from './more';
+export function double(n: number) { return n * 2 + extra - extra; }
+export class Scale {}",
+ ),
+ ("/src/more.ts", "export const extra = 3;"),
+ (
+ "/src/named.ts",
+ "export default function named() { return 1 }\nexport const other = 1",
+ ),
+ ("/src/anonymous.ts", "export default class { }"),
+ (
+ "/src/legacy.js",
+ "const { extra } = require('./more');\nconst compiled = require('./compiled');\nmodule.exports = { legacy: extra + compiled.default + compiled.named, default: 'ignored' };",
+ ),
+ (
+ "/src/compiled.js",
+ "'use strict';\nObject.defineProperty(exports, '__esModule', { value: true });\nexports.named = 2;\nexports.default = 10;",
+ ),
+ ("/src/value.js", "module.exports = 5;"),
+ ];
+
+ const STYLEX_MODULES: &[(&str, &str)] = &[
+ (
+ "/src/vars.stylex.ts",
+ r"import * as stylex from '@stylexjs/stylex';
+import { defineConsts, create } from '@stylexjs/stylex';
+import sx from '@stylexjs/stylex';
+import other from './brand';
+import { BRAND } from './brand';
+const DARK = '@media (prefers-color-scheme: dark)';
+const GAP = 'gap';
+export const colors = stylex.defineVars({ primary: 'blue', brand: BRAND, mode: { default: 'light', [DARK]: 'dark' }, typed: stylex.types.color({ default: 'red', '@supports (color: red)': null }) });
+export const consts = defineConsts({ [GAP]: '8px', size: 4 });
+export const contract = sx.createThemeContract({ accent: null });
+export const dark = stylex.createTheme(colors, { primary: 'navy' });
+export const notStylex = other.defineVars({ a: 'b' });
+export const unknownCallee = missing.defineVars({ a: 'b' });
+export const called = stylex({ a: 'b' });
+export const nested = stylex.types.defineVars({ a: 'b' });
+export const memberOfNamed = create.defineVars({ a: 'b' });
+export const indirect = (0, stylex.defineVars)({ a: 'b' });
+export const styles = stylex.create({ base: { color: 'red' } });",
+ ),
+ (
+ "/src/brand.ts",
+ "export const BRAND = 'green';\nexport default {};",
+ ),
+ (
+ "/src/loose.stylex.ts",
+ r"import * as stylex from '@stylexjs/stylex';
+import { consts } from './vars.stylex';
+export const loose = stylex.defineVars({ fine: 'ok', call: getColor(), pseudo: { default: 'a', ':hover': 'b' }, spread: { ...base }, unknownKey: { [unknown]: 'x' }, [computedKey]: 'x' });
+export const unknownTheme = stylex.createTheme(unknown, { primary: 'navy' });
+export const notContract = stylex.createTheme(consts, { primary: 'navy' });
+export const looseConsts = stylex.defineConsts({ fine: '1px', dynamic: getSize() });",
+ ),
+ (
+ "/src/themes.ts",
+ r"import * as stylex from '@stylexjs/stylex';
+import { colors } from './vars.stylex';
+export const light = stylex.createTheme(colors, { primary: 'white' });",
+ ),
+ ];
+
+ #[test]
+ #[serial]
+ fn test_stylex_code_that_cannot_compile() {
+ for (code, errors) in [
(
- "identifier",
- r"import {Box} from '@devup-ui/react'
-const hoverStyle = { opacity: 1 };
-export const A = () => ;
-",
+ "let key = 'base';\nconst styles = stylex.create({ [key]: { color: 'red' } });",
+ &["`stylex.create()` cannot use `[key]` at build time: its keys must be known"][..],
),
(
- "call expression",
- r"import {Box} from '@devup-ui/react'
-declare const getHover: () => object;
-export const A = () => ;
-",
+ "const styles = stylex.create(\"not-object\");",
+ &[
+ "`stylex.create()` cannot use `\"not-object\"` at build time: it takes one object literal",
+ ],
),
(
- "member expression",
- r"import {Box} from '@devup-ui/react'
-declare const styles: { hover: object };
-export const A = () => ;
-",
+ "const styles = stylex.create(...args);",
+ &[
+ "`stylex.create()` cannot use `...args` at build time: it takes one object literal",
+ ],
),
(
- "binary expression",
- r"import {Box} from '@devup-ui/react'
-declare const a: any; declare const b: any;
-export const A = () => ;
-",
+ "const styles = stylex.create({ base: { opacity: { default: [1, 2] } } });",
+ &["`stylex.create()` cannot use `[1, 2]` at build time"],
),
(
- "template literal",
- r"import {Box} from '@devup-ui/react'
-declare const x: string;
-export const A = () => ;
-",
+ "let prop = 'color';\nconst styles = stylex.create({ base: { [prop]: 'red' } });",
+ &["`stylex.create()` cannot use `[prop]` at build time"],
),
(
- "unary expression",
- r"import {Box} from '@devup-ui/react'
-declare const v: any;
-export const A = () => ;
-",
+ "let key = ':hover';\nconst styles = stylex.create({ base: { color: { [key]: 'blue', default: 'red' } } });",
+ &["`stylex.create()` cannot use `[key]` at build time"],
),
(
- "computed member expression (array index)",
- r"import {Box} from '@devup-ui/react'
-declare const arr: any[];
-export const A = () => ;
-",
+ "const styles = stylex.create({ base: { color: { default: 'red', hover: 'blue' } } });",
+ &[
+ "`stylex.create()` cannot use `hover` at build time: a condition is `default`, a pseudo-class",
+ ],
),
- ];
-
- for (label, src) in cases {
+ (
+ "const styles = stylex.create({ a: (x) => x, b: (x) => { return { color: x }; }, c: ({ x }) => ({ color: x }) });",
+ &[
+ "`stylex.create()` cannot use `(x) => x` at build time: a dynamic style is",
+ "`stylex.create()` cannot use `(x) => { return { color: x }; }` at build time",
+ "`stylex.create()` cannot use `({ x }) => ({ color: x })` at build time",
+ ],
+ ),
+ (
+ "const styles = stylex.create({ base: (x) => ({ ...other, [Symbol()]: x, fontSize: someVar }) });",
+ &[
+ "`stylex.create()` cannot use `...other` at build time: write every entry out",
+ "`stylex.create()` cannot use `[Symbol()]` at build time",
+ "`stylex.create()` cannot use `someVar` at build time: a dynamic style's value",
+ ],
+ ),
+ (
+ "const { base } = stylex.create({ base: { color: 'red' } });",
+ &["test.tsx:2:7: `stylex.create()` cannot be destructured at build time"],
+ ),
+ (
+ "const styles = stylex.create({ ...other, base: { ...shared, ...notInclude(), color: { ...spreadObj, default: 'red' } } });",
+ &[
+ "`stylex.create()` cannot use `...other`",
+ "`stylex.create()` cannot use `...shared`",
+ "`stylex.create()` cannot use `...notInclude()`",
+ "`stylex.create()` cannot use `...spreadObj`",
+ ],
+ ),
+ (
+ "const styles = stylex.create({ base: 'not-an-object', hover: { ':hover': 'invalid' }, inner: { ':hover': { ...other, [Symbol()]: 'val' } } });",
+ &[
+ "`stylex.create()` cannot use `\"not-an-object\"` at build time: a namespace is",
+ "`stylex.create()` cannot use `\"invalid\"` at build time: a pseudo-class or pseudo-element key takes an object of styles",
+ "`stylex.create()` cannot use `...other`",
+ ],
+ ),
+ (
+ "const styles = stylex.create({ base: { fontSize: stylex.types.length(someVar), width: { default: stylex.types.length() }, position: stylex.firstThatWorks('sticky', someVar, ...rest) } });",
+ &[
+ "`stylex.create()` cannot use `stylex.types.length(someVar)` at build time",
+ "`stylex.create()` cannot use `stylex.types.length()` at build time",
+ "`stylex.firstThatWorks()` cannot use `someVar` at build time",
+ "`stylex.firstThatWorks()` cannot use `...rest` at build time",
+ ],
+ ),
+ (
+ "const styles = stylex.create({ base: { ...stylex.include(getStyles()) }, other: { ...stylex.include(elsewhere.base) } });",
+ &[
+ "`stylex.include()` cannot use `stylex.include(getStyles())` at build time: it takes a namespace such as `styles.base`",
+ "`stylex.include()` cannot use `elsewhere.base` at build time: it takes a namespace `stylex.create()` defines earlier in this file",
+ ],
+ ),
+ (
+ "const consts = stylex.defineConsts({ ...other, [computed]: '1px', dynamic: someVar });",
+ &[
+ "`stylex.defineConsts()` cannot use `...other`",
+ "`stylex.defineConsts()` cannot use `[computed]`",
+ "`stylex.defineConsts()` cannot use `someVar`",
+ ],
+ ),
+ (
+ "const vars = stylex.defineVars({ ...other, [computed]: 'red' });\nconst contract = stylex.createThemeContract({ ...other });",
+ &[
+ "`stylex.defineVars()` cannot use `...other`",
+ "`stylex.defineVars()` cannot use `[computed]`",
+ "`stylex.createThemeContract()` cannot use `...other`",
+ ],
+ ),
+ (
+ "const c = stylex.createThemeContract(x);\nconst d = stylex.defineConsts(x);\nconst v = stylex.viewTransitionClass(x);",
+ &[
+ "`stylex.createThemeContract()` cannot use `x`",
+ "`stylex.defineConsts()` cannot use `x`",
+ "`stylex.viewTransitionClass()` cannot use `x`",
+ ],
+ ),
+ (
+ "const vars = stylex.defineVars(someVariable);\nconst theme = stylex.createTheme(notAContract, { primary: 'navy' });",
+ &[
+ "`stylex.defineVars()` cannot use `someVariable` at build time: it takes one object literal",
+ "`stylex.createTheme()` cannot use `notAContract, { primary: \"navy\" }` at build time: it takes a `defineVars()` group",
+ ],
+ ),
+ (
+ "const vars = stylex.defineVars({ primary: 'blue' });\nconst theme = stylex.createTheme(vars, { ...other, [computed]: 'red', missing: 'navy' });",
+ &[
+ "`stylex.createTheme()` cannot use `...other`",
+ "`stylex.createTheme()` cannot use `[computed]`",
+ "`stylex.createTheme()` cannot use `missing` at build time: `vars` has no such variable",
+ ],
+ ),
+ (
+ "const fallback = stylex.positionTry(notAnObject);\nconst entries = stylex.positionTry({ ...spread, [computed]: '0', top: someVar });\nconst transition = stylex.viewTransitionClass({ animationDuration: later });\nconst fade = stylex.keyframes(frames);",
+ &[
+ "`stylex.positionTry()` cannot use `notAnObject` at build time: it takes one object literal",
+ "`stylex.positionTry()` cannot use `...spread`",
+ "`stylex.positionTry()` cannot use `[computed]`",
+ "`stylex.positionTry()` cannot use `someVar`",
+ "`stylex.viewTransitionClass()` cannot use `later`",
+ "`stylex.keyframes()` cannot use `frames` at build time: it takes one object literal",
+ ],
+ ),
+ ] {
reset_class_map();
reset_file_map();
- let result = std::panic::catch_unwind(|| {
- extract(
- "test.tsx",
- src,
- 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(),
- },
- )
- });
- match result {
- Ok(Ok(output)) => {
- println!(
- "[OK] {label}: extract succeeded, {} styles",
- output.styles.len()
- );
- }
- Ok(Err(e)) => {
- panic!("[FAIL] {label}: extract returned Err: {e}");
- }
- Err(panic_payload) => {
- let msg = panic_payload
- .downcast_ref::<&'static str>()
- .map(|s| (*s).to_string())
- .or_else(|| panic_payload.downcast_ref::().cloned())
- .unwrap_or_else(|| "".to_string());
- panic!("[FAIL] {label}: extract panicked: {msg}");
- }
+ let message = extract(
+ "test.tsx",
+ &format!("import stylex from '@stylexjs/stylex';\n{code}"),
+ ExtractOption::default(),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ for error in errors {
+ assert!(message.contains(error), "{error}\nmissing from\n{message}");
}
}
}
#[test]
#[serial]
- fn extract_pseudo_selector_with_identifier_snapshot() {
- // Snapshot-locks the current behavior for the minimal ERROR.tsx
- // reduction so future refactors can't silently regress either
- // direction (re-introducing the panic, or over-eagerly turning the
- // `_hover={ident}` attribute into something surprising).
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
+ fn test_stylex_props_join_styles_compiled_elsewhere() {
+ assert_debug_snapshot!(ToBTreeSet::from(extract_tsx(
+ r"import * as stylex from '@stylexjs/stylex';
+const fade = stylex.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } });
+const colors = stylex.defineVars({ text: 'black' });
+const styles = stylex.create({
+ base: { animationName: { default: fade, ':hover': 'none' }, color: { default: colors.text, ':hover': 'red' } },
+ bar: (h) => ({ height: h }),
+ none: null,
+});
+export const A = ({ style, rest }) =>
;"
+ )));
+ }
+
+ #[test]
+ #[serial]
+ fn test_stylex_conditional_variables() {
+ assert_debug_snapshot!(ToBTreeSet::from(extract_tsx(
+ r"import * as stylex from '@stylexjs/stylex';
+const DARK = '@media (prefers-color-scheme: dark)';
+const colors = stylex.defineVars({
+ layered: { '@supports (display: grid)': { [DARK]: 'navy' }, [DARK]: 'teal', default: 'white' },
+ text: { default: 'black', [DARK]: 'white' },
+ accent: stylex.types.color({
+ default: 'blue',
+ [DARK]: 'lightblue',
+ '@supports (color: oklch(0 0 0))': { default: 'oklch(0.6 0.2 250)', [DARK]: 'oklch(0.8 0.1 250)' },
+ }),
+ onlyDark: { default: null, [DARK]: 'gray' },
+ size: '4px',
+});
+const dracula = stylex.createTheme(colors, { text: { [DARK]: 'pink', default: 'purple' }, accent: 'red' });
+const styles = stylex.create({ box: { color: colors.text, borderColor: colors.accent } });
+export const A = () =>
;"
+ )));
+ }
+
+ #[test]
+ #[serial]
+ fn test_stylex_variable_values_known_only_at_runtime() {
+ for (code, error) in [
+ (
+ "const colors = stylex.defineVars({ text: someValue });",
+ "`stylex.defineVars()` cannot use `someValue`",
+ ),
+ (
+ "const colors = stylex.defineVars({ text: { default: 'a', ':hover': 'b' } });",
+ "`stylex.defineVars()` cannot use `{",
+ ),
+ (
+ "const colors = stylex.defineVars({ text: { default: 'a', ...rest } });",
+ "`stylex.defineVars()` cannot use `{",
+ ),
+ (
+ "const colors = stylex.defineVars({ text: 'a' });\nconst theme = stylex.createTheme(colors, { text: getText() });",
+ "`stylex.createTheme()` cannot use `getText()`",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
"test.tsx",
- r#"import {Box} from '@devup-ui/react'
-const hoverStyle = { opacity: 1 };
-export const A = () => ;
-"#,
- 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()
- },
+ &format!("import * as stylex from '@stylexjs/stylex';\n{code}"),
+ ExtractOption::default(),
)
- .unwrap()
- ));
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains(error), "{message}");
+ }
}
- // Coverage for extract_style_from_expression.rs:206 — the
- // `Expression::BinaryExpression | StaticMemberExpression | CallExpression`
- // arm. Both internal branches must be exercised in a single, dedicated
- // test so coverage tooling can attribute hits to this exact line:
- //
- // 207: `if let Some(name) = name { dynamic_style(...) }` (Some path)
- // 211: `else { ExtractResult::default() }` (None path,
- // reached only via `_xxx={...}` selector recursion)
- //
- // Each variant (binary / static-member / call) is asserted on its own so
- // a regression in any single pattern fails loudly instead of being hidden
- // behind a multi-snapshot test.
#[test]
#[serial]
- fn extract_dynamic_style_props_binary_member_call_arm() {
- // ── Some(name) branch — line 207 ────────────────────────────────
- // BinaryExpression on a real prop name
- reset_class_map();
- reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
- "test.tsx",
- r#"import { Box } from "@devup-ui/core";
- ;
-"#,
+ fn test_stylex_values_imported_from_other_modules() {
+ for single_css in [true, false] {
+ reset_class_map();
+ reset_file_map();
+ let option = ExtractOption {
+ single_css,
+ ..ExtractOption::default()
+ };
+ let resolver = memory_resolver(STYLEX_MODULES);
+ let extract_module = |path: &str| {
+ let code = STYLEX_MODULES
+ .iter()
+ .find(|(file, _)| *file == path)
+ .map(|(_, code)| *code)
+ .unwrap();
+ extract_with_modules(path, code, option.clone(), false, &resolver).unwrap()
+ };
+ let vars = extract_module("/src/vars.stylex.ts");
+ let themes = extract_module("/src/themes.ts");
+ let app = extract_with_modules(
+ "/src/App.tsx",
+ r"import * as stylex from '@stylexjs/stylex';
+import { colors, consts, contract, dark } from './vars.stylex';
+import { loose } from './loose.stylex';
+import { light } from './themes';
+const s = stylex.create({ base: { color: colors.primary, backgroundColor: colors.brand, marginTop: consts.gap, zIndex: consts.size, borderColor: contract.accent, outlineColor: colors.mode, caretColor: colors.typed, accentColor: loose.fine } });
+const custom = stylex.createTheme(colors, { primary: 'purple' });
+export const A = () =>
;",
+ option.clone(),
+ false,
+ &resolver,
+ )
+ .unwrap();
+ let styles = format!("{:?}", app.styles);
+ // Each name the defining modules generate is the one the importer reads
+ let text_after = |code: &str, marker: &str, end: char| {
+ let (_, rest) = code.split_once(marker).unwrap();
+ rest.split(end).next().unwrap().to_string()
+ };
+ for key in ["primary", "brand", "mode", "typed"] {
+ let reference = format!(
+ "var({})",
+ text_after(&vars.code, &format!("\"{key}\": \"var("), ')')
+ );
+ assert!(
+ styles.contains(&reference),
+ "{reference} missing from {styles}"
+ );
+ }
+ for (module, name) in [(&vars.code, "dark"), (&themes.code, "light")] {
+ let class = text_after(module, &format!("export const {name} = \""), '"');
+ assert!(
+ app.code.contains(&class),
+ "{class} missing from {}",
+ app.code
+ );
+ }
+ assert!(styles.contains("value: \"8px\""), "{styles}");
+ assert!(styles.contains("--"), "{styles}");
+ assert!(!app.code.contains("createTheme"), "{}", app.code);
+ let vars_css = format!("{:?}", vars.styles);
+ assert!(
+ vars_css.contains("@media(prefers-color-scheme:dark){:root{--"),
+ "{vars_css}"
+ );
+ assert_eq!(
+ app.dependencies,
+ vec![
+ "/src/brand.ts",
+ "/src/loose.stylex.ts",
+ "/src/themes.ts",
+ "/src/vars.stylex.ts"
+ ]
+ );
+ }
+ }
+
+ const CONSTANT_MODULES: &[(&str, &str)] = &[
+ (
+ "/src/tokens.ts",
+ r"import { base } from './base';
+import * as spaced from './base';
+import baseDefault from './base';
+export const FROM_DEFAULT = baseDefault;
+console.log(FROM_DEFAULT);
+export const PRIMARY = 'red';
+export const SIZE = 4;
+export const NEG = -2;
+export const TEMPLATE = `10px`;
+export const colors = { [computed]: 'x', ...rest, primary: 'blue', nested: { deep: 'green' }, fromBase: base, skip: fn() } as const;
+export const ALIAS = (PRIMARY satisfies string);
+export let MUTABLE = 'no';
+export const DYNAMIC = fn(), [destructured] = [1];
+export const NEG_STRING = -'a';
+export const TEMPLATE_EXPRESSION = `${PRIMARY}`;
+export const SPACED = spaced.value;
+export const NOT_OBJECT = PRIMARY.length;
+const LOCAL = 'purple';
+export { LOCAL as renamed, MUTABLE as alsoMutable };
+export { base as reBase, missing as notThere } from './base';
+export { gone } from './missing';
+export * from './base';
+export * from './missing';
+export * as ns from './base';
+export default 'orange';
+export function helper() {}",
+ ),
+ (
+ "/src/base.ts",
+ r"import { x } from './loop';
+export const base = 'teal';
+export const value = 'navy';
+export default 'hidden-by-star';
+export const looped = x;",
+ ),
+ (
+ "/src/loop.ts",
+ "import { looped } from './base';\nexport const x = looped;",
+ ),
+ (
+ "/src/named.ts",
+ "const DEFAULT = 'olive';\nexport default DEFAULT;",
+ ),
+ ("/src/handler.ts", "export const handler = () => {};"),
+ (
+ "/src/cjs-tokens.js",
+ "'use strict';\nObject.defineProperty(exports, '__esModule', { value: true });\nexports.COMPILED = exports.OTHER = void 0;\nconst base_1 = require('./base');\nconst { value: navy } = require('./base');\nlet loose = require('./base');\nconst notRequire = load('./base');\nconst [first] = require('./base');\nconst fromName = require(name);\nother.exports.y = 1;\nlist[0].x = 1;\nexports.COMPILED = 'maroon';\nexports.FROM_REQUIRE = base_1.base;\nexports.DESTRUCTURED = navy;\nexports.MUTATED = 'a';\nfunction mutate() { exports.MUTATED = 'b'; }\nexports['computed'] = 'x';\nexports.DYNAMIC = compute();\nother.thing = 1;\nexports.default = 'indigo';",
+ ),
+ (
+ "/src/cjs-object.js",
+ "module.exports = { OBJ: 'plum', OVERRIDDEN: 'x' };\nmodule.exports.OVERRIDDEN = 'y';\nmodule.exports.LATER = 'lime';\nmodule.id = 1;",
+ ),
+ ("/src/cjs-value.js", "module.exports = 12;"),
+ (
+ "/src/cjs-twice.js",
+ "module.exports = { A: 'a' };\nmodule.exports = { A: 'b' };",
+ ),
+ (
+ "/src/cjs-marker.js",
+ "exports.__esModule = true;\nexports.named = 'teal';",
+ ),
+ ];
+
+ const CHANGED_MODULES: &[(&str, &str)] = &[
+ (
+ "/src/tokens.ts",
+ r"import { css } from '@devup-ui/react';
+export const base = { p: 4 };
+export const fixed = { p: 2 };
+export const colors = { primary: 'red' };
+export const grid = [1, 2];
+const local = { m: 1 };
+export { local as renamed };
+export default base;
+export const styles = css(fixed);
+export function dark() { base.p = 8; }
+export const hover = darken(colors.primary);
+register(grid.length);",
+ ),
+ (
+ "/src/reexport.ts",
+ "export { base, fixed } from './tokens';",
+ ),
+ ];
+
+ #[test]
+ #[serial]
+ fn test_changed_constants() {
+ let cases = [
+ "const base = { p: 4 };\nbase.p = 8;\nexport const a = css(base);",
+ "const base = { p: 4 };\nObject.assign(base, { p: 8 });\nexport const a = ;\nexport const b = css({ p: base.p });",
+ "const base = { p: 4 };\nregister(base);\nexport const a = ;\nexport const b = styled.div(base);\nexport const c = css(x ? base : null);\nexport const d = css(x ? null : [y && base]);\nexport const e = css(...[base]);",
+ "const base = { p: 4 };\nbase.p = 8;\nconst baseline = x;\nexport const a = ;\nexport const b = css({ p: baseline });",
+ "const colors = { primary: 'red' };\nconst hover = darken(colors.primary);\nexport const a = ;",
+ "const colors = { primary: 'red' };\nexport const theme = { colors };\nexport const a = ;",
+ "const colors = { primary: 'red' };\nexport const theme = { colors };\ntheme.colors.primary = 'blue';\nexport const a = ;",
+ "const loop1 = { x: loop2 };\nconst loop2 = { y: loop1 };\nexport const a = ;",
+ "const list = [{ p: 1 }];\nwatch(...list);\nexport const a = ;",
+ "const flat = [1, 2];\nwatch(...flat);\nexport const a = ;",
+ "const partial = { ...unknown, p: 1 };\nwatch(partial.q);\nexport const a = ;",
+ "const record = { p: 1 };\nwatch(record.q);\nexport const a = ;",
+ "const spread = { ...unknown, p: 1 };\nwatch({ ...spread });\nexport const a = ;",
+ "export enum E { A = 1 }\nenum F { B = 2 }\nexport function g() {}\nvar v = 1;\nconst make = () => ({ p: 1 });\nconst made = make();\nwatch(made);\nconst f = () => made.p;\nexport const a = css({ p: f() });",
+ "const make = () => ({ p: 1 });\nconst made = make();\nmade.p = 2;\nconst f = () => made.p;\nexport const a = css({ p: f() });",
+ "const pick = () => (typeof window === 'undefined' ? 'white' : 'black');\nexport const a = css({ color: pick() });",
+ "const brand = () => globalThis.BRAND ?? 'red';\nexport const a = css({ color: brand() });",
+ "const node = () => process.env.NODE_ENV;\nexport const a = css({ content: node() });",
+ "import { base } from './tokens';\nconst f = () => base.p;\nexport const a = css({ p: f() });",
+ "import base, { fixed, colors, grid, renamed } from './tokens';\nimport * as tokens from './tokens';\nexport const a = ;",
+ "import { base as again } from './reexport';\nexport const a = css(again);",
+ ];
+ let outputs: Vec = cases
+ .iter()
+ .map(|case| {
+ reset_class_map();
+ reset_file_map();
+ match extract_with_modules(
+ "/src/App.tsx",
+ &format!("import {{ Box, css, styled }} from '@devup-ui/react';\n{case}"),
+ ExtractOption::default(),
+ false,
+ &memory_resolver(CHANGED_MODULES),
+ ) {
+ Ok(output) => output.code,
+ Err(error) => format!("Error: {error}"),
+ }
+ })
+ .collect();
+ assert_debug_snapshot!(outputs);
+ }
+
+ const RUNTIME_MODULES: &[(&str, &str)] = &[
+ (
+ "/src/tokens.ts",
+ "export const colors = { primary: 'red' };\nexport default { colors };",
+ ),
+ (
+ "/src/frozen.ts",
+ "export const heading = { fontSize: 24 };\nexport const typography = Object.freeze({ heading });",
+ ),
+ (
+ "/src/changed.ts",
+ "export const base = { p: 4 };\nexport const colors = { primary: 'red' };\nexport const helper = () => 1;\nexport function dark() { base.p = 8; }",
+ ),
+ (
+ "/src/cjs.js",
+ "const colors = { primary: 'red' };\ncolors.primary = 'blue';\nmodule.exports = { colors };",
+ ),
+ (
+ "/src/cjs-plain.js",
+ "const colors = { primary: 'red' };\nconst spacing = { m: 2 };\nmodule.exports = { colors };\nexports.spacing = spacing;",
+ ),
+ (
+ "/src/env.ts",
+ "import { base } from './changed';\nconsole.log(base);\nexport const isBrowser = typeof window !== 'undefined';\nexport const mode = isBrowser ? 'dark' : 'light';\nexport const label = (1).toLocaleString();\nexport const format = (n) => n.toLocaleString();\nexport const double = (n) => n * 2;\nexport function triple(n) { return n * 3; }\nexport class Scale { static of(n) { return n * 4; } }",
+ ),
+ ];
+
+ #[test]
+ #[serial]
+ fn test_values_known_only_at_runtime() {
+ let cases = [
+ "import { colors } from './tokens';\nexport const a = ;",
+ "import { heading } from './frozen';\nexport const a = ;",
+ "const theme = { text: { color: 'red' } };\nexport const App = () => ;",
+ "const handle = { current: null, p: 1 };\nexport const App = () =>
;",
+ "import * as tokens from './changed';\nexport const a = css(tokens.base);\nexport const b = ;\nexport const c = ;\nexport const d = css(tokens[key]);\nexport const e = css(make() || tokens.base);\ntokens.helper();\ntokens.colors = null;",
+ "import * as tokens from './changed';\ntokens.colors.primary = 'blue';\nexport const a = ;",
+ "import * as tokens from './changed';\nregister(tokens);\nexport const a = ;",
+ "import { base } from './changed';\nimport * as tokens from './changed';\nconst theme = { base };\nexport const a = css(theme.base, tokens.base, base);\nexport const b = ;\nexport const c = css(theme.missing);",
+ "import { double, triple, Scale, mode } from './env';\nexport const a = css({ w: double(2), h: triple(2), m: Scale.of(1) });\nexport const b = css({ color: mode });",
+ "import { label, format } from './env';\nexport const a = css({ content: label });\nexport const b = css({ content: format(1) });",
+ "const label = () => (1234.5).toLocaleString();\nconst LABEL = 'i'.toLocaleUpperCase();\nconst pick = () => LABEL;\nexport const a = css({ content: label() });\nexport const b = css({ content: pick() });\nexport const c = css({ w: window.innerWidth });\nexport const d = css({ content: new Intl.NumberFormat().format(1) });",
+ "import { colors } from './cjs';\nimport cjs, { colors as plain, spacing } from './cjs-plain';\nexport const a = ;",
+ "const list = [{ p: 1 }];\nfor (const item of list) item.p = 2;\nexport const a = ;",
+ "const list = [{ p: 1 }];\nlist.forEach((item) => { item.p = 2; });\nexport const a = ;",
+ "const store = { items: [1], add(x) { this.items.push(x); } };\nstore.add(2);\nexport const a = ;",
+ "const theme = { spacing: (n) => n * 4, colors: { primary: 'red' }, label: 'x' };\nconst gap = theme.spacing(2);\ntheme.label.custom();\nexport const a = ;",
+ "const sizes = [1, 2];\nconst copy = Array.from(sizes);\nexport const a = ;",
+ "const isBrowser = typeof window !== 'undefined';\nconst pick = () => (!isBrowser ? 'white' : 'black');\nexport const a = css({ color: pick() });",
+ "const parsed = JSON.parse('{bad');\nconst pick = () => (parsed ? 'a' : 'b');\nexport const a = css({ content: pick() });",
+ "import { isMobile } from 'unknown-package';\nconst pick = () => (isMobile ? 'a' : 'b');\nexport const a = css({ content: pick() });",
+ "export const a = css({ content: '1234.5', fontSizeAdjust: 0.5, mathDepth: 2 });",
+ ];
+ let outputs: Vec = cases
+ .iter()
+ .map(|case| {
+ reset_class_map();
+ reset_file_map();
+ match extract_with_modules(
+ "/src/App.tsx",
+ &format!("import {{ Box, css, styled }} from '@devup-ui/react';\n{case}"),
+ ExtractOption::default(),
+ false,
+ &memory_resolver(RUNTIME_MODULES),
+ ) {
+ Ok(output) => format!("{:?}", ToBTreeSet::from(output)),
+ Err(error) => format!("Error: {error}"),
+ }
+ })
+ .collect();
+ assert_debug_snapshot!(outputs);
+
+ reset_class_map();
+ reset_file_map();
+ let stylesheet = extract(
+ "/src/log.css.ts",
+ "import { style } from '@devup-ui/react';\nconsole.warn('loaded');\nexport const box = style({ color: 'red' });",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ assert_eq!(stylesheet.styles.len(), 1);
+ }
+
+ #[test]
+ #[serial]
+ fn test_inline_imported_constants() {
+ reset_class_map();
+ reset_file_map();
+ let output = extract_with_modules(
+ "/src/App.tsx",
+ r"import { Box, css, styled } from '@devup-ui/react';
+import * as Devup from '@devup-ui/react';
+import { PRIMARY, SIZE, NEG, TEMPLATE, colors, ALIAS, MUTABLE, DYNAMIC, renamed, alsoMutable, reBase, notThere, gone, value, ns, NEG_STRING, TEMPLATE_EXPRESSION, SPACED, NOT_OBJECT, helper, looped } from './tokens';
+import orange from './tokens';
+import * as tokens from './tokens';
+import named from './named';
+import { handler } from './handler';
+import cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from './cjs-tokens';
+import cjsObject, { OBJ, OVERRIDDEN, LATER } from './cjs-object';
+import twelve from './cjs-value';
+import twice from './cjs-twice';
+import marker, { named as markerNamed } from './cjs-marker';
+import { unused } from './unused';
+import missing from './missing';
+export const a = ;
+export const b = css({ color: PRIMARY });
+export const c = ;
+export const d = styled.div`color: ${PRIMARY};`;
+export const e = (PRIMARY) => ;
+export const f = { PRIMARY, unused, SIZE: tokens.SIZE };
+export const g =
;
+export const h = ;
+export const i = Devup['css']({ color: SIZE });
+export const l = ;
+export const j = ;
+export const k = styled('div')({ color: SIZE });",
+ 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(),
+ },
+ false,
+ &memory_resolver(CONSTANT_MODULES),
+ )
+ .unwrap();
+ assert_eq!(
+ output.dependencies,
+ [
+ "/src/base.ts",
+ "/src/cjs-marker.js",
+ "/src/cjs-object.js",
+ "/src/cjs-tokens.js",
+ "/src/cjs-twice.js",
+ "/src/cjs-value.js",
+ "/src/handler.ts",
+ "/src/loop.ts",
+ "/src/named.ts",
+ "/src/tokens.ts"
+ ]
+ );
+ assert_debug_snapshot!(ToBTreeSet::from(output));
+ let without_imports = extract_with_modules(
+ "/src/Plain.tsx",
+ "import { Box } from '@devup-ui/react';\nexport const a = ;",
+ ExtractOption::default(),
+ false,
+ &memory_resolver(CONSTANT_MODULES),
+ )
+ .unwrap();
+ assert!(without_imports.dependencies.is_empty());
+ let without_constants = extract_with_modules(
+ "/src/Handler.tsx",
+ "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;",
+ ExtractOption::default(),
+ false,
+ &memory_resolver(CONSTANT_MODULES),
+ )
+ .unwrap();
+ assert_eq!(without_constants.dependencies, ["/src/handler.ts"]);
+ }
+
+ #[test]
+ #[serial]
+ fn test_constant_objects_arrays_and_enums() {
+ reset_class_map();
+ reset_file_map();
+ let modules: &[(&str, &str)] = &[(
+ "/src/tokens.ts",
+ "export const space = [0, 4, 8];\nexport enum Size { S = 4, M = 8 }\nenum Local { A = 'a' }\nexport { Local };\nexport const focus = { color: 'navy', ...{ outlineColor: 'teal' } };",
+ )];
+ let output = extract_with_modules(
+ "/src/App.tsx",
+ r"import { Box, css, styled, globalCss, keyframes } from '@devup-ui/react';
+import * as Devup from '@devup-ui/react';
+import * as stylex from '@stylexjs/stylex';
+import { space, Size, Local, focus } from './tokens';
+const base = { p: 4, color: 'red', color: 'blue' };
+const hover = { opacity: 0.5 };
+const merged = { ...base, m: 1 };
+const partial = { ...unknown, m: 1 };
+const proto = { __proto__: null, a: 1 };
+const getter = { get a() { return 1; } };
+export const withGetter = ;
+const NONE = null;
+const MEDIA = '@media (min-width: 1px)';
+const keyed = { [MEDIA]: { color: 'green' } };
+const widths = [10, null, 20];
+const more = [...widths, 30];
+const bad = [...unknown];
+enum Level { Low = 1, High, Name = 'n', Computed = Math.max(1, 2), Last = 9 }
+declare enum Ambient { X = 1 }
+const sx = { color: 'red' };
+export const a = css(base);
+export const b = css({ ...merged, _hover: hover, ...keyed, color: NONE });
+export const c = css(base, { m: 3 });
+export const d = styled.div(base);
+export const e = styled.div({ ...base, _hover: hover });
+export const f = (Comp, handler) => ;
+export const g = ;
+export const h = Devup.css(base);
+export const i = styled(Box)(base);
+export const j = styled.div.attrs({ role: 'button' })(base);
+export const k = stylex.create({ x: sx });
+globalCss({ body: base });
+export const l = keyframes({ from: hover });
+export const m = css({ right: proto.a, zIndex: partial.m, order: Level.Computed, flexGrow: Level.Last });",
+ ExtractOption::default(),
+ false,
+ &memory_resolver(modules),
+ );
+ assert_debug_snapshot!(
+ output
+ .map(ToBTreeSet::from)
+ .map_err(|error| error.to_string())
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn test_styles_the_build_cannot_read() {
+ for (code, error) in [
+ (
+ "export const a = (x) => css({ ...x });",
+ "`css()` cannot use `x`",
+ ),
+ (
+ "const shared = { m: 1, ...unknown };\nexport const a = css({ m: shared.m });",
+ "`css()` cannot use `shared.m`",
+ ),
+ (
+ "const scale = [0, 4];\nexport const a = css({ top: scale[9], left: scale['01'] });",
+ "`css()` cannot use `scale[",
+ ),
+ (
+ "export const a = (x) => css({ _hover: x });",
+ "`css()` cannot use `x`",
+ ),
+ (
+ "export const a = (k) => css({ [k]: 1 });",
+ "`css()` cannot use `[k]`",
+ ),
+ (
+ "export const a = (x) => css({ _hover: { ...x } });",
+ "`css()` cannot use `x`",
+ ),
+ (
+ "export const a = (k) => css({ _hover: { [k]: 'red' } });",
+ "`css()` cannot use `[k]`",
+ ),
+ (
+ "export const a = (x) => css({ _hover: new x() });",
+ "`css()` cannot use `new x()`",
+ ),
+ (
+ "export const a = (x) => css({ _hover: () => x });",
+ "`css()` cannot use `() => x`",
+ ),
+ (
+ "export const a = (make) => css(make());",
+ "Cannot compose `make()`",
+ ),
+ (
+ "export const a = (x) => globalCss(x);",
+ "`globalCss()` cannot use `x`",
+ ),
+ (
+ "export const a = (k) => globalCss({ [k]: {} });",
+ "`globalCss()` cannot use `[k]`",
+ ),
+ (
+ "export const a = (x) => globalCss({ body: { ...x } });",
+ "`globalCss()` cannot use `x`",
+ ),
+ (
+ "export const a = (x) => keyframes(x);",
+ "`keyframes()` cannot use `x`",
+ ),
+ (
+ "export const a = (k) => keyframes({ [k]: {} });",
+ "`keyframes()` cannot use `[k]`",
+ ),
+ (
+ "export const a = (x) => keyframes({ ...x });",
+ "`keyframes()` cannot use `...x`",
+ ),
+ (
+ "export const a = (x) => styled.div({ ...x });",
+ "`styled()` cannot use `x`",
+ ),
+ (
+ "export const a = (x) => ;",
+ "`` cannot use `x`",
+ ),
+ (
+ "export const a = (x) => ;",
+ "`` cannot use `x`",
+ ),
+ (
+ "export const a = (k) => ;",
+ "`` cannot use `[k]`",
+ ),
+ (
+ "export const a = (on, rest) => ;",
+ "`` cannot use `rest`",
+ ),
+ (
+ "export const a = (list) => ;",
+ "`` cannot use `[...list][0]`",
+ ),
+ (
+ "export const a = (list, i) => ;",
+ "`` cannot use `[...list, {}][i]`",
+ ),
+ (
+ "export const a = (extra) => ;",
+ "`` cannot use `({ ...extra",
+ ),
+ (
+ "export const a = (styles, k) => ;",
+ "`` cannot use `styles.all[k]`",
+ ),
+ (
+ "export const a = (c, x) => ;",
+ "`` cannot use `x`",
+ ),
+ (
+ "export const a = (rest) => ;",
+ "`` cannot use `rest`",
+ ),
+ (
+ "declare const X: number;\nexport const a = css({ w: String(X) });",
+ "`css()` cannot use `String(X)`",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
+ "test.tsx",
+ &format!("import {{ Box, css, styled, globalCss, keyframes }} from '@devup-ui/react';\n{code}"),
+ ExtractOption::default(),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains(error), "{code}\n{message}");
+ }
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "test.tsx",
+ r"import { Box, css, styled, keyframes } from '@devup-ui/react';
+export const a = (base) => css(base);
+export const b = (on) => css(on && { color: 'blue' });
+export const c = css('color: green; margin: 0');
+export const d = (on, base) => css(on ? base : { color: 'red' });
+export const e = css({ ...null, ...false, ...undefined, ...(void 0), p: 1 });
+export const f = (rest, handler, k) => ;
+export const g = (theme, key) => ;
+export const h = (Base) => styled.div(Base);
+export const i = keyframes({ from: { opacity: 0 } } as const);
+export const j = css(...{ bg: 'red' });",
+ ExtractOption::default(),
+ )
+ .unwrap()
+ ));
+ reset_class_map();
+ reset_file_map();
+ assert_eq!(
+ extract(
+ "test.tsx",
+ "import { Global } from '@emotion/react';\nexport const a = (x) => ;",
+ ExtractOption {
+ import_aliases: HashMap::from([(
+ "@emotion/react".to_string(),
+ ImportAlias::NamedToNamed,
+ )]),
+ ..ExtractOption::default()
+ },
+ )
+ .unwrap_err()
+ .to_string(),
+ "test.tsx:2:41: `` cannot use `x` at build time: its values must be literals, theme tokens or constants"
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn test_computed_values_through_functions_and_style_packages() {
+ reset_class_map();
+ reset_file_map();
+ let modules: &[(&str, &str)] = &[(
+ "/src/sx.ts",
+ "import * as stylex from '@stylexjs/stylex';\nexport const S = stylex.create({ x: { color: 'red' } });\nexport const W = String(3) + 'px';",
+ )];
+ let output = extract_with_modules(
+ "/src/App.tsx",
+ r"import { Box, css } from '@devup-ui/react';
+import { W } from './sx';
+const space = [0, 4, 8];
+const NONE = null;
+const IDX = space[1];
+const LABEL = `x${NONE}`;
+const bad = [...'ab'];
+export function triple(n: number) { return n * 3; }
+export const a = css({ h: triple(2), m: IDX, content: LABEL, p: bad });
+export const w = ;
+export function b() { return ; }",
+ ExtractOption::default(),
+ false,
+ &memory_resolver(modules),
+ )
+ .unwrap();
+ assert_eq!(output.dependencies, ["/src/sx.ts"]);
+ let styles = format!("{:?}", ToBTreeSet::from(output).styles);
+ for value in ["24px", "16px", "xnull", "identifier: \"W\""] {
+ assert!(styles.contains(value), "{value}\n{styles}");
+ }
+ assert!(!styles.contains("3px"), "{styles}");
+ }
+
+ #[test]
+ #[serial]
+ fn test_compose_rules_the_module_computes() {
+ reset_class_map();
+ reset_file_map();
+ let modules: &[(&str, &str)] = &[
+ (
+ "/src/styles.ts",
+ "import { css, styled } from '@devup-ui/react';\nexport const card = css({ p: 1 });\nexport const Card = styled.div({ p: 2 });\nexport const Linked = styled('a')({ p: 3 });\nexport const tagged = css`color: red;`;\nexport const make = (n: number) => ({ m: n });\nexport const computed = make(3);\nexport const rules = { m: 7 };\nexport const nested = { x: make(9), known: 'k' };",
+ ),
+ (
+ "/src/emotion.ts",
+ "import { css } from '@emotion/react';\nexport const emotionClass = css({ p: 1 });",
+ ),
+ ];
+ let output = extract_with_modules(
+ "/src/App.tsx",
+ r"import { css, styled } from '@devup-ui/react';
+import * as Devup from '@devup-ui/react';
+import * as tokens from './styles';
+import { card, rules } from './styles';
+import { emotionClass } from './emotion';
+const make = (n: number) => ({ m: n });
+const local = make(2);
+const computed = make(3);
+const name = String('named');
+const shared = { rules: make(4), fixed: { p: 5 } };
+export const a = css(local);
+export const b = css(card, computed);
+export const c = css(name, null, true, 'x', `y`);
+export const d = styled.div(local);
+export const e = styled('span')(computed);
+export const f = styled.p.attrs({ role: 'note' })(shared.rules);
+export const g = (on) => css(on ? local : card, on || computed, on && local, [local, shared.fixed]);
+export const h = css(rules, tokens.rules);
+export const i = Devup.css(local);
+export const k = css(tokens.nested.known, tokens.card);
+export const l = css(emotionClass, { m: 1 });
+export const n = ;",
+ ExtractOption {
+ import_aliases: HashMap::from([(
+ "@emotion/react".to_string(),
+ ImportAlias::NamedToNamed,
+ )]),
+ ..ExtractOption::default()
+ },
+ false,
+ &memory_resolver(modules),
+ )
+ .unwrap();
+ assert_eq!(output.dependencies, ["/src/emotion.ts", "/src/styles.ts"]);
+ assert_debug_snapshot!(ToBTreeSet::from(output));
+
+ for (code, part) in [
+ ("css(tokens.computed)", "tokens.computed"),
+ ("css(tokens.nested.x, { m: 1 })", "tokens.nested.x"),
+ ("css(tokens.make(12))", "tokens.make(12)"),
+ ("styled.div(computed)", "computed"),
+ ("(key) => css(shared[key])", "shared[key]"),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract_with_modules(
+ "/src/App.tsx",
+ &format!("import {{ css, styled }} from '@devup-ui/react';\nimport * as tokens from './styles';\nimport {{ computed }} from './styles';\nconst shared = {{ rules: tokens.make(4) }};\nexport const a = {code};"),
+ ExtractOption::default(),
+ false,
+ &memory_resolver(modules),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(
+ message.contains(&format!("cannot use `{part}`")),
+ "{code}\n{message}"
+ );
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_source_map_points_at_the_code_as_written() {
+ reset_class_map();
+ reset_file_map();
+ let code = "import { css } from '@emotion/react';\nconst PRIMARY = 'red';\nconst darken = (amount, color) => color;\nexport const a = css({\n color: darken(\n 0.1,\n PRIMARY,\n ),\n});\nexport const after = 1;\n";
+ let output = extract(
+ "src/App.tsx",
+ code,
+ ExtractOption {
+ import_aliases: HashMap::from([(
+ "@emotion/react".to_string(),
+ ImportAlias::NamedToNamed,
+ )]),
+ ..ExtractOption::default()
+ },
+ )
+ .unwrap();
+ let map =
+ oxc_sourcemap::SourceMap::from_json_string(output.map.as_deref().unwrap()).unwrap();
+ assert_eq!(map.get_source_content(0), Some(code));
+ let (line, generated) = output
+ .code
+ .lines()
+ .enumerate()
+ .find(|(_, line)| line.contains("after"))
+ .unwrap();
+ let column = generated.find("after").unwrap();
+ let token = map
+ .get_tokens()
+ .find(|token| {
+ token.get_dst_line() as usize == line && token.get_dst_col() as usize == column
+ })
+ .unwrap();
+ let written = code.lines().nth(token.get_src_line() as usize).unwrap();
+ assert!(
+ written[token.get_src_col() as usize..].starts_with("after"),
+ "{written}"
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn test_styles_with_no_class_to_switch() {
+ for (code, error) in [
+ (
+ "export const a = (on) => keyframes({ from: { opacity: on ? 0 : 1 } });",
+ "`keyframes()` cannot use `on`",
+ ),
+ (
+ "export const a = (on) => globalCss({ body: { color: on ? 'red' : 'blue' } });",
+ "`globalCss()` cannot use `on`",
+ ),
+ (
+ "export const a = (k) => globalCss({ body: { color: { a: 'red', b: 'blue' }[k] } });",
+ "`globalCss()` cannot use `k`",
+ ),
+ (
+ "export const a = (on) => globalCss({ body: { color: [on ? 'red' : 'blue'] } });",
+ "`globalCss()` cannot use `on`",
+ ),
+ (
+ "export const a = (on) => ;",
+ "`` cannot use `on`",
+ ),
+ (
+ "export const a = (side) => globalCss({ body: { positioning: side } });",
+ "`globalCss()` cannot use `side`",
+ ),
+ (
+ "export const a = (on) => globalCss({ body: { color: 'red', bg: on ? 'a' : 'b' } });",
+ "`globalCss()` cannot use `on`",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
+ "test.tsx",
+ &format!(
+ "import {{ Global, globalCss, keyframes }} from '@devup-ui/react';\n{code}"
+ ),
+ ExtractOption::default(),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains(error), "{code}\n{message}");
+ }
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "test.tsx",
+ r"import { css, globalCss, keyframes } from '@devup-ui/react';
+const DARK = true;
+export const a = css({ color: DARK ? 'red' : x, bg: false || 'blue', m: null ?? 2, p: 0 && x, w: 'a' && 3, h: undefined ?? 4, opacity: 0 ? 1 : 0.5 });
+export const g = globalCss({ body: { color: DARK ? 'red' : 'blue' } });
+export const h = () => globalCss({ body: { m: 0 } });
+export const i = [globalCss({ html: { p: 0 } })];
+export const j = (on) => on && globalCss`body { color: red; }`;
+globalCss({ div: { m: 1 } });
+export const k = keyframes({ from: { opacity: DARK ? 0 : 1 } });",
+ ExtractOption::default(),
+ )
+ .unwrap()
+ ));
+ }
+
+ #[test]
+ #[serial]
+ fn test_every_runtime_value_an_element_holds() {
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "test.tsx",
+ "import { Box } from '@devup-ui/react';\nclass A { #w = 1; render(f) { return ; } }\nexport const a = async (f, X, tag) => ;",
+ ExtractOption::default(),
+ )
+ .unwrap()
+ ));
+ for value in ["function () {}", "class {}"] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
+ "test.tsx",
+ &format!("import {{ Box }} from '@devup-ui/react';\nexport const a = ;"),
+ ExtractOption::default(),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains("`` cannot use"), "{value}\n{message}");
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_compute_what_elements_and_styled_objects_take() {
+ reset_class_map();
+ reset_file_map();
+ let modules: &[(&str, &str)] = &[(
+ "/src/tokens.ts",
+ "export const make = (n: number) => ({ m: n });\nexport const KEY = ['co', 'lor'].join('');\nexport const PRIMARY = '#336699';\nexport function darken(amount: number, color: string) { return color === PRIMARY ? '#112233' : color; }",
+ )];
+ let output = extract_with_modules(
+ "/src/App.tsx",
+ r"import { Box, css, styled } from '@devup-ui/react';
+import * as Devup from '@devup-ui/react';
+import { PRIMARY } from './tokens';
+const make = (n: number) => ({ m: n });
+const KEY = ['co', 'lor'].join('');
+function darken(amount: number, color: string) { return color === PRIMARY ? '#112233' : color; }
+const card = make(2);
+const hover = make(3);
+const parts = [make(4), { p: 1 }];
+export const a = 1} />;
+export const b = ;
+export const c = ;
+export const d = ;
+export const e = styled.div({ ...make(6), _hover: hover, [KEY]: 'red' });
+export const f = css(...parts);
+export const g = css(`color: ${darken(0.1, PRIMARY)};`);
+export const h =
;
+function local() { return { p: 7 }; }
+export const i = ;
+export const j = (register) => ;",
+ ExtractOption::default(),
+ false,
+ &memory_resolver(modules),
+ )
+ .unwrap();
+ assert_debug_snapshot!(ToBTreeSet::from(output));
+
+ reset_class_map();
+ reset_file_map();
+ let imported = extract_with_modules(
+ "/src/App.tsx",
+ r"import { Box } from '@devup-ui/react';
+import { make } from './tokens';
+export const a = ;",
+ ExtractOption::default(),
+ false,
+ &memory_resolver(modules),
+ )
+ .unwrap();
+ assert!(imported.code.contains(")(make(5))"), "{}", imported.code);
+ }
+
+ #[test]
+ #[serial]
+ fn test_spreads_are_read_once() {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ r"import { Box } from '@devup-ui/react';
+import { jsx as _jsx } from 'react/jsx-runtime';
+export const a = (register, rest, C) => (
+
+
+
+);
+export const b = async (load) => ;
+export const c = (props) => ;
+export const d = (f) => _jsx(Box, { ...f(), bg: 'red' });
+export const e = async (f, g) => _jsx(Box, { ...f(), title: await g() });
+export const g = async (f, g, h) => ;
+export const h = async (f, g) => {await g()} ;
+export const i = async (f, g) => _jsx(Box, { className: await g(), ...f() });",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ assert_debug_snapshot!(ToBTreeSet::from(output));
+ }
+
+ #[test]
+ #[serial]
+ fn test_read_once_in_every_place() {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ r"import { Box } from '@devup-ui/react';
+import { jsx as _jsx } from 'react/jsx-runtime';
+export const a = async (f, g, list, C) => (
+ await g()} title= >
+ text
+ <>{g}>
+ {...list()}
+ {g}
+ {await g()}
+ after
+
+);
+export const b = function* (f, g) { yield ; };
+export const c = (props) => _jsx(Box, props);
+export const d = async (f, g) => _jsx(Box, { ...f(), [g()]: await g() });
+export const e = async (rest, g) => _jsx(Box, { id: await g(), ...rest });
+export const h = (f, g, k) => _jsx(Box, { className: 'z', onClick: g(), [k]: 2, ...f(), [k]: 1, title: 'x' });
+export const i = (rest) => _jsx(Box, { ...rest, className: 'a' });
+export const j = (rest) => ;",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ assert_debug_snapshot!(ToBTreeSet::from(output));
+ }
+
+ #[test]
+ #[serial]
+ fn test_dynamic_as_where_the_element_stands() {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ r"import { Box } from '@devup-ui/react';
+export const a = (on, C, items) => (
+ }>
+
+ {items.map((item) => )}
+ {on}
+
+);
+export const after = 42;",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ assert_debug_snapshot!(ToBTreeSet::from(output));
+ }
+
+ #[test]
+ #[serial]
+ fn test_dynamic_as_elements() {
+ let outputs: Vec<(&str, String)> = [
+ " ",
+ " ",
+ " ",
+ "x ",
+ " ",
+ " ",
+ "{items.map((i) => )}
",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ "{render()} ",
+ " ",
+ " ",
+ " ",
+ ]
+ .into_iter()
+ .map(|code| {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ &format!("import {{ Box, Text }} from '@devup-ui/react';\n{code}"),
+ ExtractOption {
+ import_main_css: false,
+ ..ExtractOption::default()
+ },
+ )
+ .unwrap();
+ let code_out = output
+ .code
+ .lines()
+ .filter(|line| !line.starts_with("import "))
+ .collect::>()
+ .join("\n");
+ (code, code_out)
+ })
+ .collect();
+ assert_debug_snapshot!(outputs);
+ }
+
+ #[test]
+ #[serial]
+ fn test_styled_components_of_any_base() {
+ let outputs: Vec<(&str, String)> = [
+ "export const A = styled(motion.div)`color: red;`;",
+ "export const B = styled((Motion as any).Div.Inner)({ color: 'red' });",
+ "export const C = styled(forwardRef((p, r) =>
)).attrs({ role: 'x' })`color: red;`;",
+ "export const D = (tag) => styled(tag, { color: 'red' });",
+ "export const E = styled(getThing().div)`color: red;`;",
+ ]
+ .into_iter()
+ .map(|code| {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ &format!("import {{ styled }} from '@devup-ui/react';\n{code}"),
+ ExtractOption {
+ import_main_css: false,
+ ..ExtractOption::default()
+ },
+ )
+ .unwrap();
+ (
+ code,
+ output
+ .code
+ .lines()
+ .filter(|line| !line.starts_with("import "))
+ .collect::>()
+ .join("\n"),
+ )
+ })
+ .collect();
+ assert_debug_snapshot!(outputs);
+
+ for (code, error) in [
+ (
+ "styled(...parts)`color: red;`",
+ "`styled()` cannot use `styled(...parts)`",
+ ),
+ (
+ "styled(null)`color: red;`",
+ "`styled()` cannot use `styled(null)`",
+ ),
+ (
+ "styled(1)({ color: 'red' })",
+ "`styled()` cannot use `styled(1)({ color: \"red\" })`",
+ ),
+ (
+ "styled(undefined, { color: 'red' })",
+ "`styled()` cannot use `styled(undefined,",
+ ),
+ (
+ "styled('div', styles)",
+ "`styled()` cannot use `styled(\"div\", styles)`",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
+ "test.tsx",
+ &format!("import {{ styled }} from '@devup-ui/react';\nlet styles = {{}};\nexport const A = {code};"),
+ ExtractOption::default(),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains(error), "{code}\n{message}");
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_package_imported_whole() {
+ let outputs: Vec<(&str, String)> = [
+ "import * as Devup from '@devup-ui/react';\nexport const k = Devup.keyframes({ from: { opacity: 0 } });\nexport const G = Devup.createGlobalStyle({ body: { m: 0 } });\nexport const c = Devup.css`color: red;`;\nexport const t = Devup.keyframes`from { opacity: 1; }`;\nDevup.globalCss`body { margin: 0; }`;",
+ "import Devup from '@devup-ui/react';\nexport const S = Devup.styled.div({ color: 'red' });\nexport const T = Devup.styled('span', { color: 'blue' });\nexport const U = Devup.styled.p.attrs({ role: 'note' })({ m: 1 });\nexport const g = ;",
+ "import * as Devup from '@devup-ui/react';\nimport { styled } from '@devup-ui/react';\nexport const S = styled.div({ p: 1 });\nexport const o = other.styled.div({ p: 1 });\nexport const m = ;",
+ ]
+ .into_iter()
+ .map(|code| {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ code,
ExtractOption {
- package: "@devup-ui/core".to_string(),
- css_dir: "@devup-ui/core".to_string(),
- single_css: true,
import_main_css: false,
- import_aliases: HashMap::new()
- }
+ ..ExtractOption::default()
+ },
)
- .unwrap()
+ .unwrap();
+ (code, output.code)
+ })
+ .collect();
+ assert_debug_snapshot!(outputs);
+ }
+
+ #[test]
+ #[serial]
+ fn test_style_results_read_as_values() {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ r"import { Box, css, keyframes, globalCss, styled } from '@devup-ui/react';
+import { Global } from '@devup-ui/react/compat';
+const fadeIn = keyframes({ from: { opacity: 0 } });
+const slide = keyframes`from { left: 0; }`;
+const card = css({ p: 1 });
+export const a = css({ animationName: fadeIn, animation: `${fadeIn} 1s` });
+export const b = css(card, { m: 1 });
+export const c = css`animation: ${slide} 2s;`;
+export const d = ;
+export const E = styled.div({ animation: `${fadeIn} 4s` });
+globalCss({ body: { animation: `${slide} 5s` } });
+export const g = ;",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ assert_debug_snapshot!(ToBTreeSet::from(output));
+
+ for code in [
+ "const fadeIn = keyframes({ from: { opacity: 0 } });\nexport const f = (fadeIn) => css({ animationName: fadeIn });",
+ "let fadeIn = keyframes({ from: { opacity: 0 } });\nexport const a = css({ animationName: fadeIn });",
+ "const { fadeIn } = { fadeIn: keyframes({ from: { opacity: 0 } }) };\nexport const a = css({ animationName: fadeIn });",
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
+ "test.tsx",
+ &format!("import {{ css, keyframes }} from '@devup-ui/react';\n{code}"),
+ ExtractOption::default(),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains("cannot use `fadeIn`"), "{code}\n{message}");
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_dynamic_values_keep_the_code_as_written() {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ "import { Box } from '@devup-ui/react';\nexport const a = (f, i) => ;",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ for written in [
+ "#336699",
+ "10px 0px",
+ "rgba(0, 0, 0, 0.5)",
+ "--i",
+ "#ffffff",
+ ] {
+ assert!(output.code.contains(written), "{written}\n{}", output.code);
+ }
+ assert!(!output.code.contains("var(--i)"), "{}", output.code);
+ assert!(!output.code.contains(";`"), "{}", output.code);
+ }
+
+ #[test]
+ #[serial]
+ fn test_imported_stylesheet_runs_once_per_script() {
+ reset_class_map();
+ reset_file_map();
+ let option = ExtractOption {
+ import_aliases: HashMap::from([(
+ "@vanilla-extract/css".to_string(),
+ ImportAlias::NamedToNamed,
+ )]),
+ ..ExtractOption::default()
+ };
+ let red = memory_resolver(&[(
+ "/src/theme.css.ts",
+ "import { style } from '@vanilla-extract/css';\nexport const brand = ['re', 'd'].join('');\nexport const base = style({ color: brand });",
+ )]);
+ let blue = memory_resolver(&[(
+ "/src/theme.css.ts",
+ "import { style } from '@vanilla-extract/css';\nexport const brand = ['blu', 'e'].join('');\nexport const base = style({ color: brand });",
+ )]);
+ let colors = |name: &str, resolver: &ModuleResolver| {
+ let output = extract_with_modules(
+ &format!("/src/{name}.css.ts"),
+ &format!("import {{ style }} from '@vanilla-extract/css';\nimport {{ brand }} from './theme.css';\nexport const {name} = style({{ color: brand }});"),
+ option.clone(),
+ false,
+ resolver,
+ )
+ .unwrap();
+ format!("{:?}", ToBTreeSet::from(output).styles)
+ };
+ for (name, resolver, color) in [
+ ("first", &red as &ModuleResolver, "\"red\""),
+ ("again", &red, "\"red\""),
+ ("changed", &blue, "\"blue\""),
+ ] {
+ let styles = colors(name, resolver);
+ assert!(styles.contains(color), "{name}: {styles}");
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_evaluate_build_time_values() {
+ reset_class_map();
+ reset_file_map();
+ let modules: &[(&str, &str)] = &[
+ (
+ "/src/color.ts",
+ "export const PRIMARY = '#336699';\nexport const SPACE = [4, 8];\nexport function darken(amount: number, color: string): string { return color === PRIMARY ? `darker(${amount})` : color; }\nexport const DARK = darken(0.5, PRIMARY);\nexport const hover = { color: darken(0.6, PRIMARY) };",
+ ),
+ (
+ "/src/throws.ts",
+ "export const boom = () => { throw new Error('x'); };",
+ ),
+ (
+ "/src/styles.ts",
+ "import { css } from '@devup-ui/react';\nimport * as stylex from '@stylexjs/stylex';\nimport styled from 'styled-components';\nimport logo from './logo.svg';\nexport const card = css({ p: 1 });\nexport const TOKEN = String(2) + 'px';\nexport const LOGO = `url(${logo})`;",
+ ),
+ (
+ "/src/fails.ts",
+ "export const BEFORE = 'teal';\nthrow new Error('x');\nexport const AFTER = String('navy');",
+ ),
+ ];
+ let resolver = memory_resolver(modules);
+ let option = ExtractOption {
+ import_aliases: HashMap::from([(
+ "styled-components".to_string(),
+ ImportAlias::DefaultToNamed("styled".to_string()),
+ )]),
+ ..ExtractOption::default()
+ };
+ let output = extract_with_modules(
+ "/src/App.tsx",
+ r"import { Box, css, keyframes, globalCss } from '@devup-ui/react';
+import * as stylex from '@stylexjs/stylex';
+import { PRIMARY, SPACE } from './color';
+import { card } from './styles';
+import { BEFORE } from './fails';
+export const SIZE = 4;
+export default function twice(n: number) { return n * 2; }
+enum Level { Low = 1, High = twice(2) }
+function darken(amount: number, color: string): string { return color === PRIMARY ? `darker(${amount})` : color; }
+const LIGHT = darken(0.2, PRIMARY);
+const color = darken(0.3, PRIMARY);
+const make = (size: number) => ({ p: size, _hover: { color: LIGHT } });
+const label = (text: string) => text.toUpperCase().padStart(4, '-');
+export const a = css({ color: darken(0.1, PRIMARY), width: twice(SIZE), height: [1, 2].map(twice)[1], top: -twice(1), margin: SPACE[1] * 2, content: JSON.stringify(label('a')) });
+export const b = keyframes({ from: { opacity: twice(0.25) } });
+globalCss({ body: { color: LIGHT } });
+const styles = stylex.create({ base: { color: LIGHT } });
+export const c = ;
+export const d = css({ color, ...make(2), [`@media (min-width: ${twice(300)}px)`]: { m: 1 }, zIndex: Level.High, outlineColor: BEFORE });
+export const e = (dark: boolean) => css({ color: dark ? LIGHT : PRIMARY, bg: dark && LIGHT, fill: [LIGHT, ...[PRIMARY]][1] });
+export const f = css(card, { p: String(SIZE) + 'px' });",
+ option,
+ false,
+ &resolver,
+ )
+ .unwrap();
+ assert_eq!(
+ output.dependencies,
+ ["/src/color.ts", "/src/fails.ts", "/src/styles.ts"]
+ );
+ assert_debug_snapshot!(ToBTreeSet::from(output));
+
+ for (code, error) in [
+ (
+ "import { boom } from './throws';\nexport const a = css({ color: boom() });",
+ "/src/App.tsx:3:18: `css()` cannot use `boom()`",
+ ),
+ (
+ "export const a = css({ color: String(Date.now()) });",
+ "/src/App.tsx:2:18: `css()` cannot use `String(Date.now())`",
+ ),
+ (
+ "export const a = css({ width: Math.random() + 'px' });",
+ "`css()` cannot use `Math.random()",
+ ),
+ (
+ "let changing = 1;\nexport const a = css({ width: String(changing) });",
+ "`css()` cannot use `String(changing)`",
+ ),
+ (
+ "export const a = (n) => css({ width: String(n) });",
+ "`css()` cannot use `String(n)`",
+ ),
+ (
+ "export const a = css({ width: String(unknownGlobal), color: ({}).toString() });",
+ "`css()` cannot use `String(unknownGlobal)`",
+ ),
+ (
+ "import { missing } from './nowhere';\nexport const a = css({ width: missing() });",
+ "`css()` cannot use `missing()`",
+ ),
+ (
+ "import { AFTER } from './fails';\nexport const a = css({ color: AFTER });",
+ "`css()` cannot use `AFTER`",
+ ),
+ (
+ "export const a = css({ ...(() => ({ get p() { return 1; } }))() });",
+ "`css()` cannot use `(() =>",
+ ),
+ (
+ "export const a = css({ ...(() => ({ p: undefined }))(), m: Object.create({ x: 1 }) });",
+ "`css()` cannot use `Object.create(",
+ ),
+ (
+ "import { darken } from 'polished';\nexport const a = css({ color: darken(0.1, 'red') });",
+ "`css()` cannot use `darken(.1,`red`)`",
+ ),
+ (
+ "import { darken } from './color';\nexport const a = css({ color: darken(0.1, 'red') });",
+ "`css()` cannot use `darken(.1,`red`)`",
+ ),
+ (
+ "import { DARK, hover } from './color';\nexport const a = css({ color: DARK });\nexport const b = css({ _hover: hover });",
+ "`css()` cannot use `DARK`",
+ ),
+ (
+ "class S { px() { return '1px'; } }\nexport const a = css({ width: new S().px() });",
+ "`css()` cannot use `new S().px()`",
+ ),
+ (
+ "const plain = (text) => text.replace(/-/g, '');\nexport const a = css({ content: plain('a-b') });",
+ "`css()` cannot use `plain(`a-b`)`",
+ ),
+ (
+ "const LABEL = 'i'.toLocaleUpperCase();\nconst pick = () => LABEL;\nexport const a = css({ content: pick() });",
+ "`css()` cannot use `pick()`",
+ ),
+ (
+ "const fold = (text) => text.normalize('NFD');\nexport const a = css({ content: fold('a') });",
+ "`css()` cannot use `fold(`a`)`",
+ ),
+ (
+ "const W = typeof IntersectionObserver === 'undefined' ? 10 : 20;\nexport const a = css({ width: W });",
+ "`css()` cannot use `W`",
+ ),
+ (
+ "const safe = () => { try { return 1; } catch { return 2; } };\nexport const a = css({ width: safe() });",
+ "`css()` cannot use `safe()`",
+ ),
+ (
+ "const root = () => 2 ** 0.5;\nexport const a = css({ width: root() });",
+ "`css()` cannot use `root()`",
+ ),
+ (
+ "const wave = () => Math.sin(1);\nexport const a = css({ opacity: wave() });",
+ "`css()` cannot use `wave()`",
+ ),
+ (
+ "const hex = () => (255).toString(16);\nexport const a = css({ color: '#' + hex() });",
+ "`css()` cannot use `",
+ ),
+ (
+ "const node = () =>
;\nexport const a = css({ content: node() });",
+ "`css()` cannot use `node()`",
+ ),
+ (
+ "const later = async () => 1;\nexport const a = css({ width: later() });",
+ "`css()` cannot use `later()`",
+ ),
+ (
+ "const box = { n: 1 };\nconst bump = () => { box.n = 2; return box.n; };\nexport const a = css({ width: bump() });",
+ "`css()` cannot use `bump()`",
+ ),
+ (
+ "const pick = { a: () => 1 };\nconst key = 'a';\nexport const a = css({ width: pick[key]() });",
+ "`css()` cannot use `pick[`a`]()`",
+ ),
+ (
+ "const read = { get a() { return 1; } };\nexport const a = css({ width: read.a });",
+ "`css()` cannot use `read.a`",
+ ),
+ (
+ "import { make } from './shapes';\nconst local = make(2);\nexport const a = css(local);\nexport const b = styled.div(local);",
+ "`css()` cannot use `local`",
+ ),
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract_with_modules(
+ "/src/App.tsx",
+ &format!("import {{ css, styled }} from '@devup-ui/react';\n{code}"),
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains(error), "{code}\n{message}");
+ }
+ reset_class_map();
+ reset_file_map();
+ let switched = extract_with_modules(
+ "/src/App.tsx",
+ "import { css } from '@devup-ui/react';\nconst hasIO = typeof IntersectionObserver !== 'undefined';\nexport const a = css({ content: hasIO ? 'a' : 'b' });",
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )
+ .unwrap();
+ assert!(switched.code.contains("hasIO ?"), "{}", switched.code);
+ let option = ExtractOption::default();
+ for (code, computes) in [
+ (
+ "import { css } from '@devup-ui/react';\nimport { darken } from 'polished';\ncss({ color: darken(0.1, 'red') });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nimport { DARK } from './color';\ncss({ color: DARK });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nimport { PRIMARY, SPACE } from './color';\nconst f = (n) => `${PRIMARY}${n}`;\ncss({ color: f(SPACE[0]) });",
+ true,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nimport { PRIMARY } from './color';\nconst X = `${PRIMARY}`;\ncss({ color: X, borderColor: PRIMARY });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\ncss({ width: Math.max(1, 2), color: 'red' });",
+ false,
+ ),
+ (
+ "import { Box } from '@devup-ui/react';\nconst f = () => 1;\n ;",
+ false,
+ ),
+ (
+ "import { css } from 'other';\nconst f = () => 1;\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst x = css({ w: this.f(), h: String(Date.now()) });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst f = () => 1;\ncss({ w: [...[f()], 2] });",
+ true,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst make = (n) => ({ m: n });\nconst local = make(2);\ncss(local);",
+ true,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst card = css({ p: 1 });\ncss(card, { m: 1 });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nimport * as styles from './styles';\ncss(styles.card, { m: 1 });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nimport * as styles from './styles';\ncss(styles.TOKEN);",
+ false,
+ ),
+ (
+ "import { Box } from '@devup-ui/react';\nconst make = (n) => ({ m: n });\nconst card = make(2);\n ;",
+ true,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nimport { darken } from './color';\ncss(`color: ${darken(0.1, 'red')};`);",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst darken = (amount, color) => color;\ncss(`color: ${darken(0.1, 'red')};`);",
+ true,
+ ),
+ ("import { css } from '@devup-ui/react';\ncss({", false),
+ ] {
+ assert_eq!(
+ has_build_time_values("/src/a.tsx", code, &option, Some(&resolver)),
+ computes,
+ "{code}"
+ );
+ }
+ assert!(has_build_time_values(
+ "a.tsx",
+ "import { css } from '@emotion/react';\nconst f = () => 1;\nexport const a = css`width: ${f()}px;`;",
+ &ExtractOption {
+ import_aliases: HashMap::from([(
+ "@emotion/react".to_string(),
+ ImportAlias::NamedToNamed,
+ )]),
+ ..ExtractOption::default()
+ },
+ None,
));
+ }
+
+ #[test]
+ #[serial]
+ fn test_strict_build_time_reads() {
+ let option = ExtractOption::default();
+ for (code, computes) in [
+ (
+ "import * as Devup from '@devup-ui/react';\nconst make = (n) => ({ m: n });\nconst local = make(2);\nDevup.css(local);",
+ true,
+ ),
+ (
+ "import { styled } from '@devup-ui/react';\nconst make = (n) => ({ m: n });\nstyled.div({ p: 1, _hover: make(2) });",
+ true,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst fresh = () => ({ n: 1 });\nconst f = () => { const o = { a: { n: 1 } }; o.a.n++; o['a'].n = 2; fresh().n = 3; return o.a.n; };\ncss({ w: f() });",
+ true,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst box = { n: 1 };\nconst bump = () => { box.n++; return 1; };\ncss({ w: bump() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst { normalize } = 'a';\nconst f = () => normalize;\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst o = { m() { return super.toString(); } };\nconst f = () => o.m();\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst f = () => class {};\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nfunction* g() { yield 1; }\nconst f = () => g();\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst f = () => import('./x');\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst f = () => <>>;\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst f = () => Math['max'](1, 2) + (255).toString().length;\ncss({ w: f() });",
+ true,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst { sin } = Math;\nconst f = () => sin(1);\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst f = () => { const M = Math; return M.sin(1); };\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst key = 'sin';\nconst f = () => Math[key](1);\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst f = () => { const t = (255).toString; return t.call(255, 16); };\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst f = () => { const { toString } = 255; return toString.call(255, 16); };\ncss({ w: f() });",
+ false,
+ ),
+ (
+ "import { css } from '@devup-ui/react';\nconst f = (n) => n.toString(16);\ncss({ w: f(255) });",
+ false,
+ ),
+ (
+ "import * as Devup from '@devup-ui/react';\nconst make = (n) => ({ m: n });\nDevup.styled.div(make(2));",
+ true,
+ ),
+ ] {
+ assert_eq!(
+ has_build_time_values("/src/a.tsx", code, &option, None),
+ computes,
+ "{code}"
+ );
+ }
+
+ let modules: &[(&str, &str)] = &[
+ (
+ "/src/t.ts",
+ "export const T = { a: null, b: true, c: undefined, d: { e: 3 } };",
+ ),
+ ("/src/cjs.js", "exports.SIZE = 5;"),
+ ];
+ let resolver = memory_resolver(modules);
+ reset_class_map();
+ reset_file_map();
+ let output = extract_with_modules(
+ "/src/App.tsx",
+ r"import { Box, css, styled } from '@devup-ui/react';
+import { T } from './t';
+import { SIZE } from './cjs';
+const ON = true;
+const U = undefined;
+const N = null;
+const OBJ = { a: 1 };
+const p = 4;
+const f = () => (T.a === null && T.b && T.c === undefined ? T.d.e : 0);
+const g = () => SIZE;
+export const a = css({ w: f(), h: g(), m: g() });
+export const b = css({ content: `${ON}-${U}`, top: U ? 1 : 2, left: N ? 1 : 2, right: OBJ ? 1 : 2, opacity: ON, p });
+export const c = styled('a')({ p: 1 });
+export const d = ;",
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )
+ .unwrap();
+ let styles = format!("{:?}", output.styles);
+ for value in [
+ "\"12px\"",
+ "\"20px\"",
+ "\"true-undefined\"",
+ "\"8px\"",
+ "\"4px\"",
+ "\"300ms\"",
+ "\".5ms\"",
+ ] {
+ assert!(styles.contains(value), "{value}\n{styles}");
+ }
+ assert!(
+ styles.contains("property: \"counter-reset\", value: \"2\""),
+ "{styles}"
+ );
+
+ reset_class_map();
+ reset_file_map();
+ let literal = extract(
+ "test.tsx",
+ "import { Box } from '@devup-ui/react';\nexport const a = ;",
+ ExtractOption::default(),
+ )
+ .unwrap();
+ let styles = format!("{:?}", literal.styles);
+ for value in ["\"blue\"", "\"8px\"", "\"12px\"", "\"20px\""] {
+ assert!(styles.contains(value), "{value}\n{styles}");
+ }
+
+ reset_class_map();
+ reset_file_map();
+ let partial = extract_with_modules(
+ "/src/App.tsx",
+ "import { css } from '@devup-ui/react';\nimport * as tokens from './tokens';\nexport const a = css(tokens.list.length);",
+ ExtractOption::default(),
+ false,
+ &memory_resolver(&[("/src/tokens.ts", "export const list = [1, 2];")]),
+ );
+ assert!(
+ partial
+ .as_ref()
+ .is_ok_and(|output| output.styles.is_empty()),
+ "{partial:?}"
+ );
+
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
+ "test.tsx",
+ "import { css } from '@devup-ui/react';\nconst pick = () => 1;\nexport const a = css(pick());",
+ ExtractOption::default(),
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains("cannot use `pick()`"), "{message}");
+ }
+
+ #[test]
+ #[serial]
+ fn test_build_time_value_candidates() {
+ let option = ExtractOption::default();
+ assert!(has_build_time_values(
+ "a.tsx",
+ r"import type { T } from './types';
+import { type U, helper } from './helper';
+import Default from './default';
+import * as ns from './ns';
+import * as Devup from '@devup-ui/react';
+import { css as devupCss, Box } from '@devup-ui/react';
+import * as sx from '@stylexjs/stylex';
+import { create, props } from '@stylexjs/stylex';
+export const A = 1;
+export function f(n: T): U { return n; }
+export class K { static v() { return 1; } }
+export interface I { a: number }
+export enum Kind { A = 'a' }
+declare enum Ambient { B }
+export default function g() { return 2; }
+let mutable = 1;
+function local() { return new.target ? 1 : 2; }
+class Local extends K { m() { return super.toString(); } }
+console.log(A);
+const cyclic1 = () => cyclic2();
+const cyclic2 = () => cyclic1();
+function wrap() { const inner = 1; return devupCss({ w: String(inner) }); }
+devupCss({ a: f(A), b: [helper(), , null, true, () => 1, 'x'], ...rest, c: Default(), d: ns.x(), e: mutable.toString(), f: this.x(), g: import.meta.url, h: Local.name.at(0), i: K.v(), j: g(), k: cyclic1(), l: new Local(), m: tag`x`, n: ((x) => x)(1), o: Math.max(1, 2), p: local(), q: (1 as number).toFixed(), r: (() => { const z = 1; return String(z); })(), s: Kind.A.repeat(2), t: Ambient.B });
+Devup.css({ a: f(1) });
+sx.create({ a: { color: f(1) } });
+sx.props(f(1));
+create({ a: { color: f(1) } });
+props(f(1));
+Devup['css']({ a: f(1) });
+devupCss`width: ${f(1)}px;`;
+devupCss(...[f(1)]);
+export default class {}",
+ &option,
+ None,
+ ));
+ assert!(!has_build_time_values(
+ "a.tsx",
+ "import { css } from '@devup-ui/react';\nexport default class Theme { static size() { return 1; } }\ncss({ w: Theme.size() });",
+ &option,
+ None,
+ ));
+ assert!(!has_build_time_values(
+ "a.tsx",
+ "import { css } from '@devup-ui/react';\nexport default 1;\ncss({ w: 1 });",
+ &option,
+ None,
+ ));
+ assert!(!has_build_time_values(
+ "a.unknown",
+ "css({ w: f() })",
+ &option,
+ None,
+ ));
+
+ reset_class_map();
+ reset_file_map();
+ let modules: &[(&str, &str)] = &[(
+ "/src/throws.ts",
+ "export const boom = () => { throw new Error('x'); };",
+ )];
+ let resolver = memory_resolver(modules);
+ for (code, error) in [
+ (
+ "const X = (() => { throw new Error('x'); })();\nexport const a = css({ width: String(X) });",
+ "`css()` cannot use `String(X)`",
+ ),
+ (
+ "import { boom } from './throws';\nexport const a = css({ width: String(1), color: boom() });",
+ "/src/App.tsx:3:18: `css()` cannot use `boom()`",
+ ),
+ ] {
+ let message = extract_with_modules(
+ "/src/App.tsx",
+ &format!("import {{ css }} from '@devup-ui/react';\n{code}"),
+ ExtractOption::default(),
+ false,
+ &resolver,
+ )
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default();
+ assert!(message.contains(error), "{code}\n{message}");
+ }
+ }
- // StaticMemberExpression on a real prop name
+ #[test]
+ #[serial]
+ fn test_fold_math_at_build_time() {
reset_class_map();
reset_file_map();
- assert_debug_snapshot!(ToBTreeSet::from(
- extract(
+ assert_debug_snapshot!(ToBTreeSet::from(extract_tsx(
+ r"import { Box, css } from '@devup-ui/react';
+const W = 10;
+const GAP = Math.round(2.5) * 4;
+const HALF = Math.round(-2.5);
+export const a = css({ width: Math.max(W, 20), height: Math.min(W, -W, 3), gap: GAP, top: HALF, left: Math.floor(W / 3), right: Math.ceil(1.2), bottom: Math.trunc(-1.7), opacity: Math.abs(-0.5), zIndex: Math.sign(-3), order: Math.sign(0), flexGrow: Math.pow(2, 3), lineHeight: Math.sqrt(4), rotate: `${Math.PI}rad`, scale: Math.round(1.4), flexShrink: Math.sign(3) });
+export const b = 2 ? 1 : 2} />;"
+ )));
+ for code in [
+ "css({ width: Math.random() })",
+ "css({ width: Math.max() })",
+ "css({ width: Math.sqrt(-1) })",
+ "css({ width: Math.NOPE })",
+ "css({ width: Math.max(...list) })",
+ "css({ width: Math.pow(2, 0.5) })",
+ "css({ width: Math.pow(2, 99) })",
+ "css({ width: Math.pow(10, 17) })",
+ ] {
+ reset_class_map();
+ reset_file_map();
+ let message = extract(
"test.tsx",
- r#"import { Box } from "@devup-ui/core";
-