diff --git a/.changepacks/changepack_log_at_rule_composition.json b/.changepacks/changepack_log_at_rule_composition.json
new file mode 100644
index 000000000..9c7889f27
--- /dev/null
+++ b/.changepacks/changepack_log_at_rule_composition.json
@@ -0,0 +1,8 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/react/package.json": "Patch"
+ },
+ "note": "Fix at-rule composition and cascade: `_print`/`_media`/`@media …` nest correctly with selectors in both directions and inside `globalCss`, nested media queries merge (`print and (…)`), breakpoints put media types first, distribute query lists, and nest `not …` queries, and at-rules are emitted after all breakpoint values while pseudo selectors keep `SELECTOR_ORDER` across breakpoints. Add `_motionReduce`, `_motionSafe`, `_portrait`, `_landscape`, `_contrastMore`, `_contrastLess` and `_forcedColors`, remove the never-matching `_speech`, normalize query whitespace, support Emotion-style `'@media …'`/`'&:hover'` object keys, fix template literals losing declarations before `@media` or selectors around nested at-rules, apply `typography` conditionally under selectors, and drop stale global at-rules on re-extraction",
+ "date": "2026-09-26T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_compat_layers_typography.json b/.changepacks/changepack_log_compat_layers_typography.json
new file mode 100644
index 000000000..88b73bad8
--- /dev/null
+++ b/.changepacks/changepack_log_compat_layers_typography.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch"
+ },
+ "note": "Fix vanilla-extract stylesheets: styles are named after the variable holding them, so style calls nested in objects no longer shift every later name onto the wrong styles and such objects export their classes; createVar, createContainer, layer, fontFace and theme variables get names unique to each stylesheet instead of colliding across files, createVar returns var() so template literals and fallbackVar work, createTheme(tokens, debugId), the three-argument createGlobalTheme and unassigned createGlobalTheme calls produce their variables, createThemeContract exports, styleVariants variants resolve in selectors and compositions and accept a map function, composition keeps transitive bases, '@layer' records, globalLayer, createGlobalThemeContract, assignVars and globalFontFace work, and layers keep their declaration order. Emotion and styled-components object styles read numbers as pixels (Devup UI shorthands keep the spacing scale), compose several style arguments or arrays, and accept Emotion's styled(tag, options) factory; css() composes class names passed with rule objects. A global '@layer' now covers responsive, selector and at-rule declarations, and layered declarations keep their breakpoints. Custom properties keep their value instead of reading a number as the spacing scale. A typography preset no longer overrides a property written beside it at a wider breakpoint, in components and globalCss",
+ "date": "2026-09-27T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_css_output_fixes.json b/.changepacks/changepack_log_css_output_fixes.json
new file mode 100644
index 000000000..1cdced775
--- /dev/null
+++ b/.changepacks/changepack_log_css_output_fixes.json
@@ -0,0 +1,8 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/eslint-plugin/package.json": "Patch"
+ },
+ "note": "Fix the full WASM build panicking on every vanilla-extract `.css.ts` file (Boa read the clock without its `js` feature), resolve vanilla-extract references to other styles, keyframes, fonts and themes in selectors, values and `globalStyle`, keep `globalCss` pseudo selectors in `SELECTOR_ORDER` across breakpoints, merge adjacent rules with the same selector, and let declarations written next to a conditional `typography` preset win over it. Add the `prefer-media-shorthand` and `no-typography-token-prefix` ESLint rules",
+ "date": "2026-09-27T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_library_numbers_full_engine.json b/.changepacks/changepack_log_library_numbers_full_engine.json
new file mode 100644
index 000000000..248990056
--- /dev/null
+++ b/.changepacks/changepack_log_library_numbers_full_engine.json
@@ -0,0 +1,8 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/next-plugin/package.json": "Patch"
+ },
+ "note": "Numbers in vanilla-extract and StyleX styles keep their library's meaning instead of Devup UI's spacing scale: vanilla-extract styles add `px` in `.css.ts` files and in `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, StyleX adds `px`/`ms` to static values and to numbers passed to dynamic styles, and unitless properties such as `columnCount`, `strokeWidth`, `orphans` or `zoom` keep a bare number everywhere. The Next.js plugin now runs every `.css.ts` module on the full engine instead of a separate static fast path, so development and production builds produce the same output",
+ "date": "2026-09-27T00:00:00.000Z"
+}
diff --git a/Cargo.lock b/Cargo.lock
index 59af8ade9..bfd4eb3fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -160,6 +160,7 @@ dependencies = [
"float16",
"futures-concurrency",
"futures-lite",
+ "getrandom",
"hashbrown 0.17.1",
"icu_calendar",
"icu_normalizer",
@@ -188,6 +189,7 @@ dependencies = [
"thiserror",
"time",
"timezone_provider",
+ "web-time",
"xsum",
]
@@ -582,6 +584,7 @@ name = "devup-ui-wasm"
version = "0.1.0"
dependencies = [
"bimap",
+ "boa_engine",
"console_error_panic_hook",
"css",
"extractor",
@@ -2380,6 +2383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
+ "js-sys",
"libc",
"num-conv",
"num_threads",
@@ -2624,6 +2628,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
diff --git a/README.md b/README.md
index d51c35d6b..354d19968 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,7 @@ Turbopack values are medians of six cold builds in alternating order:
| **devup-ui (direct APIs, single CSS)** | 1.0.40 | **5.46s** | **36,507,782 bytes** |
| **devup-ui (static `.css.ts`)** | 1.0.40 | **5.41s** | 36,586,883 bytes |
-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.
## How it works
@@ -186,7 +186,7 @@ const example = (
```tsx
// Type-safe theme tokens
const textExample =
-const boxExample =
+const boxExample =
```
**Custom shorthands:**
diff --git a/README_ko.md b/README_ko.md
index 095a0183c..11c0caf7c 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -103,7 +103,7 @@ Turbopack 값은 실행 순서를 번갈아 측정한 cold build 6회의 중앙
| **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** |
| **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes |
-Turbopack 측정 범위는 서로 겹치므로 direct API 결과는 이 fixture에서 Tailwind와 사실상 동률입니다. static `.css.ts` 행은 `lite` WASM 빠른 경로를 사용하며, full Boa evaluator가 필요한 dynamic `.css.ts`는 해당 중앙값에 포함되지 않습니다.
+Turbopack 측정 범위는 서로 겹치므로 direct API 결과는 이 fixture에서 Tailwind와 사실상 동률입니다. static `.css.ts` 행은 정적 모듈용 `lite` 빠른 경로가 있던 때에 측정한 값입니다. 지금은 모든 `.css.ts`를 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다.
## 작동 원리
@@ -186,7 +186,7 @@ const generated = (
```tsx
// 타입 세이프 테마 토큰
const textExample =
-const boxExample =
+const boxExample =
```
**사용자 정의 shorthand:**
diff --git a/SKILL.md b/SKILL.md
index ade2c1c06..4b7f3c4f4 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -148,6 +148,10 @@ All standard CSS properties from `csstype` are also accepted directly (e.g., `di
// padding: 20px (with unit = exact value)
```
+Unitless CSS properties keep the number as written: `lineHeight`, `zIndex`, `opacity`, `fontWeight`, `flexGrow`, `order`, `aspectRatio`, `columnCount`, `strokeWidth`, `zoom`, and the rest of the unitless list vanilla-extract uses.
+
+Styles from other libraries keep that library's number meaning instead of the scale: in `.css.ts` files, in vanilla-extract `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, in Emotion and styled-components object styles, and in `stylex.create()`, `fontSize: 16` is `16px`. Devup UI shorthands (`p`, `bg`, ...) keep the scale everywhere.
+
### Responsive Arrays (5 breakpoints)
```tsx
@@ -215,8 +219,23 @@ Available: `_groupHover`, `_groupFocus`, `_groupActive`, `_groupDisabled`.
// @ prefix syntax (equivalent)
+
+// Query in the key (Emotion style), also inside `selectors` and css()/styled()
+
+```
+
+Media shorthands wrap styles in a fixed query and nest in either direction with selectors:
+`_print`, `_screen`, `_all`, `_portrait`, `_landscape`, `_motionReduce` (`prefers-reduced-motion: reduce`), `_motionSafe` (`no-preference`), `_contrastMore`, `_contrastLess`, `_forcedColors`.
+
+```tsx
+
+
+globalCss({ _motionReduce: { "*, *::before, *::after": { transition: "none" } } })
```
+- Conditions beat breakpoint values: at-rule styles are emitted after every responsive rule.
+- Nested `@media` rules merge into one query (`print and (prefers-reduced-motion:reduce)`); ones that can never match together (`_print` inside `_screen`) are dropped.
+
### Custom Selectors
```tsx
@@ -365,7 +384,7 @@ const spin = keyframes({ from: { transform: "rotate(0)" }, to: { transform: "rot
```
- **Colors**: Use with `$` prefix in JSX props: ``
-- **Typography**: Use with `$` prefix: ``
+- **Typography**: Use the preset name without `$`: ``. Under selectors or at-rules (`_hover={{ typography: "heading" }}`) the preset applies only under that condition.
- **Length**: Responsive length tokens: ``, ``
- **Shadow**: Responsive shadow tokens: ``
- **extends**: Inherit from base config files (deep merge, last wins)
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..f1fd2d529 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
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/selector/page.mdx b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx
index f48115420..ce48b5440 100644
--- a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx
@@ -182,3 +182,129 @@ The attributes defined in Pseudo-Class correspond to those defined in Selector,
+
+## Media Shorthands
+
+These props wrap their styles in a fixed media query. They accept responsive arrays and nest in either direction with selectors and other at-rules.
+
+
+
+
+
+
+
Selector
+
Media Query
+
+
+
+
+
+ _print
+
+
+ print
+
+
+
+
+ _screen
+
+
+ screen
+
+
+
+
+ _all
+
+
+ all
+
+
+
+
+ _portrait
+
+
+ (orientation:portrait)
+
+
+
+
+ _landscape
+
+
+ (orientation:landscape)
+
+
+
+
+ _motionReduce
+
+
+ (prefers-reduced-motion:reduce)
+
+
+
+
+ _motionSafe
+
+
+ (prefers-reduced-motion:no-preference)
+
+
+
+
+ _contrastMore
+
+
+ (prefers-contrast:more)
+
+
+
+
+ _contrastLess
+
+
+ (prefers-contrast:less)
+
+
+
+
+ _forcedColors
+
+
+ (forced-colors:active)
+
+
+
+
+
+```tsx
+const example = (
+
+)
+```
+
+## At-Rules
+
+`_media`, `_supports` and `_container` take a record of queries to styles. A query can also sit in the key itself, as in Emotion, and `globalCss` accepts at-rules around whole selector maps.
+
+```tsx
+const example = (
+
+)
+
+globalCss({
+ _motionReduce: { '*, *::before, *::after': { transition: 'none' } },
+})
+```
+
+Conditions always win over breakpoint values: at-rule styles come after every responsive rule. Among themselves they are ordered by condition, with width queries mobile-first (`min-width` ascending, then `max-width` descending), then media types, other features, and user preferences such as `prefers-*` last.
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/breakpoints/page.mdx b/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx
index 205036bb9..77195034b 100644
--- a/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx
@@ -172,7 +172,7 @@ Or use responsive typography from `devup.json`:
```
```tsx
-Responsive Heading
+Responsive Heading
```
## Responsive with Pseudo Selectors
diff --git a/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx b/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx
index 9344e90d1..462776cec 100644
--- a/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx
@@ -223,8 +223,8 @@ See [Shadow](/docs/devup/shadow) for more details.
### Typography
```tsx
-const headingExample = Heading
-const bodyExample = Body text
+const headingExample = Heading
+const bodyExample = Body text
```
### Length
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 a356fffd6..e32fe65b4 100644
--- a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx
@@ -60,10 +60,10 @@ Define typography styles in `devup.json`:
Use typography with the `typography` prop:
```tsx
-const h1Example = Heading 1
-const h2Example = Heading 2
-const bodyExample = Body text
-const captionExample = Caption text
+const h1Example = Heading 1
+const h2Example = Heading 2
+const bodyExample = Body text
+const captionExample = Caption text
```
## Typography Properties
@@ -298,28 +298,28 @@ Here's a recommended typography scale:
```tsx
// With Text component
-const titleExample = Main Title
-const bodyExample = Paragraph text goes here.
+const titleExample = Main Title
+const bodyExample = Paragraph text goes here.
// With Box component
-const boxExample = Caption in a box
+const boxExample = Caption in a box
```
```tsx
// With styled components
const Title = styled('h1', {
- typography: '$h1',
+ typography: 'h1',
color: '$text',
})
```
## 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:
```tsx
const example = (
-
+
Centered Title
)
@@ -330,9 +330,9 @@ const example = (
Typography tokens are fully type-safe:
```tsx
-// TypeScript autocomplete shows: $h1, $h2, $body, etc.
-const example =
+// TypeScript autocomplete shows: h1, h2, body, etc.
+const example =
// Invalid tokens show TypeScript errors
-const invalidExample = // Error!
+const invalidExample = // Error!
```
diff --git a/apps/landing/src/app/(detail)/docs/features/page.mdx b/apps/landing/src/app/(detail)/docs/features/page.mdx
index 26c9e0071..ebb85645c 100644
--- a/apps/landing/src/app/(detail)/docs/features/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/features/page.mdx
@@ -162,7 +162,7 @@ Define themes in `devup.json` with full TypeScript support.
```tsx
// Type-safe theme tokens with autocomplete
const textExample =
-const boxExample =
+const boxExample =
```
Theme switching happens via CSS variables — zero JavaScript execution required.
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..996ff9048 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
@@ -26,6 +26,8 @@ const E = styled(Component)({ bg: 'red' })
const F = styled('div', { bg: 'red' })
```
+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.
+
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 +96,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()` | not supported yet |
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..f800a8b0a 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
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..fc3c0bcc8 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,13 @@ 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.
+
+Each stylesheet is evaluated on its own, so importing values from another `.css.ts` file is not supported yet.
+
## 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 9b61daaf9..aecbd6c33 100644
--- a/bindings/devup-ui-wasm/Cargo.toml
+++ b/bindings/devup-ui-wasm/Cargo.toml
@@ -15,7 +15,11 @@ crate-type = ["cdylib"]
[features]
default = ["vanilla-extract"]
-vanilla-extract = ["extractor/vanilla-extract", "sheet/vanilla-extract"]
+vanilla-extract = [
+ "extractor/vanilla-extract",
+ "sheet/vanilla-extract",
+ "dep:boa_engine",
+]
[dependencies]
wasm-bindgen = "0.2.128"
@@ -39,6 +43,10 @@ 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"] }
[dev-dependencies]
wasm-bindgen-test = "0.3.78"
diff --git a/bindings/devup-ui-wasm/tests/wasm.rs b/bindings/devup-ui-wasm/tests/wasm.rs
index 677639f4f..51176bdda 100644
--- a/bindings/devup-ui-wasm/tests/wasm.rs
+++ b/bindings/devup-ui-wasm/tests/wasm.rs
@@ -46,3 +46,16 @@ fn test_object_to_typography() {
assert_eq!(typography.line_height.unwrap(), "1.5");
assert_eq!(typography.letter_spacing.unwrap(), "1px");
}
+
+#[allow(dead_code)]
+#[wasm_bindgen_test]
+fn test_extract_vanilla_extract_file() {
+ let output = extractor::extract(
+ "styles.css.ts",
+ "import { style } from '@devup-ui/react'\nexport const box = style({ color: 'red' })\n",
+ extractor::ExtractOption::default(),
+ )
+ .unwrap();
+ assert_eq!(output.styles.len(), 1);
+ assert!(!output.code.contains("style("));
+}
diff --git a/libs/css/src/at_rule.rs b/libs/css/src/at_rule.rs
new file mode 100644
index 000000000..55b0c8e41
--- /dev/null
+++ b/libs/css/src/at_rule.rs
@@ -0,0 +1,570 @@
+//! At-rule preludes: media shorthands, query normalization, media query
+//! combination, and the deterministic order at-rule blocks are emitted in.
+
+use crate::style_selector::AtRuleKind;
+use crate::utils::to_kebab_case;
+
+/// Media shorthand props and the media query each one stands for, keyed by the
+/// kebab-cased name shared with the matching Tailwind variant.
+const MEDIA_SHORTHANDS: [(&str, &str); 10] = [
+ ("all", "all"),
+ ("contrast-less", "(prefers-contrast:less)"),
+ ("contrast-more", "(prefers-contrast:more)"),
+ ("forced-colors", "(forced-colors:active)"),
+ ("landscape", "(orientation:landscape)"),
+ ("motion-reduce", "(prefers-reduced-motion:reduce)"),
+ ("motion-safe", "(prefers-reduced-motion:no-preference)"),
+ ("portrait", "(orientation:portrait)"),
+ ("print", "print"),
+ ("screen", "screen"),
+];
+
+/// The media query a shorthand prop (`print`, `motionReduce`, `motion-reduce`, …)
+/// stands for.
+#[must_use]
+pub fn media_shorthand_query(name: &str) -> Option<&'static str> {
+ let name = to_kebab_case(name);
+ MEDIA_SHORTHANDS
+ .iter()
+ .find(|(key, _)| *key == name)
+ .map(|(_, query)| *query)
+}
+
+/// Split an at-rule key such as `@media print` into its kind and prelude.
+#[must_use]
+pub fn split_at_rule_key(key: &str) -> Option<(AtRuleKind, &str)> {
+ let rest = key.trim().strip_prefix('@')?;
+ [
+ ("media", AtRuleKind::Media),
+ ("supports", AtRuleKind::Supports),
+ ("container", AtRuleKind::Container),
+ ]
+ .into_iter()
+ .find_map(|(name, kind)| {
+ let query = rest.strip_prefix(name)?;
+ (query.starts_with([' ', '(']) && !query.trim().is_empty()).then_some((kind, query.trim()))
+ })
+}
+
+const fn is_word_char(c: char) -> bool {
+ c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '%') || !c.is_ascii()
+}
+
+fn leading_word(text: &str) -> &str {
+ let end = text
+ .char_indices()
+ .find(|(_, c)| !is_word_char(*c))
+ .map_or(text.len(), |(index, _)| index);
+ &text[..end]
+}
+
+fn trailing_word(text: &str) -> &str {
+ let start = text
+ .char_indices()
+ .rev()
+ .take_while(|(_, c)| is_word_char(*c))
+ .last()
+ .map_or(text.len(), |(index, _)| index);
+ &text[start..]
+}
+
+fn is_keyword(word: &str) -> bool {
+ ["and", "or", "not", "only"]
+ .iter()
+ .any(|keyword| word.eq_ignore_ascii_case(keyword))
+}
+
+/// Whether `text` ends with a standalone `and`/`or`/`not`/`only` keyword, which
+/// needs a space before a following `(` so it is not read as a function.
+fn ends_with_keyword(text: &str) -> bool {
+ let word = trailing_word(text);
+ is_keyword(word)
+ && text[..text.len() - word.len()]
+ .chars()
+ .next_back()
+ .is_none_or(|c| matches!(c, ' ' | '(' | ')' | ','))
+}
+
+/// Whether the whitespace between `prev` and `next` must survive as one space.
+const fn keeps_space(prev: char, next: char, in_function: bool) -> bool {
+ if matches!(prev, '(' | ',') || matches!(next, ')' | ',') {
+ return false;
+ }
+ in_function
+ || !(matches!(prev, ':' | '<' | '>' | '=' | ')') || matches!(next, ':' | '<' | '>' | '='))
+}
+
+/// Rewrite an at-rule prelude into its shortest equivalent spelling.
+///
+/// Whitespace is dropped wherever the tokens stay unambiguous (`(min-width: 1px)`
+/// becomes `(min-width:1px)`, `) and (` becomes `)and (`) and kept where CSS
+/// needs it: between words and before a `(` that follows a word, since `and(`
+/// or `sidebar(` would tokenize as a function. A missing space in `and(` is
+/// added back. Function arguments (`calc(1px + 2px)`, `selector(a :hover)`)
+/// and quoted strings keep their significant whitespace.
+#[must_use]
+pub fn normalize_query(query: &str) -> String {
+ let mut out = String::with_capacity(query.len());
+ // One entry per open parenthesis: `true` when it opened a function, whose
+ // arguments can contain significant whitespace.
+ let mut parens: Vec = Vec::new();
+ let mut pending_space = false;
+ let mut chars = query.chars();
+ while let Some(c) = chars.next() {
+ if c.is_whitespace() {
+ pending_space = true;
+ continue;
+ }
+ let in_function = parens.last().copied().unwrap_or(false);
+ let keyword_paren = c == '(' && !in_function && ends_with_keyword(&out);
+ let function_paren = c == '('
+ && !pending_space
+ && !keyword_paren
+ && out.chars().next_back().is_some_and(is_word_char);
+ if let Some(prev) = out.chars().next_back()
+ && (keyword_paren || (pending_space && keeps_space(prev, c, in_function)))
+ {
+ out.push(' ');
+ }
+ pending_space = false;
+ out.push(c);
+ match c {
+ '(' => parens.push(function_paren),
+ ')' => {
+ parens.pop();
+ }
+ '"' | '\'' => {
+ let mut escaped = false;
+ for inner in chars.by_ref() {
+ out.push(inner);
+ if escaped {
+ escaped = false;
+ } else if inner == '\\' {
+ escaped = true;
+ } else if inner == c {
+ break;
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ out
+}
+
+/// Result of folding two media queries into one.
+#[derive(Debug, PartialEq, Eq)]
+pub enum MediaCombination {
+ /// Both queries hold exactly when this single query list holds.
+ Merged(String),
+ /// The queries can never hold together (e.g. `print` inside `screen`).
+ Never,
+ /// No single query expresses both; emit nested `@media` blocks instead.
+ Nest,
+}
+
+struct MediaQuery<'a> {
+ negated: bool,
+ only: bool,
+ media_type: Option<&'a str>,
+ condition: Option<&'a str>,
+}
+
+fn split_list(query: &str) -> Vec<&str> {
+ let mut parts = Vec::new();
+ let mut depth = 0usize;
+ let mut start = 0;
+ for (index, c) in query.char_indices() {
+ match c {
+ '(' => depth += 1,
+ ')' => depth = depth.saturating_sub(1),
+ ',' if depth == 0 => {
+ parts.push(query[start..index].trim());
+ start = index + 1;
+ }
+ _ => {}
+ }
+ }
+ parts.push(query[start..].trim());
+ parts
+}
+
+fn parse_media_query(query: &str) -> Option> {
+ let condition_only = MediaQuery {
+ negated: false,
+ only: false,
+ media_type: None,
+ condition: Some(query),
+ };
+ if query.starts_with('(') {
+ return Some(condition_only);
+ }
+ let first = leading_word(query);
+ let rest = &query[first.len()..];
+ let (negated, only, typed) = if first.eq_ignore_ascii_case("not") {
+ if rest.starts_with(" (") {
+ return Some(condition_only);
+ }
+ (true, false, rest.strip_prefix(' ')?)
+ } else if first.eq_ignore_ascii_case("only") {
+ (false, true, rest.strip_prefix(' ')?)
+ } else {
+ (false, false, query)
+ };
+ let media_type = leading_word(typed);
+ if media_type.is_empty() || is_keyword(media_type) {
+ return None;
+ }
+ let after = &typed[media_type.len()..];
+ let condition = if after.is_empty() {
+ None
+ } else {
+ Some(
+ after
+ .strip_prefix(" and ")
+ .filter(|condition| condition.starts_with('(') || condition.starts_with("not "))?,
+ )
+ };
+ Some(MediaQuery {
+ negated,
+ only,
+ media_type: Some(media_type),
+ condition,
+ })
+}
+
+/// Turn `not print` into `screen` (and back), the only negations with a
+/// positive spelling; anything else cannot be folded into another query.
+const fn resolve_negation(query: MediaQuery<'_>) -> Result, MediaCombination> {
+ if !query.negated {
+ return Ok(query);
+ }
+ if query.condition.is_some() {
+ return Err(MediaCombination::Nest);
+ }
+ let complement = match query.media_type {
+ Some(media_type) if media_type.eq_ignore_ascii_case("print") => "screen",
+ Some(media_type) if media_type.eq_ignore_ascii_case("screen") => "print",
+ Some(media_type) if media_type.eq_ignore_ascii_case("all") => {
+ return Err(MediaCombination::Never);
+ }
+ _ => return Err(MediaCombination::Nest),
+ };
+ Ok(MediaQuery {
+ negated: false,
+ only: false,
+ media_type: Some(complement),
+ condition: None,
+ })
+}
+
+fn has_top_level_or(condition: &str) -> bool {
+ let mut depth = 0usize;
+ let mut previous = ' ';
+ for (index, c) in condition.char_indices() {
+ match c {
+ '(' => depth += 1,
+ ')' => depth = depth.saturating_sub(1),
+ _ if depth == 0
+ && !is_word_char(previous)
+ && leading_word(&condition[index..]).eq_ignore_ascii_case("or") =>
+ {
+ return true;
+ }
+ _ => {}
+ }
+ previous = c;
+ }
+ false
+}
+
+fn specific_type(media_type: Option<&str>) -> Option<&str> {
+ media_type.filter(|media_type| !media_type.eq_ignore_ascii_case("all"))
+}
+
+fn combine_single(outer: MediaQuery<'_>, inner: MediaQuery<'_>) -> MediaCombination {
+ let (outer, inner) = match (resolve_negation(outer), resolve_negation(inner)) {
+ (Ok(outer), Ok(inner)) => (outer, inner),
+ (Err(MediaCombination::Never), _) | (_, Err(MediaCombination::Never)) => {
+ return MediaCombination::Never;
+ }
+ _ => return MediaCombination::Nest,
+ };
+ let media_type = match (
+ specific_type(outer.media_type),
+ specific_type(inner.media_type),
+ ) {
+ (None, media_type) | (media_type, None) => media_type,
+ (Some(a), Some(b)) if a.eq_ignore_ascii_case(b) => Some(a),
+ _ => return MediaCombination::Never,
+ };
+ let conditions: Vec<&str> = [outer.condition, inner.condition]
+ .into_iter()
+ .flatten()
+ .collect();
+ let joined = conditions.len() > 1;
+
+ let mut result = String::new();
+ if let Some(media_type) = media_type {
+ if outer.only || inner.only {
+ result.push_str("only ");
+ }
+ result.push_str(media_type);
+ if !conditions.is_empty() {
+ result.push_str(" and ");
+ }
+ }
+ for (index, condition) in conditions.iter().enumerate() {
+ if index > 0 {
+ result.push_str("and ");
+ }
+ let wrap = (has_top_level_or(condition) && (joined || media_type.is_some()))
+ || (joined && leading_word(condition).eq_ignore_ascii_case("not"));
+ if wrap {
+ result.push('(');
+ result.push_str(condition);
+ result.push(')');
+ } else {
+ result.push_str(condition);
+ }
+ }
+ if result.is_empty() {
+ result.push_str("all");
+ }
+ MediaCombination::Merged(result)
+}
+
+/// Fold `inner` into `outer` so one `@media` holds exactly when both would.
+///
+/// Both preludes must already be normalized. Query lists distribute over each
+/// other, `not print`/`not screen` become `screen`/`print`, conflicting media
+/// types collapse to [`MediaCombination::Never`], and anything that has no
+/// single-query spelling (such as `not print and (color)`) asks for nesting.
+#[must_use]
+pub fn combine_media_queries(outer: &str, inner: &str) -> MediaCombination {
+ let mut merged: Vec = Vec::new();
+ for outer_query in split_list(outer) {
+ for inner_query in split_list(inner) {
+ let (Some(outer_query), Some(inner_query)) = (
+ parse_media_query(outer_query),
+ parse_media_query(inner_query),
+ ) else {
+ return MediaCombination::Nest;
+ };
+ match combine_single(outer_query, inner_query) {
+ MediaCombination::Merged(query) => {
+ if !merged.contains(&query) {
+ merged.push(query);
+ }
+ }
+ MediaCombination::Never => {}
+ MediaCombination::Nest => return MediaCombination::Nest,
+ }
+ }
+ }
+ if merged.is_empty() {
+ MediaCombination::Never
+ } else {
+ MediaCombination::Merged(merged.join(","))
+ }
+}
+
+fn length_px(value: &str) -> Option {
+ let number_end = value
+ .char_indices()
+ .find(|(_, c)| !(c.is_ascii_digit() || *c == '.'))
+ .map_or(value.len(), |(index, _)| index);
+ let number: f64 = value[..number_end].parse().ok()?;
+ let unit = &value[number_end..];
+ let scale = if unit.eq_ignore_ascii_case("em") || unit.eq_ignore_ascii_case("rem") {
+ 16.0
+ } else {
+ 1.0
+ };
+ Some((number * scale * 100.0).round() as i64)
+}
+
+/// The first width bound of a normalized query: `(true, px)` for a lower bound
+/// (`min-width`, `width>=`), `(false, px)` for an upper one (`max-width`, `width<=`).
+fn width_bound(query: &str) -> Option<(bool, i64)> {
+ query.split(['(', ')']).find_map(|term| {
+ if let Some(value) = term.strip_prefix("min-width:") {
+ return Some((true, length_px(value)?));
+ }
+ if let Some(value) = term.strip_prefix("max-width:") {
+ return Some((false, length_px(value)?));
+ }
+ let split = term.find(['<', '>'])?;
+ let (left, right) = term.split_at(split);
+ let lower = right.starts_with('>');
+ let right = right.trim_start_matches(['<', '>', '=']);
+ if left == "width" {
+ Some((lower, length_px(right)?))
+ } else if right == "width" || right.starts_with("width<") || right.starts_with("width>") {
+ Some((!lower, length_px(left)?))
+ } else {
+ None
+ }
+ })
+}
+
+/// Sort key giving at-rule blocks a deterministic cascade.
+///
+/// Width queries come first, mobile-first (`min-width` ascending, then
+/// `max-width` descending), then bare media types, then other media features,
+/// then user preferences (`prefers-*`, `forced-colors`) so those win last.
+#[must_use]
+pub fn query_order(kind: AtRuleKind, query: &str) -> (u8, i64) {
+ if matches!(kind, AtRuleKind::Media | AtRuleKind::Container)
+ && let Some((lower, px)) = width_bound(query)
+ {
+ return if lower { (0, px) } else { (1, -px) };
+ }
+ if kind != AtRuleKind::Media {
+ (3, 0)
+ } else if query.contains("prefers-") || query.contains("forced-colors") {
+ (4, 0)
+ } else if query.contains('(') {
+ (3, 0)
+ } else {
+ (2, 0)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rstest::rstest;
+
+ #[rstest]
+ #[case("print", Some("print"))]
+ #[case("screen", Some("screen"))]
+ #[case("all", Some("all"))]
+ #[case("motionReduce", Some("(prefers-reduced-motion:reduce)"))]
+ #[case("motion-reduce", Some("(prefers-reduced-motion:reduce)"))]
+ #[case("motionSafe", Some("(prefers-reduced-motion:no-preference)"))]
+ #[case("portrait", Some("(orientation:portrait)"))]
+ #[case("landscape", Some("(orientation:landscape)"))]
+ #[case("contrastMore", Some("(prefers-contrast:more)"))]
+ #[case("contrastLess", Some("(prefers-contrast:less)"))]
+ #[case("forcedColors", Some("(forced-colors:active)"))]
+ #[case("speech", None)]
+ #[case("hover", None)]
+ fn test_media_shorthand_query(#[case] name: &str, #[case] expected: Option<&str>) {
+ assert_eq!(media_shorthand_query(name), expected);
+ }
+
+ #[rstest]
+ #[case("@media print", Some((AtRuleKind::Media, "print")))]
+ #[case("@media(min-width: 1px)", Some((AtRuleKind::Media, "(min-width: 1px)")))]
+ #[case(" @supports (display: grid) ", Some((AtRuleKind::Supports, "(display: grid)")))]
+ #[case("@container sidebar (min-width: 1px)", Some((AtRuleKind::Container, "sidebar (min-width: 1px)")))]
+ #[case("@media", None)]
+ #[case("@media ", None)]
+ #[case("@mediaprint", None)]
+ #[case("@layer reset", None)]
+ #[case("media print", None)]
+ fn test_split_at_rule_key(#[case] key: &str, #[case] expected: Option<(AtRuleKind, &str)>) {
+ assert_eq!(split_at_rule_key(key), expected);
+ }
+
+ #[rstest]
+ #[case("print", "print")]
+ #[case(" print ", "print")]
+ #[case("(prefers-reduced-motion: reduce)", "(prefers-reduced-motion:reduce)")]
+ #[case("( min-width : 768px )", "(min-width:768px)")]
+ #[case(
+ "(min-width: 768px) and (max-width: 1024px)",
+ "(min-width:768px)and (max-width:1024px)"
+ )]
+ #[case("screen and (min-width: 500px)", "screen and (min-width:500px)")]
+ #[case(
+ "only screen and(min-width:768px)",
+ "only screen and (min-width:768px)"
+ )]
+ #[case("not (display: grid)", "not (display:grid)")]
+ #[case("not(display:grid)", "not (display:grid)")]
+ #[case("(a) or(b)", "(a)or (b)")]
+ #[case("sidebar (min-width: 400px)", "sidebar (min-width:400px)")]
+ #[case(
+ "(orientation: portrait), (hover: none)",
+ "(orientation:portrait),(hover:none)"
+ )]
+ #[case("(width >= 600px)", "(width>=600px)")]
+ #[case("(400px <= width <= 700px)", "(400px<=width<=700px)")]
+ #[case("(min-width: calc(100px + 2em))", "(min-width:calc(100px + 2em))")]
+ #[case("selector(a :hover)", "selector(a :hover)")]
+ #[case("selector(:not(a))", "selector(:not(a))")]
+ #[case("style(--label: \"a b\")", "style(--label: \"a b\")")]
+ #[case("style(--label: 'it\\'s x')", "style(--label: 'it\\'s x')")]
+ #[case("(not (color)) or (hover)", "(not (color))or (hover)")]
+ #[case("", "")]
+ fn test_normalize_query(#[case] query: &str, #[case] expected: &str) {
+ assert_eq!(normalize_query(query), expected);
+ }
+
+ #[rstest]
+ #[case("(min-width:768px)", "(prefers-reduced-motion:reduce)", MediaCombination::Merged("(min-width:768px)and (prefers-reduced-motion:reduce)".to_string()))]
+ #[case("(min-width:768px)", "print", MediaCombination::Merged("print and (min-width:768px)".to_string()))]
+ #[case("print", "(prefers-reduced-motion:reduce)", MediaCombination::Merged("print and (prefers-reduced-motion:reduce)".to_string()))]
+ #[case("(min-width:768px)", "screen and (min-width:100px)", MediaCombination::Merged("screen and (min-width:768px)and (min-width:100px)".to_string()))]
+ #[case("(min-width:768px)", "only screen", MediaCombination::Merged("only screen and (min-width:768px)".to_string()))]
+ #[case("(min-width:768px)", "not print", MediaCombination::Merged("screen and (min-width:768px)".to_string()))]
+ #[case("(min-width:768px)", "not screen", MediaCombination::Merged("print and (min-width:768px)".to_string()))]
+ #[case("(min-width:768px)", "not all", MediaCombination::Never)]
+ #[case("(min-width:768px)", "not tv", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "not print and (color)", MediaCombination::Nest)]
+ #[case("print", "screen", MediaCombination::Never)]
+ #[case("print", "PRINT", MediaCombination::Merged("print".to_string()))]
+ #[case("all", "print", MediaCombination::Merged("print".to_string()))]
+ #[case("all", "all", MediaCombination::Merged("all".to_string()))]
+ #[case("(min-width:768px)", "(orientation:portrait),(hover:none)", MediaCombination::Merged("(min-width:768px)and (orientation:portrait),(min-width:768px)and (hover:none)".to_string()))]
+ #[case("(min-width:768px)", "print,screen", MediaCombination::Merged("print and (min-width:768px),screen and (min-width:768px)".to_string()))]
+ #[case("print", "print,screen", MediaCombination::Merged("print".to_string()))]
+ #[case("(min-width:768px)", "(a)or (b)", MediaCombination::Merged("(min-width:768px)and ((a)or (b))".to_string()))]
+ #[case("print", "(a)or (b)", MediaCombination::Merged("print and ((a)or (b))".to_string()))]
+ #[case("(min-width:768px)", "not (color)", MediaCombination::Merged("(min-width:768px)and (not (color))".to_string()))]
+ #[case("print", "not (color)", MediaCombination::Merged("print and not (color)".to_string()))]
+ #[case("(a)", "(b)", MediaCombination::Merged("(a)and (b)".to_string()))]
+ #[case("(min-width:768px)", "print and speech", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "only", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "not", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "and", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "", MediaCombination::Nest)]
+ fn test_combine_media_queries(
+ #[case] outer: &str,
+ #[case] inner: &str,
+ #[case] expected: MediaCombination,
+ ) {
+ assert_eq!(combine_media_queries(outer, inner), expected);
+ }
+
+ #[rstest]
+ #[case(AtRuleKind::Media, "(min-width:500px)", (0, 50_000))]
+ #[case(AtRuleKind::Media, "(min-width:30em)", (0, 48_000))]
+ #[case(AtRuleKind::Media, "(min-width:2rem)", (0, 3_200))]
+ #[case(AtRuleKind::Media, "(width>=600px)", (0, 60_000))]
+ #[case(AtRuleKind::Media, "(width>600px)", (0, 60_000))]
+ #[case(AtRuleKind::Media, "(600px<=width)", (0, 60_000))]
+ #[case(AtRuleKind::Media, "(400px<=width<=700px)", (0, 40_000))]
+ #[case(AtRuleKind::Media, "(max-width:1000px)", (1, -100_000))]
+ #[case(AtRuleKind::Media, "(width<=900px)", (1, -90_000))]
+ #[case(AtRuleKind::Media, "(900px>=width)", (1, -90_000))]
+ #[case(AtRuleKind::Media, "screen and (min-width:10px)", (0, 1_000))]
+ #[case(AtRuleKind::Media, "(min-width:abc)", (3, 0))]
+ #[case(AtRuleKind::Media, "(height>=10px)", (3, 0))]
+ #[case(AtRuleKind::Media, "print", (2, 0))]
+ #[case(AtRuleKind::Media, "(orientation:portrait)", (3, 0))]
+ #[case(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", (4, 0))]
+ #[case(AtRuleKind::Media, "(forced-colors:active)", (4, 0))]
+ #[case(AtRuleKind::Container, "(min-width:400px)", (0, 40_000))]
+ #[case(AtRuleKind::Container, "sidebar (orientation:portrait)", (3, 0))]
+ #[case(AtRuleKind::Supports, "(display:grid)", (3, 0))]
+ fn test_query_order(
+ #[case] kind: AtRuleKind,
+ #[case] query: &str,
+ #[case] expected: (u8, i64),
+ ) {
+ assert_eq!(query_order(kind, query), expected);
+ }
+}
diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs
index f9479f02e..bdd33131e 100644
--- a/libs/css/src/lib.rs
+++ b/libs/css/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod at_rule;
pub mod atom_hoist;
pub mod class_map;
mod constant;
@@ -186,6 +187,11 @@ impl ExactSizeIterator for DisassembleProperty {}
#[must_use]
pub fn disassemble_property(property: &str) -> DisassembleProperty {
+ // Nested selector keys (`&:hover`, `:focus`, `.parent &`) are not properties;
+ // keep them verbatim so class names and case survive.
+ if property.starts_with(':') || property.contains('&') {
+ return DisassembleProperty::Fallback(Some(property.to_string()));
+ }
if let Some(properties) = HAS_CUSTOM_SHORTHANDS
.load(Ordering::Relaxed)
.then(|| {
@@ -292,10 +298,14 @@ pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelect
kind,
query,
selector,
+ outer,
+ file,
} => StyleSelector::At {
kind,
query,
selector: selector.map(|s| format!("{s}({params})")),
+ outer,
+ file,
},
}
}
@@ -1120,7 +1130,9 @@ mod tests {
Some(&StyleSelector::At {
kind: AtRuleKind::Media,
query: "print".to_string(),
- selector: None
+ selector: None,
+ outer: vec![],
+ file: None,
})
),
".cls"
@@ -1132,7 +1144,9 @@ mod tests {
Some(&StyleSelector::At {
kind: AtRuleKind::Media,
query: "print".to_string(),
- selector: Some("&:hover".to_string())
+ selector: Some("&:hover".to_string()),
+ outer: vec![],
+ file: None,
})
),
".cls:hover"
@@ -1197,14 +1211,18 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "print".to_string(),
- selector: Some("&:is".to_string())
+ selector: Some("&:is".to_string()),
+ outer: vec![],
+ file: None,
},
"test"
),
StyleSelector::At {
kind: AtRuleKind::Media,
query: "print".to_string(),
- selector: Some("&:is(test)".to_string())
+ selector: Some("&:is(test)".to_string()),
+ outer: vec![],
+ file: None,
}
);
}
diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs
index ff839bb3c..a36bdc0f4 100644
--- a/libs/css/src/style_selector.rs
+++ b/libs/css/src/style_selector.rs
@@ -7,6 +7,7 @@ use std::{
use serde::{Deserialize, Serialize};
use crate::{
+ at_rule::{MediaCombination, combine_media_queries, media_shorthand_query, normalize_query},
constant::SELECTOR_ORDER,
selector_separator::SelectorSeparator,
to_kebab_case,
@@ -47,12 +48,25 @@ impl From<&str> for AtRuleKind {
}
}
+#[derive(Debug, PartialEq, PartialOrd, Ord, Clone, Hash, Eq, Serialize, Deserialize)]
+pub struct AtRule {
+ pub kind: AtRuleKind,
+ pub query: String,
+}
+
#[derive(Debug, PartialEq, Clone, Hash, Eq, Serialize, Deserialize)]
pub enum StyleSelector {
At {
kind: AtRuleKind,
query: String,
selector: Option,
+ /// At-rules enclosing this one, outermost first. Only set when the
+ /// rules cannot be folded into a single `@media` query.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ outer: Vec,
+ /// Source file of a `globalCss` rule, so re-extracting that file drops it.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ file: Option,
},
Selector(String),
// selector, file
@@ -84,10 +98,14 @@ pub fn optimize_selector(selector: StyleSelector) -> StyleSelector {
kind,
query,
selector,
+ outer,
+ file,
} => StyleSelector::At {
kind,
query,
selector: selector.map(collapse_owned_selector),
+ outer,
+ file,
},
StyleSelector::Selector(selector) => {
StyleSelector::Selector(collapse_owned_selector(selector))
@@ -111,20 +129,22 @@ impl Ord for StyleSelector {
kind: ka,
query: a,
selector: aa,
+ outer: oa,
+ file: fa,
},
StyleSelector::At {
kind: kb,
query: b,
selector: bb,
+ outer: ob,
+ file: fb,
},
- ) => {
- let k = (*ka as u8).cmp(&(*kb as u8));
- if k != Ordering::Equal {
- return k;
- }
- let c = a.cmp(b);
- if c == Ordering::Equal { aa.cmp(bb) } else { c }
- }
+ ) => (*ka as u8)
+ .cmp(&(*kb as u8))
+ .then_with(|| a.cmp(b))
+ .then_with(|| aa.cmp(bb))
+ .then_with(|| oa.cmp(ob))
+ .then_with(|| fa.cmp(fb)),
(StyleSelector::Selector(a), StyleSelector::Selector(b)) => {
let order_cmp = get_selector_order(a).cmp(&get_selector_order(b));
if order_cmp == Ordering::Equal {
@@ -181,11 +201,13 @@ impl From<&str> for StyleSelector {
} else if let Some(s) = value.strip_prefix("theme-") {
// first character should lower case
StyleSelector::Selector(format!(":root[data-theme={}] &", to_camel_case(s)))
- } else if matches!(value.as_ref(), "print" | "screen" | "speech" | "all") {
+ } else if let Some(query) = media_shorthand_query(&value) {
StyleSelector::At {
kind: AtRuleKind::Media,
- query: value.into_owned(),
+ query: query.to_string(),
selector: None,
+ outer: vec![],
+ file: None,
}
} else {
let post = to_kebab_case(&value);
@@ -214,23 +236,19 @@ impl From<[&str; 2]> for StyleSelector {
))
}
}
-impl From<(&StyleSelector, &str)> for StyleSelector {
- fn from(value: (&StyleSelector, &str)) -> Self {
- if let StyleSelector::Global(_, file) = value.0 {
- let post = to_kebab_case(value.1);
- StyleSelector::Global(
- format!(
- "{}{}{}",
- value.0,
- SelectorSeparator::from(post.as_ref()),
- post
- ),
- file.clone(),
- )
- } else {
- StyleSelector::from([&value.0.to_string(), value.1])
- }
+
+/// Write an at-rule prelude (`@media print`, `@media(min-width:1px)`), spacing
+/// the query only when it does not open with a parenthesis.
+pub fn write_at_rule(
+ out: &mut impl std::fmt::Write,
+ kind: AtRuleKind,
+ query: &str,
+) -> std::fmt::Result {
+ write!(out, "@{kind}")?;
+ if !query.starts_with('(') {
+ out.write_char(' ')?;
}
+ out.write_str(query)
}
impl Display for StyleSelector {
@@ -241,12 +259,14 @@ impl Display for StyleSelector {
kind,
query,
selector,
+ outer,
+ ..
} => {
- write!(f, "@{kind}")?;
- if !query.starts_with('(') {
+ for rule in outer {
+ write_at_rule(f, rule.kind, &rule.query)?;
f.write_str(" ")?;
}
- f.write_str(query)?;
+ write_at_rule(f, *kind, query)?;
if let Some(selector) = selector {
write!(f, " {selector}")?;
}
@@ -272,6 +292,87 @@ impl StyleSelector {
StyleSelector::At { .. } => std::borrow::Cow::Owned(self.to_string()),
}
}
+
+ /// Nest a selector `template` (e.g. `&:hover`, `:root[data-theme=dark] &`)
+ /// inside `parent`, substituting the parent's selector for `&`.
+ #[must_use]
+ pub fn nest_selector(parent: Option<&Self>, template: &str) -> Self {
+ match parent {
+ None => Self::Selector(template.to_string()),
+ Some(Self::Selector(selector)) => Self::Selector(template.replace('&', selector)),
+ Some(Self::Global(selector, file)) => {
+ Self::Global(template.replace('&', selector), file.clone())
+ }
+ Some(Self::At {
+ kind,
+ query,
+ selector,
+ outer,
+ file,
+ }) => Self::At {
+ kind: *kind,
+ query: query.clone(),
+ selector: Some(
+ selector
+ .as_deref()
+ .map_or_else(|| template.to_string(), |s| template.replace('&', s)),
+ ),
+ outer: outer.clone(),
+ file: file.clone(),
+ },
+ }
+ }
+
+ /// Wrap `parent` (the bare class when `None`) in an at-rule. Nested
+ /// `@media` rules fold into one query where possible; `None` means the
+ /// combined condition can never match, so the styles must be dropped.
+ #[must_use]
+ pub fn nest_at_rule(parent: Option<&Self>, kind: AtRuleKind, query: &str) -> Option {
+ let query = normalize_query(query);
+ let (selector, outer, file) = match parent {
+ None => (None, vec![], None),
+ Some(Self::Selector(selector)) => (Some(selector.clone()), vec![], None),
+ Some(Self::Global(selector, file)) => {
+ (Some(selector.clone()), vec![], Some(file.clone()))
+ }
+ Some(Self::At {
+ kind: parent_kind,
+ query: parent_query,
+ selector,
+ outer,
+ file,
+ }) => {
+ if *parent_kind == AtRuleKind::Media && kind == AtRuleKind::Media {
+ match combine_media_queries(parent_query, &query) {
+ MediaCombination::Merged(query) => {
+ return Some(Self::At {
+ kind,
+ query,
+ selector: selector.clone(),
+ outer: outer.clone(),
+ file: file.clone(),
+ });
+ }
+ MediaCombination::Never => return None,
+ MediaCombination::Nest => {}
+ }
+ }
+ let mut outer = outer.clone();
+ outer.push(AtRule {
+ kind: *parent_kind,
+ query: parent_query.clone(),
+ });
+ (selector.clone(), outer, file.clone())
+ }
+ };
+ Some(Self::At {
+ kind,
+ query,
+ selector,
+ outer,
+ file,
+ })
+ }
}
/// Rank a `Global` selector's pseudo suffix by scanning `SELECTOR_ORDER` once.
@@ -355,6 +456,7 @@ mod tests {
#[case("group-focus-visible", StyleSelector::Selector(":is([role=group],[data-group]):focus-visible &".to_string()))]
#[case("group-1", StyleSelector::Selector(":is([role=group],[data-group]):1 &".to_string()))]
#[case(["theme-dark", "placeholder"], StyleSelector::Selector(":root[data-theme=dark] &::placeholder".to_string()))]
+ #[case(["theme-dark", "&:hover"], StyleSelector::Selector(":root[data-theme=dark] &:hover".to_string()))]
#[case("theme-light", StyleSelector::Selector(":root[data-theme=light] &".to_string()))]
#[case("*[aria=disabled='true'] &:hover", StyleSelector::Selector("*[aria=disabled='true'] &:hover".to_string()))]
fn test_style_selector(
@@ -369,28 +471,28 @@ mod tests {
#[case(StyleSelector::At {
kind: AtRuleKind::Media,
query: "screen and (max-width: 600px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@media screen and (max-width: 600px)"
)]
#[case(StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display: grid)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@supports(display: grid)"
)]
#[case(StyleSelector::At {
kind: AtRuleKind::Container,
query: "(min-width: 768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@container(min-width: 768px)"
)]
#[case(StyleSelector::At {
kind: AtRuleKind::Container,
query: "sidebar (min-width: 400px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@container sidebar (min-width: 400px)"
)]
@@ -398,7 +500,7 @@ mod tests {
#[case(StyleSelector::At {
kind: AtRuleKind::Layer,
query: "reset".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@layer reset"
)]
@@ -412,7 +514,7 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "screen".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
StyleSelector::Selector("&:hover".to_string()),
std::cmp::Ordering::Greater
@@ -426,12 +528,12 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "a".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
StyleSelector::At {
kind: AtRuleKind::Media,
query: "b".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
std::cmp::Ordering::Less
)]
@@ -439,12 +541,12 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width: 768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display: grid)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
std::cmp::Ordering::Less
)]
@@ -463,7 +565,7 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "screen".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
std::cmp::Ordering::Less
)]
@@ -631,6 +733,180 @@ mod tests {
);
}
+ fn at(kind: AtRuleKind, query: &str, selector: Option<&str>) -> StyleSelector {
+ StyleSelector::At {
+ kind,
+ query: query.to_string(),
+ selector: selector.map(str::to_string),
+ outer: vec![],
+ file: None,
+ }
+ }
+
+ #[rstest]
+ #[case(None, "&:hover", StyleSelector::Selector("&:hover".to_string()))]
+ #[case(
+ Some(StyleSelector::Selector("&:hover".to_string())),
+ ":root[data-theme=dark] &",
+ StyleSelector::Selector(":root[data-theme=dark] &:hover".to_string())
+ )]
+ #[case(
+ Some(StyleSelector::Global("body".to_string(), "a.tsx".to_string())),
+ "&:hover",
+ StyleSelector::Global("body:hover".to_string(), "a.tsx".to_string())
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", None)),
+ "&:hover",
+ at(AtRuleKind::Media, "print", Some("&:hover"))
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", Some("&:focus"))),
+ "&:hover",
+ at(AtRuleKind::Media, "print", Some("&:focus:hover"))
+ )]
+ fn test_nest_selector(
+ #[case] parent: Option,
+ #[case] template: &str,
+ #[case] expected: StyleSelector,
+ ) {
+ assert_eq!(
+ StyleSelector::nest_selector(parent.as_ref(), template),
+ expected
+ );
+ }
+
+ #[rstest]
+ #[case(
+ None,
+ AtRuleKind::Media,
+ "(prefers-reduced-motion: reduce)",
+ Some(at(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", None))
+ )]
+ #[case(
+ Some(StyleSelector::Selector("&:hover".to_string())),
+ AtRuleKind::Media,
+ "print",
+ Some(at(AtRuleKind::Media, "print", Some("&:hover")))
+ )]
+ #[case(
+ Some(StyleSelector::Global("body".to_string(), "a.tsx".to_string())),
+ AtRuleKind::Media,
+ "print",
+ Some(StyleSelector::At {
+ kind: AtRuleKind::Media,
+ query: "print".to_string(),
+ selector: Some("body".to_string()),
+ outer: vec![],
+ file: Some("a.tsx".to_string()),
+ })
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", Some("&:hover"))),
+ AtRuleKind::Media,
+ "(prefers-reduced-motion:reduce)",
+ Some(at(
+ AtRuleKind::Media,
+ "print and (prefers-reduced-motion:reduce)",
+ Some("&:hover")
+ ))
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", None)),
+ AtRuleKind::Media,
+ "screen",
+ None
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", None)),
+ AtRuleKind::Supports,
+ "(display: grid)",
+ Some(StyleSelector::At {
+ kind: AtRuleKind::Supports,
+ query: "(display:grid)".to_string(),
+ selector: None,
+ outer: vec![AtRule { kind: AtRuleKind::Media, query: "print".to_string() }],
+ file: None,
+ })
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "not print and (color)", None)),
+ AtRuleKind::Media,
+ "(hover:none)",
+ Some(StyleSelector::At {
+ kind: AtRuleKind::Media,
+ query: "(hover:none)".to_string(),
+ selector: None,
+ outer: vec![AtRule { kind: AtRuleKind::Media, query: "not print and (color)".to_string() }],
+ file: None,
+ })
+ )]
+ fn test_nest_at_rule(
+ #[case] parent: Option,
+ #[case] kind: AtRuleKind,
+ #[case] query: &str,
+ #[case] expected: Option,
+ ) {
+ assert_eq!(
+ StyleSelector::nest_at_rule(parent.as_ref(), kind, query),
+ expected
+ );
+ }
+
+ #[test]
+ fn test_nested_at_rule_display_and_order() {
+ let nested = StyleSelector::At {
+ kind: AtRuleKind::Supports,
+ query: "(display:grid)".to_string(),
+ selector: Some("&:hover".to_string()),
+ outer: vec![AtRule {
+ kind: AtRuleKind::Media,
+ query: "print".to_string(),
+ }],
+ file: None,
+ };
+ assert_eq!(
+ nested.to_string(),
+ "@media print @supports(display:grid) &:hover"
+ );
+ assert_eq!(
+ nested.as_class_str(),
+ "@media print @supports(display:grid) &:hover"
+ );
+
+ let plain = at(AtRuleKind::Supports, "(display:grid)", Some("&:hover"));
+ assert_eq!(plain.cmp(&nested), std::cmp::Ordering::Less);
+ let global = StyleSelector::At {
+ kind: AtRuleKind::Supports,
+ query: "(display:grid)".to_string(),
+ selector: Some("&:hover".to_string()),
+ outer: vec![],
+ file: Some("a.tsx".to_string()),
+ };
+ assert_eq!(plain.cmp(&global), std::cmp::Ordering::Less);
+ assert_eq!(
+ optimize_selector(global.clone()),
+ global,
+ "optimizing keeps the enclosing rules and file"
+ );
+ }
+
+ #[test]
+ fn test_media_shorthand_selector() {
+ assert_eq!(
+ StyleSelector::from("motion-reduce"),
+ at(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", None)
+ );
+ assert_eq!(
+ StyleSelector::from("print"),
+ at(AtRuleKind::Media, "print", None)
+ );
+ assert_eq!(
+ StyleSelector::from("speech"),
+ StyleSelector::Selector("&:speech".to_string())
+ );
+ }
+
#[rstest]
#[case(AtRuleKind::Media, "media")]
#[case(AtRuleKind::Supports, "supports")]
diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs
index 961e8c1f4..d98859d87 100644
--- a/libs/css/src/theme_tokens.rs
+++ b/libs/css/src/theme_tokens.rs
@@ -5,6 +5,7 @@ use std::sync::{LazyLock, RwLock};
struct ThemeTokenRegistry {
length: BTreeMap>,
shadow: BTreeMap>,
+ typography: Vec,
}
static TOKEN_REGISTRY: LazyLock> =
@@ -20,6 +21,21 @@ pub fn set_theme_token_levels(
}
}
+pub fn set_typography_keys(keys: Vec) {
+ if let Ok(mut registry) = TOKEN_REGISTRY.write() {
+ registry.typography = keys;
+ }
+}
+
+/// Typography names defined by the registered theme, so a dynamic
+/// `typography` value under a selector can resolve to one class per name.
+pub fn get_typography_keys() -> Vec {
+ TOKEN_REGISTRY
+ .read()
+ .map(|registry| registry.typography.clone())
+ .unwrap_or_default()
+}
+
/// Look up a `$token` in the length and shadow registries.
/// Returns the responsive breakpoint levels if the token is defined
/// with more than one level, regardless of which CSS property it's used on.
@@ -94,6 +110,14 @@ mod tests {
assert!(!is_responsive_theme_token("noprefix"));
}
+ #[test]
+ fn test_typography_keys() {
+ set_typography_keys(vec!["body".to_string(), "title".to_string()]);
+ assert_eq!(get_typography_keys(), vec!["body", "title"]);
+ set_typography_keys(vec![]);
+ assert!(get_typography_keys().is_empty());
+ }
+
#[test]
fn test_is_responsive_theme_token_without_prefix() {
let value = std::hint::black_box("noprefix");
diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs
index be4b5e863..b594560af 100644
--- a/libs/extractor/src/css_utils.rs
+++ b/libs/extractor/src/css_utils.rs
@@ -4,9 +4,10 @@ use std::fmt::Write as _;
use crate::utils::{get_str_by_property_key, get_string_by_literal_expression, wrap_direct_call};
use css::{
+ at_rule::split_at_rule_key,
optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value},
rm_css_comment::rm_css_comment,
- style_selector::{AtRuleKind, StyleSelector},
+ style_selector::StyleSelector,
};
use oxc_allocator::Allocator;
use oxc_span::SPAN;
@@ -421,238 +422,13 @@ pub fn css_to_style_literal(
styles
}
-const AT_RULES: [(&str, AtRuleKind); 3] = [
- ("@media", AtRuleKind::Media),
- ("@supports", AtRuleKind::Supports),
- ("@container", AtRuleKind::Container),
-];
-
pub fn css_to_style(
css: &str,
level: u8,
selector: &Option,
) -> Vec {
let mut styles = vec![];
- let mut input = css;
-
- // Split by at-rules (@media, @supports, @container) to handle multiple at-rules in a single input.
- // Every at-rule prefix begins with `@`, so a single `@` byte scan is a sound necessary-condition
- // guard: if the input has no `@` at all (the overwhelmingly common declaration block), none of the
- // three prefixes can match, so skip all three `input.contains(at_rule)` substring scans entirely.
- if input.contains('@') {
- // Classify which of the three at-rule prefixes are present in ONE pass over the
- // `@`-anchored positions, instead of re-scanning the whole input with a separate
- // `input.contains(at_rule)` per prefix inside the loop. For an `@`-bearing block
- // carrying just one kind (the common single-`@media`/`@supports`/`@container` case),
- // the two absent kinds previously each paid a full substring scan; now every
- // `@`-run is examined once here and the loop reads a precomputed `bool`. The
- // presence check keys off the byte right after `@` (`m`/`s`/`c`), matching the
- // prefixes' first distinguishing letter, so it is byte-identical to `contains`.
- let mut present = [false; AT_RULES.len()];
- for (pos, _) in input.match_indices('@') {
- let rest = &input[pos..];
- for (i, (at_rule, _)) in AT_RULES.iter().enumerate() {
- if !present[i] && rest.starts_with(at_rule) {
- present[i] = true;
- }
- }
- }
- for (idx, (at_rule, _)) in AT_RULES.iter().enumerate() {
- // Only the multi-segment case recurses. Walk the non-empty trimmed `@rule` segments with
- // a single `split` pass (dropping the earlier separate `count` scan + identical `collect`
- // scan) via a peekable iterator: confirm a *second* non-empty segment before allocating,
- // so the common single-`@media`/`@supports`/`@container` block (already dispatched by an
- // outer recursion level) still skips materializing a throwaway `Vec`.
- // Absent prefixes are skipped via the precomputed `present` flags (no re-scan).
- if !present[idx] {
- continue;
- }
- let mut segments = input.split(at_rule).filter_map(|s| {
- let s = s.trim();
- (!s.is_empty()).then_some(s)
- });
- if let Some(first) = segments.next()
- && let Some(second) = segments.next()
- {
- // Re-attach the known `at_rule` prefix to each segment with a presized
- // `String` + two `push_str` instead of `format!`, which pulls in the
- // `Arguments` formatting machinery and its grow path. Both lengths are
- // known up front, so a single exact allocation suffices. Byte-identical
- // to `format!("{at_rule}{seg}")`.
- let join_at = |seg: &str| {
- let mut s = String::with_capacity(at_rule.len() + seg.len());
- s.push_str(at_rule);
- s.push_str(seg);
- s
- };
- styles.extend(css_to_style(&join_at(first), level, selector));
- styles.extend(css_to_style(&join_at(second), level, selector));
- for rest in segments {
- styles.extend(css_to_style(&join_at(rest), level, selector));
- }
- return styles;
- }
- }
- }
-
- if input.contains('{') {
- while let Some(start) = input.find('{') {
- // Check if there are properties before the selector
- let before_brace = &input[..start].trim();
-
- // The overwhelmingly common case has no `;`-separated plain props before the
- // selector (e.g. `&:hover { ... }`), which maps to the single-part `else`
- // branch below. Only build the `Vec<&str>` split when a `;` is actually present.
- let (plain_props, selector_part): (&str, &str) = if before_brace.contains(';') {
- // Split by semicolon to find the last part which should be the selector
- let parts: Vec<&str> = before_brace.split(';').map(str::trim).collect();
-
- // Find the selector part (the last part that doesn't contain ':')
- // or if all parts contain ':', then the last part is the selector
- // `before_brace` contains `;`, so `split` always yields at least two parts.
- // Check if any part doesn't contain ':' (which would be a selector).
- let mut selector_idx = parts.len();
- for (i, part) in parts.iter().enumerate().rev() {
- if !part.contains(':') || part.starts_with('&') || part.starts_with('@') {
- selector_idx = i;
- break;
- }
- }
-
- // Borrow the props/selector partition directly from `before_brace`
- // instead of allocating two `join(";")` Strings. The split boundary is the
- // byte offset of the `selector_idx`-th `;` (parts are `;`-separated), so
- // `[..boundary]` is the props run and `[boundary + 1..]` the selector run.
- // `css_to_style_block` re-splits/re-trims each `;` part on the props side and
- // the selector side is `.trim()`ed downstream, so the parsed output stays
- // byte-identical to the previous `join(";")` form.
- // `selector_idx` is 0 when the very first `;`-part is the selector
- // (e.g. `foo;color:red { .. }`), leaving no props run before it.
- // `checked_sub` yields `None` there, which the match below already
- // treats as "the whole run is the selector" - the same result release
- // builds reached by wrapping, without the debug-build overflow panic.
- let boundary = selector_idx
- .checked_sub(1)
- .and_then(|nth| before_brace.match_indices(';').nth(nth).map(|(idx, _)| idx));
- match boundary {
- Some(b) => (before_brace[..b].trim(), before_brace[b + 1..].trim()),
- // No selector boundary: whole `before_brace` is the selector.
- None => ("", before_brace),
- }
- } else {
- ("", before_brace)
- };
-
- // Process plain properties if any
- if !plain_props.is_empty() {
- styles.extend(css_to_style_block(plain_props, level, selector));
- }
-
- let rest = &input[start + 1..];
-
- // Find the matching closing brace by counting braces
- let mut brace_count = 1;
- let mut end = 0;
- for (i, byte) in rest.bytes().enumerate() {
- match byte {
- b'{' => brace_count += 1,
- b'}' => {
- brace_count -= 1;
- if brace_count == 0 {
- end = i;
- break;
- }
- }
- _ => {}
- }
- }
-
- // If we didn't find a matching brace, use the first '}' as fallback
- if brace_count > 0 {
- end = rest.find('}').unwrap_or(rest.len());
- }
- let block = &rest[..end];
- let sel = &if let Some(StyleSelector::At { kind, query, .. }) = selector {
- let local_sel = selector_part.trim().to_string();
- Some(StyleSelector::At {
- kind: *kind,
- query: query.clone(),
- selector: if local_sel == "&" {
- None
- } else {
- Some(local_sel)
- },
- })
- } else {
- let sel = selector_part.trim().to_string();
- if let Some((prefix, kind)) =
- AT_RULES.iter().find(|(prefix, _)| sel.starts_with(prefix))
- {
- // The prefix contains neither spaces nor "and(", so slicing it off
- // first is equivalent to slicing after the replaces. Strip spaces into
- // one pre-sized buffer, then a single "and(" -> "and (" normalization.
- let rest = &sel[prefix.len()..];
- let mut query = String::with_capacity(rest.len());
- for ch in rest.chars() {
- if ch != ' ' {
- query.push(ch);
- }
- }
- // `and(` only appears in multi-condition media queries. Skip the
- // second-buffer allocation of `replace` on the common
- // single-condition path where the normalization is a no-op.
- let query = if query.contains("and(") {
- query.replace("and(", "and (")
- } else {
- query
- };
- Some(StyleSelector::At {
- kind: *kind,
- query,
- selector: None,
- })
- } else if sel.is_empty() {
- selector.clone()
- } else {
- Some(StyleSelector::Selector(sel))
- }
- };
- let block = if block.contains('{') {
- css_to_style(block, level, sel)
- } else {
- css_to_style_block(block, level, sel)
- };
-
- // Find the matching closing brace
- let closing_brace_pos = start + 1 + end;
-
- // Process the block
- styles.extend(block);
-
- // Update input to continue processing after the closing brace
- // Check if there's more content after the closing brace
- if closing_brace_pos + 1 < input.len() {
- let remaining = &input[closing_brace_pos + 1..].trim();
- if remaining.is_empty() {
- break;
- }
- // If there's remaining text after the closing brace, process it
- // This handles cases like "} color: blue;"
- if remaining.contains('{') {
- // If it contains '{', continue the loop
- input = remaining;
- } else {
- // If it doesn't contain '{', process it as a block and break
- styles.extend(css_to_style_block(remaining, level, selector));
- break;
- }
- } else {
- break;
- }
- }
- } else {
- styles.extend(css_to_style_block(input, level, selector));
- }
+ collect_css_block(&rm_css_comment(css), level, selector, &mut styles);
// A single declaration (or none) is trivially ordered, so skip the comparison
// sort's setup entirely for the very common single-property case. The multi-source
@@ -664,6 +440,70 @@ pub fn css_to_style(
styles
}
+/// Walk one level of CSS text: declarations apply to `selector`, and every
+/// nested `prelude { … }` block recurses with its prelude composed onto it, so
+/// `&:hover { @media print { … } }` keeps both the hover and the media query.
+fn collect_css_block(
+ css: &str,
+ level: u8,
+ selector: &Option,
+ styles: &mut Vec,
+) {
+ let mut rest = css;
+ while let Some(open) = rest.find('{') {
+ let head = &rest[..open];
+ let (declarations, prelude) = head.rsplit_once(';').unwrap_or(("", head));
+ styles.extend(css_to_style_block(declarations, level, selector));
+
+ let body_start = open + 1;
+ let mut depth = 1usize;
+ let body_end = rest[body_start..]
+ .char_indices()
+ .find_map(|(index, c)| {
+ match c {
+ '{' => depth += 1,
+ '}' => depth -= 1,
+ _ => {}
+ }
+ (depth == 0).then_some(body_start + index)
+ })
+ .unwrap_or(rest.len());
+ let body = &rest[body_start..body_end];
+ let prelude = prelude.trim();
+ if prelude.is_empty() || prelude == "&" {
+ collect_css_block(body, level, selector, styles);
+ } else if let Some(nested) = nest_prelude(selector.as_ref(), prelude) {
+ collect_css_block(body, level, &Some(nested), styles);
+ }
+ rest = rest.get(body_end + 1..).unwrap_or_default();
+ }
+ styles.extend(css_to_style_block(rest, level, selector));
+}
+
+/// The selector a nested block applies to: at-rule preludes wrap `parent`,
+/// `&`-selectors substitute it, and a bare selector under a selector nests as
+/// a descendant. `None` drops the block (an unknown at-rule, or conditions
+/// that can never match together).
+fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option {
+ if prelude.starts_with('@') {
+ let (kind, query) = split_at_rule_key(prelude)?;
+ return StyleSelector::nest_at_rule(parent, kind, query);
+ }
+ let parent_selector = match parent {
+ Some(StyleSelector::Selector(selector) | StyleSelector::Global(selector, _)) => {
+ Some(selector)
+ }
+ Some(StyleSelector::At { selector, .. }) => selector.as_ref(),
+ None => None,
+ };
+ let template = if prelude.contains('&') || parent_selector.is_none() {
+ Cow::Borrowed(prelude)
+ } else {
+ Cow::Owned(format!("& {prelude}"))
+ };
+ Some(StyleSelector::nest_selector(parent, &template))
+}
+
/// Optimize a declaration's value only when its property warrants multi-value
/// optimization, borrowing otherwise. Shared by `css_to_style_block` and
/// `optimize_css_block`, which both made this identical decision inline.
@@ -868,6 +708,7 @@ fn remove_semicolon_before_closing_brace(value: &mut String) {
mod tests {
use super::*;
+ use css::style_selector::AtRuleKind;
use oxc_allocator::Allocator;
use oxc_ast::ast::{Expression, Statement};
use oxc_parser::Parser;
@@ -942,12 +783,12 @@ mod tests {
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -965,22 +806,22 @@ mod tests {
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)and (max-width:1024px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)and (max-width:1024px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -999,22 +840,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover,&:active,&:nth-child(2)".to_string()),
+ selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover,&:active,&:nth-child(2)".to_string()),
+ selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1033,22 +874,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1077,42 +918,42 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1131,22 +972,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1169,12 +1010,12 @@ mod tests {
("display", "grid", Some(StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display:grid)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("grid-template-columns", "1fr 1fr", Some(StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display:grid)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1188,7 +1029,7 @@ mod tests {
("display", "flex", Some(StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display:flex)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1199,8 +1040,8 @@ mod tests {
vec![
("display", "block", Some(StyleSelector::At {
kind: AtRuleKind::Supports,
- query: "not(display:grid)".to_string(),
- selector: None,
+ query: "not (display:grid)".to_string(),
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1213,7 +1054,7 @@ mod tests {
("padding", "10px", Some(StyleSelector::At {
kind: AtRuleKind::Container,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1224,8 +1065,8 @@ mod tests {
vec![
("display", "flex", Some(StyleSelector::At {
kind: AtRuleKind::Container,
- query: "sidebar(min-width:400px)".to_string(),
- selector: None,
+ query: "sidebar (min-width:400px)".to_string(),
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1451,12 +1292,12 @@ mod tests {
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1474,22 +1315,22 @@ mod tests {
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)and (max-width:1024px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)and (max-width:1024px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1508,22 +1349,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover,&:active,&:nth-child(2)".to_string()),
+ selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover,&:active,&:nth-child(2)".to_string()),
+ selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1542,22 +1383,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1586,42 +1427,42 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1640,22 +1481,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1681,29 +1522,30 @@ mod tests {
("background", "blue", Some(StyleSelector::Selector("div".to_string()))),
]
)]
+ // As in CSS nesting, only the text after the last `;` is the nested rule's
+ // selector; everything before it is declarations.
#[case(
"color:red;background:blue { width: 1px; }",
- vec![(
- "width",
- "1px",
- Some(StyleSelector::Selector(
- "color:red;background:blue".to_string()
- ))
- )]
+ vec![
+ ("color", "red", None),
+ ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))),
+ ]
)]
- // The selector is the very first `;`-part, so there is no props run before it.
#[case(
"foo;color:red { width: 1px; }",
vec![(
"width",
"1px",
- Some(StyleSelector::Selector("foo;color:red".to_string()))
+ Some(StyleSelector::Selector("color:red".to_string()))
)]
)]
#[case(
"color: red;;invalid;display: block;",
vec![("color", "red", None), ("display", "block", None)]
)]
+ // Blocks that can never apply produce nothing: unknown at-rules and
+ // mutually exclusive media types.
+ #[case("@layer base { color: red; } @media print { @media screen { color: red; } }", vec![])]
fn test_css_to_style(
#[case] input: &str,
#[case] expected: Vec<(&str, &str, Option)>,
diff --git a/libs/extractor/src/extract_style/constant.rs b/libs/extractor/src/extract_style/constant.rs
index a1e1821c4..1bb014102 100644
--- a/libs/extractor/src/extract_style/constant.rs
+++ b/libs/extractor/src/extract_style/constant.rs
@@ -1,6 +1,9 @@
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.
+pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! {
"opacity",
"flex",
"z-index",
@@ -12,6 +15,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 +25,32 @@ 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"
};
diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs
index 91ad74822..12d3249ba 100644
--- a/libs/extractor/src/extract_style/extract_static_style.rs
+++ b/libs/extractor/src/extract_style/extract_static_style.rs
@@ -65,22 +65,23 @@ 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 {
- 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.
@@ -210,6 +211,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/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs
index 15ba1c04f..5da9cad18 100644
--- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs
@@ -9,7 +9,10 @@ use crate::{
},
extractor::{
GlobalExtractResult,
- extract_style_from_expression::{LiteralHandling, extract_style_from_expression},
+ extract_style_from_expression::{
+ LiteralHandling, at_rule_record_kind, extract_style_from_expression, place_in_layer,
+ yield_typography,
+ },
},
utils::{
get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key,
@@ -17,9 +20,10 @@ use crate::{
},
};
use css::{
+ at_rule::{media_shorthand_query, split_at_rule_key},
disassemble_property,
optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value, wrap_url},
- style_selector::StyleSelector,
+ style_selector::{AtRule, AtRuleKind, StyleSelector},
};
use oxc_ast::{
ast::{ArrayExpressionElement, Expression, ObjectPropertyKind},
@@ -32,6 +36,35 @@ pub fn extract_global_style_from_expression<'a>(
file: &str,
) -> GlobalExtractResult<'a> {
let mut styles = vec![];
+ collect_global_styles(ast_builder, expression, file, &[], &mut styles);
+ GlobalExtractResult {
+ styles,
+ style_order: None,
+ }
+}
+
+/// A top-level at-rule key wrapping a selector map: `'@media print'` or a
+/// media shorthand such as `_print` / `_motionReduce`.
+fn global_at_rule_key(name: &str) -> Option {
+ let (kind, query) = split_at_rule_key(name).or_else(|| {
+ let query = media_shorthand_query(name.strip_prefix('_')?)?;
+ Some((AtRuleKind::Media, query))
+ })?;
+ Some(AtRule {
+ kind,
+ query: query.to_string(),
+ })
+}
+
+/// Collect a `globalCss` selector map whose rules sit inside `at_rules`
+/// (outermost first).
+fn collect_global_styles<'a>(
+ ast_builder: &AstBuilder<'a>,
+ expression: &mut Expression<'a>,
+ file: &str,
+ at_rules: &[AtRule],
+ styles: &mut Vec>,
+) {
let expression = unwrap_syntax_only_mut(expression);
if let Expression::ObjectExpression(obj) = expression {
@@ -39,7 +72,29 @@ pub fn extract_global_style_from_expression<'a>(
match p {
ObjectPropertyKind::ObjectProperty(o) => {
if let Some(name) = get_string_by_property_key(&o.key) {
- if name == "imports" {
+ if let Some(kind) = at_rule_record_kind(&name)
+ && let Expression::ObjectExpression(record) = &mut o.value
+ {
+ for entry in &mut record.properties {
+ if let ObjectPropertyKind::ObjectProperty(entry) = entry
+ && let Some(query) = get_string_by_property_key(&entry.key)
+ {
+ let mut nested = at_rules.to_vec();
+ nested.push(AtRule { kind, query });
+ collect_global_styles(
+ ast_builder,
+ &mut entry.value,
+ file,
+ &nested,
+ styles,
+ );
+ }
+ }
+ } else if let Some(at_rule) = global_at_rule_key(&name) {
+ let mut nested = at_rules.to_vec();
+ nested.push(at_rule);
+ collect_global_styles(ast_builder, &mut o.value, file, &nested, styles);
+ } else if name == "imports" {
if let Expression::ArrayExpression(arr) = &o.value {
for p in &arr.elements {
// `...spread` elements carry no statically readable url.
@@ -169,57 +224,55 @@ pub fn extract_global_style_from_expression<'a>(
None
};
- let extracted = extract_style_from_expression(
- ast_builder,
- None,
- &mut o.value,
- 0,
- &Some(StyleSelector::Global(
- if let Some(name) = name.strip_prefix("_") {
- StyleSelector::from(name).to_string().replace('&', "*")
- } else {
- name
- },
- file.to_string(),
- )),
- LiteralHandling::ExpandResponsiveThemeToken,
+ let global = StyleSelector::Global(
+ if let Some(name) = name.strip_prefix("_") {
+ StyleSelector::from(name).to_string().replace('&', "*")
+ } else {
+ name
+ },
+ file.to_string(),
);
+ // `None` when the enclosing at-rules can never match together.
+ let selector = at_rules.iter().try_fold(global, |selector, rule| {
+ StyleSelector::nest_at_rule(Some(&selector), rule.kind, &rule.query)
+ });
+ let extracted = selector
+ .map(|selector| {
+ extract_style_from_expression(
+ ast_builder,
+ None,
+ &mut o.value,
+ 0,
+ &Some(selector),
+ LiteralHandling::ExpandResponsiveThemeToken,
+ )
+ })
+ .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);
}
}
}
ObjectPropertyKind::SpreadProperty(o) => {
- styles.extend(
- extract_global_style_from_expression(
- ast_builder,
- o.argument.get_inner_expression_mut(),
- file,
- )
- .styles,
+ collect_global_styles(
+ ast_builder,
+ o.argument.get_inner_expression_mut(),
+ file,
+ at_rules,
+ styles,
);
}
}
}
}
- GlobalExtractResult {
- styles,
- style_order: None,
- }
}
diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs
index cc4a9311b..bc058ceb2 100644
--- a/libs/extractor/src/extractor/extract_style_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_style_from_expression.rs
@@ -16,11 +16,12 @@ use crate::{
},
};
use css::{
- add_selector_params, disassemble_property, get_enum_property_map, get_enum_property_value,
- is_enum_property,
+ add_selector_params,
+ at_rule::{media_shorthand_query, split_at_rule_key},
+ disassemble_property, get_enum_property_map, get_enum_property_value, is_enum_property,
is_special_property::is_special_property,
- style_selector::StyleSelector,
- theme_tokens::{get_responsive_theme_token, is_responsive_theme_token},
+ style_selector::{AtRuleKind, StyleSelector, optimize_selector},
+ theme_tokens::{get_responsive_theme_token, get_typography_keys, is_responsive_theme_token},
utils::to_kebab_case,
};
use oxc_allocator::{CloneIn, GetAllocator};
@@ -32,6 +33,7 @@ use oxc_ast::{
builder::AstBuilder,
};
use oxc_span::SPAN;
+use std::collections::BTreeMap;
const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"];
@@ -220,6 +222,31 @@ pub fn extract_style_from_expression<'a>(
};
}
+ if let Some(name) = name
+ && is_nested_selector_key(name)
+ {
+ let mut styles = vec![];
+ for part in split_selector_list(name) {
+ if let Some(nested) = nest_selectors_key(selector.as_ref(), part) {
+ styles.extend(
+ extract_style_from_expression(
+ ast_builder,
+ None,
+ expression,
+ level,
+ &Some(nested),
+ literal_handling,
+ )
+ .styles,
+ );
+ }
+ }
+ return ExtractResult {
+ styles,
+ ..ExtractResult::default()
+ };
+ }
+
if let Some(name) = name
// First-byte dispatch: only names beginning with `a` (`as`), `s`
// (`selectors`), `@`/`_` (at-rule + `_`-selector prefixes) or `t`
@@ -242,76 +269,23 @@ 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() else {
- continue;
- };
- let name = key_name.trim();
- let mut part_of_selector = vec![];
-
- let mut level = 0;
- let mut last_idx = 0;
- for (idx, c) in name.char_indices() {
- if c == '(' {
- level += 1;
- }
- if c == ')' {
- level -= 1;
- }
- if c == ',' && level == 0 {
- part_of_selector.push(name[last_idx..idx].trim());
- last_idx = idx + 1;
- }
- }
- if !name.is_empty() {
- part_of_selector.push(name[last_idx..].trim());
- }
-
- // The parent selector's owned string is invariant across every
- // comma-part, so materialize it ONCE here instead of calling
- // `selector.to_string()` up to 3× per part inside the closure.
- let parent_sel = selector.as_ref().map(ToString::to_string);
- for sel in part_of_selector.iter().map(|name| {
- if let Some(parent_sel) = parent_sel.as_deref() {
- if name.starts_with('_') {
- if name.starts_with("_theme") {
- StyleSelector::from([
- to_kebab_case(name.strip_prefix("_").unwrap_or(name))
- .as_ref(),
- parent_sel,
- ])
- .to_string()
- } else {
- StyleSelector::from([
- parent_sel,
- to_kebab_case(name.strip_prefix("_").unwrap_or(name))
- .as_ref(),
- ])
- .to_string()
- }
- } else {
- name.replace('&', parent_sel)
- }
- } else if name.starts_with('_') {
- StyleSelector::from(
- to_kebab_case(name.strip_prefix("_").unwrap_or(name)).as_ref(),
- )
- .to_string()
- } else {
- StyleSelector::from(name.strip_prefix("_").unwrap_or(name)).to_string()
+ if let ObjectPropertyKind::ObjectProperty(o) = p
+ && let Some(key_name) = o.key.name()
+ {
+ for part in split_selector_list(key_name.trim()) {
+ if let Some(child) = nest_selectors_key(selector.as_ref(), part) {
+ props.extend(
+ extract_style_from_expression(
+ ast_builder,
+ None,
+ &mut o.value,
+ level,
+ &Some(child),
+ literal_handling,
+ )
+ .styles,
+ );
}
- }) {
- props.extend(
- extract_style_from_expression(
- ast_builder,
- None,
- &mut o.value,
- level,
- &Some(StyleSelector::Selector(sel)),
- literal_handling,
- )
- .styles,
- );
}
}
}
@@ -321,31 +295,44 @@ pub fn extract_style_from_expression<'a>(
};
}
- // Handle at-rules: @media, @supports, @container (or _media, _supports, _container)
- let at_rule_name = name
- .strip_prefix("@")
- .or_else(|| name.strip_prefix("_"))
- .filter(|n| matches!(*n, "media" | "supports" | "container"));
+ // 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(at_rule) = at_rule_name
+ if let Some(kind) = at_rule_record_kind(name)
&& let Expression::ObjectExpression(obj) = expression
{
let mut props = vec![];
- // The parent selector renders identically for every query in this
- // at-rule object: format it once and clone the resulting `String`
- // per iteration instead of re-running `Display` formatting. `query`
- // is moved into the selector (it was previously cloned even though
- // the original was dropped immediately after).
- let parent = selector.as_ref().map(ToString::to_string);
for p in &mut obj.properties {
if let ObjectPropertyKind::ObjectProperty(o) = p
&& let Some(query) = get_string_by_property_key(&o.key)
+ && let Some(at_selector) =
+ StyleSelector::nest_at_rule(selector.as_ref(), kind, &query)
{
- let at_selector = StyleSelector::At {
- kind: at_rule.into(),
- query,
- selector: parent.clone(),
- };
props.extend(
extract_style_from_expression(
ast_builder,
@@ -365,19 +352,23 @@ pub fn extract_style_from_expression<'a>(
};
}
- if let Some(new_selector) = name.strip_prefix("_") {
- return extract_style_from_expression(
- ast_builder,
- None,
- expression,
- level,
- &Some(if let Some(selector) = selector {
- (selector, new_selector).into()
- } else {
- new_selector.into()
- }),
- literal_handling,
- );
+ let nested = if let Some((kind, query)) = split_at_rule_key(name) {
+ Some(StyleSelector::nest_at_rule(selector.as_ref(), kind, query))
+ } else {
+ name.strip_prefix('_')
+ .map(|child| nest_underscore_name(selector.as_ref(), child))
+ };
+ if let Some(nested) = nested {
+ return nested.map_or_else(ExtractResult::default, |nested| {
+ extract_style_from_expression(
+ ast_builder,
+ None,
+ expression,
+ level,
+ &Some(nested),
+ literal_handling,
+ )
+ });
}
typo = name == "typography";
}
@@ -385,9 +376,7 @@ pub fn extract_style_from_expression<'a>(
if let Some(name) = name {
ExtractResult {
styles: if typo {
- vec![ExtractStyleProp::Static(ExtractStyleValue::Typography(
- value.into_owned(),
- ))]
+ vec![typography_style(value.into_owned(), level, selector)]
} else if matches!(
literal_handling,
LiteralHandling::ExpandResponsiveThemeToken
@@ -508,7 +497,12 @@ pub fn extract_style_from_expression<'a>(
// 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 {
+ 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,
@@ -566,6 +560,18 @@ pub fn extract_style_from_expression<'a>(
// like any other non-extracted style prop.
if IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) {
ExtractResult::default()
+ } else if typo
+ && let Some(style) = conditional_typography(
+ ast_builder,
+ &Expression::new_identifier(SPAN, identifier.name.as_str(), ast_builder),
+ level,
+ selector,
+ )
+ {
+ ExtractResult {
+ styles: vec![style],
+ ..ExtractResult::default()
+ }
} else if let Some(name) = name {
if typo {
ExtractResult {
@@ -841,6 +847,283 @@ pub fn extract_style_from_expression<'a>(
}
}
+/// 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) {
+ for prop in props {
+ match prop {
+ ExtractStyleProp::Static(ExtractStyleValue::Static(style)) => {
+ style.layer = Some(match style.layer.take() {
+ Some(inner) => format!("{layer}.{inner}"),
+ None => layer.to_string(),
+ });
+ }
+ 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);
+ }
+ }
+ // Dynamic values and dynamic `typography` carry no static declaration
+ ExtractStyleProp::Static(_) | ExtractStyleProp::Expression { .. } => {}
+ }
+ }
+}
+
+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 { .. } => 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 { .. } => {}
+ }
+ }
+}
+
+pub(crate) fn at_rule_record_kind(name: &str) -> Option {
+ match name.strip_prefix('@').or_else(|| name.strip_prefix('_'))? {
+ "media" => Some(AtRuleKind::Media),
+ "supports" => Some(AtRuleKind::Supports),
+ "container" => Some(AtRuleKind::Container),
+ _ => None,
+ }
+}
+
+fn split_selector_list(key: &str) -> Vec<&str> {
+ let mut parts = vec![];
+ let mut depth = 0usize;
+ let mut start = 0;
+ for (index, c) in key.char_indices() {
+ match c {
+ '(' => depth += 1,
+ ')' => depth = depth.saturating_sub(1),
+ ',' if depth == 0 => {
+ parts.push(key[start..index].trim());
+ start = index + 1;
+ }
+ _ => {}
+ }
+ }
+ if !key.is_empty() {
+ parts.push(key[start..].trim());
+ }
+ parts
+}
+
+/// Resolve a `_name` style key (without the `_`) under `parent`: media
+/// shorthands such as `print` or `motionReduce` wrap it in `@media`, anything
+/// else nests a selector. `None` means the styles can never apply.
+fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option {
+ let name = to_kebab_case(name);
+ if let Some(query) = media_shorthand_query(&name) {
+ return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query);
+ }
+ Some(StyleSelector::nest_selector(
+ parent,
+ &StyleSelector::from(name.as_ref()).to_string(),
+ ))
+}
+
+/// Emotion/styled-components style object keys that are nested selectors
+/// (`'&:hover'`, `':hover'`, `'.parent &'`) rather than CSS properties.
+fn is_nested_selector_key(key: &str) -> bool {
+ key.starts_with(':') || key.contains('&')
+}
+
+fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option {
+ if let Some((kind, query)) = split_at_rule_key(key) {
+ StyleSelector::nest_at_rule(parent, kind, query)
+ } else if let Some(name) = key.strip_prefix('_') {
+ nest_underscore_name(parent, name)
+ } else if key.starts_with(':') {
+ Some(StyleSelector::nest_selector(parent, &format!("&{key}")))
+ } else if parent.is_some() {
+ Some(StyleSelector::nest_selector(parent, key))
+ } else {
+ Some(StyleSelector::from(key))
+ }
+}
+
+fn typography_atom(name: &str, level: u8, selector: &Option) -> ExtractStaticStyle {
+ ExtractStaticStyle {
+ property: "typography".to_string(),
+ value: name.to_string(),
+ level,
+ selector: selector.clone().map(optimize_selector),
+ style_order: None,
+ layer: None,
+ theme_token_resolution: ThemeTokenResolution::default(),
+ }
+}
+
+/// A `typography` preset outside any selector or breakpoint uses the theme's
+/// shared `typo-*` class. Under a condition the class would apply
+/// unconditionally, so it becomes a `typography` atom the sheet expands into
+/// the preset's declarations under that condition.
+fn typography_style<'a>(
+ name: String,
+ level: u8,
+ selector: &Option,
+) -> ExtractStyleProp<'a> {
+ ExtractStyleProp::Static(if selector.is_none() && level == 0 {
+ ExtractStyleValue::Typography(name)
+ } else {
+ ExtractStyleValue::Static(typography_atom(&name, level, selector))
+ })
+}
+
+/// A dynamic `typography` value under a condition picks, at runtime, one
+/// conditional atom per typography preset registered in the theme.
+fn conditional_typography<'a>(
+ ast_builder: &AstBuilder<'a>,
+ condition: &Expression<'a>,
+ level: u8,
+ selector: &Option,
+) -> Option> {
+ if selector.is_none() && level == 0 {
+ return None;
+ }
+ let condition = condition.clone_in(ast_builder.allocator());
+ let map = get_typography_keys()
+ .into_iter()
+ .map(|key| {
+ let atom = ExtractStyleValue::Static(typography_atom(&key, level, selector));
+ (key, vec![ExtractStyleProp::Static(atom)])
+ })
+ .collect();
+ Some(ExtractStyleProp::Enum { condition, map })
+}
+
pub fn dynamic_style<'a>(
ast_builder: &AstBuilder<'a>,
name: &str,
@@ -886,6 +1169,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_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs
index 492264a49..bad073af9 100644
--- a/libs/extractor/src/extractor/extract_style_from_styled.rs
+++ b/libs/extractor/src/extractor/extract_style_from_styled.rs
@@ -11,7 +11,10 @@ use crate::{
},
gen_class_name::gen_class_names,
gen_style::gen_styles,
- utils::{merge_object_expressions, unwrap_syntax_only, wrap_array_filter},
+ utils::{
+ StyleArguments, merge_object_expressions, style_arguments, unwrap_syntax_only,
+ wrap_array_filter,
+ },
};
use oxc_allocator::{CloneIn, FromIn, GetAllocator};
use oxc_ast::{
@@ -104,6 +107,16 @@ pub fn extract_style_from_styled<'a>(
split_filename: Option<&str>,
imports: &FxHashMap,
) -> (ExtractResult<'a>, Expression<'a>) {
+ if let Expression::CallExpression(call) = expression
+ && extract_base_tag_and_class_name(&call.callee, imports)
+ .0
+ .is_some()
+ && let Some(StyleArguments { classes, rules }) =
+ style_arguments(ast_builder, &call.arguments)
+ && classes.is_empty()
+ {
+ call.arguments = oxc_allocator::Vec::from_array_in([Argument::from(rules)], ast_builder);
+ }
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)
diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs
index 568ec9b42..7b469760b 100644
--- a/libs/extractor/src/extractor/extract_style_from_stylex.rs
+++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs
@@ -3,10 +3,10 @@ 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,
+ SelectorPart, StylexIncludeRef, decompose_value_conditions, dynamic_number_suffix,
+ is_first_that_works_call, is_include_call_static, is_types_call, 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;
@@ -45,12 +45,9 @@ pub fn extract_stylex_declarations(value: &Expression<'_>) -> Vec<(String, Strin
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(),
- ))
+ let property = normalize_stylex_property(get_str_by_property_key(&prop.key)?.as_ref());
+ let value = stylex_value(&property, &prop.value)?;
+ Some((property, optimize_value(&value).into_owned()))
})
.collect()
}
@@ -110,7 +107,7 @@ pub fn extract_stylex_namespace_styles<'a>(
) -> Vec<(
String,
Vec>,
- Option>,
+ Option>,
Vec,
)> {
let Expression::ObjectExpression(obj) = expression else {
@@ -250,7 +247,7 @@ pub fn extract_stylex_namespace_styles<'a>(
}
// Phase 1: static string/number values
- let css_value = if let Some(s) = get_string_by_literal_expression(&style_prop.value) {
+ let css_value = if let Some(s) = stylex_value(&css_property, &style_prop.value) {
s
} else if matches!(&style_prop.value, Expression::ObjectExpression(_)) {
// Phase 2: value-level conditions
@@ -272,7 +269,7 @@ pub fn extract_stylex_namespace_styles<'a>(
// 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)
+ && let Some(s) = stylex_value(&css_property, arg_expr)
{
styles.push(raw_static_style(css_property.clone(), &s, None));
}
@@ -283,7 +280,7 @@ pub fn extract_stylex_namespace_styles<'a>(
// 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) {
+ let css_value = if let Some(s) = stylex_value(&css_property, inner) {
s
} else {
continue; // Can't resolve inner value
@@ -310,12 +307,16 @@ pub fn extract_stylex_namespace_styles<'a>(
}
/// 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.
#[allow(clippy::type_complexity)]
fn extract_stylex_dynamic_namespace<'a>(
arrow: &oxc_ast::ast::ArrowFunctionExpression<'a>,
keyframe_names: &FxHashMap,
-) -> Option<(Vec>, Vec<(usize, String)>)> {
+) -> Option<(
+ Vec>,
+ Vec<(usize, String, &'static str)>,
+)> {
// 1. Extract parameter names
let param_names: Vec = arrow
.params
@@ -373,7 +374,7 @@ 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),
@@ -386,7 +387,7 @@ fn extract_stylex_dynamic_namespace<'a>(
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) {
+ let css_value = if let Some(s) = stylex_value(&css_property, &prop.value) {
s
} else {
continue;
diff --git a/libs/extractor/src/gen_class_name.rs b/libs/extractor/src/gen_class_name.rs
index 3101e5667..dad28c98d 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
diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs
index 259bf357f..c3a0626b6 100644
--- a/libs/extractor/src/import_alias_visit.rs
+++ b/libs/extractor/src/import_alias_visit.rs
@@ -8,14 +8,156 @@
//! - `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)
+ {
+ if matches!(property.value, Expression::ObjectExpression(_)) {
+ if key != "vars" {
+ self.pixelify(&property.value);
+ }
+ } else if let Some(number) = js_number_literal(&property.value)
+ && number != 0.0
+ && !keeps_bare_number(&key)
+ {
+ let span = property.value.span();
+ self.replacements.push((
+ span.start as usize,
+ span.end as usize,
+ format!("\"{number}px\""),
+ ));
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+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.
@@ -87,9 +229,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,9 +240,54 @@ 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() {
@@ -501,6 +689,68 @@ 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 })
+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 })
+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/lib.rs b/libs/extractor/src/lib.rs
index 62018cfa3..512c050f1 100644
--- a/libs/extractor/src/lib.rs
+++ b/libs/extractor/src/lib.rs
@@ -269,37 +269,28 @@ fn extract_with_source_map(
match vanilla_extract::execute_vanilla_extract(&transformed_code, &option.package, filename)
{
Ok(collected) => {
- // Check if any styles are referenced in selectors
- let referenced = vanilla_extract::find_selector_references(&collected);
-
- if referenced.is_empty() {
- // No selector references, use simple code generation
- Some(vanilla_extract::collected_styles_to_code(
- &collected,
- &option.package,
- ))
+ // 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);
+ let keyframes_names = if referenced.is_empty() {
+ FxHashMap::default()
} else {
- // Two-pass extraction: first extract referenced styles to get their class names
- let partial_code = vanilla_extract::collected_styles_to_code_partial(
- &collected,
- &option.package,
+ extract_class_map_from_code(
+ filename,
+ &vanilla_extract::keyframes_to_code(
+ &collected,
+ &option.package,
+ &referenced,
+ ),
+ &option,
&referenced,
- );
-
- // Build class map by extracting the partial code
- let class_map = if partial_code.is_empty() {
- FxHashMap::default()
- } else {
- extract_class_map_from_code(filename, &partial_code, &option, &referenced)?
- };
-
- // Generate full code with class names substituted into selectors
- Some(vanilla_extract::collected_styles_to_code_with_classes(
- &collected,
- &option.package,
- &class_map,
- ))
- }
+ )?
+ };
+ Some(vanilla_extract::collected_styles_to_code_with_keyframes(
+ &collected,
+ &option.package,
+ &keyframes_names,
+ ))
}
// Fall back to treating as regular file if execution fails
Err(_) => None,
@@ -11077,14 +11068,14 @@ const color = "red";
.unwrap()
));
- // Test _speech media query selector
+ // Test _motionReduce media query shorthand
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
r#"import {Box} from '@devup-ui/core'
-
+
"#,
ExtractOption {
package: "@devup-ui/core".to_string(),
@@ -11985,6 +11976,66 @@ export const darkTheme = createTheme(vars, {
));
}
+ #[test]
+ #[serial]
+ fn test_layer_record_places_every_declaration() {
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "test.tsx",
+ r"import { css } from '@devup-ui/react'
+const a = css({ '@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() {
@@ -14755,6 +14806,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() {
@@ -17679,6 +17856,90 @@ export const App = () => <><
));
}
+ #[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_composed_css_keeps_composed_classes() {
+ reset_class_map();
+ reset_file_map();
+ assert_debug_snapshot!(ToBTreeSet::from(
+ extract(
+ "test.tsx",
+ 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::from([
+ ("@emotion/react".to_string(), ImportAlias::NamedToNamed),
+ (
+ "@vanilla-extract/css".to_string(),
+ ImportAlias::NamedToNamed
+ )
+ ])
+ },
+ )
+ .unwrap()
+ ));
+ }
+
#[test]
#[serial]
fn test_devup_props_typescript_wrappers() {
diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs
index bf26a8bb6..07cd67a71 100644
--- a/libs/extractor/src/prop_modify_utils.rs
+++ b/libs/extractor/src/prop_modify_utils.rs
@@ -2,7 +2,9 @@ use crate::extract_style::ExtractStyleProperty;
use crate::extract_style::style_property::StyleProperty;
use crate::gen_class_name::gen_class_names;
use crate::gen_style::gen_styles;
-use crate::tailwind::{has_tailwind_classes, parse_single_class, parse_tailwind_to_styles};
+use crate::tailwind::{
+ TailwindClass, has_tailwind_classes, parse_single_class, parse_tailwind_to_styles,
+};
use crate::utils::{get_str_by_property_key, merge_object_expressions};
use crate::{ExtractStyleProp, ExtractStyleValue};
use oxc_allocator::{CloneIn, FromIn, GetAllocator};
@@ -432,8 +434,10 @@ fn extract_tailwind_from_class_name<'a>(
let mut tailwind_styles: Vec =
Vec::with_capacity(all_classes.bytes().filter(u8::is_ascii_whitespace).count() + 1);
for class in all_classes.split_whitespace() {
- if let Some(parsed) = parse_single_class(class) {
- let mut static_style = parsed.to_static_style();
+ if let Some(mut static_style) = parse_single_class(class)
+ .as_ref()
+ .and_then(TailwindClass::to_static_style)
+ {
if let Some(order) = style_order {
static_style.style_order = Some(order);
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap
index 64b06f5e4..d997635d3 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap
index 76e04e7ef..3c0446efc 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: flex)",
+ query: "(display:flex)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap
index 4955f592a..50e7177f0 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap
index 6838840f4..0519ae097 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap
index c8acba680..831bd6971 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap
index 46797c5fa..5784017e3 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "sidebar (min-width: 400px)",
+ query: "sidebar (min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap
index 24ed3c839..ebb729213 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap
new file mode 100644
index 000000000..9cde0e41c
--- /dev/null
+++ b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap
@@ -0,0 +1,39 @@
+---
+source: libs/extractor/src/lib.rs
+expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { style } from '@vanilla-extract/css';\nimport { css } from '@emotion/react';\nconst base = style({ color: 'red' });\nexport const a = style([base, { margin: 2 }]);\nexport const b = css(base, 'extra', { padding: 1 });\nexport const c = css([base]);\nexport const d = style([cond && base]);\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@emotion/react\".to_string(), ImportAlias::NamedToNamed),\n (\"@vanilla-extract/css\".to_string(), ImportAlias::NamedToNamed)])\n},).unwrap())"
+---
+ToBTreeSet {
+ styles: {
+ Static(
+ ExtractStaticStyle {
+ property: "color",
+ value: "red",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "margin",
+ value: "2px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "padding",
+ value: "1px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ },
+ code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const a = `b ${base}`;\nexport const b = `extra c ${base}`;\nexport const c = base;\nexport const d = \"\";\n",
+}
diff --git a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap
index 83b4b00f5..b22b41278 100644
--- a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 500px)",
+ query: "(min-width:500px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap
index 039f02dde..d5654738f 100644
--- a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap b/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap
index aaee60699..6d92afc50 100644
--- a/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 300px)",
+ query: "(min-width:300px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap
new file mode 100644
index 000000000..30712f964
--- /dev/null
+++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap
@@ -0,0 +1,29 @@
+---
+source: libs/extractor/src/lib.rs
+expression: "ToBTreeSet::from(extract(\"test.tsx\", code, ExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: aliases.clone(),\n},).unwrap())"
+---
+ToBTreeSet {
+ styles: {
+ Static(
+ ExtractStaticStyle {
+ property: "height",
+ value: "2px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "width",
+ value: "1px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ },
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const G = Styled.div.attrs({ type: \"button\" }).withConfig({ displayName: \"g\" })({ right: \"7px\" });\nexport const H = Styled(Base).withConfig({ displayName: \"h\" })({ left: \"9px\" });\nexport const I = ({ style, className, ...rest }) => ;\nexport const j = \"b\";\n",
+}
diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap
new file mode 100644
index 000000000..161dd9866
--- /dev/null
+++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap
@@ -0,0 +1,150 @@
+---
+source: libs/extractor/src/lib.rs
+expression: "ToBTreeSet::from(extract(\"test.tsx\", code, ExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: aliases.clone(),\n},).unwrap())"
+---
+ToBTreeSet {
+ styles: {
+ Static(
+ ExtractStaticStyle {
+ property: "color",
+ value: "blue",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "color",
+ value: "red",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "left",
+ value: "3px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "line-height",
+ value: "1.5",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "margin",
+ value: "1px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "margin",
+ value: "5px",
+ level: 0,
+ selector: Some(
+ Selector(
+ "&:hover",
+ ),
+ ),
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "margin",
+ value: "8px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "margin",
+ value: "8px",
+ level: 0,
+ selector: Some(
+ Global(
+ "body",
+ "test.tsx",
+ ),
+ ),
+ style_order: Some(
+ 0,
+ ),
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "padding",
+ value: "6px",
+ level: 0,
+ selector: Some(
+ Selector(
+ "&:hover",
+ ),
+ ),
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "padding",
+ value: "8px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "top",
+ value: "3px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Keyframes(
+ ExtractKeyframes {
+ keyframes: {
+ "to": [
+ ExtractStaticStyle {
+ property: "width",
+ value: "10px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ],
+ },
+ },
+ ),
+ },
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const a = \"a b c d\";\nexport const B = ({ style, className, ...rest }) => ;\nexport const C = ({ style, className, ...rest }) => ;\nexport const D = ({ style, className, ...rest }) => ;\nexport const E = styled.div({ bottom: \"2px\" }, cond && { right: 4 });\nexport const F = ({ style, className, ...rest }) => ;\nexport const spin = \"k\";\nexport const App = () => <>>;\n",
+}
diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap
index fbecc3169..98721d76f 100644
--- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap
@@ -16,6 +16,8 @@ ToBTreeSet {
selector: Some(
"&:active",
),
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -34,6 +36,8 @@ ToBTreeSet {
selector: Some(
"&:hover",
),
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap
index 36e107415..fe6259b5e 100644
--- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap
index 2077d8c2d..ec26c961a 100644
--- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap
index 5d0a57e40..d0c56ad6b 100644
--- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap
@@ -12,10 +12,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: Some(
".grid-container",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap
index b8b66f73a..87e38938b 100644
--- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap
@@ -12,10 +12,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: Some(
".card",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap
index 111d8b3ac..603a5090c 100644
--- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap
@@ -12,10 +12,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(prefers-color-scheme: dark)",
+ query: "(prefers-color-scheme:dark)",
selector: Some(
"body",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
@@ -49,10 +53,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: Some(
"body",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
@@ -69,10 +77,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: Some(
".container",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap
index 6344e3cac..22912fd62 100644
--- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap
@@ -12,10 +12,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: Some(
"body",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
diff --git a/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap b/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap
new file mode 100644
index 000000000..86d85457d
--- /dev/null
+++ b/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap
@@ -0,0 +1,150 @@
+---
+source: libs/extractor/src/lib.rs
+expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { css } from '@devup-ui/react'\nconst a = css({ '@layer': { base: {\n color: cond ? 'red' : 'blue',\n positioning: pos,\n bg: { a: 'red', b: 'blue' }[key],\n typography: typo,\n width: w,\n p: [1, 2],\n '@layer': { inner: { m: 1 } },\n} } })\n\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())"
+---
+ToBTreeSet {
+ styles: {
+ Static(
+ ExtractStaticStyle {
+ property: "background",
+ value: "blue",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "background",
+ value: "red",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "bottom",
+ value: "0",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "color",
+ value: "blue",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "color",
+ value: "red",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "left",
+ value: "0",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "margin",
+ value: "4px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base.inner",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "padding",
+ value: "4px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "padding",
+ value: "8px",
+ level: 1,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "right",
+ value: "0",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "top",
+ value: "0",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "base",
+ ),
+ },
+ ),
+ Dynamic(
+ ExtractDynamicStyle {
+ property: "width",
+ level: 0,
+ identifier: "w",
+ selector: None,
+ style_order: None,
+ },
+ ),
+ },
+ code: "import \"@devup-ui/react/devup-ui.css\";\nconst a = `i k l m ${cond ? \"a\" : \"b\"} ${{\n\t\"bottom\": \"c\",\n\t\"bottom-left\": \"c d\",\n\t\"bottom-right\": \"c e\",\n\t\"left\": \"d\",\n\t\"right\": \"e\",\n\t\"top\": \"f\",\n\t\"top-left\": \"d f\",\n\t\"top-right\": \"e f\"\n}[pos] || \"\"} ${{\n\t\"a\": \"g\",\n\t\"b\": \"h\"\n}[key] || \"\"} ${typo ? `typo-${typo}` : \"\"}`;\n",
+}
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap
index 55c45fd83..35ea4ba4c 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "screen",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap
index 65ff6056f..6eed386ef 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "speech",
+ query: "(prefers-reduced-motion:reduce)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap
index 53fca335f..98fafeea5 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "all",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap
index d0dbe75ae..7f9d008aa 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "screen",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -40,6 +42,8 @@ ToBTreeSet {
kind: Media,
query: "print",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap
index b344f636e..748b4a0d2 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "print",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -30,6 +32,8 @@ ToBTreeSet {
kind: Media,
query: "print",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap
index 3fb1e1961..c9f83cbe8 100644
--- a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap
@@ -6,7 +6,7 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--colors-primary-global_theme_0-0",
+ property: "--colors-primary-0-0",
value: "blue",
level: 0,
selector: Some(
@@ -23,7 +23,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--colors-secondary-global_theme_0-1",
+ property: "--colors-secondary-0-1",
value: "green",
level: 0,
selector: Some(
@@ -55,7 +55,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -63,5 +63,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-global_theme_0-0)\",\n\t\"secondary\": \"var(--colors-secondary-global_theme_0-1)\"\n} };\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-0-0)\",\n\t\"secondary\": \"var(--colors-secondary-0-1)\"\n} };\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap
index d8a33c39a..af003b424 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-size",
- value: "16",
+ value: "16px",
level: 0,
selector: None,
style_order: None,
@@ -47,7 +47,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "8",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap
index 4478cd7c8..5a952aed5 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-size",
- value: "16",
+ value: "16px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap
index 0e64214a1..2432d59ed 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap
@@ -14,5 +14,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b\" };\nconst result = {\n\tclassName: \"b\",\n\tstyle: { \"--a\": h }\n};\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b\" };\nconst result = {\n\tclassName: \"b\",\n\tstyle: { \"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(h) }\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap
index 255d23b9e..d4c3552f1 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap
@@ -24,5 +24,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b c\" };\nconst result = {\n\tclassName: \"b c\",\n\tstyle: { \"--a\": h }\n};\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b c\" };\nconst result = {\n\tclassName: \"b c\",\n\tstyle: { \"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(h) }\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap
index af63ba3ef..81725cdab 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap
@@ -23,5 +23,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"c d\" };\nconst result = {\n\tclassName: \"c d\",\n\tstyle: {\n\t\t\"--a\": myH,\n\t\t\"--b\": myW\n\t}\n};\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"c d\" };\nconst result = {\n\tclassName: \"c d\",\n\tstyle: {\n\t\t\"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(myH),\n\t\t\"--b\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(myW)\n\t}\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap
index 21272d366..d2c86cbbf 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-size",
- value: "16",
+ value: "16px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap
index 28cdad898..f5a9bc039 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(max-width: 600px)",
+ query: "(max-width:600px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap
index 61381013d..fd53e2ba7 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap
@@ -12,10 +12,12 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(hover: hover)",
+ query: "(hover:hover)",
selector: Some(
"&:hover",
),
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap
index f03c0266e..02e2c49cd 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap
@@ -12,10 +12,12 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: Some(
"&:hover",
),
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -30,8 +32,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap
index fcfcdda02..bd72492ed 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap
index 2488f50ad..3dc45fd4e 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap
index 28c1eda92..b04a3ed7c 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-size",
- value: "16",
+ value: "16px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap
index 6c1e22b62..0f8d800f2 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap
@@ -6,7 +6,7 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--color-primary-global_theme_0-0",
+ property: "--color-primary-0-4",
value: "blue",
level: 0,
selector: Some(
@@ -44,7 +44,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-family",
- value: "__devup_font_0_0",
+ value: "font-0-3",
level: 0,
selector: None,
style_order: None,
@@ -71,18 +71,24 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
layer: None,
},
),
+ Css(
+ ExtractCss {
+ css: "@layer components-0-2;",
+ file: "all-combined.css.ts",
+ },
+ ),
FontFace(
ExtractFontFace {
file: "all-combined.css.ts",
properties: {
- "font-family": "__devup_font_0_0",
+ "font-family": "font-0-3",
"src": "local(Arial)",
},
},
@@ -114,5 +120,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const box = \"b\";\n;\n;\n;\nexport const fade = \"c\";\nexport const buttons = {\n\tprimary: \"a d\",\n\tsecondary: \"e\"\n};\nexport const myLayer = \"--var-0\";\nexport const myVar = \"__devup_font_0_0\";\nexport const myFont = \"__container_0__\";\nexport const vars = \"components\";\nexport const myContainer = { \"color\": { \"primary\": \"var(--color-primary-global_theme_0-0)\" } };\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const fade = \"a\";\nconst _ve2 = \"b c\";\nconst _ve3 = \"d\";\nconst base = \"b\";\nexport const box = \"e\";\n;\n;\n;\nexport const myVar = \"var(--var-0-0)\";\nexport const myContainer = \"container-0-1\";\nexport const myLayer = \"components-0-2\";\nexport const myFont = \"font-0-3\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-0-4)\" } };\nexport const buttons = {\n\t\"primary\": _ve2,\n\t\"secondary\": _ve3\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap
index a4a0297fe..43bfc306b 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--color-primary-style_0-0",
+ property: "--color-primary-0-1",
value: "blue",
level: 0,
selector: Some(
Global(
- ".f0_theme",
+ ".theme-0-0",
"all-imports.css.ts",
),
),
@@ -75,5 +75,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const [theme, vars] = [\"f0_theme\", { \"color\": { \"primary\": \"var(--color-primary-style_0-0)\" } }];\n;\n;\nexport const fadeIn = \"b\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const fadeIn = \"a\";\nexport const box = \"b\";\n;\n;\nexport const theme = \"theme-0-0\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-0-1)\" } };\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap
index 796a2aade..307ac06da 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "border-radius",
- value: "16px",
+ value: "4px",
level: 0,
selector: None,
style_order: None,
@@ -47,7 +47,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "48px",
+ value: "12px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap
index 239a9d9a2..e1dd693b1 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
@@ -15,5 +15,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const COLORS = {\n\tprimary: \"blue\",\n\tsecondary: \"green\"\n};\nexport const SPACING = 8;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const SPACING = 8;\nexport const COLORS = {\n\t\"primary\": \"blue\",\n\t\"secondary\": \"green\"\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap
index 070fd6a42..b52d5c2eb 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "container-name",
- value: "__container_0__",
+ value: "container-0-0",
level: 0,
selector: None,
style_order: None,
@@ -32,8 +32,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "__container_0__ (min-width: 400px)",
+ query: "container-0-0 (min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -41,5 +43,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const responsive = \"a\";\nexport const sidebarContainer = \"b c\";\nexport const sidebar = \"__container_0__\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const responsive = \"a\";\nexport const sidebarContainer = \"b c\";\nexport const sidebar = \"container-0-0\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap
index 842b47cc1..fe015c8a4 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "color",
- value: "var(--var-0,red)",
+ value: "var(--var-0-0,red)",
level: 0,
selector: None,
style_order: None,
@@ -15,5 +15,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const colorVar = \"--var-0\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const colorVar = \"var(--var-0-0)\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap
index 01c826e3f..fc6f9e988 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap
@@ -6,7 +6,7 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--var-0",
+ property: "--var-0-0",
value: "blue",
level: 0,
selector: None,
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "color",
- value: "var(--var-0)",
+ value: "var(--var-0-0)",
level: 0,
selector: None,
style_order: None,
@@ -25,5 +25,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"--var-0\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"var(--var-0-0)\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap
index ad214187d..cbaf24c7f 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap
@@ -27,7 +27,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-size",
- value: "128px",
+ value: "32px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap
index 108b4267d..c66396a1d 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-family",
- value: "__devup_font_0_0",
+ value: "font-0-0",
level: 0,
selector: None,
style_order: None,
@@ -18,7 +18,7 @@ ToBTreeSet {
ExtractFontFace {
file: "fonts-multi.css.ts",
properties: {
- "font-family": "__devup_font_0_0",
+ "font-family": "font-0-0",
"font-style": "normal",
"font-weight": "400",
"src": "url(\"/fonts/Roboto.woff2\") format(\"woff2\")",
@@ -26,5 +26,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const body = \"a\";\n;\nconst roboto = \"__devup_font_0_0\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const body = \"a\";\n;\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap
index c3ad374c6..2b8fc7ced 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-family",
- value: "__devup_font_0_0",
+ value: "font-0-0",
level: 0,
selector: None,
style_order: None,
@@ -18,11 +18,11 @@ ToBTreeSet {
ExtractFontFace {
file: "fonts.css.ts",
properties: {
- "font-family": "__devup_font_0_0",
+ "font-family": "font-0-0",
"src": "local(\"Comic Sans MS\")",
},
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nconst myFont = \"__devup_font_0_0\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap
index 4a785cde4..02e3a50f0 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-family",
- value: "__devup_font_0_0",
+ value: "font-0-0",
level: 0,
selector: None,
style_order: None,
@@ -18,10 +18,10 @@ ToBTreeSet {
ExtractFontFace {
file: "fontface-minimal.css.ts",
properties: {
- "font-family": "__devup_font_0_0",
+ "font-family": "font-0-0",
},
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const minimalFont = \"__devup_font_0_0\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const minimalFont = \"font-0-0\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap
index 05f207b27..68e3d3981 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-family",
- value: "__devup_font_0_0",
+ value: "font-0-0",
level: 0,
selector: None,
style_order: None,
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-family",
- value: "__devup_font_0_1",
+ value: "font-0-1",
level: 0,
selector: None,
style_order: None,
@@ -29,7 +29,7 @@ ToBTreeSet {
file: "fontface-remap.css.ts",
properties: {
"font-display": "swap",
- "font-family": "__devup_font_0_0",
+ "font-family": "font-0-0",
"font-weight": "400",
"src": "url(\"/fonts/custom.woff2\")",
},
@@ -39,11 +39,11 @@ ToBTreeSet {
ExtractFontFace {
file: "fontface-remap.css.ts",
properties: {
- "font-family": "__devup_font_0_1",
+ "font-family": "font-0-1",
"src": "local(\"Helvetica\")",
},
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const heading = \"a\";\nexport const text = \"b\";\n;\n;\nexport const customFont = \"__devup_font_0_0\";\nexport const secondFont = \"__devup_font_0_1\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const heading = \"a\";\nexport const text = \"b\";\n;\nexport const customFont = \"font-0-0\";\nexport const secondFont = \"font-0-1\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap
index 0f1958b14..ebc837d86 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-family",
- value: "__devup_font_0_0",
+ value: "font-0-0",
level: 0,
selector: None,
style_order: None,
@@ -18,12 +18,12 @@ ToBTreeSet {
ExtractFontFace {
file: "font-usage.css.ts",
properties: {
- "font-family": "__devup_font_0_0",
+ "font-family": "font-0-0",
"font-weight": "400",
"src": "local(\"Comic Sans MS\")",
},
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const myFont = \"__devup_font_0_0\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const myFont = \"font-0-0\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap
index 891cd1fb3..5a49e712f 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap
@@ -6,7 +6,7 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--color-background-global_theme_0-2",
+ property: "--color-background-0-2",
value: "white",
level: 0,
selector: Some(
@@ -23,7 +23,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--color-brand-global_theme_0-0",
+ property: "--color-brand-0-0",
value: "blue",
level: 0,
selector: Some(
@@ -40,7 +40,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--color-text-global_theme_0-1",
+ property: "--color-text-0-1",
value: "black",
level: 0,
selector: Some(
@@ -57,7 +57,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--font-body-global_theme_0-3",
+ property: "--font-body-0-3",
value: "system-ui,sans-serif",
level: 0,
selector: Some(
@@ -73,5 +73,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-global_theme_0-0)\",\n\t\t\"text\": \"var(--color-text-global_theme_0-1)\",\n\t\t\"background\": \"var(--color-background-global_theme_0-2)\"\n\t},\n\t\"font\": { \"body\": \"var(--font-body-global_theme_0-3)\" }\n};\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-0-0)\",\n\t\t\"text\": \"var(--color-text-0-1)\",\n\t\t\"background\": \"var(--color-background-0-2)\"\n\t},\n\t\"font\": { \"body\": \"var(--font-body-0-3)\" }\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap
index 1c1914d52..754ab6170 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap
index e25bfd199..f22c68d5e 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap
@@ -6,7 +6,7 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--colors-bg-global_theme_0-0",
+ property: "--colors-bg-0-0",
value: "white",
level: 0,
selector: Some(
@@ -23,7 +23,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--colors-bg-global_theme_1-0",
+ property: "--colors-bg-0-2",
value: "black",
level: 0,
selector: Some(
@@ -40,7 +40,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--colors-text-global_theme_0-1",
+ property: "--colors-text-0-1",
value: "black",
level: 0,
selector: Some(
@@ -57,7 +57,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--colors-text-global_theme_1-1",
+ property: "--colors-text-0-3",
value: "white",
level: 0,
selector: Some(
@@ -75,7 +75,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
@@ -83,5 +83,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\n;\nexport const darkVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-global_theme_1-0)\",\n\t\"text\": \"var(--colors-text-global_theme_1-1)\"\n} };\nexport const lightVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-global_theme_0-0)\",\n\t\"text\": \"var(--colors-text-global_theme_0-1)\"\n} };\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\n;\nexport const lightVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-0-0)\",\n\t\"text\": \"var(--colors-text-0-1)\"\n} };\nexport const darkVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-0-2)\",\n\t\"text\": \"var(--colors-text-0-3)\"\n} };\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap
index f6dafe73f..5bb202f10 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap
@@ -6,7 +6,7 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--color-primary-global_theme_0-0",
+ property: "--color-primary-0-0",
value: "blue",
level: 0,
selector: Some(
@@ -23,7 +23,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--color-secondary-global_theme_0-1",
+ property: "--color-secondary-0-1",
value: "green",
level: 0,
selector: Some(
@@ -40,7 +40,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--space-large-global_theme_0-3",
+ property: "--space-large-0-3",
value: "16px",
level: 0,
selector: Some(
@@ -57,7 +57,7 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--space-small-global_theme_0-2",
+ property: "--space-small-0-2",
value: "4px",
level: 0,
selector: Some(
@@ -75,7 +75,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
@@ -83,5 +83,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-global_theme_0-0)\",\n\t\t\"secondary\": \"var(--color-secondary-global_theme_0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-global_theme_0-2)\",\n\t\t\"large\": \"var(--space-large-global_theme_0-3)\"\n\t}\n};\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-0-0)\",\n\t\t\"secondary\": \"var(--color-secondary-0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-0-2)\",\n\t\t\"large\": \"var(--space-large-0-3)\"\n\t}\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap
index 7c1f58e30..3909d8ce8 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "animation",
- value: "myFont",
+ value: "a",
level: 0,
selector: None,
style_order: None,
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-family",
- value: "__font_0__",
+ value: "font-0-0",
level: 0,
selector: Some(
Global(
@@ -52,7 +52,7 @@ ToBTreeSet {
ExtractFontFace {
file: "imports-combo.css.ts",
properties: {
- "font-family": "__devup_font_0_0",
+ "font-family": "font-0-0",
"src": "local(Arial)",
},
},
@@ -84,5 +84,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"a\";\n;\n;\nexport const myFont = \"b\";\nexport const fadeIn = \"__devup_font_0_0\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const fadeIn = \"a\";\nexport const animated = \"b\";\n;\n;\nexport const myFont = \"font-0-0\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap
index 4f9210114..a425143e6 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "animation",
- value: "spin",
+ value: "a",
level: 0,
selector: None,
style_order: None,
@@ -67,5 +67,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const spinner = \"a\";\nconst internal = \"b\";\nexport const spin = \"c\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nconst internal = \"b\";\nexport const spin = \"a\";\nexport const spinner = \"c\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap
index 03ac65369..6a8489aa5 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap
@@ -19,7 +19,7 @@ ToBTreeSet {
0,
),
layer: Some(
- "reset",
+ "reset-0-0",
),
},
),
@@ -38,10 +38,16 @@ ToBTreeSet {
0,
),
layer: Some(
- "reset",
+ "reset-0-0",
),
},
),
+ Css(
+ ExtractCss {
+ css: "@layer reset-0-0,base-0-1,components-0-2;",
+ file: "layers.css.ts",
+ },
+ ),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const base = \"base\";\nexport const components = \"components\";\nexport const reset = \"reset\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\n;\nexport const reset = \"reset-0-0\";\nexport const base = \"base-0-1\";\nexport const components = \"components-0-2\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap
index 3b4e51f96..969713595 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "container-name",
- value: "__container_0__",
+ value: "container-0-2",
level: 0,
selector: None,
style_order: None,
@@ -24,6 +24,12 @@ ToBTreeSet {
layer: None,
},
),
+ Css(
+ ExtractCss {
+ css: "@layer reset-0-0,base-0-1;",
+ file: "layer-container.css.ts",
+ },
+ ),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const containerStyle = \"a b\";\nexport const myContainer = \"__container_0__\";\nexport const baseLayer = \"base\";\nexport const resetLayer = \"reset\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const containerStyle = \"a b\";\nexport const resetLayer = \"reset-0-0\";\nexport const baseLayer = \"base-0-1\";\nexport const myContainer = \"container-0-2\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap
new file mode 100644
index 000000000..7a5d2d645
--- /dev/null
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap
@@ -0,0 +1,104 @@
+---
+source: libs/extractor/src/lib.rs
+expression: "ToBTreeSet::from(extract(\"layers.css.ts\",\nr\"import { layer, style, globalStyle } from '@vanilla-extract/css'\nconst reset = layer('reset')\nexport const components = layer('components')\nglobalStyle('a', { color: 'blue', '@layer': { [reset]: { color: 'inherit', padding: 0 } } })\nexport const button = style({ color: 'red', '@layer': { [components]: { color: 'green', padding: 4 } } })\n\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@vanilla-extract/css\".to_string(),\n ImportAlias::NamedToNamed)])\n}).unwrap())"
+---
+ToBTreeSet {
+ styles: {
+ Static(
+ ExtractStaticStyle {
+ property: "color",
+ value: "blue",
+ level: 0,
+ selector: Some(
+ Global(
+ "a",
+ "layers.css.ts",
+ ),
+ ),
+ style_order: Some(
+ 0,
+ ),
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "color",
+ value: "green",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "components-0-1",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "color",
+ value: "inherit",
+ level: 0,
+ selector: Some(
+ Global(
+ "a",
+ "layers.css.ts",
+ ),
+ ),
+ style_order: Some(
+ 0,
+ ),
+ layer: Some(
+ "reset-0-0",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "color",
+ value: "red",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "padding",
+ value: "0",
+ level: 0,
+ selector: Some(
+ Global(
+ "a",
+ "layers.css.ts",
+ ),
+ ),
+ style_order: Some(
+ 0,
+ ),
+ layer: Some(
+ "reset-0-0",
+ ),
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "padding",
+ value: "4px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: Some(
+ "components-0-1",
+ ),
+ },
+ ),
+ Css(
+ ExtractCss {
+ css: "@layer reset-0-0,components-0-1;",
+ file: "layers.css.ts",
+ },
+ ),
+ },
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const button = \"a b c\";\n;\nexport const components = \"components-0-1\";\n",
+}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap
index b90173c4b..b693d81a3 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(prefers-color-scheme: dark)",
+ query: "(prefers-color-scheme:dark)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -28,8 +30,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(prefers-color-scheme: dark)",
+ query: "(prefers-color-scheme:dark)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -54,8 +58,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "screen and (min-width: 768px)",
+ query: "screen and (min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -70,8 +76,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "screen and (min-width: 1024px)",
+ query: "screen and (min-width:1024px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap
index 15a6ef98d..113b631a8 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "margin",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
@@ -17,13 +17,19 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "16px",
+ value: "4px",
level: 0,
selector: None,
style_order: None,
layer: None,
},
),
+ Css(
+ ExtractCss {
+ css: "@layer internal-0-2;",
+ file: "mixed-exports.css.ts",
+ },
+ ),
Keyframes(
ExtractKeyframes {
keyframes: {
@@ -51,5 +57,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nconst internalStyle = \"a\";\nexport const publicStyle = \"b\";\nconst internalKeyframe = \"c\";\nconst internalVar = \"--var-0\";\nconst internalContainer = \"__container_0__\";\nconst internalLayer = \"internal\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\nconst internalKeyframe = \"a\";\nconst internalStyle = \"b\";\nexport const publicStyle = \"c\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap
index fafa89b1f..4508bb9a0 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--colors-bg-style_0-0",
+ property: "--colors-bg-0-1",
value: "white",
level: 0,
selector: Some(
Global(
- ".f0_internalTheme",
+ ".theme-0-0",
"internal-theme.css.ts",
),
),
@@ -23,12 +23,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--colors-text-style_0-1",
+ property: "--colors-text-0-2",
value: "black",
level: 0,
selector: Some(
Global(
- ".f0_internalTheme",
+ ".theme-0-0",
"internal-theme.css.ts",
),
),
@@ -49,5 +49,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\nconst [internalTheme, themeVars] = [\"f0_internalTheme\", { \"colors\": {\n\t\"bg\": \"var(--colors-bg-style_0-0)\",\n\t\"text\": \"var(--colors-text-style_0-1)\"\n} }];\n;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\n;\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap
index 86bf61d85..8ef25f573 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap
@@ -11,7 +11,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -29,5 +29,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap
index c591ffd98..39460fa16 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap
@@ -21,7 +21,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -31,11 +31,11 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "margin-top",
- value: "32px",
+ value: "8px",
level: 0,
selector: Some(
Selector(
- "a + &",
+ ".f0_parent + &",
),
),
style_order: None,
@@ -43,5 +43,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\nexport const sibling = \"c\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\nexport const sibling = \"c\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap
index 833027ad5..61195fd1d 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap
@@ -11,7 +11,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -45,11 +45,17 @@ ToBTreeSet {
layer: None,
},
),
+ Css(
+ ExtractCss {
+ css: "@layer ui-0-2;",
+ file: "refs.css.ts",
+ },
+ ),
FontFace(
ExtractFontFace {
file: "refs.css.ts",
properties: {
- "font-family": "__devup_font_0_0",
+ "font-family": "font-0-3",
"src": "local(Arial)",
},
},
@@ -81,5 +87,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n;\n;\nexport const fade = \"c\";\nexport const myContainer = \"--var-0\";\nexport const colorVar = \"__devup_font_0_0\";\nexport const myLayer = \"__container_0__\";\nexport const myFont = \"ui\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const fade = \"a\";\nexport const child = \"b\";\nexport const parent = \"c\" + \" f0_parent\";\n;\n;\nexport const colorVar = \"var(--var-0-0)\";\nexport const myContainer = \"container-0-1\";\nexport const myLayer = \"ui-0-2\";\nexport const myFont = \"font-0-3\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap
index 97d273b2f..65fd54dcf 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap
@@ -21,7 +21,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -31,7 +31,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "margin-top",
- value: "32px",
+ value: "8px",
level: 0,
selector: Some(
Selector(
@@ -43,5 +43,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b c\";\nexport const parent = \"a\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a b\";\nexport const parent = \"c\" + \" f0_parent\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap
index 6f0442004..143f62597 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "margin",
- value: "16px",
+ value: "4px",
level: 0,
selector: None,
style_order: None,
@@ -27,7 +27,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap
index 2fc830773..019824caf 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "margin",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap
index 9ae96afb3..f3d821654 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "64px",
+ value: "16px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap
index a546eb220..8b1746923 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap
@@ -27,7 +27,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "border-radius",
- value: "16px",
+ value: "4px",
level: 0,
selector: None,
style_order: None,
@@ -57,7 +57,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "48px",
+ value: "12px",
level: 0,
selector: None,
style_order: None,
@@ -65,5 +65,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a b\";\nexport const button = {\n\tprimary: \"a b c d\",\n\tsecondary: \"a b e f\"\n};\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve1 = \"a b c d\";\nconst _ve2 = \"a b e f\";\nconst base = \"a b\";\nexport const button = {\n\t\"primary\": _ve1,\n\t\"secondary\": _ve2\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap
index 8426209d6..5334d9dcb 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap
@@ -35,5 +35,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const background = {\n\tdanger: \"a\",\n\tprimary: \"b\",\n\tsecondary: \"c\"\n};\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve0 = \"a\";\nconst _ve1 = \"b\";\nconst _ve2 = \"c\";\nexport const background = {\n\t\"primary\": _ve0,\n\t\"secondary\": _ve1,\n\t\"danger\": _ve2\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap
index 204774f28..620b8faf2 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap
@@ -37,7 +37,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "border-radius",
- value: "16px",
+ value: "4px",
level: 0,
selector: None,
style_order: None,
@@ -67,7 +67,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
@@ -75,5 +75,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a b\";\nexport const buttons = {\n\tdanger: \"a b c\",\n\tprimary: \"a b d e\",\n\tsecondary: \"f g\"\n};\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve1 = \"a b c d\";\nconst _ve2 = \"e f\";\nconst _ve3 = \"a b g\";\nconst base = \"a b\";\nexport const buttons = {\n\t\"primary\": _ve1,\n\t\"secondary\": _ve2,\n\t\"danger\": _ve3\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap
index 31b0af989..9ed8520d8 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "border-radius",
- value: "16px",
+ value: "4px",
level: 0,
selector: None,
style_order: None,
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-size",
- value: "48px",
+ value: "12px",
level: 0,
selector: None,
style_order: None,
@@ -27,7 +27,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-size",
- value: "64px",
+ value: "16px",
level: 0,
selector: None,
style_order: None,
@@ -37,7 +37,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-size",
- value: "80px",
+ value: "20px",
level: 0,
selector: None,
style_order: None,
@@ -47,7 +47,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
@@ -55,5 +55,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a b\";\nexport const sizes = {\n\tlarge: \"a b c\",\n\tmedium: \"a b d\",\n\tsmall: \"a b e\"\n};\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve1 = \"a b c\";\nconst _ve2 = \"a b d\";\nconst _ve3 = \"a b e\";\nconst base = \"a b\";\nexport const sizes = {\n\t\"small\": _ve1,\n\t\"medium\": _ve2,\n\t\"large\": _ve3\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap
index d2aa8abd1..d68c6b0da 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap
index db6a47630..a61f4db70 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--color-brand",
+ property: "--color-brand-0-0",
value: "blue",
level: 0,
selector: Some(
Global(
- ".f0_lightTheme",
+ ".theme-0-2",
"theme-contract.css.ts",
),
),
@@ -23,12 +23,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--color-brand",
+ property: "--color-brand-0-0",
value: "lightblue",
level: 0,
selector: Some(
Global(
- ".f0_darkTheme",
+ ".theme-0-3",
"theme-contract.css.ts",
),
),
@@ -40,12 +40,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--color-text",
+ property: "--color-text-0-1",
value: "black",
level: 0,
selector: Some(
Global(
- ".f0_lightTheme",
+ ".theme-0-2",
"theme-contract.css.ts",
),
),
@@ -57,12 +57,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--color-text",
+ property: "--color-text-0-1",
value: "white",
level: 0,
selector: Some(
Global(
- ".f0_darkTheme",
+ ".theme-0-3",
"theme-contract.css.ts",
),
),
@@ -73,5 +73,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const darkTheme = \"f0_darkTheme\";\nexport const lightTheme = \"f0_lightTheme\";\n;\n;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\n;\nexport const lightTheme = \"theme-0-2\";\nexport const darkTheme = \"theme-0-3\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap
index 37a0103e4..dbeae3270 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--color-brand-style_0-0",
+ property: "--color-brand-0-1",
value: "blue",
level: 0,
selector: Some(
Global(
- ".f0_themeClass",
+ ".theme-0-0",
"theme.css.ts",
),
),
@@ -23,12 +23,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--color-text-style_0-1",
+ property: "--color-text-0-2",
value: "black",
level: 0,
selector: Some(
Global(
- ".f0_themeClass",
+ ".theme-0-0",
"theme.css.ts",
),
),
@@ -40,12 +40,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--space-large-style_0-4",
+ property: "--space-large-0-5",
value: "16px",
level: 0,
selector: Some(
Global(
- ".f0_themeClass",
+ ".theme-0-0",
"theme.css.ts",
),
),
@@ -57,12 +57,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--space-medium-style_0-3",
+ property: "--space-medium-0-4",
value: "8px",
level: 0,
selector: Some(
Global(
- ".f0_themeClass",
+ ".theme-0-0",
"theme.css.ts",
),
),
@@ -74,12 +74,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--space-small-style_0-2",
+ property: "--space-small-0-3",
value: "4px",
level: 0,
selector: Some(
Global(
- ".f0_themeClass",
+ ".theme-0-0",
"theme.css.ts",
),
),
@@ -92,7 +92,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "color",
- value: "var(--color-text-style_0-1)",
+ value: "var(--color-text-0-2)",
level: 0,
selector: None,
style_order: None,
@@ -102,7 +102,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "var(--space-medium-style_0-3)",
+ value: "var(--space-medium-0-4)",
level: 0,
selector: None,
style_order: None,
@@ -110,5 +110,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const [themeClass, vars] = [\"f0_themeClass\", {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-style_0-0)\",\n\t\t\"text\": \"var(--color-text-style_0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-style_0-2)\",\n\t\t\"medium\": \"var(--space-medium-style_0-3)\",\n\t\t\"large\": \"var(--space-large-style_0-4)\"\n\t}\n}];\n;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\n;\nexport const themeClass = \"theme-0-0\";\nexport const vars = {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-0-1)\",\n\t\t\"text\": \"var(--color-text-0-2)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-0-3)\",\n\t\t\"medium\": \"var(--space-medium-0-4)\",\n\t\t\"large\": \"var(--space-large-0-5)\"\n\t}\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap
index 9ce62724b..b001724d0 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--colors-primary-style_0-0",
+ property: "--colors-primary-0-1",
value: "blue",
level: 0,
selector: Some(
Global(
- ".f0_themeClass",
+ ".theme-0-0",
"theme-array.css.ts",
),
),
@@ -23,12 +23,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--colors-secondary-style_0-1",
+ property: "--colors-secondary-0-2",
value: "green",
level: 0,
selector: Some(
Global(
- ".f0_themeClass",
+ ".theme-0-0",
"theme-array.css.ts",
),
),
@@ -40,12 +40,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--spacing-medium-style_0-3",
+ property: "--spacing-medium-0-4",
value: "8px",
level: 0,
selector: Some(
Global(
- ".f0_themeClass",
+ ".theme-0-0",
"theme-array.css.ts",
),
),
@@ -57,12 +57,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--spacing-small-style_0-2",
+ property: "--spacing-small-0-3",
value: "4px",
level: 0,
selector: Some(
Global(
- ".f0_themeClass",
+ ".theme-0-0",
"theme-array.css.ts",
),
),
@@ -75,7 +75,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "color",
- value: "var(--colors-primary-style_0-0)",
+ value: "var(--colors-primary-0-1)",
level: 0,
selector: None,
style_order: None,
@@ -83,5 +83,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\nexport const [themeClass, themeVars] = [\"f0_themeClass\", {\n\t\"colors\": {\n\t\t\"primary\": \"var(--colors-primary-style_0-0)\",\n\t\t\"secondary\": \"var(--colors-secondary-style_0-1)\"\n\t},\n\t\"spacing\": {\n\t\t\"small\": \"var(--spacing-small-style_0-2)\",\n\t\t\"medium\": \"var(--spacing-medium-style_0-3)\"\n\t}\n}];\n;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\n;\nexport const themeClass = \"theme-0-0\";\nexport const themeVars = {\n\t\"colors\": {\n\t\t\"primary\": \"var(--colors-primary-0-1)\",\n\t\t\"secondary\": \"var(--colors-secondary-0-2)\"\n\t},\n\t\"spacing\": {\n\t\t\"small\": \"var(--spacing-small-0-3)\",\n\t\t\"medium\": \"var(--spacing-medium-0-4)\"\n\t}\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap
index 32beafe5d..a04dc8332 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--colors-bg",
+ property: "--colors-bg-0-0",
value: "gray",
level: 0,
selector: Some(
Global(
- ".f0_internalTheme",
+ ".theme-0-3",
"theme-exports.css.ts",
),
),
@@ -23,12 +23,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--colors-bg",
+ property: "--colors-bg-0-0",
value: "white",
level: 0,
selector: Some(
Global(
- ".f0_lightTheme",
+ ".theme-0-2",
"theme-exports.css.ts",
),
),
@@ -40,12 +40,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--colors-text",
+ property: "--colors-text-0-1",
value: "black",
level: 0,
selector: Some(
Global(
- ".f0_lightTheme",
+ ".theme-0-2",
"theme-exports.css.ts",
),
),
@@ -57,12 +57,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--colors-text",
+ property: "--colors-text-0-1",
value: "darkgray",
level: 0,
selector: Some(
Global(
- ".f0_internalTheme",
+ ".theme-0-3",
"theme-exports.css.ts",
),
),
@@ -75,7 +75,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
@@ -83,5 +83,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nconst internalTheme = \"f0_internalTheme\";\nexport const lightTheme = \"f0_lightTheme\";\n;\n;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\n;\nexport const lightTheme = \"theme-0-2\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap
index c16a72393..af0af474c 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--color-primary-style_0-0",
+ property: "--color-primary-0-1",
value: "blue",
level: 0,
selector: Some(
Global(
- ".f0_myTheme",
+ ".theme-0-0",
"theme-vars-name.css.ts",
),
),
@@ -22,5 +22,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const myTheme = \"f0_myTheme\";\n;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const myTheme = [\"theme-0-0\", { \"color\": { \"primary\": \"var(--color-primary-0-1)\" } }];\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap
index 9c73d68bd..86248e218 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--color-primary-style_0-0",
+ property: "--color-primary-0-1",
value: "blue",
level: 0,
selector: Some(
Global(
- ".f0_lightTheme",
+ ".theme-0-0",
"theme-vars.css.ts",
),
),
@@ -23,12 +23,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--color-secondary-style_0-1",
+ property: "--color-secondary-0-2",
value: "green",
level: 0,
selector: Some(
Global(
- ".f0_lightTheme",
+ ".theme-0-0",
"theme-vars.css.ts",
),
),
@@ -40,12 +40,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--space-medium-style_0-3",
+ property: "--space-medium-0-4",
value: "8px",
level: 0,
selector: Some(
Global(
- ".f0_lightTheme",
+ ".theme-0-0",
"theme-vars.css.ts",
),
),
@@ -57,12 +57,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--space-small-style_0-2",
+ property: "--space-small-0-3",
value: "4px",
level: 0,
selector: Some(
Global(
- ".f0_lightTheme",
+ ".theme-0-0",
"theme-vars.css.ts",
),
),
@@ -73,5 +73,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const [lightTheme, vars] = [\"f0_lightTheme\", {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-style_0-0)\",\n\t\t\"secondary\": \"var(--color-secondary-style_0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-style_0-2)\",\n\t\t\"medium\": \"var(--space-medium-style_0-3)\"\n\t}\n}];\n;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const lightTheme = \"theme-0-0\";\nexport const vars = {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-0-1)\",\n\t\t\"secondary\": \"var(--color-secondary-0-2)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-0-3)\",\n\t\t\"medium\": \"var(--space-medium-0-4)\"\n\t}\n};\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap
index 0379d0c0d..60c87875b 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--colors-primary",
+ property: "--colors-primary-0-0",
value: "blue",
level: 0,
selector: Some(
Global(
- ".f0_lightTheme",
+ ".theme-0-1",
"theme-simple.css.ts",
),
),
@@ -24,7 +24,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
@@ -32,5 +32,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const lightTheme = \"f0_lightTheme\";\n;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\nexport const lightTheme = \"theme-0-1\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap
index a54ab0006..dc17f9fa9 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap
@@ -6,12 +6,12 @@ ToBTreeSet {
styles: {
Static(
ExtractStaticStyle {
- property: "--color-brand",
+ property: "--color-brand-0-0",
value: "white",
level: 0,
selector: Some(
Global(
- ".f0_darkTheme",
+ ".theme-0-2",
"theme-two-arg.css.ts",
),
),
@@ -23,12 +23,12 @@ ToBTreeSet {
),
Static(
ExtractStaticStyle {
- property: "--color-text",
+ property: "--color-text-0-1",
value: "lightgray",
level: 0,
selector: Some(
Global(
- ".f0_darkTheme",
+ ".theme-0-2",
"theme-two-arg.css.ts",
),
),
@@ -39,5 +39,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const darkTheme = \"f0_darkTheme\";\n;\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const darkTheme = \"theme-0-2\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap
index c22f5b86f..3106b9d67 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "color",
- value: "var(--var-0,blue)",
+ value: "var(--var-0-0,blue)",
level: 0,
selector: None,
style_order: None,
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-size",
- value: "var(--var-1)",
+ value: "var(--var-0-1)",
level: 0,
selector: None,
style_order: None,
@@ -25,5 +25,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"--var-0\";\nexport const sizeVar = \"--var-1\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"var(--var-0-0)\";\nexport const sizeVar = \"var(--var-0-1)\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap
index 20ef3082f..096ea417c 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -38,8 +40,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap
index ce2e9b896..e4a5505d8 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "margin",
- value: "16px",
+ value: "4px",
level: 0,
selector: None,
style_order: None,
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "64px",
+ value: "16px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap
index 5addf3a3d..bcad15fec 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap
@@ -41,5 +41,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"a\";\nexport const fadeIn = \"b\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const fadeIn = \"a\";\nexport const animated = \"b\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap
index 474649ffd..cc83fec00 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "margin",
- value: "16px",
+ value: "4px",
level: 0,
selector: None,
style_order: None,
@@ -27,7 +27,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "32px",
+ value: "8px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap
index 5a9821856..bb689c12b 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "padding",
- value: "64px",
+ value: "16px",
level: 0,
selector: None,
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap
index 59c8f8892..29fba7a3c 100644
--- a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap
@@ -3,6 +3,17 @@ source: libs/extractor/src/lib.rs
expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {styled} from '@devup-ui/core'\n const StyledDiv = styled(\"div\")({ bg: \"red\" }, {})\n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())"
---
ToBTreeSet {
- styles: {},
- code: "const StyledDiv = styled(\"div\")({ bg: \"red\" }, {});\n",
+ styles: {
+ Static(
+ ExtractStaticStyle {
+ property: "background",
+ value: "red",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ },
+ code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst StyledDiv = ({ style, className, ...rest }) => ;\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap
index 86997e507..8c0401c2d 100644
--- a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap
@@ -3,6 +3,17 @@ source: libs/extractor/src/lib.rs
expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {styled} from '@devup-ui/core'\n const StyledDiv = styled(\"div\")({ bg: \"red\" }, {})``\n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())"
---
ToBTreeSet {
- styles: {},
- code: "const StyledDiv = styled(\"div\")({ bg: \"red\" }, {})``;\n",
+ styles: {
+ Static(
+ ExtractStaticStyle {
+ property: "background",
+ value: "red",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ },
+ code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst StyledDiv = (({ style, className, ...rest }) => )``;\n",
}
diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs
index 9f3519ab0..134360fdc 100644
--- a/libs/extractor/src/stylex.rs
+++ b/libs/extractor/src/stylex.rs
@@ -1,7 +1,11 @@
+use std::borrow::Cow;
+
use css::style_selector::{AtRuleKind, StyleSelector};
use oxc_ast::ast::{Expression, ObjectPropertyKind};
-use crate::utils::{get_string_by_literal_expression, get_string_by_property_key};
+use crate::utils::{
+ get_string_by_literal_expression, get_string_by_property_key, js_number_literal,
+};
/// Which `StyleX` function a named import refers to
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -113,6 +117,248 @@ pub fn normalize_stylex_property(name: &str) -> String {
css::utils::to_kebab_case(name).into_owned()
}
+/// Properties `StyleX` leaves unitless when given a number.
+static UNITLESS_NUMBER_PROPERTIES: phf::Set<&'static str> = phf::phf_set! {
+ "webkit-line-clamp",
+ "animation-iteration-count",
+ "aspect-ratio",
+ "border-image-outset",
+ "border-image-slice",
+ "border-image-width",
+ "counter-set",
+ "counter-reset",
+ "column-count",
+ "flex",
+ "flex-grow",
+ "flex-shrink",
+ "flex-order",
+ "grid-row",
+ "grid-row-start",
+ "grid-row-end",
+ "grid-column",
+ "grid-column-start",
+ "grid-column-end",
+ "grid-area",
+ "font-size-adjust",
+ "font-weight",
+ "hyphenate-limit-chars",
+ "line-clamp",
+ "line-height",
+ "mask-border-outset",
+ "mask-border-slice",
+ "mask-border-width",
+ "opacity",
+ "order",
+ "orphans",
+ "tab-size",
+ "widows",
+ "z-index",
+ "fill-opacity",
+ "flood-opacity",
+ "rotate",
+ "scale",
+ "shape-image-threshold",
+ "stop-opacity",
+ "stroke-dasharray",
+ "stroke-dashoffset",
+ "stroke-miterlimit",
+ "stroke-opacity",
+ "stroke-width",
+ "math-depth",
+ "zoom",
+};
+
+/// Properties whose numbers `StyleX` reads as milliseconds.
+static TIME_PROPERTIES: phf::Set<&'static str> = phf::phf_set! {
+ "animation-delay",
+ "animation-duration",
+ "transition-delay",
+ "transition-duration",
+ "voice-duration",
+};
+
+/// Properties whose dynamic values `StyleX` gives a unit when they are numbers.
+static LENGTH_PROPERTIES: phf::Set<&'static str> = phf::phf_set! {
+ "background-position-x",
+ "background-position-y",
+ "block-size",
+ "border-block-end-width",
+ "border-block-start-width",
+ "border-block-width",
+ "border-vertical-width",
+ "border-bottom-left-radius",
+ "border-bottom-right-radius",
+ "border-bottom-width",
+ "border-end-end-radius",
+ "border-end-start-radius",
+ "border-inline-end-width",
+ "border-end-width",
+ "border-inline-start-width",
+ "border-start-width",
+ "border-inline-width",
+ "border-horizontal-width",
+ "border-left-width",
+ "border-right-width",
+ "border-spacing",
+ "border-start-end-radius",
+ "border-start-start-radius",
+ "border-top-left-radius",
+ "border-top-right-radius",
+ "border-top-width",
+ "bottom",
+ "column-gap",
+ "column-rule-width",
+ "column-width",
+ "contain-intrinsic-block-size",
+ "contain-intrinsic-height",
+ "contain-intrinsic-inline-size",
+ "contain-intrinsic-width",
+ "flex-basis",
+ "font-size",
+ "font-smooth",
+ "height",
+ "inline-size",
+ "inset-block-end",
+ "inset-block-start",
+ "inset-inline-end",
+ "inset-inline-start",
+ "left",
+ "letter-spacing",
+ "margin-block-end",
+ "margin-block-start",
+ "margin-bottom",
+ "margin-inline-end",
+ "margin-end",
+ "margin-inline-start",
+ "margin-start",
+ "margin-left",
+ "margin-right",
+ "margin-top",
+ "max-block-size",
+ "max-height",
+ "max-inline-size",
+ "max-width",
+ "min-block-size",
+ "min-height",
+ "min-inline-size",
+ "min-width",
+ "offset-distance",
+ "outline-offset",
+ "outline-width",
+ "overflow-clip-margin",
+ "padding-block-end",
+ "padding-block-start",
+ "padding-bottom",
+ "padding-inline-end",
+ "padding-end",
+ "padding-inline-start",
+ "padding-start",
+ "padding-left",
+ "padding-right",
+ "padding-top",
+ "perspective",
+ "right",
+ "row-gap",
+ "scroll-margin-block-end",
+ "scroll-margin-block-start",
+ "scroll-margin-bottom",
+ "scroll-margin-inline-end",
+ "scroll-margin-inline-start",
+ "scroll-margin-left",
+ "scroll-margin-right",
+ "scroll-margin-top",
+ "scroll-padding-block-end",
+ "scroll-padding-block-start",
+ "scroll-padding-bottom",
+ "scroll-padding-inline-end",
+ "scroll-padding-inline-start",
+ "scroll-padding-left",
+ "scroll-padding-right",
+ "scroll-padding-top",
+ "scroll-snap-margin-bottom",
+ "scroll-snap-margin-left",
+ "scroll-snap-margin-right",
+ "scroll-snap-margin-top",
+ "shape-margin",
+ "tab-size",
+ "text-decoration-thickness",
+ "text-indent",
+ "text-underline-offset",
+ "top",
+ "transform-origin",
+ "translate",
+ "vertical-align",
+ "width",
+ "word-spacing",
+ "border",
+ "border-block",
+ "border-block-end",
+ "border-block-start",
+ "border-bottom",
+ "border-left",
+ "border-radius",
+ "border-right",
+ "border-top",
+ "border-width",
+ "column-rule",
+ "contain-intrinsic-size",
+ "gap",
+ "inset",
+ "inset-block",
+ "inset-inline",
+ "margin",
+ "margin-block",
+ "margin-vertical",
+ "margin-inline",
+ "margin-horizontal",
+ "offset",
+ "outline",
+ "padding",
+ "padding-block",
+ "padding-vertical",
+ "padding-inline",
+ "padding-horizontal",
+ "scroll-margin",
+ "scroll-margin-block",
+ "scroll-margin-inline",
+ "scroll-padding",
+ "scroll-padding-block",
+ "scroll-padding-inline",
+ "scroll-snap-margin",
+};
+
+/// The unit `StyleX` appends to a number on `property`.
+fn number_suffix(property: &str) -> &'static str {
+ if UNITLESS_NUMBER_PROPERTIES.contains(property) || property.starts_with("--") {
+ ""
+ } else if TIME_PROPERTIES.contains(property) {
+ "ms"
+ } else {
+ "px"
+ }
+}
+
+/// The unit a dynamic value on `property` gets when it is a number at runtime.
+pub fn dynamic_number_suffix(property: &str) -> &'static str {
+ if TIME_PROPERTIES.contains(property) || LENGTH_PROPERTIES.contains(property) {
+ number_suffix(property)
+ } else {
+ ""
+ }
+}
+
+/// A literal `StyleX` value as CSS text: a number gets the unit `StyleX` gives it.
+pub fn stylex_value<'a>(property: &str, value: &Expression<'a>) -> Option> {
+ js_number_literal(value).map_or_else(
+ || get_string_by_literal_expression(value),
+ |number| {
+ // `+ 0.0` turns `-0` into `0`, as JS prints it.
+ let rounded = (number * 10_000.0).round() / 10_000.0 + 0.0;
+ Some(Cow::Owned(format!("{rounded}{}", number_suffix(property))))
+ },
+ )
+}
+
/// Intermediate selector parts collected during recursion.
#[derive(Debug, Clone)]
pub enum SelectorPart {
@@ -136,8 +382,8 @@ pub struct DecomposedStyle {
pub struct StylexDynamicInfo {
/// Combined class name string for all properties (static + dynamic)
pub class_name: String,
- /// Maps (`param_index`, `css_variable_name`) for each dynamic property
- pub css_vars: Vec<(usize, String)>,
+ /// (`param_index`, `css_variable_name`, unit for a number) for each dynamic property
+ pub css_vars: Vec<(usize, String, &'static str)>,
}
/// A `StyleX` namespace entry — either static or dynamic (arrow function)
@@ -163,22 +409,18 @@ pub fn decompose_value_conditions(
value: &Expression,
parent_selectors: &[SelectorPart],
) -> Vec {
- // String literal → leaf
- if let Some(s) = get_string_by_literal_expression(value) {
- return vec![DecomposedStyle {
- property: css_property.to_string(),
- value: Some(s.into_owned()),
- selector: compose_selectors(parent_selectors),
- }];
+ // String or number literal → leaf
+ if let Some(s) = stylex_value(css_property, value) {
+ return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors)
+ .into_iter()
+ .collect();
}
// NullLiteral → tracked but no CSS
if matches!(value, Expression::NullLiteral(_)) {
- return vec![DecomposedStyle {
- property: css_property.to_string(),
- value: None,
- selector: compose_selectors(parent_selectors),
- }];
+ return decomposed_leaf(css_property, None, parent_selectors)
+ .into_iter()
+ .collect();
}
// CallExpression: firstThatWorks() → multiple fallback values with current selectors
@@ -188,13 +430,13 @@ pub fn decompose_value_conditions(
let mut results = vec![];
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)
+ && let Some(s) = stylex_value(css_property, arg_expr)
{
- results.push(DecomposedStyle {
- property: css_property.to_string(),
- value: Some(s.into_owned()),
- selector: compose_selectors(parent_selectors),
- });
+ results.extend(decomposed_leaf(
+ css_property,
+ Some(s.into_owned()),
+ parent_selectors,
+ ));
}
}
return results;
@@ -205,12 +447,10 @@ pub fn decompose_value_conditions(
&& is_types_call(&call.callee)
&& let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression())
{
- if let Some(s) = get_string_by_literal_expression(inner) {
- return vec![DecomposedStyle {
- property: css_property.to_string(),
- value: Some(s.into_owned()),
- selector: compose_selectors(parent_selectors),
- }];
+ if let Some(s) = stylex_value(css_property, inner) {
+ return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors)
+ .into_iter()
+ .collect();
}
return vec![];
}
@@ -258,14 +498,15 @@ pub fn decompose_value_conditions(
results
}
-/// Compose a list of selector parts into a single `StyleSelector`.
-fn compose_selectors(parts: &[SelectorPart]) -> Option {
- if parts.is_empty() {
- return None;
- }
-
+/// A leaf style under `parent_selectors`, or `None` when those conditions can
+/// never match together (e.g. `@media print` inside `@media screen`).
+fn decomposed_leaf(
+ css_property: &str,
+ value: Option,
+ parent_selectors: &[SelectorPart],
+) -> Option {
let mut pseudo_str: Option = None;
- for p in parts {
+ for p in parent_selectors {
if let SelectorPart::Pseudo(s) = p {
pseudo_str
.get_or_insert_with(|| String::from("&"))
@@ -273,21 +514,23 @@ fn compose_selectors(parts: &[SelectorPart]) -> Option {
}
}
- // Last at-rule wins; read it with a reverse scan (no intermediate Vec).
- let last_at_rule = parts.iter().rev().find_map(|p| match p {
- SelectorPart::AtRule { kind, query } => Some((*kind, query.as_str())),
- SelectorPart::Pseudo(_) => None,
- });
-
- if let Some((kind, query)) = last_at_rule {
- Some(StyleSelector::At {
- kind,
- query: query.to_string(),
- selector: pseudo_str,
- })
- } else {
- pseudo_str.map(StyleSelector::Selector)
+ // Every enclosing at-rule applies, outermost first.
+ let mut selector = pseudo_str.map(StyleSelector::Selector);
+ for p in parent_selectors {
+ if let SelectorPart::AtRule { kind, query } = p {
+ selector = Some(StyleSelector::nest_at_rule(
+ selector.as_ref(),
+ *kind,
+ query,
+ )?);
+ }
}
+
+ Some(DecomposedStyle {
+ property: css_property.to_string(),
+ value,
+ selector,
+ })
}
/// Parse an at-rule key like `"@media (max-width: 600px)"` into kind + query.
@@ -319,4 +562,32 @@ mod tests {
assert_eq!(normalize_stylex_property("color"), "color");
assert_eq!(normalize_stylex_property("zIndex"), "z-index");
}
+
+ #[test]
+ fn test_decompose_folds_every_at_rule() {
+ let allocator = oxc_allocator::Allocator::default();
+ let source = "({ default: 'red', '@media print': { ':hover': { '@media (prefers-reduced-motion: reduce)': 'blue', '@media screen': 'green' } } })";
+ let program = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::ts())
+ .parse()
+ .program;
+ let oxc_ast::ast::Statement::ExpressionStatement(statement) = &program.body[0] else {
+ panic!("expected expression statement");
+ };
+
+ let value = statement.expression.without_parentheses();
+ let styles: Vec<_> = decompose_value_conditions("color", value, &[])
+ .into_iter()
+ .map(|style| (style.value, style.selector.map(|s| s.to_string())))
+ .collect();
+ assert_eq!(
+ styles,
+ vec![
+ (Some("red".to_string()), None),
+ (
+ Some("blue".to_string()),
+ Some("@media print and (prefers-reduced-motion:reduce) &:hover".to_string())
+ ),
+ ]
+ );
+ }
}
diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs
index 26ecb8357..87e9f5178 100644
--- a/libs/extractor/src/tailwind.rs
+++ b/libs/extractor/src/tailwind.rs
@@ -179,51 +179,16 @@ impl TailwindVariant {
TailwindVariant::PeerInvalid => {
StyleSelector::Selector(".peer:invalid ~ &".to_string())
}
- TailwindVariant::Print => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "print".to_string(),
- selector: None,
- },
- TailwindVariant::Screen => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "screen".to_string(),
- selector: None,
- },
- TailwindVariant::Portrait => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(orientation: portrait)".to_string(),
- selector: None,
- },
- TailwindVariant::Landscape => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(orientation: landscape)".to_string(),
- selector: None,
- },
- TailwindVariant::MotionReduce => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(prefers-reduced-motion: reduce)".to_string(),
- selector: None,
- },
- TailwindVariant::MotionSafe => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(prefers-reduced-motion: no-preference)".to_string(),
- selector: None,
- },
- TailwindVariant::ContrastMore => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(prefers-contrast: more)".to_string(),
- selector: None,
- },
- TailwindVariant::ContrastLess => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(prefers-contrast: less)".to_string(),
- selector: None,
- },
- TailwindVariant::ForcedColors => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(forced-colors: active)".to_string(),
- selector: None,
- },
+ // Media variants share the `_print`/`_motionReduce`/… prop table.
+ TailwindVariant::Print => StyleSelector::from("print"),
+ TailwindVariant::Screen => StyleSelector::from("screen"),
+ TailwindVariant::Portrait => StyleSelector::from("portrait"),
+ TailwindVariant::Landscape => StyleSelector::from("landscape"),
+ TailwindVariant::MotionReduce => StyleSelector::from("motion-reduce"),
+ TailwindVariant::MotionSafe => StyleSelector::from("motion-safe"),
+ TailwindVariant::ContrastMore => StyleSelector::from("contrast-more"),
+ TailwindVariant::ContrastLess => StyleSelector::from("contrast-less"),
+ TailwindVariant::ForcedColors => StyleSelector::from("forced-colors"),
TailwindVariant::Rtl => StyleSelector::Selector("[dir=rtl] &".to_string()),
TailwindVariant::Ltr => StyleSelector::Selector("[dir=ltr] &".to_string()),
}
@@ -331,8 +296,9 @@ fn remove_all_substr(haystack: &mut String, needle: &str) {
}
impl TailwindClass {
- /// Convert to `ExtractStaticStyle`
- pub fn to_static_style(&self) -> ExtractStaticStyle {
+ /// Convert to `ExtractStaticStyle`; `None` when the variants' media
+ /// conditions exclude each other (e.g. `print:screen:`).
+ pub fn to_static_style(&self) -> Option {
// For transform property, negative is already incorporated into the value
// (e.g., translateX(-1rem)), so don't add prefix again. Only the negative
// branch needs an owned String for the `-` prefix; the common non-negative
@@ -350,22 +316,27 @@ impl TailwindClass {
None
} else {
// Combine multiple variants into a single selector
- Some(self.combine_selectors())
+ Some(self.combine_selectors()?)
};
- ExtractStaticStyle::new(self.property, value.as_ref(), self.responsive, selector)
+ Some(ExtractStaticStyle::new(
+ self.property,
+ value.as_ref(),
+ self.responsive,
+ selector,
+ ))
}
- /// Combine multiple variant selectors
- fn combine_selectors(&self) -> StyleSelector {
+ /// Combine multiple variant selectors; `None` when they can never match together.
+ fn combine_selectors(&self) -> Option {
if self.variants.len() == 1 {
- return self.variants[0].to_selector();
+ return Some(self.variants[0].to_selector());
}
// For multiple variants, combine them
// e.g., dark:hover: becomes :root[data-theme=dark] &:hover
let mut selector_str = String::new();
- let mut has_at_rule = None;
+ let mut at_rules = vec![];
for variant in &self.variants {
let sel = variant.to_selector();
@@ -394,9 +365,7 @@ impl TailwindClass {
}
}
}
- StyleSelector::At { kind, query, .. } => {
- has_at_rule = Some((kind, query));
- }
+ StyleSelector::At { kind, query, .. } => at_rules.push((kind, query)),
// SAFETY: TailwindVariant::to_selector() never produces Global.
// This arm exists only for exhaustive matching. If reached, it indicates
// a bug where a new TailwindVariant was added that produces Global.
@@ -406,19 +375,18 @@ impl TailwindClass {
}
}
- if let Some((kind, query)) = has_at_rule {
- StyleSelector::At {
+ // Every media variant applies, so fold them all in order (`print:motion-reduce:`
+ // becomes `@media print and (prefers-reduced-motion:reduce)`).
+ let mut selector =
+ (!selector_str.is_empty()).then_some(StyleSelector::Selector(selector_str));
+ for (kind, query) in at_rules {
+ selector = Some(StyleSelector::nest_at_rule(
+ selector.as_ref(),
kind,
- query,
- selector: if selector_str.is_empty() {
- None
- } else {
- Some(selector_str)
- },
- }
- } else {
- StyleSelector::Selector(selector_str)
+ &query,
+ )?);
}
+ selector
}
}
@@ -1292,8 +1260,10 @@ pub fn parse_tailwind_to_styles(class_str: &str) -> Vec {
Vec::with_capacity(class_str.bytes().filter(u8::is_ascii_whitespace).count() + 1);
for class in class_str.split_whitespace() {
- if let Some(parsed) = parse_single_class(class) {
- let static_style = parsed.to_static_style();
+ if let Some(static_style) = parse_single_class(class)
+ .as_ref()
+ .and_then(TailwindClass::to_static_style)
+ {
styles.push(ExtractStyleValue::Static(static_style));
}
}
@@ -3715,7 +3685,7 @@ mod tests {
assert_eq!(parsed.value, "1rem");
assert!(parsed.negative);
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
assert_eq!(static_style.value(), "-1rem");
}
@@ -3749,7 +3719,7 @@ mod tests {
#[test]
fn test_to_static_style() {
let parsed = parse_single_class("bg-red-500").expect("Should parse");
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
assert_eq!(static_style.property(), "background-color");
// ExtractStaticStyle::new() uses optimize_value() which uppercases hex colors
@@ -3761,7 +3731,7 @@ mod tests {
#[test]
fn test_to_static_style_with_responsive() {
let parsed = parse_single_class("md:p-4").expect("Should parse");
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
assert_eq!(static_style.property(), "padding");
assert_eq!(static_style.value(), "1rem");
@@ -3771,7 +3741,7 @@ mod tests {
#[test]
fn test_to_static_style_with_variant() {
let parsed = parse_single_class("hover:bg-blue-500").expect("Should parse");
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
assert_eq!(static_style.property(), "background-color");
assert!(static_style.selector().is_some());
@@ -4072,14 +4042,15 @@ mod tests {
// Wave 1.4: Media variant selectors (lines 153-162)
#[rstest]
+ #[case(TailwindVariant::Print, "print")]
#[case(TailwindVariant::Screen, "screen")]
- #[case(TailwindVariant::Portrait, "(orientation: portrait)")]
- #[case(TailwindVariant::Landscape, "(orientation: landscape)")]
- #[case(TailwindVariant::MotionReduce, "(prefers-reduced-motion: reduce)")]
- #[case(TailwindVariant::MotionSafe, "(prefers-reduced-motion: no-preference)")]
- #[case(TailwindVariant::ContrastMore, "(prefers-contrast: more)")]
- #[case(TailwindVariant::ContrastLess, "(prefers-contrast: less)")]
- #[case(TailwindVariant::ForcedColors, "(forced-colors: active)")]
+ #[case(TailwindVariant::Portrait, "(orientation:portrait)")]
+ #[case(TailwindVariant::Landscape, "(orientation:landscape)")]
+ #[case(TailwindVariant::MotionReduce, "(prefers-reduced-motion:reduce)")]
+ #[case(TailwindVariant::MotionSafe, "(prefers-reduced-motion:no-preference)")]
+ #[case(TailwindVariant::ContrastMore, "(prefers-contrast:more)")]
+ #[case(TailwindVariant::ContrastLess, "(prefers-contrast:less)")]
+ #[case(TailwindVariant::ForcedColors, "(forced-colors:active)")]
fn test_variant_to_selector_media_queries(
#[case] variant: TailwindVariant,
#[case] expected_query: &str,
@@ -4132,13 +4103,14 @@ mod tests {
assert_eq!(parsed.variants[0], TailwindVariant::Print);
assert_eq!(parsed.variants[1], TailwindVariant::Hover);
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
let selector = static_style.selector().expect("Should have selector");
// Should combine into At rule with nested hover selector
if let StyleSelector::At {
kind,
query,
selector: nested,
+ ..
} = selector
{
assert_eq!(*kind, css::style_selector::AtRuleKind::Media);
@@ -4149,6 +4121,29 @@ mod tests {
}
}
+ #[rstest]
+ #[case(
+ "print:motion-reduce:hidden",
+ Some("@media print and (prefers-reduced-motion:reduce)")
+ )]
+ #[case(
+ "motion-reduce:landscape:hover:hidden",
+ Some("@media(prefers-reduced-motion:reduce)and (orientation:landscape) &:hover")
+ )]
+ #[case("print:screen:hidden", None)]
+ fn test_combine_selectors_folds_every_media_variant(
+ #[case] class: &str,
+ #[case] expected: Option<&str>,
+ ) {
+ let parsed = parse_single_class(class).expect("Should parse");
+ assert_eq!(
+ parsed
+ .to_static_style()
+ .and_then(|style| style.selector().map(ToString::to_string)),
+ expected.map(str::to_string)
+ );
+ }
+
#[test]
fn test_remove_all_substr_matches_str_replace() {
for (haystack, needle) in [
@@ -4221,7 +4216,7 @@ mod tests {
negative: false,
};
let expected = old_combine(variants);
- match cls.combine_selectors() {
+ match cls.combine_selectors().expect("selector") {
StyleSelector::Selector(actual) => {
assert_eq!(actual, expected, "variants={variants:?}");
}
diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs
index d976d7ed6..de37c9638 100644
--- a/libs/extractor/src/utils.rs
+++ b/libs/extractor/src/utils.rs
@@ -1,5 +1,7 @@
use std::borrow::Cow;
+use crate::extract_style::constant::MAINTAIN_VALUE_PROPERTIES;
+use css::utils::to_kebab_case;
use oxc_allocator::{Allocator, CloneIn, GetAllocator};
use oxc_ast::{
ast::{
@@ -25,6 +27,46 @@ pub(super) fn is_vanilla_extract_file(filename: &str) -> bool {
filename.ends_with(".css.ts") || filename.ends_with(".css.js")
}
+/// Whether vanilla-extract leaves a number on `key` without a unit: unitless
+/// properties, custom properties, and array indices.
+pub(super) fn is_unitless_key(key: &str) -> bool {
+ key.starts_with("--")
+ || key.starts_with("var(")
+ || key.bytes().all(|byte| byte.is_ascii_digit())
+ || MAINTAIN_VALUE_PROPERTIES.contains(to_kebab_case(key).as_ref())
+}
+
+/// Whether a number on `key` stays bare in a library whose numbers mean pixels:
+/// unitless keys, and Devup UI shorthands (`p`, `bg`, `mx`, ...), which keep
+/// Devup UI's spacing scale because no such library defines them.
+pub(super) fn keeps_bare_number(key: &str) -> bool {
+ if is_unitless_key(key) {
+ return true;
+ }
+ let mut properties = css::disassemble_property(key);
+ let kebab = to_kebab_case(key);
+ !(properties.len() == 1
+ && properties.next().is_some_and(|property| {
+ property.trim_start_matches('-') == kebab.trim_start_matches('-')
+ }))
+}
+
+/// A JS number literal (`8`, `-8`, `(8)`), unlike a numeric string.
+pub(super) fn js_number_literal(value: &Expression) -> Option {
+ match value {
+ Expression::NumericLiteral(number) => Some(number.value),
+ Expression::ParenthesizedExpression(inner) => js_number_literal(&inner.expression),
+ Expression::UnaryExpression(unary) => {
+ js_number_literal(&unary.argument).and_then(|number| match unary.operator {
+ UnaryOperator::UnaryNegation => Some(-number),
+ UnaryOperator::UnaryPlus => Some(number),
+ _ => None,
+ })
+ }
+ _ => None,
+ }
+}
+
/// Strip the wrappers that exist only in the source text: TypeScript's `as` /
/// `satisfies` / `!` / explicit type arguments, plus redundant parentheses. Every
/// one is erased before the code runs, so extraction must see through them —
@@ -436,6 +478,112 @@ pub(super) fn merge_object_expressions<'a>(
Some(Expression::new_object_expression(SPAN, props, ast_builder))
}
+/// Several style arguments, or arrays of them, as vanilla-extract, Emotion and
+/// styled-components compose them
+pub(super) struct StyleArguments<'a> {
+ /// Classes composed as they are: other styles held in variables, strings
+ pub classes: Vec>,
+ /// The rule objects merged: later declarations replace earlier ones and
+ /// nested rules merge
+ pub rules: Expression<'a>,
+}
+
+/// `None` for a single non-array argument, which needs no composing, or when a
+/// part is neither a rule object nor a class (`null`/`undefined`/`false` parts
+/// are skipped).
+pub(super) fn style_arguments<'a>(
+ ast_builder: &AstBuilder<'a>,
+ arguments: &[Argument<'a>],
+) -> Option> {
+ if let [argument] = arguments
+ && !matches!(argument, Argument::ArrayExpression(_))
+ {
+ return None;
+ }
+ let mut objects = Vec::new();
+ let mut classes = Vec::new();
+ for argument in arguments {
+ collect_style_parts(argument.as_expression()?, &mut objects, &mut classes)?;
+ }
+ let mut merged = oxc_allocator::Vec::new_in(ast_builder);
+ for object in objects {
+ merge_style_properties(ast_builder, &mut merged, &object.properties);
+ }
+ Some(StyleArguments {
+ classes: classes
+ .into_iter()
+ .map(|class| class.clone_in(ast_builder.allocator()))
+ .collect(),
+ rules: Expression::new_object_expression(SPAN, merged, ast_builder),
+ })
+}
+
+fn collect_style_parts<'b, 'a>(
+ expression: &'b Expression<'a>,
+ objects: &mut Vec<&'b oxc_ast::ast::ObjectExpression<'a>>,
+ classes: &mut Vec<&'b Expression<'a>>,
+) -> Option<()> {
+ match expression {
+ Expression::ObjectExpression(object) => objects.push(object),
+ Expression::ArrayExpression(array) => {
+ for element in &array.elements {
+ collect_style_parts(element.as_expression()?, objects, classes)?;
+ }
+ }
+ Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => {}
+ Expression::Identifier(identifier) if identifier.name == "undefined" => {}
+ Expression::Identifier(_)
+ | Expression::StaticMemberExpression(_)
+ | Expression::ComputedMemberExpression(_)
+ | Expression::StringLiteral(_)
+ | Expression::TemplateLiteral(_) => classes.push(expression),
+ _ => return None,
+ }
+ Some(())
+}
+
+fn merge_style_properties<'a>(
+ ast_builder: &AstBuilder<'a>,
+ merged: &mut oxc_allocator::Vec<'a, ObjectPropertyKind<'a>>,
+ properties: &[ObjectPropertyKind<'a>],
+) {
+ for property in properties {
+ let existing = match property {
+ ObjectPropertyKind::ObjectProperty(property) if !property.computed => {
+ get_str_by_property_key(&property.key).and_then(|key| {
+ merged.iter().position(|existing| {
+ matches!(existing, ObjectPropertyKind::ObjectProperty(existing)
+ if !existing.computed
+ && get_str_by_property_key(&existing.key).as_deref() == Some(key.as_ref()))
+ })
+ })
+ }
+ _ => None,
+ };
+ let Some(index) = existing else {
+ merged.push(property.clone_in(ast_builder.allocator()));
+ continue;
+ };
+ let previous = merged.remove(index);
+ if let (
+ ObjectPropertyKind::ObjectProperty(previous),
+ ObjectPropertyKind::ObjectProperty(next),
+ ) = (&previous, property)
+ && let (Expression::ObjectExpression(before), Expression::ObjectExpression(after)) =
+ (&previous.value, &next.value)
+ {
+ let mut nested = oxc_allocator::Vec::new_in(ast_builder);
+ merge_style_properties(ast_builder, &mut nested, &before.properties);
+ merge_style_properties(ast_builder, &mut nested, &after.properties);
+ let mut combined = next.clone_in(ast_builder.allocator());
+ combined.value = Expression::new_object_expression(SPAN, nested, ast_builder);
+ merged.push(ObjectPropertyKind::ObjectProperty(combined));
+ } else {
+ merged.push(property.clone_in(ast_builder.allocator()));
+ }
+ }
+}
+
/// Borrowing variant of [`get_string_by_property_key`].
///
/// For a `StaticIdentifier` key this returns `Cow::Borrowed`, avoiding the heap
@@ -496,6 +644,85 @@ mod tests {
assert!(matches!(convert_value("4"), Cow::Owned(_)));
}
+ #[test]
+ fn test_js_number_literal() {
+ let allocator = Allocator::default();
+ for (source, expected) in [
+ ("8", Some(8.0)),
+ ("(8)", Some(8.0)),
+ ("+8", Some(8.0)),
+ ("-8", Some(-8.0)),
+ ("-(1.5)", Some(-1.5)),
+ ("!8", None),
+ ("'8'", None),
+ ("-x", None),
+ ] {
+ let expression = Parser::new(&allocator, source, SourceType::ts())
+ .parse_expression()
+ .unwrap();
+ assert_eq!(js_number_literal(&expression), expected, "{source}");
+ }
+ assert!(is_unitless_key("lineHeight"));
+ assert!(is_unitless_key("--gap"));
+ assert!(is_unitless_key("0"));
+ assert!(!is_unitless_key("padding"));
+ assert!(keeps_bare_number("lineHeight"));
+ assert!(keeps_bare_number("p"));
+ assert!(keeps_bare_number("mx"));
+ assert!(!keeps_bare_number("padding"));
+ assert!(!keeps_bare_number("backgroundColor"));
+ assert!(!keeps_bare_number("WebkitTextStrokeWidth"));
+ }
+
+ #[test]
+ #[allow(clippy::literal_string_with_formatting_args)]
+ fn test_style_arguments() {
+ let allocator = Allocator::default();
+ let ast_builder = AstBuilder::new(&allocator);
+ let compose = |source: &str| {
+ let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse();
+ let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else {
+ unreachable!()
+ };
+ let Expression::CallExpression(call) = &statement.expression else {
+ unreachable!()
+ };
+ style_arguments(&ast_builder, &call.arguments).map(
+ |StyleArguments { classes, rules }| {
+ (
+ classes
+ .iter()
+ .map(expression_to_code)
+ .collect::>(),
+ expression_to_code(&rules),
+ )
+ },
+ )
+ };
+ assert_eq!(compose("f({ a: 1 })"), None);
+ assert_eq!(compose("f(x)"), None);
+ assert_eq!(compose("f(...x)"), None);
+ assert_eq!(compose("f([{ a: 1 }, [cond && x]])"), None);
+ assert_eq!(
+ compose(
+ "f([{ a: 1, b: { c: 1 }, d: { e: 1 } }, null, undefined, false, [{ a: 2, b: { f: 2 }, d: 3, [g]: 1, ...h, 'i': 1 }]], { [g]: 2, i: 2 })"
+ ),
+ Some((
+ std::vec::Vec::new(),
+ "({a:2,b:{c:1,f:2},d:3,[g]:1,...h,[g]:2,i:2});".to_string()
+ ))
+ );
+ assert_eq!(
+ compose("f([x, a.b, a[b], 'c', `d`, { e: 1 }])"),
+ Some((
+ ["x;", "a.b;", "a[b];", "`c`;", "`d`;"]
+ .map(ToString::to_string)
+ .to_vec(),
+ "({e:1});".to_string()
+ ))
+ );
+ }
+
#[test]
fn test_get_number_by_literal_expression() {
let allocator = Allocator::default();
diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs
index 7c0bd3cef..d7f601f56 100644
--- a/libs/extractor/src/vanilla_extract.rs
+++ b/libs/extractor/src/vanilla_extract.rs
@@ -3,9 +3,11 @@
//! This module uses `boa_engine` to execute vanilla-extract style files
//! and extract style definitions for processing by the existing extract logic.
+use crate::utils::keeps_bare_number;
use boa_engine::{
- Context, JsArgs, JsValue, NativeFunction, Source, js_string, object::ObjectInitializer,
- property::Attribute,
+ Context, JsArgs, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string,
+ object::{FunctionObjectBuilder, ObjectInitializer, builtins::JsArray},
+ property::{Attribute, PropertyKey},
};
use css::file_map::get_file_num_by_filename;
use oxc_allocator::Allocator;
@@ -20,335 +22,189 @@ use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
-/// A single variant in styleVariants
-#[derive(Debug, Clone)]
-pub struct StyleVariant {
- /// Base class reference (variable name), if composing
- pub base: Option,
- /// The style object JSON for this variant
- pub styles_json: String,
-}
-
-/// Style with export info
-#[derive(Debug, Clone)]
+/// A `style()` or `keyframes()` call
+#[derive(Debug, Clone, Default)]
pub struct StyleEntry {
- /// The style object JSON
+ /// The rule object JSON
pub json: String,
- /// Whether this style is exported
+ /// Whether the variable holding it is exported
pub exported: bool,
- /// Base class references for composition (placeholder IDs like "__`style_0`__")
+ /// Styles composed before this one: placeholders, names once named
pub bases: SmallVec<[String; 2]>,
+ /// Class names composed as they are
+ pub classes: SmallVec<[String; 1]>,
}
-/// Entry for `createGlobalTheme()` - CSS variables scoped to a selector
-#[derive(Debug, Clone, Default)]
-pub struct GlobalThemeEntry {
- /// CSS selector (e.g., ":root")
- pub selector: String,
- /// CSS variables: Vec<(`var_name`, value)> e.g. [("--color-brand-0-0", "blue")]
- pub css_vars: SmallVec<[(String, String); 8]>,
- /// Serialized JS object with `var()` references
- pub vars_object_json: String,
- /// Whether this is exported
- pub exported: bool,
-}
-
-/// Entry for `createTheme()` - CSS variables scoped to a generated class
-#[derive(Debug, Clone, Default)]
-pub struct ThemeEntry {
- /// CSS variables: Vec<(`var_name`, value)> e.g. [("--color-brand", "blue")]
- pub css_vars: SmallVec<[(String, String); 8]>,
- /// Whether this is exported
- pub exported: bool,
- /// For single-arg createTheme: the vars object JSON with `var()` references
- /// Used to generate the second element of the returned array
- pub vars_object_json: Option,
- /// For single-arg createTheme: the name of the vars variable from [themeClass, vars]
- pub vars_name: Option,
- /// The unique generated class name (`file_prefix` + `variable_name`)
- pub class_name: String,
-}
-
-/// Collected style definitions from vanilla-extract API calls
+/// What a vanilla-extract stylesheet defines
#[derive(Debug, Default, Clone)]
pub struct CollectedStyles {
- /// `style()` calls: `variable_name` -> (json, exported)
+ /// `style()` calls, every `styleVariants()` variant included: name -> entry
pub styles: FxHashMap,
- /// `globalStyle()` calls: selector -> style object JSON
- pub global_styles: Vec<(String, String)>,
- /// `keyframes()` calls: `variable_name` -> (json, exported)
+ /// `keyframes()` calls: name -> entry
pub keyframes: FxHashMap,
- /// `createVar()` calls: `variable_name` -> (CSS variable string, exported)
- pub vars: FxHashMap,
- /// `fontFace()` calls: `placeholder_id` -> (`font_face` JSON, font-family name, exported)
- pub font_faces: FxHashMap,
- /// `styleVariants()` calls: `variable_name` -> (variants, exported)
- pub style_variants: FxHashMap, bool)>,
- /// `createContainer()` calls: `variable_name` -> (container name string, exported)
- pub containers: FxHashMap,
- /// `layer()` calls: `variable_name` -> (layer name string, exported)
- pub layers: FxHashMap,
- /// `createGlobalTheme()` calls: `variable_name` -> `GlobalThemeEntry`
- pub global_themes: FxHashMap,
- /// `createTheme()` calls: `variable_name` -> `ThemeEntry`
- pub themes: FxHashMap,
- /// Non-style constant exports: `variable_name` -> value (as code string)
- pub constant_exports: FxHashMap,
-}
-
-/// Internal state for collecting styles during JS execution
-#[derive(Default)]
-struct StyleCollectorInner {
- styles: CollectedStyles,
- style_counter: usize,
- font_counter: usize,
- global_theme_counter: usize,
+ /// Global rules in call order: selector -> rule object JSON. Themes declare
+ /// their variables here, on their class or selector.
+ pub global_styles: Vec<(String, String)>,
+ /// `@font-face` rule objects, each naming its `fontFamily`
+ pub font_faces: Vec,
+ /// Layers in declaration order
+ pub layers: Vec,
+ /// `@property` rules registered by `createVar()`
+ pub property_rules: Vec,
+ /// Exported values other than styles and keyframes: name -> code
+ pub constant_exports: Vec<(String, String)>,
+ /// `__style_N__` placeholder -> what it stands for, for placeholders left in
+ /// selectors and values
+ pub references: FxHashMap,
}
-type StyleCollector = Rc>;
-
-fn next_style_id(collector: &StyleCollector) -> String {
- let mut inner = collector.borrow_mut();
- let id = format!("__style_{}__", inner.style_counter);
- inner.style_counter += 1;
- id
+/// Target of a placeholder that a selector or value interpolated
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum Reference {
+ /// `style()`: its name and the unique class it gains when referenced
+ Style { name: String, class_name: String },
+ /// `keyframes()`: its name, resolved to the generated keyframes name
+ Keyframes(String),
}
-fn next_font_id(collector: &StyleCollector) -> String {
- let mut inner = collector.borrow_mut();
- let id = format!("__font_{}__", inner.font_counter);
- inner.font_counter += 1;
- id
+/// State the mock API shares while a stylesheet runs
+#[derive(Default)]
+struct Collector {
+ styles: CollectedStyles,
+ file_num: usize,
+ placeholders: usize,
+ identifiers: usize,
}
-fn next_global_theme_id(collector: &StyleCollector) -> String {
- let mut inner = collector.borrow_mut();
- let id = format!("__global_theme_{}__", inner.global_theme_counter);
- inner.global_theme_counter += 1;
- id
+type StyleCollector = Rc>;
+
+impl Collector {
+ fn placeholder(&mut self) -> String {
+ let id = format!("__style_{}__", self.placeholders);
+ self.placeholders += 1;
+ id
+ }
+
+ /// A name no other stylesheet produces: `debug_id` (or `fallback`) made an
+ /// identifier, then the file number and a per-file counter
+ fn identifier(&mut self, debug_id: Option, fallback: &str) -> String {
+ let base = debug_id
+ .filter(|id| !id.is_empty())
+ .unwrap_or_else(|| fallback.to_string());
+ let mut name: String = base
+ .chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
+ c
+ } else {
+ '_'
+ }
+ })
+ .collect();
+ if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
+ name.insert(0, '_');
+ }
+ let id = format!("{name}-{}-{}", self.file_num, self.identifiers);
+ self.identifiers += 1;
+ id
+ }
}
-/// Parse font-face JSON and return list of (key, value) pairs
-/// Input: {"src":"local(...)","fontWeight":400}
-/// Output: vec![("src", "\"local(...)\""), ("fontWeight", "400")]
-fn parse_font_face_json(json: &str) -> Vec<(String, String)> {
- // Use serde_json to parse properly
- let Ok(value) = serde_json::from_str::(json) else {
- return Vec::new();
- };
+fn js_str(value: &JsValue) -> Option {
+ value.as_string().map(|s| s.to_std_string_escaped())
+}
- let Some(obj) = value.as_object() else {
- return Vec::new();
- };
+fn to_text(value: &JsValue, context: &mut Context) -> JsResult {
+ Ok(value.to_string(context)?.to_std_string_escaped())
+}
- obj.iter()
- .map(|(k, v)| {
- let val = match v {
- serde_json::Value::String(s) => {
- format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
- }
- serde_json::Value::Number(n) => n.to_string(),
- serde_json::Value::Bool(b) => b.to_string(),
- _ => v.to_string(),
+/// String and index keys of `object`, in property order
+fn own_keys(object: &JsObject, context: &mut Context) -> JsResult> {
+ Ok(object
+ .own_property_keys(context)?
+ .into_iter()
+ .filter_map(|key| {
+ let name = match &key {
+ PropertyKey::String(s) => s.to_std_string_escaped(),
+ PropertyKey::Index(i) => i.get().to_string(),
+ PropertyKey::Symbol(_) => return None,
};
- (k.clone(), val)
+ Some((key, name))
})
- .collect()
+ .collect())
}
-/// Recursively transform theme contract object to CSS `var()` references
-/// Returns a new JS object with null leaves replaced by var(--path)
-fn transform_contract_to_vars(
- value: &JsValue,
- ctx: &mut Context,
- path: &mut Vec,
-) -> JsValue {
- if let Some(obj) = value.as_object() {
- // Check if it's an array
- if obj.is_array() {
- return value.clone();
- }
-
- // It's an object - recursively transform each property
- let new_obj = boa_engine::object::ObjectInitializer::new(ctx).build();
-
- if let Ok(keys) = obj.own_property_keys(ctx) {
- for key in keys {
- let key_string = match &key {
- boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(),
- boa_engine::property::PropertyKey::Symbol(_) => continue,
- boa_engine::property::PropertyKey::Index(i) => i.get().to_string(),
- };
-
- if let Ok(prop_value) = obj.get(js_string!(key_string.as_str()), ctx) {
- path.push(key_string.clone());
-
- let transformed = transform_contract_to_vars(&prop_value, ctx, path);
-
- path.pop();
-
- let _ = new_obj.set(js_string!(key_string.as_str()), transformed, false, ctx);
- }
- }
- }
-
- JsValue::from(new_obj)
- } else {
- // Leaf value (should be null) - create CSS variable reference
- let var_name = format!("--{}", path.join("-"));
- JsValue::from(js_string!(format!("var({})", var_name)))
- }
+/// Elements of `value` when it is an array
+fn array_items(value: &JsValue, context: &mut Context) -> JsResult