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. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SelectorMedia 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>> { + let Some(array) = value.as_object().filter(JsObject::is_array) else { + return Ok(None); + }; + let length = array.get(js_string!("length"), context)?.to_u32(context)?; + (0..length) + .map(|index| array.get(index, context)) + .collect::>>() + .map(Some) } -/// Extract CSS variable assignments by matching contract vars with values -fn extract_theme_vars( - contract: &JsValue, - values: &JsValue, - ctx: &mut Context, - css_vars: &mut SmallVec<[(String, String); 8]>, - path: &mut Vec, -) { - if let (Some(contract_obj), Some(values_obj)) = (contract.as_object(), values.as_object()) { - // Both are objects - recurse into properties - if let Ok(keys) = contract_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(contract_prop), Ok(value_prop)) = ( - contract_obj.get(js_string!(key_string.as_str()), ctx), - values_obj.get(js_string!(key_string.as_str()), ctx), - ) { - path.push(key_string); - - extract_theme_vars(&contract_prop, &value_prop, ctx, css_vars, path); - - path.pop(); - } - } - } - } else if let Some(contract_str) = contract.as_string() { - // Contract leaf is a var(--name) string - let contract_str = contract_str.to_std_string_escaped(); - if contract_str.starts_with("var(") && contract_str.ends_with(')') { - // Extract var name from var(--name) - let var_name = &contract_str[4..contract_str.len() - 1]; - - // Get the value - let value_str = values - .as_string() - .map(|s| s.to_std_string_escaped()) - .or_else(|| { - values - .to_string(ctx) - .ok() - .map(|s| s.to_std_string_escaped()) - }) - .unwrap_or_default(); - - css_vars.push((var_name.to_string(), value_str)); - } - } +/// The custom property `var(--x)` reads +fn var_name(reference: &str) -> &str { + reference + .strip_prefix("var(") + .and_then(|name| name.strip_suffix(')')) + .unwrap_or(reference) } -/// Recursively transform theme object to CSS `var()` references -/// Returns a new JS object with the same structure but leaf values replaced with var(--path) -fn transform_theme_to_vars( - value: &JsValue, - ctx: &mut Context, - placeholder_id: &str, - css_vars: &mut SmallVec<[(String, String); 8]>, - var_counter: &mut usize, - path: &mut Vec, -) -> JsValue { - if let Some(obj) = value.as_object() { - // Check if it's an array (shouldn't happen in theme objects, but handle it) - 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(); - - // Get own property keys - if let Ok(keys) = obj.own_property_keys(ctx) { - for key in keys { - // Convert PropertyKey to string - 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_theme_to_vars( - &prop_value, - ctx, - placeholder_id, - css_vars, - var_counter, - path, - ); - - path.pop(); - - let _ = new_obj.set(js_string!(key_string.as_str()), transformed, false, ctx); - } - } - } - - JsValue::from(new_obj) - } else { - // Leaf value - create CSS variable - let var_name = format!( - "--{}-{}-{}", - path.join("-"), - placeholder_id.trim_matches('_').replace("__", "-"), - var_counter - ); - *var_counter += 1; - - // Get the actual value as string - let value_str = value - .as_string() - .map(|s| s.to_std_string_escaped()) - .or_else(|| value.to_string(ctx).ok().map(|s| s.to_std_string_escaped())) - .unwrap_or_default(); - - css_vars.push((var_name.clone(), value_str)); +fn json_string(text: &str) -> String { + serde_json::Value::String(text.to_string()).to_string() +} - // Return var(--name) - JsValue::from(js_string!(format!("var({})", var_name))) - } +/// `JSON.stringify(value, replacer)`, `None` when it produces no string +fn stringify(value: &JsValue, replacer: JsValue, context: &mut Context) -> Option { + let json = context.intrinsics().objects().json(); + let stringify = json.get(js_string!("stringify"), context).ok()?; + stringify + .as_callable()? + .call(&JsValue::undefined(), &[value.clone(), replacer], context) + .ok()? + .as_string() + .map(|s| s.to_std_string_escaped()) } -/// Convert `JsValue` to JSON string using JSON.stringify +/// Serialize `value` as it is fn js_value_to_json(value: &JsValue, context: &mut Context) -> String { - // Use JSON.stringify to convert the value - let json_obj = context.intrinsics().objects().json(); - let stringify_result = json_obj.get(js_string!("stringify"), context); - - if let Ok(stringify) = stringify_result - && let Some(callable) = stringify.as_callable() - && let Ok(result) = - callable.call(&JsValue::undefined(), std::slice::from_ref(value), context) - && let Some(s) = result.as_string() + stringify(value, JsValue::undefined(), context).unwrap_or_else(|| "{}".to_string()) +} + +/// `JSON.stringify` replacer adding `px` to numbers the way vanilla-extract does, +/// so they are not read as Devup UI's spacing scale, and keying `vars` by the +/// custom properties their `var()` references name. +fn pixelify(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let key = to_text(args.get_or_undefined(0), context)?; + let value = args.get_or_undefined(1); + if key == "vars" + && let Some(vars) = value.as_object() { - return s.to_std_string_escaped(); + let declared = ObjectInitializer::new(context).build(); + for (property, name) in own_keys(&vars, context)? { + let value = vars.get(property, context)?; + declared.set(js_string!(var_name(&name)), value, false, context)?; + } + return Ok(declared.into()); } + Ok(match value.as_number() { + Some(number) if number != 0.0 && !keeps_bare_number(&key) => { + js_string!(format!("{number}px")).into() + } + _ => value.clone(), + }) +} - // Fallback: simple conversion using boa_engine 0.21 API - match value.variant() { - boa_engine::value::JsVariant::String(str) => format!("\"{}\"", str.to_std_string_escaped()), - boa_engine::value::JsVariant::Null => "null".to_string(), - boa_engine::value::JsVariant::Undefined => "undefined".to_string(), - _ => "{}".to_string(), - } +/// Serialize a style rule object with vanilla-extract's number units. +fn style_to_json(value: &JsValue, context: &mut Context) -> String { + let realm = context.realm().clone(); + let replacer = + FunctionObjectBuilder::new(&realm, NativeFunction::from_fn_ptr(pixelify)).build(); + stringify(value, replacer.into(), context).unwrap_or_else(|| "{}".to_string()) } /// Execute vanilla-extract style file and collect styles @@ -357,324 +213,248 @@ pub fn execute_vanilla_extract( package: &str, filename: &str, ) -> Result { - let collector: StyleCollector = Rc::new(RefCell::new(StyleCollectorInner::default())); let file_num = get_file_num_by_filename(filename); - + let collector: StyleCollector = Rc::new(RefCell::new(Collector { + file_num, + ..Collector::default() + })); let mut context = Context::default(); + register_vanilla_extract_apis(&mut context, &collector)?; - // Create the mock module object - register_vanilla_extract_apis(&mut context, collector.clone(), package, file_num)?; - - // Preprocess code: convert TypeScript to JavaScript using Oxc Transformer - let js_code = preprocess_typescript(code, package); - - // Extract variable names from the original code before execution - let var_names = extract_var_names(code, package); - - // Execute the code context - .eval(Source::from_bytes(js_code.as_bytes())) + .eval(Source::from_bytes( + preprocess_typescript(code, package).as_bytes(), + )) .map_err(|e| format!("JS execution error: {e}"))?; - // Map placeholder IDs back to original variable names - let mut result = std::mem::take(&mut collector.borrow_mut().styles); - remap_style_names(&mut result, &var_names, &mut context, file_num); - - Ok(result) + let mut collected = std::mem::take(&mut collector.borrow_mut().styles); + name_entries( + &mut collected, + &top_level_bindings(code), + &mut context, + file_num, + ); + Ok(collected) } -/// Info about a variable declaration -#[derive(Debug, Clone)] -enum VarInfo { - /// A style API call (style, keyframes, createContainer, etc.) - StyleApi { exported: bool }, - /// A regular constant export with its original code - Constant(String), - /// The vars object from createTheme array destructuring [themeClass, vars] - ThemeVars, +/// A name a top-level variable declaration of the stylesheet binds +struct Binding { + name: String, + exported: bool, + /// Initializer source, when the declaration binds this name alone + init: Option, } -/// Extract all variable names and their info from the original code -/// Returns (`style_api_vars`, `exported_constants`) -fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> { +fn top_level_bindings(code: &str) -> Vec { let allocator = Allocator::default(); - let source_type = SourceType::ts(); - let ret = Parser::new(&allocator, code, source_type).parse(); - - let mut vars = Vec::new(); - let mut collect_decl = |decl: &oxc_ast::ast::VariableDeclarator<'_>, exported: bool| { - let Some(init) = &decl.init else { - return; + let program = Parser::new(&allocator, code, SourceType::ts()) + .parse() + .program; + let mut bindings = Vec::new(); + for statement in &program.body { + let (declaration, exported) = match statement { + oxc_ast::ast::Statement::ExportDeclaration(export) => match &export.declaration { + oxc_ast::ast::Declaration::VariableDeclaration(declaration) => (declaration, true), + _ => continue, + }, + oxc_ast::ast::Statement::VariableDeclaration(declaration) => (declaration, false), + _ => continue, }; - - // Check for array destructuring: const [themeClass, vars] = createTheme(...) - if let oxc_ast::ast::BindingPattern::ArrayPattern(array_pat) = &decl.id { - if is_style_api_call(init) { - // First element is the theme class - if let Some(Some(first)) = array_pat.elements.first() - && let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = first - { - vars.push((id.name.to_string(), VarInfo::StyleApi { exported })); - } - // Second element is the vars object - mark as ThemeVars - if let Some(Some(second)) = array_pat.elements.get(1) - && let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = second - { - vars.push((id.name.to_string(), VarInfo::ThemeVars)); - } - } - } else if let Some(name) = decl.id.get_identifier_name() { - if is_style_api_call(init) { - vars.push((name.to_string(), VarInfo::StyleApi { exported })); - } else if exported { - // Extract the original init expression using span - let span = init.span(); - let init_code = &code[span.start as usize..span.end as usize]; - vars.push((name.to_string(), VarInfo::Constant(init_code.to_string()))); - } - } - }; - - for stmt in &ret.program.body { - match stmt { - // Exported variable declarations - oxc_ast::ast::Statement::ExportDeclaration(export) => { - if let oxc_ast::ast::Declaration::VariableDeclaration(var_decl) = - &export.declaration - { - for decl in &var_decl.declarations { - collect_decl(decl, true); - } - } - } - // Non-exported variable declarations - oxc_ast::ast::Statement::VariableDeclaration(var_decl) => { - for decl in &var_decl.declarations { - collect_decl(decl, false); - } + for declarator in &declaration.declarations { + let init = declarator + .init + .as_ref() + .filter(|_| declarator.id.get_identifier_name().is_some()) + .map(|init| code[init.span().start as usize..init.span().end as usize].to_string()); + for identifier in declarator.id.get_binding_identifiers() { + bindings.push(Binding { + name: identifier.name.to_string(), + exported, + init: init.clone(), + }); } - _ => {} } } - - vars + bindings } -/// Check if an expression is a call to a style API (style, keyframes, styleVariants, etc.) -fn is_style_api_call(expr: &oxc_ast::ast::Expression) -> bool { - if let oxc_ast::ast::Expression::CallExpression(call) = expr - && let oxc_ast::ast::Expression::Identifier(id) = &call.callee - { - let name = id.name.as_str(); - return matches!( - name, - "style" - | "keyframes" - | "styleVariants" - | "fontFace" - | "createVar" - | "createContainer" - | "layer" - | "createGlobalTheme" - | "createTheme" - ); - } - false +fn placeholder_index(id: &str) -> usize { + id.trim_start_matches("__style_") + .trim_end_matches("__") + .parse() + .unwrap_or_default() } -/// Remap style placeholder IDs to original variable names -fn remap_style_names( +/// Name every style and keyframes after the variable holding it (`_veN` when +/// none does), and turn the other exported values into code. +/// +/// Names come from the values the variables hold after the stylesheet ran, so +/// calls nested in objects, arrays or helpers do not shift them. +fn name_entries( collected: &mut CollectedStyles, - vars: &[(String, VarInfo)], - _context: &mut Context, + bindings: &[Binding], + context: &mut Context, file_num: usize, ) { - // Generate a file-based prefix for unique class names - // e.g., file_num 0 -> "f0" - let file_prefix = format!("f{file_num}"); - // Build mapping from placeholder ID to original name - // The order of style() calls matches the order of variable declarations - let mut placeholder_to_name: FxHashMap = FxHashMap::default(); - let mut font_placeholder_to_name: FxHashMap = FxHashMap::default(); - let mut new_styles = FxHashMap::default(); - let mut new_keyframes = FxHashMap::default(); - let mut new_style_variants = FxHashMap::default(); - let mut new_vars = FxHashMap::default(); - let mut new_containers = FxHashMap::default(); - let mut new_layers = FxHashMap::default(); - let mut new_font_faces = FxHashMap::default(); - let mut new_global_themes = FxHashMap::default(); - let mut new_themes = FxHashMap::default(); - let mut style_idx = 0; - let mut font_idx = 0; - let mut global_theme_idx = 0; - // Track the last processed theme's vars_object_json for ThemeVars handling - let mut last_theme_vars_json: Option = None; - - // First pass: take ownership of old entries (avoids drain+collect overhead) - let mut old_styles = std::mem::take(&mut collected.styles); - let mut old_keyframes = std::mem::take(&mut collected.keyframes); - let mut old_style_variants = std::mem::take(&mut collected.style_variants); - let mut old_vars = std::mem::take(&mut collected.vars); - let mut old_containers = std::mem::take(&mut collected.containers); - let mut old_layers = std::mem::take(&mut collected.layers); - let mut old_font_faces = std::mem::take(&mut collected.font_faces); - let mut old_global_themes = std::mem::take(&mut collected.global_themes); - let mut old_themes = std::mem::take(&mut collected.themes); - - for (name, info) in vars { - match info { - VarInfo::StyleApi { exported } => { - // First check if this is a fontFace (uses __font_N__ placeholder) - let font_placeholder = format!("__font_{font_idx}__"); - if let Some((json, font_family, _)) = old_font_faces.remove(&font_placeholder) { - font_placeholder_to_name.insert(font_placeholder, name.clone()); - new_font_faces.insert(name.clone(), (json, font_family, *exported)); - font_idx += 1; - continue; - } - - // Check if this is a createGlobalTheme (uses __global_theme_N__ placeholder) - let global_theme_placeholder = format!("__global_theme_{global_theme_idx}__"); - if let Some(mut entry) = old_global_themes.remove(&global_theme_placeholder) { - entry.exported = *exported; - new_global_themes.insert(name.clone(), entry); - global_theme_idx += 1; - continue; - } - - let placeholder = format!("__style_{style_idx}__"); - placeholder_to_name.insert(placeholder.clone(), name.clone()); - - if let Some(mut entry) = old_styles.remove(&placeholder) { - entry.exported = *exported; - new_styles.insert(name.clone(), entry); - style_idx += 1; - } else if let Some(mut entry) = old_keyframes.remove(&placeholder) { - entry.exported = *exported; - new_keyframes.insert(name.clone(), entry); - style_idx += 1; - } else if let Some((variants, _)) = old_style_variants.remove(&placeholder) { - new_style_variants.insert(name.clone(), (variants, *exported)); - style_idx += 1; - } else if let Some((value, _)) = old_vars.remove(&placeholder) { - new_vars.insert(name.clone(), (value, *exported)); - style_idx += 1; - } else if let Some((value, _)) = old_containers.remove(&placeholder) { - new_containers.insert(name.clone(), (value, *exported)); - style_idx += 1; - } else if let Some((value, _)) = old_layers.remove(&placeholder) { - new_layers.insert(name.clone(), (value, *exported)); - style_idx += 1; - } else if let Some(mut entry) = old_themes.remove(&placeholder) { - // Track this theme name for the next ThemeVars entry - if entry.vars_object_json.is_some() { - last_theme_vars_json = Some(name.clone()); - } + let values: Vec<(&Binding, JsValue)> = bindings + .iter() + .filter_map(|binding| { + context + .eval(Source::from_bytes(binding.name.as_bytes())) + .ok() + .map(|value| (binding, value)) + }) + .collect(); - // Generate unique class name: file_prefix + variable_name - let class_name = format!("{file_prefix}_{name}"); - - // Add CSS variables to global_styles with class selector - if !entry.css_vars.is_empty() { - let vars_json = format!( - "{{ {} }}", - entry - .css_vars - .iter() - .map(|(var_name, value)| format!("\"{var_name}\": \"{value}\"")) - .collect::>() - .join(", ") - ); - collected - .global_styles - .push((format!(".{class_name}"), vars_json)); - } + let mut names: FxHashMap = FxHashMap::default(); + for (binding, value) in &values { + if let Some(id) = js_str(value) + && !names.contains_key(&id) + && let Some(entry) = collected + .styles + .get_mut(&id) + .or_else(|| collected.keyframes.get_mut(&id)) + { + entry.exported = binding.exported; + names.insert(id, binding.name.clone()); + } + } - entry.exported = *exported; - entry.vars_name = None; - entry.class_name = class_name; - new_themes.insert(name.clone(), entry); - style_idx += 1; - } - } - VarInfo::ThemeVars => { - // This is the vars object from [themeClass, vars] = createTheme(...) - // Set vars_name on the theme we just processed - if let Some(theme_name) = last_theme_vars_json.take() - && let Some(theme_entry) = new_themes.get_mut(&theme_name) - { - theme_entry.vars_name = Some(name.clone()); - } - } - VarInfo::Constant(code) => { - collected - .constant_exports - .insert(name.clone(), code.clone()); - } + let declared: FxHashSet<&str> = bindings + .iter() + .map(|binding| binding.name.as_str()) + .collect(); + let mut anonymous: Vec = collected + .styles + .keys() + .chain(collected.keyframes.keys()) + .filter(|id| !names.contains_key(*id)) + .cloned() + .collect(); + anonymous.sort_by_key(|id| placeholder_index(id)); + for id in anonymous { + let mut name = format!("_ve{}", placeholder_index(&id)); + while declared.contains(name.as_str()) { + name.push('_'); } + names.insert(id, name); } - // Remap base references in styleVariants - for (variants, _) in new_style_variants.values_mut() { - for variant in variants.values_mut() { - if let Some(base) = &variant.base - && let Some(name) = placeholder_to_name.get(base) - { - variant.base = Some(name.clone()); - } + for (binding, value) in &values { + let names_entry = js_str(value).is_some_and(|id| names.get(&id) == Some(&binding.name)); + if binding.exported + && !names_entry + && let Some(code) = value_to_code(value, context, &names, &mut Vec::new()) + .or_else(|| binding.init.clone()) + { + collected + .constant_exports + .push((binding.name.clone(), code)); } } - // Remap base references in styles (for composition) - for entry in new_styles.values_mut() { - let old_bases = std::mem::take(&mut entry.bases); - entry.bases = old_bases - .into_iter() - .map(|b| { - if let Some(name) = placeholder_to_name.get(&b) { - name.clone() - } else { - b + for (id, name) in &names { + let reference = if let Some(mut entry) = collected.styles.remove(id) { + for base in &mut entry.bases { + if let Some(base_name) = names.get(base.as_str()) { + base_name.clone_into(base); } - }) - .collect(); + } + collected.styles.insert(name.clone(), entry); + Reference::Style { + name: name.clone(), + class_name: format!("f{file_num}_{name}"), + } + } else { + if let Some(entry) = collected.keyframes.remove(id) { + collected.keyframes.insert(name.clone(), entry); + } + Reference::Keyframes(name.clone()) + }; + collected.references.insert(id.clone(), reference); } +} - // Replace font placeholders in style JSONs with actual font-family names - // Build a mapping from placeholder to font-family name - let font_family_map: FxHashMap<&str, &str> = font_placeholder_to_name - .iter() - .filter_map(|(placeholder, name)| { - new_font_faces - .get(name) - .map(|(_, font_family, _)| (placeholder.as_str(), font_family.as_str())) - }) - .collect(); +fn is_plain_object(object: &JsObject, context: &Context) -> bool { + object.prototype().is_none_or(|prototype| { + JsObject::equals( + &prototype, + &context.intrinsics().constructors().object().prototype(), + ) + }) +} - // Replace placeholders in a single pass per style JSON. - // This is needed for font-family references and selectors like `${parent}:hover &`. - for entry in new_styles.values_mut() { - replace_placeholders_in_json(&mut entry.json, &font_family_map, &placeholder_to_name); +/// JavaScript for a data `value`, placeholders turned into the names of their +/// styles; `None` for functions, class instances, symbols and cycles +fn value_to_code( + value: &JsValue, + context: &mut Context, + names: &FxHashMap, + seen: &mut Vec, +) -> Option { + if let Some(text) = js_str(value) { + return Some(string_code(&text, names)); + } + if value.is_null_or_undefined() || value.is_number() || value.as_boolean().is_some() { + return to_text(value, context).ok(); + } + let object = value.as_object()?; + if object.is_callable() + || seen + .iter() + .any(|visited| JsObject::equals(visited, &object)) + { + return None; } + seen.push(object.clone()); + let mut parts = Vec::new(); + let code = if let Some(items) = array_items(value, context).ok()? { + for item in &items { + parts.push(value_to_code(item, context, names, seen)?); + } + format!("[{}]", parts.join(", ")) + } else if is_plain_object(&object, context) { + for (key, name) in own_keys(&object, context).ok()? { + let item = object.get(key, context).ok()?; + parts.push(format!( + "{}: {}", + json_string(&name), + value_to_code(&item, context, names, seen)? + )); + } + if parts.is_empty() { + "{}".to_string() + } else { + format!("{{ {} }}", parts.join(", ")) + } + } else { + return None; + }; + seen.pop(); + Some(code) +} - collected.styles = new_styles; - collected.keyframes = new_keyframes; - collected.style_variants = new_style_variants; - collected.vars = new_vars; - collected.containers = new_containers; - collected.layers = new_layers; - collected.font_faces = new_font_faces; - collected.global_themes = new_global_themes; - collected.themes = new_themes; +/// A string literal, or a template literal when it interpolates placeholders +fn string_code(text: &str, names: &FxHashMap) -> String { + if let Some(name) = names.get(text) { + return name.clone(); + } + let escaped = text + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${"); + replace_placeholders(&escaped, |token, _| { + names.get(token).map(|name| format!("${{{name}}}")) + }) + .map_or_else(|| json_string(text), |template| format!("`{template}`")) } -fn replace_placeholders_in_json( - json: &mut String, - font_family_map: &FxHashMap<&str, &str>, - placeholder_to_name: &FxHashMap, -) { - let source = json.as_str(); +/// Replace every `__name__` token that `lookup` resolves; `None` when nothing +/// was replaced. +fn replace_placeholders( + source: &str, + mut lookup: impl FnMut(&str, &str) -> Option, +) -> Option { let mut search_start = 0; let mut last_copied = 0; let mut output = None::; @@ -685,16 +465,10 @@ fn replace_placeholders_in_json( break; }; let end = start + 2 + relative_end + 2; - let placeholder = &source[start..end]; - let replacement = font_family_map - .get(placeholder) - .copied() - .or_else(|| placeholder_to_name.get(placeholder).map(String::as_str)); - - if let Some(replacement) = replacement { + if let Some(replacement) = lookup(&source[start..end], &source[end..]) { let output = output.get_or_insert_with(|| String::with_capacity(source.len())); output.push_str(&source[last_copied..start]); - output.push_str(replacement); + output.push_str(&replacement); last_copied = end; search_start = end; } else { @@ -702,10 +476,10 @@ fn replace_placeholders_in_json( } } - if let Some(mut output) = output { + output.map(|mut output| { output.push_str(&source[last_copied..]); - *json = output; - } + output + }) } /// Convert TypeScript to JavaScript using Oxc Transformer and replace imports @@ -729,41 +503,52 @@ fn preprocess_typescript(code: &str, package: &str) -> String { // Generate JavaScript let js_code = Codegen::new().build(&program).code; - // Replace import from package with our mock object destructuring - // e.g., import { style } from '@devup-ui/react' -> const { style } = __vanilla_extract__; - // Note: Import aliases (like @vanilla-extract/css) are already transformed by import_alias_visit + // Bind imports of the package to the mock object instead, e.g. + // `import { style } from '@devup-ui/react'` -> `const { style } = __vanilla_extract__;` + // Import aliases (like @vanilla-extract/css) are already transformed by import_alias_visit let import_patterns = [format!("from \"{package}\""), format!("from '{package}'")]; - - // Process all import patterns (multiple imports may exist) let mut transformed = String::with_capacity(js_code.len()); for (idx, line) in js_code.lines().enumerate() { if idx > 0 { transformed.push('\n'); } - let mut matched = false; - for pattern in &import_patterns { - if line.contains(pattern) { - // Extract imported names from: import { a, b, c } from 'package' - if let Some(start) = line.find('{') - && let Some(end) = line.find('}') - { - let imports = &line[start + 1..end]; - transformed.push_str("const {"); - transformed.push_str(imports); - transformed.push_str("} = __vanilla_extract__;"); - matched = true; - break; - } - } - } - if !matched { + if import_patterns + .iter() + .any(|pattern| line.contains(pattern.as_str())) + && let Some(binding) = mock_binding(line) + { + transformed.push_str("const "); + transformed.push_str(&binding); + transformed.push_str(" = __vanilla_extract__;"); + } else { transformed.push_str(strip_export_keyword(line)); } } transformed } +/// What an import of the package binds: `{ a, b: c }` for named imports, the +/// namespace for `import * as ns` +fn mock_binding(line: &str) -> Option { + if let (Some(start), Some(end)) = (line.find('{'), line.find('}')) { + let names: Vec = line[start + 1..end] + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| name.replacen(" as ", ": ", 1)) + .collect(); + return Some(format!("{{ {} }}", names.join(", "))); + } + line.strip_prefix("import * as ") + .and_then(|rest| rest.split_whitespace().next()) + .map(ToString::to_string) +} + fn strip_export_keyword(line: &str) -> &str { + // The transformer marks a module whose imports it all removed with `export {};` + if line == "export {};" { + return ""; + } line.strip_prefix("export ").map_or(line, |rest| { if rest.starts_with("const ") || rest.starts_with("let ") @@ -777,2624 +562,1231 @@ fn strip_export_keyword(line: &str) -> &str { }) } +type Api = fn(&StyleCollector, &[JsValue], &mut Context) -> JsResult; + +fn api(collector: &StyleCollector, function: Api) -> NativeFunction { + let collector = collector.clone(); + // SAFETY: the closure captures only an `Rc>`, which holds no + // garbage-collected value, and a stylesheet runs on a single thread + unsafe { + NativeFunction::from_closure(move |_this, args, context| { + function(&collector, args, context) + }) + } +} + /// Register vanilla-extract mock APIs in the JS context fn register_vanilla_extract_apis( context: &mut Context, - collector: StyleCollector, - _package: &str, - file_num: usize, + collector: &StyleCollector, ) -> Result<(), String> { - // style() function - let collector_style = collector.clone(); - // SAFETY: The closure only captures Rc> which is safe to use in single-threaded JS context - let style_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let style_obj = args.get_or_undefined(0); - let id = next_style_id(&collector_style); - - // Check if argument is an array (composition syntax) - let (json, bases) = if let Some(obj) = style_obj.as_object() - && let Ok(length_val) = obj.get(js_string!("length"), ctx) - && let Some(len) = length_val.as_number() - { - // It's an array - handle composition - let len = len as u32; - let mut base_classes = SmallVec::new(); - let mut merged_styles = String::from("{"); - let mut first_style = true; - - for i in 0..len { - if let Ok(elem) = obj.get(i, ctx) { - if let Some(base_str) = elem.as_string() { - // It's a base class reference (string) - base_classes.push(base_str.to_std_string_escaped()); - } else if elem.is_object() { - // It's a style object - merge it - let elem_json = js_value_to_json(&elem, ctx); - // Strip outer braces and merge - let inner = elem_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !inner.is_empty() { - if !first_style { - merged_styles.push(','); - } - merged_styles.push_str(inner); - first_style = false; - } - } - } - } - merged_styles.push('}'); - (merged_styles, base_classes) - } else { - // No length property, just a style object - (js_value_to_json(style_obj, ctx), SmallVec::new()) - }; - collector_style.borrow_mut().styles.styles.insert( - id.clone(), - StyleEntry { - json, - exported: false, // Will be updated in remap_style_names - bases, - }, - ); - - Ok(JsValue::from(js_string!(id))) - }) - }; - - // globalStyle() function - let collector_global = collector.clone(); - let global_style_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let selector = args - .get_or_undefined(0) - .to_string(ctx)? - .to_std_string_escaped(); - let style_obj = args.get_or_undefined(1); - let json = js_value_to_json(style_obj, ctx); - - collector_global - .borrow_mut() - .styles - .global_styles - .push((selector, json)); + let apis = [ + ("style", api(collector, style), 1), + ("globalStyle", api(collector, global_style), 2), + ("styleVariants", api(collector, style_variants), 1), + ( + "keyframes", + api(collector, |collector, args, context| { + Ok(keyframes(collector, args, context)) + }), + 1, + ), + ( + "fontFace", + api(collector, |collector, args, context| { + Ok(font_face(collector, args, context)) + }), + 1, + ), + ("globalFontFace", api(collector, global_font_face), 2), + ("createVar", api(collector, create_var), 0), + ("fallbackVar", NativeFunction::from_fn_ptr(fallback_var), 2), + ( + "createContainer", + api(collector, |collector, args, _context| { + Ok(create_container(collector, args)) + }), + 0, + ), + ("layer", api(collector, layer), 0), + ("globalLayer", api(collector, global_layer), 1), + ( + "createThemeContract", + api(collector, create_theme_contract), + 1, + ), + ( + "createGlobalThemeContract", + NativeFunction::from_fn_ptr(create_global_theme_contract), + 1, + ), + ( + "assignVars", + NativeFunction::from_fn_ptr(assign_vars_api), + 2, + ), + ("createTheme", api(collector, create_theme), 1), + ("createGlobalTheme", api(collector, create_global_theme), 2), + ]; + let mut builder = ObjectInitializer::new(context); + for (name, function, length) in apis { + builder.function(function, JsString::from(name), length); + } + let mock = builder.build(); + context + .register_global_property(js_string!("__vanilla_extract__"), mock, Attribute::all()) + .map_err(|e| format!("Failed to register __vanilla_extract__: {e}")) +} - Ok(JsValue::undefined()) - }) - }; +fn style(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsResult { + let id = register_style(collector, args.get_or_undefined(0), context)?; + Ok(js_string!(id).into()) +} - // keyframes() function - let collector_keyframes = collector.clone(); - let keyframes_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let keyframes_obj = args.get_or_undefined(0); - let json = js_value_to_json(keyframes_obj, ctx); - let id = next_style_id(&collector_keyframes); - - collector_keyframes.borrow_mut().styles.keyframes.insert( - id.clone(), - StyleEntry { - json, - exported: false, - bases: SmallVec::new(), - }, - ); - - Ok(JsValue::from(js_string!(id))) - }) - }; +fn register_style( + collector: &StyleCollector, + rule: &JsValue, + context: &mut Context, +) -> JsResult { + let mut entry = StyleEntry::default(); + let mut rules = Vec::new(); + compose(rule, &mut entry, &mut rules, context)?; + entry.json = format!("{{{}}}", rules.join(",")); + let mut collector = collector.borrow_mut(); + let id = collector.placeholder(); + collector.styles.styles.insert(id.clone(), entry); + Ok(id) +} - // createVar() function - // Returns CSS custom property name like "--var-0", not wrapped in var() - // When used as a key in vars: {[colorVar]: 'blue'}, it becomes the property name - // When used as a value, the extraction logic will wrap it in var() - let collector_var = collector.clone(); - let create_var_fn = unsafe { - NativeFunction::from_closure(move |_this, _args, _ctx| { - let id = next_style_id(&collector_var); - let var_name = format!("--var-{}", collector_var.borrow().styles.vars.len()); - collector_var - .borrow_mut() - .styles - .vars - .insert(id, (var_name.clone(), false)); - // Return just the CSS custom property name, without var() wrapper - Ok(JsValue::from(js_string!(var_name))) - }) - }; +fn is_placeholder(token: &str) -> bool { + token.starts_with("__style_") && token.ends_with("__") +} - // fontFace() function - // Returns a placeholder ID that will be replaced with generated font-family name - let collector_font = collector.clone(); - let font_face_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let font_obj = args.get_or_undefined(0); - let json = js_value_to_json(font_obj, ctx); - let id = next_font_id(&collector_font); - - // Generate a unique font-family name for this font - // Include file_num to ensure uniqueness across different files - let font_family = format!( - "__devup_font_{}_{}", - file_num, - collector_font.borrow().font_counter - 1 - ); - - collector_font - .borrow_mut() - .styles - .font_faces - .insert(id.clone(), (json, font_family, false)); +/// Sort a composition list into the styles and classes it composes and the +/// inner JSON of its rules +fn compose( + value: &JsValue, + entry: &mut StyleEntry, + rules: &mut Vec, + context: &mut Context, +) -> JsResult<()> { + if let Some(items) = array_items(value, context)? { + for item in &items { + compose(item, entry, rules, context)?; + } + } else if let Some(classes) = js_str(value) { + for class in classes.split_whitespace() { + if is_placeholder(class) { + entry.bases.push(class.to_string()); + } else { + entry.classes.push(class.to_string()); + } + } + } else if value.is_object() { + let json = style_to_json(value, context); + let inner = inner_json(&json); + if !inner.is_empty() { + rules.push(inner.to_string()); + } + } + Ok(()) +} - // Return the placeholder ID - will be replaced in code generation - Ok(JsValue::from(js_string!(id))) - }) - }; +fn global_style( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let selector = to_text(args.get_or_undefined(0), context)?; + let json = style_to_json(args.get_or_undefined(1), context); + collector + .borrow_mut() + .styles + .global_styles + .push((selector, json)); + Ok(JsValue::undefined()) +} - // styleVariants() function - let collector_variants = collector.clone(); - let style_variants_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let variants_obj = args.get_or_undefined(0); - let variants = parse_style_variants(variants_obj, ctx); - let id = next_style_id(&collector_variants); +/// `styleVariants(variants, map?)`: a style per key, of the value or of what +/// `map(value, key)` returns +fn style_variants( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let variants = ObjectInitializer::new(context).build(); + let map = args.get_or_undefined(1).as_callable(); + if let Some(data) = args.get_or_undefined(0).as_object() { + for (key, name) in own_keys(&data, context)? { + let value = data.get(key, context)?; + let rule = match &map { + Some(map) => map.call( + &JsValue::undefined(), + &[value, js_string!(name.as_str()).into()], + context, + )?, + None => value, + }; + let id = register_style(collector, &rule, context)?; + variants.set(js_string!(name), js_string!(id), false, context)?; + } + } + Ok(variants.into()) +} - collector_variants - .borrow_mut() - .styles - .style_variants - .insert(id.clone(), (variants, false)); +fn keyframes(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { + let json = style_to_json(args.get_or_undefined(0), context); + let mut collector = collector.borrow_mut(); + let id = collector.placeholder(); + collector.styles.keyframes.insert( + id.clone(), + StyleEntry { + json, + ..StyleEntry::default() + }, + ); + js_string!(id).into() +} - // Return an object placeholder - the actual object will be built in code generation - Ok(JsValue::from(js_string!(id))) - }) - }; +/// `fontFace(rule, debugId?)`: a generated family for one rule or a list of them +fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { + let family = collector + .borrow_mut() + .identifier(js_str(args.get_or_undefined(1)), "font"); + let faces = font_face_rules(&family, args.get_or_undefined(0), context); + collector.borrow_mut().styles.font_faces.extend(faces); + js_string!(family).into() +} - // fallbackVar() function - returns var(--x, fallback) format - let fallback_var_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let var_value = args - .get_or_undefined(0) - .to_string(ctx)? - .to_std_string_escaped(); - let fallback = args - .get_or_undefined(1) - .to_string(ctx)? - .to_std_string_escaped(); - - // var_value is now just "--var-0" (CSS custom property name) - // Return var(--var-0, fallback) - let result = format!("var({var_value}, {fallback})"); - Ok(JsValue::from(js_string!(result))) - }) - }; +fn global_font_face( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let family = to_text(args.get_or_undefined(0), context)?; + let faces = font_face_rules(&family, args.get_or_undefined(1), context); + collector.borrow_mut().styles.font_faces.extend(faces); + Ok(JsValue::undefined()) +} - // createThemeContract() function - transforms null leaves to var(--path) references - let create_theme_contract_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let contract_obj = args.get_or_undefined(0); - let transformed = transform_contract_to_vars(contract_obj, ctx, &mut Vec::new()); - Ok(transformed) - }) - }; +fn font_face_rule(family_json: &str, rule: &JsValue, context: &mut Context) -> String { + let json = js_value_to_json(rule, context); + match inner_json(&json) { + "" => format!("{{\"fontFamily\":{family_json}}}"), + inner => format!("{{{inner},\"fontFamily\":{family_json}}}"), + } +} - // createTheme() function - creates a class with CSS variable assignments - // Single arg: createTheme({ color: { brand: 'blue' } }) -> returns [themeClass, vars] - // Two args: createTheme(contract, values) -> returns themeClass - let collector_theme = collector.clone(); - let create_theme_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let first_arg = args.get_or_undefined(0); - let second_arg = args.get_or_undefined(1); - - // Check if it's single-arg (values only) or two-arg (contract, values) - let is_single_arg = second_arg.is_undefined(); - - let id = next_style_id(&collector_theme); - - if is_single_arg { - // Single arg: createTheme({ color: { brand: 'blue' } }) - // Returns [themeClass, vars] where vars has var() references - let mut css_vars = SmallVec::new(); - let mut var_counter = 0usize; - - // Transform values to var() references and collect CSS variables - let vars_obj = transform_theme_to_vars( - first_arg, - ctx, - &id, - &mut css_vars, - &mut var_counter, - &mut Vec::new(), - ); - - let vars_object_json = js_value_to_json(&vars_obj, ctx); - - // Store the theme entry with vars object - collector_theme.borrow_mut().styles.themes.insert( - id.clone(), - ThemeEntry { - css_vars, - exported: false, - vars_object_json: Some(vars_object_json), - vars_name: None, // Will be set during remapping - class_name: String::new(), // Will be set during remapping - }, - ); - - // Return [themeId, varsObject] as an array - let result_array = boa_engine::object::builtins::JsArray::new(ctx)?; - result_array.push(JsValue::from(js_string!(id.clone())), ctx)?; - result_array.push(vars_obj, ctx)?; - - Ok(JsValue::from(result_array)) - } else { - // Two args: createTheme(contract, values) - // Returns just the themeClass - let mut css_vars = SmallVec::new(); - extract_theme_vars(first_arg, second_arg, ctx, &mut css_vars, &mut Vec::new()); - - // Store the theme entry - collector_theme.borrow_mut().styles.themes.insert( - id.clone(), - ThemeEntry { - css_vars, - exported: false, - vars_object_json: None, - vars_name: None, - class_name: String::new(), // Will be set during remapping - }, - ); - - Ok(JsValue::from(js_string!(id))) +/// `@font-face` rule objects for `rule`, or each rule of a list, naming `family` +fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec { + let family_json = json_string(family); + match array_items(rule, context).ok().flatten() { + Some(rules) => { + let mut faces = Vec::with_capacity(rules.len()); + for rule in &rules { + faces.push(font_face_rule(&family_json, rule, context)); } - }) - }; + faces + } + None => vec![font_face_rule(&family_json, rule, context)], + } +} - // layer() function - let collector_layer = collector.clone(); - let layer_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let id = next_style_id(&collector_layer); - let name = args - .get_or_undefined(0) - .to_string(ctx)? - .to_std_string_escaped(); - collector_layer - .borrow_mut() - .styles - .layers - .insert(id, (name.clone(), false)); - Ok(JsValue::from(js_string!(name))) - }) +/// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a +/// generated custom property, registered with `@property` when declared +fn create_var( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let first = args.get_or_undefined(0); + let (declaration, debug_id) = match first.as_object() { + Some(declaration) => (Some(declaration), args.get_or_undefined(1)), + None => (None, first), }; + let name = format!( + "--{}", + collector.borrow_mut().identifier(js_str(debug_id), "var") + ); + if let Some(declaration) = declaration { + let rule = property_rule(&name, &declaration, context)?; + collector.borrow_mut().styles.property_rules.push(rule); + } + Ok(js_string!(format!("var({name})")).into()) +} - // createContainer() function - let collector_container = collector.clone(); - let create_container_fn = unsafe { - NativeFunction::from_closure(move |_this, _args, _ctx| { - let id = next_style_id(&collector_container); - let container_name = format!( - "__container_{}__", - collector_container.borrow().styles.containers.len() - ); - collector_container - .borrow_mut() - .styles - .containers - .insert(id, (container_name.clone(), false)); - Ok(JsValue::from(js_string!(container_name))) - }) +fn property_rule(name: &str, declaration: &JsObject, context: &mut Context) -> JsResult { + let syntax = declaration.get(js_string!("syntax"), context)?; + let syntax = match array_items(&syntax, context)? { + Some(items) => items + .iter() + .map(|item| to_text(item, context)) + .collect::>>()? + .join(" | "), + None => to_text(&syntax, context)?, }; + let inherits = declaration + .get(js_string!("inherits"), context)? + .to_boolean(); + let mut rule = format!("@property {name}{{syntax:\"{syntax}\";inherits:{inherits}"); + let initial_value = declaration.get(js_string!("initialValue"), context)?; + if !initial_value.is_null_or_undefined() { + rule.push_str(";initial-value:"); + rule.push_str(&to_text(&initial_value, context)?); + } + rule.push('}'); + Ok(rule) +} - // createGlobalTheme() function - let collector_global_theme = collector; - let create_global_theme_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let placeholder_id = next_global_theme_id(&collector_global_theme); - let selector = args - .get_or_undefined(0) - .to_string(ctx) - .map_or_else(|_| ":root".to_string(), |s| s.to_std_string_escaped()); - let theme_obj = args.get_or_undefined(1); - - // Collect CSS variables and build new object with var() references - let mut css_vars = SmallVec::new(); - let mut var_counter = 0usize; - let result_obj = transform_theme_to_vars( - theme_obj, - ctx, - &placeholder_id, - &mut css_vars, - &mut var_counter, - &mut Vec::new(), - ); - - // Serialize the result object to JSON for code generation - let vars_object_json = js_value_to_json(&result_obj, ctx); - - // Store the collected CSS variables and vars object - collector_global_theme - .borrow_mut() - .styles - .global_themes - .insert( - placeholder_id, - GlobalThemeEntry { - selector, - css_vars, - vars_object_json, - exported: false, - }, - ); - - Ok(result_obj) - }) - }; +/// `fallbackVar(...values)`: each `var()` falls back to the values after it +fn fallback_var(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let mut result = String::new(); + for (index, value) in args.iter().rev().enumerate() { + let value = to_text(value, context)?; + result = match value.strip_suffix(')') { + Some(head) if index > 0 => format!("{head}, {result})"), + _ => value, + }; + } + Ok(js_string!(result).into()) +} - // Build the mock object - let mut ve_builder = ObjectInitializer::new(context); - ve_builder.function(style_fn, js_string!("style"), 1); - ve_builder.function(global_style_fn, js_string!("globalStyle"), 2); - ve_builder.function(keyframes_fn, js_string!("keyframes"), 1); - ve_builder.function(create_var_fn, js_string!("createVar"), 0); - ve_builder.function(font_face_fn, js_string!("fontFace"), 1); - ve_builder.function(style_variants_fn, js_string!("styleVariants"), 1); - ve_builder.function(fallback_var_fn, js_string!("fallbackVar"), 2); - ve_builder.function(create_theme_fn, js_string!("createTheme"), 1); - ve_builder.function( - create_theme_contract_fn, - js_string!("createThemeContract"), - 1, - ); - ve_builder.function(layer_fn, js_string!("layer"), 1); - ve_builder.function(create_container_fn, js_string!("createContainer"), 0); - ve_builder.function(create_global_theme_fn, js_string!("createGlobalTheme"), 2); +fn create_container(collector: &StyleCollector, args: &[JsValue]) -> JsValue { + let name = collector + .borrow_mut() + .identifier(js_str(args.get_or_undefined(0)), "container"); + js_string!(name).into() +} - let ve_obj = ve_builder.build(); +/// `(parent, name argument)` of `layer(options?, debugId?)` and +/// `globalLayer(options?, name)` +fn layer_args(args: &[JsValue], context: &mut Context) -> JsResult<(Option, JsValue)> { + let first = args.get_or_undefined(0); + Ok(match first.as_object() { + Some(options) => ( + js_str(&options.get(js_string!("parent"), context)?), + args.get_or_undefined(1).clone(), + ), + None => (None, first.clone()), + }) +} - // Register as global __vanilla_extract__ - context - .register_global_property(js_string!("__vanilla_extract__"), ve_obj, Attribute::all()) - .map_err(|e| format!("Failed to register __vanilla_extract__: {e}"))?; +fn declare_layer(collector: &StyleCollector, parent: Option, name: String) -> JsValue { + let name = match parent { + Some(parent) => format!("{parent}.{name}"), + None => name, + }; + collector.borrow_mut().styles.layers.push(name.clone()); + js_string!(name).into() +} - Ok(()) +fn layer(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsResult { + let (parent, debug_id) = layer_args(args, context)?; + let name = collector + .borrow_mut() + .identifier(js_str(&debug_id), "layer"); + Ok(declare_layer(collector, parent, name)) } -/// Find all style names that are referenced in selectors of other styles -/// Returns a set of style names that need to be extracted first -pub fn find_selector_references(collected: &CollectedStyles) -> FxHashSet { - let mut referenced = rustc_hash::FxHashSet::default(); - // Build the probe strings once per style name instead of per (entry, name) pair - let probes: Vec<(String, String, &str)> = collected - .styles - .keys() - .map(|name| (format!("\"{name}:"), format!("\"{name} "), name.as_str())) - .collect(); +fn global_layer( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let (parent, name) = layer_args(args, context)?; + let name = to_text(&name, context)?; + Ok(declare_layer(collector, parent, name)) +} - for entry in collected.styles.values() { - // Cheap necessary-condition gate: a `":"` / `" "` selector reference can - // only appear inside a `"selectors":{...}` block, so a JSON with no `selectors` object - // cannot match ANY probe. Skip the whole K-probe inner loop for those entries (the - // common case — most style JSONs carry no cross-references). Byte-identical to probing - // every entry: the skipped entries provably contribute nothing to `referenced`. - if !entry.json.contains("selectors") { - continue; - } - // Check if this style's JSON contains references to other style names - for (probe_colon, probe_space, style_name) in &probes { - // Look for patterns like "stylename:" or "stylename " in selectors - // The JSON has selectors like {"selectors":{"parent:hover &":{...}}} - if entry.json.contains(probe_colon.as_str()) - || entry.json.contains(probe_space.as_str()) - { - referenced.insert((*style_name).to_string()); +type Leaf<'a> = dyn FnMut(&JsValue, &[String], &mut Context) -> JsResult + 'a; + +/// vanilla-extract's `walkObject`: `tokens` with every string, number or empty +/// leaf replaced by `leaf(value, path)`; other values are skipped +fn walk_object( + tokens: &JsValue, + context: &mut Context, + path: &mut Vec, + leaf: &mut Leaf<'_>, +) -> JsResult { + let walked = ObjectInitializer::new(context).build(); + if let Some(object) = tokens.as_object() { + for (key, name) in own_keys(&object, context)? { + let value = object.get(key, context)?; + path.push(name.clone()); + let mapped = if value.is_string() || value.is_number() || value.is_null_or_undefined() { + Some(leaf(&value, path, context)?) + } else if value.as_object().is_some_and(|object| !object.is_array()) { + Some(walk_object(&value, context, path, leaf)?) + } else { + None + }; + path.pop(); + if let Some(mapped) = mapped { + walked.set(js_string!(name), mapped, false, context)?; } } } + Ok(walked.into()) +} - referenced +/// A contract of `tokens`' shape whose leaves read generated custom properties +fn contract_of( + collector: &StyleCollector, + tokens: &JsValue, + context: &mut Context, +) -> JsResult { + walk_object( + tokens, + context, + &mut Vec::new(), + &mut |_value, path, _context| { + let name = collector + .borrow_mut() + .identifier(Some(path.join("-")), "var"); + Ok(js_string!(format!("var(--{name})")).into()) + }, + ) } -/// Generate code only for specific styles (used for first-pass extraction) -pub fn collected_styles_to_code_partial( - collected: &CollectedStyles, - package: &str, - style_names: &FxHashSet, -) -> String { - let mut output = String::new(); +fn create_theme_contract( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + contract_of(collector, args.get_or_undefined(0), context) +} + +/// `createGlobalThemeContract(tokens, map?)`: leaves name their custom property, +/// or `map(value, path)` does +fn create_global_theme_contract( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let map = args.get_or_undefined(1).as_callable(); + walk_object( + args.get_or_undefined(0), + context, + &mut Vec::new(), + &mut |value, path, context| { + let name = match &map { + Some(map) => { + let path = JsArray::from_iter( + path.iter().map(|key| js_string!(key.as_str()).into()), + context, + ); + map.call( + &JsValue::undefined(), + &[value.clone(), path.into()], + context, + )? + } + None => value.clone(), + }; + let name = to_text(&name, context)?; + Ok(js_string!(format!( + "var(--{})", + name.strip_prefix("--").unwrap_or(&name) + )) + .into()) + }, + ) +} + +/// `(custom property, value)` for every leaf of `tokens`, read from the same +/// path of `contract` +fn assign_vars( + contract: &JsValue, + tokens: &JsValue, + context: &mut Context, +) -> JsResult> { + let mut vars = Vec::new(); + walk_object( + tokens, + context, + &mut Vec::new(), + &mut |value, path, context| { + let mut target = contract.clone(); + for key in path { + target = match target.as_object() { + Some(object) => object.get(js_string!(key.as_str()), context)?, + None => JsValue::undefined(), + }; + } + if let Some(reference) = js_str(&target) { + vars.push((var_name(&reference).to_string(), to_text(value, context)?)); + } + Ok(JsValue::undefined()) + }, + )?; + Ok(vars) +} - if !style_names.is_empty() { - output.push_str("import { css } from '"); - output.push_str(package); - output.push('\''); +fn assign_vars_api(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let assigned = ObjectInitializer::new(context).build(); + for (name, value) in assign_vars(args.get_or_undefined(0), args.get_or_undefined(1), context)? { + assigned.set( + js_string!(format!("var({name})")), + js_string!(value), + false, + context, + )?; } + Ok(assigned.into()) +} - // Generate only the specified styles - let mut styles: Vec<_> = collected - .styles +/// `tokens` without its `@layer`, and the layer it names +fn split_layer(tokens: &JsValue, context: &mut Context) -> JsResult<(JsValue, Option)> { + let Some(object) = tokens.as_object() else { + return Ok((tokens.clone(), None)); + }; + let mut layer = None; + let rest = ObjectInitializer::new(context).build(); + for (key, name) in own_keys(&object, context)? { + let value = object.get(key, context)?; + if name == "@layer" { + layer = Some(to_text(&value, context)?); + } else { + rest.set(js_string!(name), value, false, context)?; + } + } + Ok((rest.into(), layer)) +} + +/// Declare on `selector` the values `tokens` gives the variables of `contract` +fn declare_theme( + collector: &StyleCollector, + selector: String, + contract: &JsValue, + tokens: &JsValue, + layer: Option, + context: &mut Context, +) -> JsResult<()> { + let vars = assign_vars(contract, tokens, context)?; + if vars.is_empty() { + return Ok(()); + } + let mut declarations: Vec = layer .iter() - .filter(|(name, _)| style_names.contains(*name)) + .map(|layer| format!("\"@layer\":{}", json_string(layer))) .collect(); - styles.sort_by_key(|(name, _)| *name); - - for (name, entry) in styles { - // Generate as non-exported for first pass - if !output.is_empty() { - output.push('\n'); - } - output.push_str("const "); - output.push_str(name); - output.push_str(" = css("); - output.push_str(&entry.json); - output.push(')'); + for (name, value) in &vars { + declarations.push(format!("{}:{}", json_string(name), json_string(value))); } + collector + .borrow_mut() + .styles + .global_styles + .push((selector, format!("{{{}}}", declarations.join(",")))); + Ok(()) +} - output +/// `createTheme(tokens, debugId?)` -> `[class, vars]`, or +/// `createTheme(contract, tokens, debugId?)` -> class +fn create_theme( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let contract_given = args.get_or_undefined(1).is_object(); + let (tokens, debug_id) = if contract_given { + (args.get_or_undefined(1), args.get_or_undefined(2)) + } else { + (args.get_or_undefined(0), args.get_or_undefined(1)) + }; + let class_name = collector.borrow_mut().identifier(js_str(debug_id), "theme"); + let (tokens, layer) = split_layer(tokens, context)?; + let contract = if contract_given { + args.get_or_undefined(0).clone() + } else { + contract_of(collector, &tokens, context)? + }; + declare_theme( + collector, + format!(".{class_name}"), + &contract, + &tokens, + layer, + context, + )?; + let class_name = JsValue::from(js_string!(class_name)); + Ok(if contract_given { + class_name + } else { + JsArray::from_iter([class_name, contract], context).into() + }) } -/// Convert collected styles to code with selector references replaced by class names -pub fn collected_styles_to_code_with_classes( - collected: &CollectedStyles, - package: &str, - class_map: &FxHashMap, -) -> String { - let mut code_parts = Vec::with_capacity(collected.styles.len() + 4); +/// `createGlobalTheme(selector, tokens)` -> vars, or +/// `createGlobalTheme(selector, contract, tokens)` +fn create_global_theme( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let selector = to_text(args.get_or_undefined(0), context)?; + let contract_given = args.get_or_undefined(2).to_boolean(); + let (tokens, layer) = split_layer( + args.get_or_undefined(if contract_given { 2 } else { 1 }), + context, + )?; + let contract = if contract_given { + args.get_or_undefined(1).clone() + } else { + contract_of(collector, &tokens, context)? + }; + declare_theme(collector, selector, &contract, &tokens, layer, context)?; + Ok(if contract_given { + JsValue::undefined() + } else { + contract + }) +} - // Generate import statement - let mut imports = Vec::new(); - if !collected.styles.is_empty() - || !collected.style_variants.is_empty() - || !collected.themes.is_empty() - { - imports.push("css"); +/// Placeholders interpolated into a selector end inside a JSON key. +fn in_json_key(rest: &str) -> bool { + rest.find('"') + .is_some_and(|i| rest[i + 1..].starts_with(':')) +} + +impl CollectedStyles { + fn resolve( + &self, + source: &str, + keyframes_names: &FxHashMap, + selector: fn(&str) -> bool, + ) -> String { + replace_placeholders(source, |token, rest| { + Some(match self.references.get(token)? { + Reference::Style { class_name, .. } => { + if selector(rest) { + format!(".{class_name}") + } else { + class_name.clone() + } + } + Reference::Keyframes(name) => keyframes_names.get(name)?.clone(), + }) + }) + .unwrap_or_else(|| source.to_string()) } - if !collected.global_styles.is_empty() - || !collected.font_faces.is_empty() - || !collected.global_themes.is_empty() - { - imports.push("globalCss"); + + fn resolve_json(&self, json: &str, keyframes_names: &FxHashMap) -> String { + self.resolve(json, keyframes_names, in_json_key) } - if !collected.keyframes.is_empty() { - imports.push("keyframes"); + + /// Every text a placeholder can be interpolated into. + fn texts(&self) -> impl Iterator { + self.styles + .values() + .chain(self.keyframes.values()) + .map(|entry| entry.json.as_str()) + .chain( + self.global_styles + .iter() + .flat_map(|(selector, json)| [selector.as_str(), json.as_str()]), + ) } - if !imports.is_empty() { - code_parts.push(format!( - "import {{ {} }} from '{}'", - imports.join(", "), - package - )); + fn referenced(&self) -> FxHashSet<&Reference> { + let mut referenced = FxHashSet::default(); + for text in self.texts() { + replace_placeholders(text, |token, _| { + if let Some(reference) = self.references.get(token) { + referenced.insert(reference); + } + None + }); + } + referenced } - // Generate style declarations with selector references replaced - let style_json_map: FxHashMap<&str, &str> = collected - .styles - .iter() - .map(|(name, entry)| (name.as_str(), entry.json.as_str())) - .collect(); + /// `variable_name` -> unique class of every style some selector or value refers to + fn referenced_classes(&self) -> FxHashMap<&str, &str> { + self.referenced() + .into_iter() + .filter_map(|reference| match reference { + Reference::Style { name, class_name } => Some((name.as_str(), class_name.as_str())), + Reference::Keyframes(_) => None, + }) + .collect() + } +} - // Pre-build search/replace pairs to avoid format!() per iteration - let replacements: Vec<_> = class_map - .iter() - .map(|(style_name, class_name)| { - ( - format!("\"{style_name}:"), - format!("\"{class_name}:"), - format!("\"{style_name} "), - format!("\"{class_name} "), - ) +/// Names of the keyframes some style refers to; their generated names have to be +/// known before the styles referring to them are generated. +pub fn referenced_keyframes(collected: &CollectedStyles) -> FxHashSet { + collected + .referenced() + .into_iter() + .filter_map(|reference| match reference { + Reference::Keyframes(name) => Some(name.clone()), + Reference::Style { .. } => None, }) - .collect(); + .collect() +} - let mut styles: Vec<_> = collected.styles.iter().collect(); - styles.sort_by_key(|(name, _)| *name); +/// Code declaring only the keyframes in `names`, extracted first to learn their +/// generated names +pub fn keyframes_to_code( + collected: &CollectedStyles, + package: &str, + names: &FxHashSet, +) -> String { + let mut keyframes: Vec<_> = collected + .keyframes + .iter() + .filter(|(name, _)| names.contains(*name)) + .collect(); + keyframes.sort_by_key(|(name, _)| *name); + let mut output = format!("import {{ keyframes }} from '{package}'"); + for (name, entry) in keyframes { + output.push_str("\nconst "); + output.push_str(name); + output.push_str(" = keyframes("); + output.push_str(&collected.resolve_json(&entry.json, &FxHashMap::default())); + output.push(')'); + } + output +} - for (name, entry) in styles { - let prefix = if entry.exported { "export " } else { "" }; +/// The members of a JSON object, without its braces +fn inner_json(json: &str) -> &str { + json.trim() + .strip_prefix('{') + .and_then(|json| json.strip_suffix('}')) + .unwrap_or_default() + .trim() +} - // Replace style name references with class names in JSON - let mut json = entry.json.clone(); - for (search_colon, replace_colon, search_space, replace_space) in &replacements { - if json.contains(search_colon.as_str()) { - json = json.replace(search_colon, replace_colon); - } - if json.contains(search_space.as_str()) { - json = json.replace(search_space, replace_space); - } +/// Styles `entry` composes, transitively and in order, each once +fn collect_bases<'a>( + collected: &'a CollectedStyles, + entry: &'a StyleEntry, + seen: &mut FxHashSet<&'a str>, + bases: &mut Vec<(&'a str, &'a StyleEntry)>, +) { + for base in &entry.bases { + if seen.insert(base.as_str()) + && let Some(base_entry) = collected.styles.get(base) + { + collect_bases(collected, base_entry, seen, bases); + bases.push((base.as_str(), base_entry)); } + } +} - if entry.bases.is_empty() { - code_parts.push(format!("{prefix}const {name} = css({json})")); - } else { - // Composition: merge all base styles - let mut merged_parts = Vec::new(); - for base_name in &entry.bases { - if let Some(base_json) = style_json_map.get(base_name.as_str()) { - let inner = base_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !inner.is_empty() { - merged_parts.push(inner.to_string()); - } - } - } - let own_inner = json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !own_inner.is_empty() { - merged_parts.push(own_inner.to_string()); - } - let merged_json = format!("{{{}}}", merged_parts.join(",")); - code_parts.push(format!("{prefix}const {name} = css({merged_json})")); +/// `css(...)` of the style `name` composed onto its bases, plus the classes it +/// composes and the unique classes that let selectors target them +fn composed_css( + collected: &CollectedStyles, + keyframes_names: &FxHashMap, + referenced_classes: &FxHashMap<&str, &str>, + name: &str, + entry: &StyleEntry, +) -> String { + let mut seen = FxHashSet::default(); + seen.insert(name); + let mut bases = Vec::new(); + collect_bases(collected, entry, &mut seen, &mut bases); + + let mut rules = Vec::with_capacity(bases.len() + 1); + let mut classes = Vec::new(); + for (base_name, base) in &bases { + let json = collected.resolve_json(&base.json, keyframes_names); + let inner = inner_json(&json); + if !inner.is_empty() { + rules.push(inner.to_string()); } + classes.extend(base.classes.iter().map(String::as_str)); + classes.extend(referenced_classes.get(base_name).copied()); } + classes.extend(entry.classes.iter().map(String::as_str)); + classes.extend(referenced_classes.get(name).copied()); - // Generate createTheme exports (class name and optionally vars object) - // Note: CSS variables are added to global_styles during remapping - let mut themes: Vec<_> = collected.themes.iter().collect(); - themes.sort_by_key(|(name, _)| *name); - for (name, entry) in themes { - let prefix = if entry.exported { "export " } else { "" }; - if let Some(vars_name) = &entry.vars_name { - if let Some(vars_json) = &entry.vars_object_json { - code_parts.push(format!( - "{}const [{}, {}] = [\"{}\", {}]", - prefix, name, vars_name, entry.class_name, vars_json - )); - } else { - code_parts.push(format!( - "{}const {} = \"{}\"", - prefix, name, entry.class_name - )); - } - } else { - code_parts.push(format!( - "{}const {} = \"{}\"", - prefix, name, entry.class_name - )); + let own = collected.resolve_json(&entry.json, keyframes_names); + let css = if bases.is_empty() { + format!("css({own})") + } else { + let inner = inner_json(&own); + if !inner.is_empty() { + rules.push(inner.to_string()); } + format!("css({{{}}})", rules.join(",")) + }; + if classes.is_empty() { + css + } else { + format!("{css} + \" {}\"", classes.join(" ")) } +} - // Add remaining code generation (globalCss, keyframes, etc.) - call original function's logic - append_non_style_code(collected, package, &mut code_parts); +fn sorted(entries: &FxHashMap) -> Vec<(&String, &StyleEntry)> { + let mut entries: Vec<_> = entries.iter().collect(); + entries.sort_by_key(|(name, _)| *name); + entries +} - code_parts.join("\n") +const fn export_prefix(exported: bool) -> &'static str { + if exported { "export " } else { "" } } -/// Append non-style code parts (globalCss, keyframes, fontFaces, etc.) -fn append_non_style_code( +/// Convert collected styles to code, with keyframes references replaced by +/// `keyframes_names` (`variable_name` -> generated name) +pub fn collected_styles_to_code_with_keyframes( collected: &CollectedStyles, - _package: &str, - code_parts: &mut Vec, -) { - // Generate globalCss calls - for (selector, json) in &collected.global_styles { - code_parts.push(format!("globalCss({{ \"{selector}\": {json} }})")); - } + package: &str, + keyframes_names: &FxHashMap, +) -> String { + let mut code = Vec::new(); - // Generate @font-face rules - let mut font_faces_sorted: Vec<_> = collected.font_faces.iter().collect(); - font_faces_sorted.sort_by_key(|(name, _)| *name); - for (_name, (json, font_family, _exported)) in &font_faces_sorted { - let props = parse_font_face_json(json); - let props_str = props - .iter() - .map(|(k, v)| format!("{k}: {v}")) - .collect::>() - .join(", "); - let code = if props_str.is_empty() { - format!("globalCss({{ fontFaces: [{{ fontFamily: \"{font_family}\" }}] }})") - } else { - format!( - "globalCss({{ fontFaces: [{{ fontFamily: \"{font_family}\", {props_str} }}] }})" - ) - }; - code_parts.push(code); - } - - // Generate createGlobalTheme CSS variables - let mut global_themes_sorted: Vec<_> = collected.global_themes.iter().collect(); - global_themes_sorted.sort_by_key(|(name, _)| *name); - for (_name, entry) in &global_themes_sorted { - if !entry.css_vars.is_empty() { - let vars_str = entry - .css_vars - .iter() - .map(|(var_name, value)| format!("\"{var_name}\": \"{value}\"")) - .collect::>() - .join(", "); - code_parts.push(format!( - "globalCss({{ \"{}\": {{ {} }} }})", - entry.selector, vars_str - )); - } + let mut imports = Vec::new(); + if !collected.styles.is_empty() { + imports.push("css"); } - - // Generate keyframes - let mut keyframes: Vec<_> = collected.keyframes.iter().collect(); - keyframes.sort_by_key(|(name, _)| *name); - for (name, entry) in keyframes { - let prefix = if entry.exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = keyframes({})", - prefix, name, entry.json - )); + if !(collected.global_styles.is_empty() + && collected.font_faces.is_empty() + && collected.layers.is_empty() + && collected.property_rules.is_empty()) + { + imports.push("globalCss"); } - - // Generate styleVariants - let mut variants: Vec<_> = collected.style_variants.iter().collect(); - if !variants.is_empty() { - let style_json_map: FxHashMap<&str, &str> = collected - .styles - .iter() - .map(|(name, entry)| (name.as_str(), entry.json.as_str())) - .collect(); - variants.sort_by_key(|(name, _)| *name); - for (name, (variant_map, exported)) in variants { - let mut variant_entries: Vec<_> = variant_map.iter().collect(); - variant_entries.sort_by_key(|(k, _)| *k); - let mut object_parts = Vec::new(); - for (variant_key, variant) in variant_entries { - let value = if let Some(base_name) = &variant.base { - let mut merged_parts = Vec::new(); - if let Some(base_json) = style_json_map.get(base_name.as_str()) { - let inner = base_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !inner.is_empty() { - merged_parts.push(inner.to_string()); - } - } - let own_inner = variant - .styles_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !own_inner.is_empty() { - merged_parts.push(own_inner.to_string()); - } - format!("css({{{}}})", merged_parts.join(",")) - } else { - format!("css({})", variant.styles_json) - }; - object_parts.push(format!(" {variant_key}: {value}")); - } - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = {{\n{}\n}}", - prefix, - name, - object_parts.join(",\n") - )); - } + if !collected.keyframes.is_empty() { + imports.push("keyframes"); } - - // Generate createVar declarations - let mut vars: Vec<_> = collected.vars.iter().collect(); - vars.sort_by_key(|(name, _)| *name); - for (name, (value, exported)) in vars { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{value}\"")); + if !imports.is_empty() { + code.push(format!( + "import {{ {} }} from '{package}'", + imports.join(", ") + )); } - // Generate fontFace declarations (reuse the name-sorted vec built above) - for (name, (_, font_family, exported)) in &font_faces_sorted { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{font_family}\"")); + // Declaring every layer up front fixes their order the way the calls did. + let mut layers: Vec<&str> = Vec::new(); + for layer in &collected.layers { + if !layers.contains(&layer.as_str()) { + layers.push(layer); + } } - - // Generate createContainer declarations - let mut containers: Vec<_> = collected.containers.iter().collect(); - containers.sort_by_key(|(name, _)| *name); - for (name, (value, exported)) in containers { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{value}\"")); + if !layers.is_empty() { + code.push(format!("globalCss`@layer {};`", layers.join(","))); } - - // Generate layer declarations - let mut layers: Vec<_> = collected.layers.iter().collect(); - layers.sort_by_key(|(name, _)| *name); - for (name, (value, exported)) in layers { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{value}\"")); + for rule in &collected.property_rules { + code.push(format!("globalCss`{rule}`")); } - // Generate createGlobalTheme vars object declarations - for (name, entry) in &global_themes_sorted { - let prefix = if entry.exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = {}", - prefix, name, entry.vars_object_json + for (name, entry) in sorted(&collected.keyframes) { + code.push(format!( + "{}const {name} = keyframes({})", + export_prefix(entry.exported), + collected.resolve_json(&entry.json, keyframes_names) )); } - // Generate constant exports - let mut constants: Vec<_> = collected.constant_exports.iter().collect(); - constants.sort_by_key(|(name, _)| *name); - for (name, value) in constants { - code_parts.push(format!("export const {name} = {value}")); + let referenced_classes = collected.referenced_classes(); + for (name, entry) in sorted(&collected.styles) { + code.push(format!( + "{}const {name} = {}", + export_prefix(entry.exported), + composed_css(collected, keyframes_names, &referenced_classes, name, entry) + )); } -} - -/// Convert collected styles to code that can be processed by existing extract logic -pub fn collected_styles_to_code(collected: &CollectedStyles, package: &str) -> String { - collected_styles_to_code_with_classes(collected, package, &FxHashMap::default()) -} - -/// Parse a styleVariants object and extract variant info -fn parse_style_variants( - variants_obj: &JsValue, - context: &mut Context, -) -> FxHashMap { - let mut result = FxHashMap::default(); - - if let Some(obj) = variants_obj.as_object() { - // Get the object's own property keys - if let Ok(keys) = obj.own_property_keys(context) { - for key in keys { - // Convert PropertyKey to string - let key_name = 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(value) = obj.get(key.clone(), context) { - let variant = parse_single_variant(&value, context); - result.insert(key_name, variant); - } - } - } + for (selector, json) in &collected.global_styles { + let selector = collected.resolve(selector, keyframes_names, |_| true); + code.push(format!( + "globalCss({{ {}: {} }})", + json_string(&selector), + collected.resolve_json(json, keyframes_names) + )); } - - result -} - -/// Parse a single variant value (either style object or [base, styleObj] array) -fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant { - // Check if value is an array by checking if it has a numeric "length" property - if let Some(obj) = value.as_object() - && let Ok(length_val) = obj.get(js_string!("length"), context) - && let Some(len) = length_val.as_number() - { - let len = len as u32; - if len >= 2 { - // It's an array with at least 2 elements - if let Ok(first) = obj.get(0, context) - && let Some(base_str) = first.as_string() - { - let base_class = base_str.to_std_string_escaped(); - // Check if it looks like a placeholder (__style_N__) or class name - if base_class.starts_with("__style_") || !base_class.contains('{') { - // Get the style object (second element) - if let Ok(style_obj) = obj.get(1, context) { - let json = js_value_to_json(&style_obj, context); - return StyleVariant { - base: Some(base_class), - styles_json: json, - }; - } - } - } - } + if !collected.font_faces.is_empty() { + code.push(format!( + "globalCss({{ fontFaces: [{}] }})", + collected.font_faces.join(", ") + )); } - // Not an array or not composition - treat as plain style object - StyleVariant { - base: None, - styles_json: js_value_to_json(value, context), + for (name, value) in &collected.constant_exports { + code.push(format!("export const {name} = {value}")); } + code.join("\n") } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; - use crate::utils::is_vanilla_extract_file; - use smallvec::smallvec; + use css::file_map::reset_file_map; + use serial_test::serial; + + const PACKAGE: &str = "@devup-ui/react"; + + fn generate_with(code: &str, keyframes_names: &[(&str, &str)]) -> String { + reset_file_map(); + let collected = execute_vanilla_extract( + &format!("import {{ style, globalStyle, styleVariants, keyframes, fontFace, globalFontFace, createVar, fallbackVar, createContainer, layer, globalLayer, createTheme, createGlobalTheme, createThemeContract, createGlobalThemeContract, assignVars }} from '{PACKAGE}'\n{code}"), + PACKAGE, + "test.css.ts", + ) + .unwrap(); + let keyframes_names = keyframes_names + .iter() + .map(|(name, generated)| (name.to_string(), generated.to_string())) + .collect(); + collected_styles_to_code_with_keyframes(&collected, PACKAGE, &keyframes_names) + } - #[test] - fn test_preprocess_typescript() { - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red" })"#; - let result = preprocess_typescript(code, "@devup-ui/react"); - // The result should have destructuring from __vanilla_extract__ and no export keyword - assert!( - result.contains("__vanilla_extract__"), - "Expected __vanilla_extract__ but got: {result}" - ); - assert!( - !result.contains("export const"), - "Should not contain 'export const': {result}" - ); + fn generate(code: &str) -> String { + generate_with(code, &[]) } #[test] - fn test_preprocess_typescript_strips_types() { - let code = r#"import { style } from '@devup-ui/react' -interface Props { - color: string; -} -export const container = style({ background: "red" })"#; - let result = preprocess_typescript(code, "@devup-ui/react"); - // TypeScript interface should be stripped + #[serial] + fn test_names_follow_the_values_variables_hold() { + assert_eq!( + generate_with( + "export const sizes = { sm: style({ padding: 1 }), lg: style({ padding: 2 }) } +export const box = style({ color: 'red' }) +const spin = keyframes({ to: { opacity: 1 } }) +export const alias = box +export const animated = style({ animationName: spin })", + &[("spin", "k")] + ), + r#"import { css, keyframes } from '@devup-ui/react' +const spin = keyframes({"to":{"opacity":1}}) +const _ve0 = css({"padding":"1px"}) +const _ve1 = css({"padding":"2px"}) +export const animated = css({"animationName":"k"}) +export const box = css({"color":"red"}) +export const sizes = { "sm": _ve0, "lg": _ve1 } +export const alias = box"# + ); + assert_eq!( + generate("const _ve0 = 1\nexport const list = [style({ color: 'red' })]"), + r#"import { css } from '@devup-ui/react' +const _ve0_ = css({"color":"red"}) +export const list = [_ve0_]"# + ); + assert_eq!( + generate("export const animated = style({ animationName: keyframes({}) })"), + r#"import { css, keyframes } from '@devup-ui/react' +const _ve0 = keyframes({}) +export const animated = css({"animationName":"__style_0__"})"# + ); + } + + #[test] + #[serial] + fn test_exported_values_become_code() { + assert_eq!( + generate( + "const box = style({ color: 'red' }) +export const text = 'plain \"quoted\"' +export const template = `${box} extra \\`tick\\` \\${x} back\\\\slash __open` +export const nothing = undefined +export const empty = null +export const flag = true +export const count = 1.5 +export const list = [box, 2] +export const nested = { a: { b: [box] } } +export const helper = (x) => x +export const fns = [() => 1] +export const date = new Date(0) +export const cyclic = (() => { const o = {}; o.self = o; return o })() +export const bare = Object.create(null) +export const { destructured } = { destructured: () => 1 } +export declare const typeOnly: string +export function makeBox() { return box } +export type Box = string +export const symbol = Symbol('x')" + ), + r#"import { css } from '@devup-ui/react' +const box = css({"color":"red"}) +export const text = "plain \"quoted\"" +export const template = `${box} extra \`tick\` \${x} back\\slash __open` +export const nothing = undefined +export const empty = null +export const flag = true +export const count = 1.5 +export const list = [box, 2] +export const nested = { "a": { "b": [box] } } +export const helper = (x) => x +export const fns = [() => 1] +export const date = new Date(0) +export const cyclic = (() => { const o = {}; o.self = o; return o })() +export const bare = {} +export const symbol = Symbol('x')"# + ); + } + + #[test] + #[serial] + fn test_style_variants_are_styles() { + assert_eq!( + generate( + "export const tone = styleVariants({ primary: { color: 'red' }, 0: { color: 'blue' }, [Symbol('s')]: { color: 'green' } }) +export const space = styleVariants({ sm: 4 }, (padding, key) => ({ padding, content: key })) +export const none = styleVariants(undefined) +export const combined = style([tone.primary, 'external', [{ margin: 1 }]]) +globalStyle(`${tone.primary} > span`, { fontWeight: 700 })" + ), + r#"import { css, globalCss } from '@devup-ui/react' +const _ve0 = css({"color":"blue"}) +const _ve1 = css({"color":"red"}) + " f0__ve1" +const _ve2 = css({"padding":"4px","content":"sm"}) +export const combined = css({"color":"red","margin":"1px"}) + " f0__ve1 external" +globalCss({ ".f0__ve1 > span": {"fontWeight":700} }) +export const tone = { "0": _ve0, "primary": _ve1 } +export const space = { "sm": _ve2 } +export const none = {}"# + ); + } + + #[test] + #[serial] + fn test_composition_is_transitive() { + assert_eq!( + generate( + "const a = style({ color: 'red' }) +const b = style([a, { margin: 2 }]) +export const c = style([b, { padding: 3 }]) +const e = style({}) +export const f = style([e, a, '__style_99__']) +export const g = style(5) +const o = {}; o.self = o +export const h = style(o) +export const hover = style({ selectors: { [`${a}:hover &`]: { color: 'blue' } } })" + ), + r#"import { css } from '@devup-ui/react' +const a = css({"color":"red"}) + " f0_a" +const b = css({"color":"red","margin":"2px"}) + " f0_a" +export const c = css({"color":"red","margin":"2px","padding":"3px"}) + " f0_a" +const e = css({}) +export const f = css({"color":"red"}) + " f0_a" +export const g = css({}) +export const h = css({}) +export const hover = css({"selectors":{".f0_a:hover &":{"color":"blue"}}})"# + ); + } + + #[test] + #[serial] + fn test_font_faces() { + assert_eq!( + generate( + "export const body = fontFace({ src: 'local(a)' }, 'Body Font') +export const icons = fontFace([{ src: 'local(b)' }, { src: 'local(c)', fontWeight: 700 }]) +fontFace({}) +const o = {}; o.self = o +fontFace(o) +globalFontFace('Inter', { src: 'local(Inter)' })" + ), + r#"import { globalCss } from '@devup-ui/react' +globalCss({ fontFaces: [{"src":"local(a)","fontFamily":"Body_Font-0-0"}, {"src":"local(b)","fontFamily":"font-0-1"}, {"src":"local(c)","fontWeight":700,"fontFamily":"font-0-1"}, {"fontFamily":"font-0-2"}, {"fontFamily":"font-0-3"}, {"src":"local(Inter)","fontFamily":"Inter"}] }) +export const body = "Body_Font-0-0" +export const icons = "font-0-1""# + ); + } + + #[test] + #[serial] + fn test_vars_follow_vanilla_extract() { + assert_eq!( + generate( + "export const plain = createVar() +export const named = createVar('9 lives') +export const typed = createVar({ syntax: '', inherits: false, initialValue: '0px' }, 'size') +export const union = createVar({ syntax: ['', ''], inherits: true }) +export const empty = createVar('') +export const fallback = fallbackVar(plain, named, 'red') +export const invalid = fallbackVar('red', 'blue') +export const none = fallbackVar() +export const box = style({ vars: { [plain]: '1px', '--raw': 2 }, width: plain, padding: 0, zIndex: 2, margin: [1, 2] })" + ), + r#"import { css, globalCss } from '@devup-ui/react' +globalCss`@property --size-0-2{syntax:"";inherits:false;initial-value:0px}` +globalCss`@property --var-0-3{syntax:" | ";inherits:true}` +export const box = css({"vars":{"--var-0-0":"1px","--raw":2},"width":"var(--var-0-0)","padding":0,"zIndex":2,"margin":[1,2]}) +export const plain = "var(--var-0-0)" +export const named = "var(--_9_lives-0-1)" +export const typed = "var(--size-0-2)" +export const union = "var(--var-0-3)" +export const empty = "var(--var-0-4)" +export const fallback = "var(--var-0-0, var(--_9_lives-0-1, red))" +export const invalid = "red" +export const none = """# + ); + } + + #[test] + #[serial] + fn test_layers_and_containers() { + assert_eq!( + generate( + "export const reset = layer() +export const base = layer('base') +export const child = layer({ parent: base }, 'child') +export const orphan = layer({}) +export const named = globalLayer('utilities') +export const nested = globalLayer({ parent: named }, 'x') +export const again = globalLayer('utilities') +export const container = createContainer('sidebar') +export const anonymous = createContainer()" + ), + r#"import { globalCss } from '@devup-ui/react' +globalCss`@layer layer-0-0,base-0-1,base-0-1.child-0-2,layer-0-3,utilities,utilities.x;` +export const reset = "layer-0-0" +export const base = "base-0-1" +export const child = "base-0-1.child-0-2" +export const orphan = "layer-0-3" +export const named = "utilities" +export const nested = "utilities.x" +export const again = "utilities" +export const container = "sidebar-0-4" +export const anonymous = "container-0-5""# + ); + } + + #[test] + #[serial] + fn test_themes_and_contracts() { + assert_eq!( + generate( + "const contract = createThemeContract({ color: { brand: null, text: null }, list: [1], flag: true }) +export const [light, lightVars] = createTheme({ '@layer': 'theme', color: { brand: 'blue' }, size: 4 }) +export const dark = createTheme(contract, { color: { brand: 'black', text: 'white' } }, 'dark') +export const partial = createTheme(contract, { color: { brand: 'x', missing: 'y' } }) +export const emptyTheme = createTheme(contract, {}) +export const globalVars = createGlobalTheme(':root', { space: '1px' }) +createGlobalTheme('.scope', contract, { '@layer': 'scoped', color: { text: 'grey' } }) +export const exact = createGlobalThemeContract({ color: 'brand-color', raw: '--raw' }) +export const mapped = createGlobalThemeContract({ color: { brand: null } }, (_value, path) => `x-${path.join('-')}`) +export const assigned = assignVars(contract, { color: { brand: 'red' } }) +export const unassigned = assignVars(undefined, { a: 'b' }) +export const box = style({ vars: assigned }) +export const notObject = createTheme('tokens')" + ), + r#"import { css, globalCss } from '@devup-ui/react' +export const box = css({"vars":{"--color-brand-0-0":"red"}}) +globalCss({ ".theme-0-2": {"@layer":"theme","--color-brand-0-3":"blue","--size-0-4":"4"} }) +globalCss({ ".dark-0-5": {"--color-brand-0-0":"black","--color-text-0-1":"white"} }) +globalCss({ ".theme-0-6": {"--color-brand-0-0":"x"} }) +globalCss({ ":root": {"--space-0-8":"1px"} }) +globalCss({ ".scope": {"@layer":"scoped","--color-text-0-1":"grey"} }) +export const light = "theme-0-2" +export const lightVars = { "color": { "brand": "var(--color-brand-0-3)" }, "size": "var(--size-0-4)" } +export const dark = "dark-0-5" +export const partial = "theme-0-6" +export const emptyTheme = "theme-0-7" +export const globalVars = { "space": "var(--space-0-8)" } +export const exact = { "color": "var(--brand-color)", "raw": "var(--raw)" } +export const mapped = { "color": { "brand": "var(--x-color-brand)" } } +export const assigned = { "var(--color-brand-0-0)": "red" } +export const unassigned = {} +export const notObject = ["theme-0-9", {}]"# + ); + } + + #[test] + #[serial] + fn test_nothing_collected() { + assert_eq!(generate("const x = 1"), ""); + reset_file_map(); + assert!(execute_vanilla_extract("throw new Error('x')", PACKAGE, "test.css.ts").is_err()); assert!( - !result.contains("interface"), - "Should not contain interface: {result}" + execute_vanilla_extract( + "import { createVar } from '@devup-ui/react'\ncreateVar({ syntax: Symbol() })", + PACKAGE, + "test.css.ts" + ) + .is_err() ); } #[test] - fn test_execute_vanilla_extract_style() { - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red", padding: 16 })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(!result.styles.is_empty()); + fn test_identifier() { + let mut collector = Collector { + file_num: 3, + ..Collector::default() + }; + assert_eq!(collector.identifier(Some("a b".into()), "x"), "a_b-3-0"); + assert_eq!(collector.identifier(Some("-y".into()), "x"), "_-y-3-1"); + assert_eq!(collector.identifier(Some(String::new()), "x"), "x-3-2"); + assert_eq!(collector.identifier(None, "_z"), "_z-3-3"); + } + + #[test] + fn test_preprocess_typescript_binds_imports() { + assert_eq!( + preprocess_typescript( + "import { style as s, globalStyle } from '@devup-ui/react' +import * as ve from '@devup-ui/react' +import '@devup-ui/react' +import { other } from 'other' +interface Props { color: string } +export const a: number = s(globalStyle, ve, other) +export function f() {} +export default a", + PACKAGE + ), + "const { style: s, globalStyle } = __vanilla_extract__; +const ve = __vanilla_extract__; +import \"@devup-ui/react\"; +import { other } from \"other\"; +const a = s(globalStyle, ve, other); +function f() {} +export default a;" + ); + assert_eq!( + preprocess_typescript( + "import { style } from '@devup-ui/react'\nconst a = 1", + PACKAGE + ), + "const a = 1;\n" + ); } #[test] - fn test_execute_vanilla_extract_style_multiple() { - // Test with multiple style exports - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red", padding: 16 }) -export const button = style({ color: "blue" })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!( - result.styles.len() >= 2, - "Expected at least 2 styles but got: {result:?}" + fn test_style_to_json_adds_vanilla_extract_units() { + let mut context = Context::default(); + let rule = context + .eval(Source::from_bytes( + "({ fontSize: 16, top: 0, lineHeight: 1.5, '--gap': 4, 'var(--x)': 2, fallback: [1, 2], ':hover': { width: 3 }, vars: { 'var(--a)': 1, '--b': 2 } })", + )) + .unwrap(); + assert_eq!( + style_to_json(&rule, &mut context), + r#"{"fontSize":"16px","top":0,"lineHeight":1.5,"--gap":4,"var(--x)":2,"fallback":[1,2],":hover":{"width":"3px"},"vars":{"--a":1,"--b":2}}"# ); + assert_eq!(style_to_json(&JsValue::undefined(), &mut context), "{}"); } #[test] - fn test_collected_styles_to_code_multiple() { - // Test the full flow: execute → collected_styles_to_code with multiple styles - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red", padding: 16 })"#; - let collected = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - let generated = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!( - !generated.is_empty(), - "Expected non-empty generated code. Collected: {collected:?}" - ); - assert!( - generated.contains("css("), - "Expected css() call in generated code: {generated}" + #[serial] + fn test_referenced_keyframes() { + reset_file_map(); + let collected = execute_vanilla_extract( + "import { style, keyframes } from '@devup-ui/react' +const fade = keyframes({ from: { opacity: 0 } }) +const unused = keyframes({ to: { opacity: 1 } }) +export const box = style({ animationName: fade })", + PACKAGE, + "test.css.ts", + ) + .unwrap(); + let names = referenced_keyframes(&collected); + assert_eq!(names, FxHashSet::from_iter(["fade".to_string()])); + assert_eq!( + keyframes_to_code(&collected, PACKAGE, &names), + r#"import { keyframes } from '@devup-ui/react' +const fade = keyframes({"from":{"opacity":0}})"# ); } #[test] - fn test_full_flow_multiline() { - // Test exactly what lib.rs extract function does (with already-transformed imports) - let code = r"import { style } from '@devup-ui/react' -export const hello = style({ - cursor: 'pointer', - fontSize: 32, - paddingTop: '28px', - paddingBottom: '28px', -}) -export const text = style({ - color: 'var(--text)', -}) -"; - let package = "@devup-ui/react"; - let filename = "styles.css.ts"; - - // This is exactly what lib.rs does - assert!( - is_vanilla_extract_file(filename), - "Should be vanilla-extract file" - ); - - let result = execute_vanilla_extract(code, package, filename); - match result { - Ok(collected) => { - assert!( - !collected.styles.is_empty(), - "Styles should not be empty. Collected: {collected:?}" - ); - let generated = super::collected_styles_to_code(&collected, package); - assert!( - !generated.is_empty(), - "Generated code should not be empty. Generated: {generated}" - ); - println!("Generated code:\n{generated}"); - } - Err(e) => { - panic!("execute_vanilla_extract failed: {e}"); - } - } - } - - #[test] - fn test_execute_vanilla_extract_global_style() { - let code = r#"import { globalStyle } from '@devup-ui/react' -globalStyle("body", { margin: 0, padding: 0 })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert_eq!(result.global_styles.len(), 1); - assert_eq!(result.global_styles[0].0, "body"); - } - - #[test] - fn test_collected_styles_to_code() { - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "container".to_string(), - StyleEntry { - json: r#"{"background":"red"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("import { css } from '@devup-ui/react'")); - assert!(code.contains(r#"export const container = css({"background":"red"})"#)); - } - - #[test] - fn test_execute_vanilla_extract_with_variable() { - // Test that variables are evaluated at execution time - let code = r#"import { style } from '@devup-ui/react' -const primaryColor = "blue"; -const spacing = 16; -export const button = style({ background: primaryColor, padding: spacing })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("button")); - let entry = &result.styles["button"]; - // The variable values should be resolved - assert!( - entry.json.contains("blue"), - "Expected 'blue' in JSON: {}", - entry.json - ); - assert!( - entry.json.contains("16"), - "Expected '16' in JSON: {}", - entry.json - ); - } - - #[test] - fn test_execute_vanilla_extract_with_computed_value() { - // Test computed values - let code = r"import { style } from '@devup-ui/react' -const base = 8; -export const box = style({ padding: base * 2, margin: base / 2 })"; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("box")); - let entry = &result.styles["box"]; - assert!( - entry.json.contains("16"), - "Expected padding 16 in JSON: {}", - entry.json - ); - assert!( - entry.json.contains('4'), - "Expected margin 4 in JSON: {}", - entry.json - ); - } - - #[test] - fn test_execute_vanilla_extract_with_conditional() { - // Test conditional expressions - let code = r#"import { style } from '@devup-ui/react' -const isDark = true; -export const theme = style({ background: isDark ? "black" : "white" })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("theme")); - let entry = &result.styles["theme"]; - assert!( - entry.json.contains("black"), - "Expected 'black' in JSON: {}", - entry.json - ); - } - - #[test] - fn test_execute_vanilla_extract_with_spread() { - // Test spread operator - let code = r#"import { style } from '@devup-ui/react' -const baseStyle = { padding: 8, margin: 4 }; -export const extended = style({ ...baseStyle, background: "red" })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("extended")); - let entry = &result.styles["extended"]; - assert!( - entry.json.contains('8'), - "Expected padding 8 in JSON: {}", - entry.json - ); - assert!( - entry.json.contains('4'), - "Expected margin 4 in JSON: {}", - entry.json - ); - assert!( - entry.json.contains("red"), - "Expected 'red' in JSON: {}", - entry.json - ); - } - - #[test] - fn test_execute_vanilla_extract_create_theme_contract() { - // Test createThemeContract + createTheme - let code = r"import { createThemeContract, createTheme } from '@devup-ui/react' -const vars = createThemeContract({ - color: { - brand: null, - text: null - } -}) -export const lightTheme = createTheme(vars, { - color: { - brand: 'blue', - text: 'black' - } -})"; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - - // Check that themes were collected - assert!( - !result.themes.is_empty(), - "Expected themes to be collected, got empty. Themes: {:?}", - result.themes - ); - assert!( - result.themes.contains_key("lightTheme"), - "Expected lightTheme in themes: {:?}", - result.themes.keys().collect::>() - ); - - // Check CSS vars - let theme_entry = &result.themes["lightTheme"]; - assert!( - !theme_entry.css_vars.is_empty(), - "Expected CSS vars in theme: {theme_entry:?}" - ); - - // Check that CSS vars are correct - let css_vars: Vec<_> = theme_entry.css_vars.iter().collect(); - assert!( - css_vars - .iter() - .any(|(name, val)| name == "--color-brand" && val == "blue"), - "Expected --color-brand: blue in {css_vars:?}" - ); - } - - #[test] - fn test_parse_font_face_json_edge_cases() { - // Test invalid JSON - let result = super::parse_font_face_json("not valid json"); - assert!(result.is_empty()); - - // Test non-object JSON - let result = super::parse_font_face_json("\"just a string\""); - assert!(result.is_empty()); - - // Test array JSON - let result = super::parse_font_face_json("[1, 2, 3]"); - assert!(result.is_empty()); - - // Test with boolean value - let result = super::parse_font_face_json(r#"{"fontDisplay": true}"#); - assert_eq!(result.len(), 1); - assert_eq!(result[0], ("fontDisplay".to_string(), "true".to_string())); - - // Test with number value - let result = super::parse_font_face_json(r#"{"fontWeight": 400}"#); - assert_eq!(result.len(), 1); - assert_eq!(result[0], ("fontWeight".to_string(), "400".to_string())); - - // Test with null value (other case) - let result = super::parse_font_face_json(r#"{"fontStyle": null}"#); - assert_eq!(result.len(), 1); - assert_eq!(result[0], ("fontStyle".to_string(), "null".to_string())); - } - - #[test] - fn test_collected_styles_to_code_with_classes_composition() { - let mut collected = CollectedStyles::default(); - // Add base style - collected.styles.insert( - "base".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - // Add composed style with bases - collected.styles.insert( - "composed".to_string(), - StyleEntry { - json: r#"{"color":"red"}"#.to_string(), - exported: true, - bases: smallvec!["base".to_string()], - }, - ); - - let class_map: rustc_hash::FxHashMap = [ - ("base".to_string(), "a".to_string()), - ("composed".to_string(), "b".to_string()), - ] - .into_iter() - .collect(); - - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - assert!(code.contains("import { css } from '@devup-ui/react'")); - // The composed style should have both base and own styles merged - assert!(code.contains("padding")); - assert!(code.contains("color")); - } - - #[test] - fn test_collected_styles_to_code_with_theme_vars() { - let mut collected = CollectedStyles::default(); - // Theme with vars_name and vars_object_json - collected.themes.insert( - "themeClass".to_string(), - super::ThemeEntry { - class_name: "f0_theme".to_string(), - css_vars: smallvec![("--color-primary".to_string(), "blue".to_string())], - exported: true, - vars_name: Some("vars".to_string()), - vars_object_json: Some( - r#"{"color":{"primary":"var(--color-primary)"}}"#.to_string(), - ), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const [themeClass, vars] = [\"f0_theme\"")); - } - - #[test] - fn test_collected_styles_to_code_with_theme_no_vars() { - let mut collected = CollectedStyles::default(); - // Theme without vars (two-arg createTheme) - collected.themes.insert( - "darkTheme".to_string(), - super::ThemeEntry { - class_name: "f1_darkTheme".to_string(), - css_vars: smallvec![("--color-primary".to_string(), "white".to_string())], - exported: true, - vars_name: None, - vars_object_json: None, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const darkTheme = \"f1_darkTheme\"")); - } - - #[test] - fn test_collected_styles_to_code_with_keyframes() { - let mut collected = CollectedStyles::default(); - collected.keyframes.insert( - "fadeIn".to_string(), - StyleEntry { - json: r#"{"from":{"opacity":"0"},"to":{"opacity":"1"}}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("import { keyframes } from '@devup-ui/react'")); - assert!(code.contains("export const fadeIn = keyframes")); - } - - #[test] - fn test_collected_styles_to_code_with_global_styles() { - let mut collected = CollectedStyles::default(); - collected - .global_styles - .push(("body".to_string(), r#"{"margin":"0"}"#.to_string())); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("import { globalCss } from '@devup-ui/react'")); - assert!(code.contains("globalCss({ \"body\":")); - } - - #[test] - fn test_collected_styles_to_code_composition() { - let mut collected = CollectedStyles::default(); - // Add base style - collected.styles.insert( - "baseStyle".to_string(), - StyleEntry { - json: r#"{"padding":"16px","margin":"8px"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - // Add composed style - collected.styles.insert( - "buttonStyle".to_string(), - StyleEntry { - json: r#"{"background":"blue"}"#.to_string(), - exported: true, - bases: smallvec!["baseStyle".to_string()], - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Both base and own styles should be present in the composed style - assert!(code.contains("padding")); - assert!(code.contains("background")); - } - - #[test] - fn test_transform_contract_to_vars_array() { - // Test transform_contract_to_vars with array (covers line 170) - let mut context = boa_engine::Context::default(); - let array = boa_engine::object::builtins::JsArray::new(&mut context) - .expect("creating an empty array should succeed"); - let _ = array.push(boa_engine::JsValue::from(1), &mut context); - let value = boa_engine::JsValue::from(array); - let result = super::transform_contract_to_vars(&value, &mut context, &mut Vec::new()); - // Arrays should be returned as-is - assert!(result.as_object().is_some()); - } - - #[test] - fn test_transform_theme_to_vars_array() { - // Test transform_theme_to_vars with array (covers line 250) - let mut context = boa_engine::Context::default(); - let array = boa_engine::object::builtins::JsArray::new(&mut context) - .expect("creating an empty array should succeed"); - let _ = array.push(boa_engine::JsValue::from(1), &mut context); - let value = boa_engine::JsValue::from(array); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - // Arrays should be returned as-is - assert!(result.as_object().is_some()); - } - - #[test] - fn test_transform_contract_to_vars_with_symbol_property() { - // Test transform_contract_to_vars with Symbol property (covers line 180) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create an object with both string and symbol properties - let obj = ObjectInitializer::new(&mut context).build(); - let _ = obj.set( - js_string!("normalProp"), - JsValue::null(), - false, - &mut context, - ); - - // Add a Symbol property - let symbol = JsSymbol::new(Some(js_string!("testSymbol"))).unwrap(); - let _ = obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::from(js_string!("symbolValue"))) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - let value = JsValue::from(obj); - let result = super::transform_contract_to_vars(&value, &mut context, &mut Vec::new()); - - // Should succeed and only process normalProp (Symbol should be skipped) - assert!(result.as_object().is_some()); - let result_obj = result.as_object().unwrap(); - // normalProp should be transformed to var(--normalProp) - let normal_prop = result_obj - .get(js_string!("normalProp"), &mut context) - .unwrap(); - assert!(normal_prop.as_string().is_some()); - } - - #[test] - fn test_transform_theme_to_vars_with_symbol_property() { - // Test transform_theme_to_vars with Symbol property (covers line 256) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create an object with both string and symbol properties - let obj = ObjectInitializer::new(&mut context).build(); - let _ = obj.set( - js_string!("color"), - JsValue::from(js_string!("red")), - false, - &mut context, - ); - - // Add a Symbol property - let symbol = JsSymbol::new(Some(js_string!("hiddenSymbol"))).unwrap(); - let _ = obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::from(js_string!("hidden"))) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - let value = JsValue::from(obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - - // Should succeed - Symbol property should be skipped - assert!(result.as_object().is_some()); - // Only the string property should generate a CSS var - assert_eq!(css_vars.len(), 1); - assert!(css_vars[0].0.contains("color")); - } - - #[test] - fn test_extract_theme_vars_with_symbol_property() { - // Test extract_theme_vars with Symbol property (covers line 211) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create contract object with symbol - let contract_obj = ObjectInitializer::new(&mut context).build(); - let _ = contract_obj.set( - js_string!("primary"), - JsValue::from(js_string!("var(--primary)")), - false, - &mut context, - ); - let symbol = JsSymbol::new(Some(js_string!("contractSymbol"))).unwrap(); - let _ = contract_obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::null()) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - // Create values object - let values_obj = ObjectInitializer::new(&mut context).build(); - let _ = values_obj.set( - js_string!("primary"), - JsValue::from(js_string!("blue")), - false, - &mut context, - ); - - let contract = JsValue::from(contract_obj); - let values = JsValue::from(values_obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - - // Should extract only the string property, symbol should be skipped - assert_eq!(css_vars.len(), 1); - assert_eq!(css_vars[0], ("--primary".to_string(), "blue".to_string())); - } - - #[test] - fn test_parse_style_variants_with_symbol_property() { - // Test parse_style_variants with Symbol property (covers line 1425) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create variants object with both string and symbol properties - let variants_obj = ObjectInitializer::new(&mut context).build(); - - // Add a normal variant - let style_obj = ObjectInitializer::new(&mut context).build(); - let _ = style_obj.set( - js_string!("color"), - JsValue::from(js_string!("red")), - false, - &mut context, - ); - let _ = variants_obj.set( - js_string!("primary"), - JsValue::from(style_obj), - false, - &mut context, - ); - - // Add a Symbol property - let symbol = JsSymbol::new(Some(js_string!("variantSymbol"))).unwrap(); - let hidden_style = ObjectInitializer::new(&mut context).build(); - let _ = hidden_style.set( - js_string!("display"), - JsValue::from(js_string!("none")), - false, - &mut context, - ); - let _ = variants_obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::from(hidden_style)) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - let value = JsValue::from(variants_obj); - let result = super::parse_style_variants(&value, &mut context); - - // Should only contain the string-keyed variant, symbol should be skipped - assert_eq!(result.len(), 1); - assert!(result.contains_key("primary")); - } - - #[test] - fn test_extract_theme_vars_non_matching() { - // Test extract_theme_vars with non-matching structure - let mut context = boa_engine::Context::default(); - let contract = boa_engine::JsValue::from(boa_engine::js_string!("not-a-var")); - let values = boa_engine::JsValue::from(boa_engine::js_string!("some-value")); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - // No CSS vars should be extracted because contract isn't a var() string - assert!(css_vars.is_empty()); - } - - #[test] - fn test_js_value_to_json_edge_cases() { - // Test js_value_to_json with various types (covers lines 314-328) - let mut context = boa_engine::Context::default(); - - // Test with boolean - let bool_val = boa_engine::JsValue::from(true); - let result = super::js_value_to_json(&bool_val, &mut context); - assert_eq!(result, "true"); - - // Test with null - let null_val = boa_engine::JsValue::null(); - let result = super::js_value_to_json(&null_val, &mut context); - assert_eq!(result, "null"); - - // Test with undefined - let undef_val = boa_engine::JsValue::undefined(); - let result = super::js_value_to_json(&undef_val, &mut context); - assert_eq!(result, "undefined"); - - // Test with number - let num_val = boa_engine::JsValue::from(42); - let result = super::js_value_to_json(&num_val, &mut context); - assert_eq!(result, "42"); - } - - #[test] - fn test_collected_styles_to_code_with_vars() { - // Test with createVar exports (covers lines 1191-1192) - let mut collected = CollectedStyles::default(); - collected - .vars - .insert("colorVar".to_string(), ("--var-0".to_string(), true)); - collected - .vars - .insert("sizeVar".to_string(), ("--var-1".to_string(), false)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const colorVar = \"--var-0\"")); - assert!(code.contains("const sizeVar = \"--var-1\"")); - } - - #[test] - fn test_collected_styles_to_code_with_containers() { - // Test with createContainer exports (covers lines 1207-1208) - let mut collected = CollectedStyles::default(); - collected - .containers - .insert("sidebar".to_string(), ("__container_0__".to_string(), true)); - collected.containers.insert( - "internalCont".to_string(), - ("__container_1__".to_string(), false), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const sidebar = \"__container_0__\"")); - assert!(code.contains("const internalCont = \"__container_1__\"")); - } - - #[test] - fn test_collected_styles_to_code_with_layers() { - // Test with layer exports (covers lines 1215-1216) - let mut collected = CollectedStyles::default(); - collected - .layers - .insert("resetLayer".to_string(), ("reset".to_string(), true)); - collected - .layers - .insert("internalLayer".to_string(), ("internal".to_string(), false)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const resetLayer = \"reset\"")); - assert!(code.contains("const internalLayer = \"internal\"")); - } - - #[test] - fn test_collected_styles_to_code_with_font_faces() { - // Test with fontFace exports (covers lines 1199-1200) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "myFont".to_string(), - ( - r#"{"src":"local(Arial)"}"#.to_string(), - "__devup_font_0_0".to_string(), - true, - ), - ); - collected.font_faces.insert( - "internalFont".to_string(), - ( - r#"{"src":"local(Times)"}"#.to_string(), - "__devup_font_0_1".to_string(), - false, - ), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const myFont = \"__devup_font_0_0\"")); - assert!(code.contains("const internalFont = \"__devup_font_0_1\"")); - } - - #[test] - fn test_collected_styles_to_code_with_global_themes() { - // Test with createGlobalTheme exports (covers lines 1221-1222) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "rootVars".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![("--color-primary".to_string(), "blue".to_string())], - vars_object_json: r#"{"color":{"primary":"var(--color-primary)"}}"#.to_string(), - exported: true, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const rootVars =")); - assert!(code.contains("globalCss({")); - } - - #[test] - fn test_collected_styles_to_code_with_constant_exports() { - // Test with constant exports (covers line 1430) - let mut collected = CollectedStyles::default(); - collected - .constant_exports - .insert("SPACING".to_string(), "8".to_string()); - collected - .constant_exports - .insert("COLORS".to_string(), "{ primary: 'blue' }".to_string()); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const SPACING = 8")); - assert!(code.contains("export const COLORS = { primary: 'blue' }")); - } - - #[test] - fn test_collected_styles_to_code_empty_font_face_props() { - // Test fontFace with empty properties (covers line 1324 branch) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "emptyFont".to_string(), - ("{}".to_string(), "__devup_font_empty".to_string(), true), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("fontFamily: \"__devup_font_empty\"")); - } - - #[test] - fn test_collected_styles_to_code_global_theme_empty_vars() { - // Test createGlobalTheme with empty css_vars (covers line 1332 branch) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "emptyTheme".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![], - vars_object_json: "{}".to_string(), - exported: true, - }, - ); - // Add a style to trigger import generation - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should not generate globalCss for empty vars - assert!(code.contains("export const emptyTheme = {}")); - } - - #[test] - fn test_style_variants_without_base() { - // Test styleVariants without base composition (covers line 1179) - let mut collected = CollectedStyles::default(); - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "small".to_string(), - super::StyleVariant { - base: None, - styles_json: r#"{"fontSize":"12px"}"#.to_string(), - }, - ); - variants.insert( - "large".to_string(), - super::StyleVariant { - base: None, - styles_json: r#"{"fontSize":"20px"}"#.to_string(), - }, - ); - collected - .style_variants - .insert("sizes".to_string(), (variants, true)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const sizes = {")); - assert!(code.contains("small: css")); - assert!(code.contains("large: css")); - } - - #[test] - fn test_style_variants_with_base_empty_inner() { - // Test styleVariants with base but empty inner styles (covers lines 1169, 1174 branches) - let mut collected = CollectedStyles::default(); - // Base with empty inner - collected.styles.insert( - "base".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "variant".to_string(), - super::StyleVariant { - base: Some("base".to_string()), - styles_json: "{}".to_string(), - }, - ); - collected - .style_variants - .insert("empty".to_string(), (variants, true)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("variant: css")); - } - - #[test] - fn test_collected_styles_clone() { - // Test Clone implementation for CollectedStyles (covers line 1437-1438) - let mut original = CollectedStyles::default(); - original.styles.insert( - "test".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - original - .global_styles - .push(("body".to_string(), "{}".to_string())); - original.keyframes.insert( - "fade".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - original - .vars - .insert("v".to_string(), ("--v".to_string(), true)); - original - .font_faces - .insert("f".to_string(), ("{}".to_string(), "ff".to_string(), true)); - original - .containers - .insert("c".to_string(), ("cn".to_string(), true)); - original - .layers - .insert("l".to_string(), ("ln".to_string(), true)); - original - .constant_exports - .insert("C".to_string(), "1".to_string()); - - let cloned = original.clone(); - assert_eq!(cloned.styles.len(), original.styles.len()); - assert_eq!(cloned.global_styles.len(), original.global_styles.len()); - } - - #[test] - fn test_parse_style_variants_index_key() { - // Test parse_style_variants with numeric index key (covers line 1454) - let mut context = boa_engine::Context::default(); - let obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property with numeric key by setting index - let _ = obj.set( - 0, - boa_engine::JsValue::from( - boa_engine::object::ObjectInitializer::new(&mut context).build(), - ), - false, - &mut context, - ); - - let value = boa_engine::JsValue::from(obj); - let result = super::parse_style_variants(&value, &mut context); - // Should have one variant with key "0" - assert!(result.contains_key("0") || result.is_empty()); // Implementation may skip numeric - } - - #[test] - fn test_extract_var_names_non_style_api() { - // Test extract_var_names with non-style-api expressions - let code = r"import { style } from '@devup-ui/react' -export const CONSTANT = 42; -export const OBJECT = { key: 'value' }; -export const computed = 1 + 2; -"; - let vars = super::extract_var_names(code, "@devup-ui/react"); - // Should have constants - assert!(vars.iter().any(|(name, _)| name == "CONSTANT")); - assert!(vars.iter().any(|(name, _)| name == "OBJECT")); - } - - #[test] - fn test_extract_var_names_skips_declaration_without_initializer() { - let code = r"import { style } from '@devup-ui/react' -let pending; -export const ready = style({ color: 'red' });"; - - let vars = super::extract_var_names(code, "@devup-ui/react"); - - assert!(!vars.iter().any(|(name, _)| name == "pending")); - assert!(vars.iter().any(|(name, _)| name == "ready")); - } - - #[test] - fn test_preprocess_typescript_single_quotes() { - // Test preprocess with single quotes in import - let code = r"import { style } from '@devup-ui/react' -export const box = style({ padding: 8 })"; - let result = super::preprocess_typescript(code, "@devup-ui/react"); - assert!(result.contains("__vanilla_extract__")); - assert!(!result.contains("export const")); - } - - // Note: @vanilla-extract/css import transformation is now handled by import_alias_visit.rs - // before the code reaches vanilla_extract.rs, so preprocess_typescript only needs to - // handle the target package imports - - #[test] - fn test_find_selector_references_no_refs() { - // Test find_selector_references with no selector references - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.styles.insert( - "text".to_string(), - StyleEntry { - json: r#"{"color":"blue"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let refs = super::find_selector_references(&collected); - assert!(refs.is_empty()); - } - - #[test] - fn test_collected_styles_to_code_partial_empty() { - // Test collected_styles_to_code_partial with empty style_names - let collected = CollectedStyles::default(); - let empty_set = rustc_hash::FxHashSet::default(); - let code = - super::collected_styles_to_code_partial(&collected, "@devup-ui/react", &empty_set); - assert!(code.is_empty()); - } - - #[test] - fn test_collected_styles_to_code_with_classes_selector_replacement() { - // Test selector class name replacement in collected_styles_to_code_with_classes - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "parent".to_string(), - StyleEntry { - json: r#"{"background":"white"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.styles.insert( - "child".to_string(), - StyleEntry { - json: r#"{"selectors":{"parent:hover &":{"color":"blue"}}}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let class_map: rustc_hash::FxHashMap = - std::iter::once(("parent".to_string(), "a".to_string())).collect(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - assert!(code.contains("a:hover")); - } - - #[test] - fn test_transform_contract_to_vars_with_index_key() { - // Test transform_contract_to_vars with index/numeric keys (covers line 181) - let mut context = boa_engine::Context::default(); - let obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property using numeric index - let inner = boa_engine::JsValue::null(); - let _ = obj.set(0u32, inner, false, &mut context); - let value = boa_engine::JsValue::from(obj); - let result = super::transform_contract_to_vars(&value, &mut context, &mut Vec::new()); - // Should handle index keys - assert!(result.as_object().is_some()); - } - - #[test] - fn test_extract_theme_vars_with_index_key() { - // Test extract_theme_vars with index/numeric keys (covers line 212) - let mut context = boa_engine::Context::default(); - let contract_obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - let values_obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property using numeric index - let _ = contract_obj.set( - 0u32, - boa_engine::JsValue::from(boa_engine::js_string!("var(--test)")), - false, - &mut context, - ); - let _ = values_obj.set( - 0u32, - boa_engine::JsValue::from(boa_engine::js_string!("blue")), - false, - &mut context, - ); - let contract = boa_engine::JsValue::from(contract_obj); - let values = boa_engine::JsValue::from(values_obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - // Should process index keys - assert!(css_vars.is_empty() || !css_vars.is_empty()); - } - - #[test] - fn test_transform_theme_to_vars_with_index_key() { - // Test transform_theme_to_vars with index/numeric keys (covers line 263) - let mut context = boa_engine::Context::default(); - let obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property using numeric index - let _ = obj.set( - 0u32, - boa_engine::JsValue::from(boa_engine::js_string!("value")), - false, - &mut context, - ); - let value = boa_engine::JsValue::from(obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - // Should handle index keys - assert!(result.as_object().is_some()); - } - - #[test] - fn test_extract_theme_vars_value_conversion() { - // Test extract_theme_vars with value that needs to_string conversion (covers lines 234, 236) - let mut context = boa_engine::Context::default(); - let contract = boa_engine::JsValue::from(boa_engine::js_string!("var(--num)")); - // Use a number instead of string - will need to_string conversion - let values = boa_engine::JsValue::from(42); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - // Should extract the value as string - assert!(!css_vars.is_empty()); - assert_eq!(css_vars[0].0, "--num"); - assert_eq!(css_vars[0].1, "42"); - } - - #[test] - fn test_transform_theme_to_vars_value_conversion() { - // Test transform_theme_to_vars with value that needs to_string conversion (covers lines 287, 289) - let mut context = boa_engine::Context::default(); - // Use a number instead of string - will need to_string conversion - let value = boa_engine::JsValue::from(123); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let _result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - // Should have converted the number to string - assert!(!css_vars.is_empty()); - assert!(css_vars[0].1.contains("123")); - } - - #[test] - fn test_js_value_to_json_string_fallback() { - // Test js_value_to_json string fallback path (covers lines 316, 318-319) - let mut context = boa_engine::Context::default(); - - // Create a string value that can't use JSON.stringify - let string_val = boa_engine::JsValue::from(boa_engine::js_string!("test string")); - let result = super::js_value_to_json(&string_val, &mut context); - // Should contain the string (either via stringify or fallback) - assert!(result.contains("test string") || result.contains("\"test string\"")); - } - - #[test] - fn test_js_value_to_json_number_fallback() { - // Test js_value_to_json number fallback path (covers lines 322-323, 325) - let mut context = boa_engine::Context::default(); - - // Test with NaN - let nan_val = boa_engine::JsValue::from(f64::NAN); - let result = super::js_value_to_json(&nan_val, &mut context); - // NaN should return "NaN" in fallback - assert!(result == "null" || result == "NaN" || result.contains("NaN")); - - // Test with Infinity - let inf_val = boa_engine::JsValue::from(f64::INFINITY); - let result = super::js_value_to_json(&inf_val, &mut context); - // Infinity should return some representation - assert!(!result.is_empty()); - } - - #[test] - fn test_js_value_to_json_object_fallback() { - // Test js_value_to_json object fallback path (covers line 328) - let mut context = boa_engine::Context::default(); - - // Create a symbol which can't be JSON stringified - let symbol = boa_engine::JsSymbol::new(Some(boa_engine::js_string!("test"))); - if let Some(sym) = symbol { - let symbol_val = boa_engine::JsValue::from(sym); - let result = super::js_value_to_json(&symbol_val, &mut context); - // Symbols should fall back to "{}" or some default - assert!(!result.is_empty()); - } - } - - #[test] - fn test_collected_styles_to_code_with_classes_theme_vars() { - // Test collected_styles_to_code_with_classes with themes that have vars (covers lines 1103-1106) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.themes.insert( - "theme".to_string(), - super::ThemeEntry { - class_name: "f0_theme".to_string(), - css_vars: smallvec![("--color".to_string(), "blue".to_string())], - exported: true, - vars_name: Some("vars".to_string()), - vars_object_json: Some(r#"{"color":"var(--color)"}"#.to_string()), - }, - ); - - let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - assert!(code.contains("[theme, vars]")); - } - - #[test] - fn test_collected_styles_to_code_with_classes_theme_no_vars() { - // Test collected_styles_to_code_with_classes with theme without vars_object_json (covers lines 1108, 1111) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.themes.insert( - "simpleTheme".to_string(), - super::ThemeEntry { - class_name: "f0_simple".to_string(), - css_vars: smallvec![], - exported: true, - vars_name: Some("vars".to_string()), - vars_object_json: None, // No vars object - }, - ); - - let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - assert!(code.contains("const simpleTheme = \"f0_simple\"")); - } - - #[test] - fn test_append_non_style_code_full() { - // Test append_non_style_code with all types populated (covers lines 1125, 1132-1135, 1142-1144, 1152-1153) - let mut collected = CollectedStyles::default(); - - // Add global styles - collected.global_styles.push(( - "html".to_string(), - r#"{"boxSizing":"border-box"}"#.to_string(), - )); - - // Add font faces with properties - collected.font_faces.insert( - "customFont".to_string(), - ( - r#"{"src":"url(font.woff2)","fontWeight":"400"}"#.to_string(), - "__font_0".to_string(), - true, - ), - ); - - // Add global themes with CSS vars - collected.global_themes.insert( - "rootTheme".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![ - ("--bg".to_string(), "white".to_string()), - ("--fg".to_string(), "black".to_string()), - ], - vars_object_json: r#"{"bg":"var(--bg)","fg":"var(--fg)"}"#.to_string(), - exported: true, - }, - ); - - // Add keyframes - collected.keyframes.insert( - "bounce".to_string(), - StyleEntry { - json: r#"{"0%":{"transform":"scale(1)"},"50%":{"transform":"scale(1.1)"}}"# - .to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should include all types - assert!(code.contains("globalCss")); - assert!(code.contains("fontFamily")); - assert!(code.contains("keyframes")); - } - - #[test] - fn test_collected_styles_style_variants_with_base_composition() { - // Test styleVariants with base style composition in code generation (covers lines 1161-1170, 1173-1175, 1177) - let mut collected = CollectedStyles::default(); - - // Add base style - collected.styles.insert( - "baseBtn".to_string(), - StyleEntry { - json: r#"{"borderRadius":"4px","padding":"8px"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - - // Add styleVariants with base - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "primary".to_string(), - super::StyleVariant { - base: Some("baseBtn".to_string()), - styles_json: r#"{"backgroundColor":"blue","color":"white"}"#.to_string(), - }, - ); - variants.insert( - "secondary".to_string(), - super::StyleVariant { - base: Some("baseBtn".to_string()), - styles_json: r#"{"backgroundColor":"gray"}"#.to_string(), - }, - ); - collected - .style_variants - .insert("buttonVariants".to_string(), (variants, true)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should have merged styles from base - assert!(code.contains("buttonVariants")); - assert!(code.contains("borderRadius") || code.contains("primary")); - } - - #[test] - fn test_collected_styles_with_classes_style_variants() { - // Test collected_styles_to_code_with_classes with styleVariants (covers lines 1161-1184 in with_classes version) - let mut collected = CollectedStyles::default(); - - collected.styles.insert( - "base".to_string(), - StyleEntry { - json: r#"{"display":"flex"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "sm".to_string(), - super::StyleVariant { - base: Some("base".to_string()), - styles_json: r#"{"fontSize":"12px"}"#.to_string(), - }, - ); - variants.insert( - "lg".to_string(), - super::StyleVariant { - base: None, - styles_json: r#"{"fontSize":"20px"}"#.to_string(), - }, - ); - collected - .style_variants - .insert("sizes".to_string(), (variants, true)); - - let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - assert!(code.contains("sizes")); - } - - #[test] - fn test_collected_styles_constant_exports_in_with_classes() { - // Test constant exports in collected_styles_to_code_with_classes (covers line 1229) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r"{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected - .constant_exports - .insert("CONFIG".to_string(), "{ debug: true }".to_string()); - - let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - assert!(code.contains("export const CONFIG")); - } - - #[test] - fn test_theme_entry_vars_name_without_json() { - // Test theme with vars_name but without vars_object_json (covers line 1301) - let mut collected = CollectedStyles::default(); - collected.themes.insert( - "partialTheme".to_string(), - super::ThemeEntry { - class_name: "f0_partial".to_string(), - css_vars: smallvec![], - exported: true, - vars_name: Some("themeVars".to_string()), - vars_object_json: None, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should output just the class name assignment - assert!(code.contains("const partialTheme = \"f0_partial\"")); - } - - #[test] - fn test_collected_styles_to_code_with_classes_all_imports() { - // Test import generation for css, globalCss, keyframes (covers lines 1049, 1052) - let mut collected = CollectedStyles::default(); - - // Add style to trigger css import - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - // Add global style to trigger globalCss import - collected - .global_styles - .push(("body".to_string(), r#"{"margin":"0"}"#.to_string())); - - // Add keyframes to trigger keyframes import - collected.keyframes.insert( - "fade".to_string(), - StyleEntry { - json: r#"{"from":{"opacity":"0"}}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let class_map: rustc_hash::FxHashMap = - std::iter::once(("box".to_string(), "a".to_string())).collect(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - - assert!(code.contains("css")); - assert!(code.contains("globalCss")); - assert!(code.contains("keyframes")); - } - - #[test] - fn test_collected_styles_to_code_with_classes_theme_no_vars_name() { - // Test theme without vars_name (covers line 1111) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r"{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.themes.insert( - "simpleTheme".to_string(), - super::ThemeEntry { - class_name: "f0_simple".to_string(), - css_vars: smallvec![], - exported: true, - vars_name: None, - vars_object_json: None, - }, - ); - - let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - assert!(code.contains("const simpleTheme = \"f0_simple\"")); - } - - #[test] - fn test_append_non_style_code_global_styles() { - // Test globalCss code generation (covers line 1125) - let mut collected = CollectedStyles::default(); - collected.global_styles.push(( - "html".to_string(), - r#"{"boxSizing":"border-box"}"#.to_string(), - )); - collected - .global_styles - .push(("body".to_string(), r#"{"margin":"0"}"#.to_string())); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("globalCss({ \"html\":")); - assert!(code.contains("globalCss({ \"body\":")); - } - - #[test] - fn test_append_non_style_code_font_faces_with_props() { - // Test fontFace code generation with properties (covers lines 1132-1135) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "myFont".to_string(), - ( - r#"{"src":"url(font.woff2)","fontWeight":"bold"}"#.to_string(), - "__font_0".to_string(), - true, - ), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("fontFaces")); - assert!(code.contains("fontFamily")); - } - - #[test] - fn test_append_non_style_code_global_themes_with_vars() { - // Test createGlobalTheme code generation with CSS vars (covers lines 1142-1144) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "rootVars".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![ - ("--primary".to_string(), "blue".to_string()), - ("--secondary".to_string(), "green".to_string()), - ], - vars_object_json: r#"{"primary":"var(--primary)"}"#.to_string(), - exported: true, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("globalCss({ \":root\":")); - assert!(code.contains("--primary")); - } - - #[test] - fn test_append_non_style_code_keyframes() { - // Test keyframes code generation (covers lines 1152-1153) - let mut collected = CollectedStyles::default(); - collected.keyframes.insert( - "spin".to_string(), - StyleEntry { - json: r#"{"from":{"transform":"rotate(0)"},"to":{"transform":"rotate(360deg)"}}"# - .to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.keyframes.insert( - "internalFade".to_string(), - StyleEntry { - json: r#"{"0%":{"opacity":"0"}}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const spin = keyframes")); - assert!(code.contains("const internalFade = keyframes")); - } - - #[test] - fn test_append_non_style_code_vars() { - // Test createVar code generation (covers lines 1191-1192) - let mut collected = CollectedStyles::default(); - collected - .vars - .insert("colorVar".to_string(), ("--color-0".to_string(), true)); - collected.vars.insert( - "internalVar".to_string(), - ("--internal-0".to_string(), false), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const colorVar = \"--color-0\"")); - assert!(code.contains("const internalVar = \"--internal-0\"")); - } - - #[test] - fn test_append_non_style_code_font_face_declarations() { - // Test fontFace declaration code generation (covers lines 1199-1200) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "customFont".to_string(), - (r"{}".to_string(), "__devup_font_custom".to_string(), true), - ); - collected.font_faces.insert( - "internalFont".to_string(), - ( - r"{}".to_string(), - "__devup_font_internal".to_string(), - false, - ), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const customFont = \"__devup_font_custom\"")); - assert!(code.contains("const internalFont = \"__devup_font_internal\"")); - } - - #[test] - fn test_append_non_style_code_containers() { - // Test createContainer code generation (covers lines 1207-1208) - let mut collected = CollectedStyles::default(); - collected.containers.insert( - "sidebar".to_string(), - ("__container_sidebar".to_string(), true), - ); - collected.containers.insert( - "internalCont".to_string(), - ("__container_internal".to_string(), false), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const sidebar = \"__container_sidebar\"")); - assert!(code.contains("const internalCont = \"__container_internal\"")); - } - - #[test] - fn test_append_non_style_code_layers() { - // Test layer code generation (covers lines 1215-1216) - let mut collected = CollectedStyles::default(); - collected - .layers - .insert("baseLayer".to_string(), ("base".to_string(), true)); - collected - .layers - .insert("internalLayer".to_string(), ("internal".to_string(), false)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const baseLayer = \"base\"")); - assert!(code.contains("const internalLayer = \"internal\"")); - } - - #[test] - fn test_append_non_style_code_global_theme_vars_object() { - // Test createGlobalTheme vars object declaration (covers lines 1221-1222) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "themeVars".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![("--bg".to_string(), "white".to_string())], - vars_object_json: r#"{"bg":"var(--bg)"}"#.to_string(), - exported: true, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const themeVars = {\"bg\":\"var(--bg)\"}")); - } - - #[test] - fn test_style_with_non_object_argument() { - // Test style() with primitive value (covers line 749) - // This is tested through execute_vanilla_extract with edge case input - let code = r" -import { style } from '@devup-ui/react' -// This should still work - style with null or undefined would be an edge case -export const empty = style({}) -"; - let result = super::execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts"); - assert!(result.is_ok()); - } - - #[test] - fn test_style_with_object_no_length() { - // Test style() with regular object (covers line 745) - let code = r" -import { style } from '@devup-ui/react' -export const box = style({ padding: 8, margin: 4 }) -"; - let result = super::execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts"); - assert!(result.is_ok()); - let collected = result.unwrap(); - assert!(!collected.styles.is_empty()); - } - - #[test] - fn test_js_value_to_json_string_fallback_path() { - // Test js_value_to_json string fallback when JSON.stringify is broken (covers line 303) - use boa_engine::{Context, JsValue, Source, js_string}; - - let mut context = Context::default(); - - // Break JSON.stringify by setting it to undefined - context - .eval(Source::from_bytes(b"JSON.stringify = undefined")) - .unwrap(); - - let string_val = JsValue::from(js_string!("test")); - let result = super::js_value_to_json(&string_val, &mut context); - assert_eq!(result, "\"test\""); - } - - #[test] - fn test_js_value_to_json_null_fallback_path() { - // Test js_value_to_json null fallback when JSON.stringify is broken (covers line 304) - use boa_engine::{Context, JsValue, Source}; - - let mut context = Context::default(); - - // Break JSON.stringify by setting it to undefined - context - .eval(Source::from_bytes(b"JSON.stringify = undefined")) - .unwrap(); - - let null_val = JsValue::null(); - let result = super::js_value_to_json(&null_val, &mut context); - assert_eq!(result, "null"); + fn test_inner_json() { + assert_eq!(inner_json(r#" {"a":{"b":1}} "#), r#""a":{"b":1}"#); + assert_eq!(inner_json("[1]"), ""); } } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 6da7cceed..4ee13f5d4 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -30,11 +30,12 @@ use oxc_ast::ast::ImportDeclarationSpecifier::{self, ImportSpecifier}; use oxc_ast::ast::JSXAttributeItem::Attribute; use oxc_ast::ast::JSXAttributeName::Identifier; use oxc_ast::ast::{ - Argument, BindingPattern, CallExpression, ChainElement, ComputedMemberExpression, Expression, - ExpressionStatement, FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, - ImportOrExportKind, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXClosingFragment, - JSXElement, JSXElementName, JSXExpressionContainer, JSXOpeningFragment, ObjectPropertyKind, - Program, PropertyKey, PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, + Argument, BinaryOperator, BindingPattern, CallExpression, ChainElement, + ComputedMemberExpression, Expression, ExpressionStatement, FormalParameter, + FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, ImportOrExportKind, + JSXAttributeItem, JSXAttributeValue, JSXChild, JSXClosingFragment, JSXElement, JSXElementName, + JSXExpressionContainer, JSXOpeningFragment, ObjectPropertyKind, Program, PropertyKey, + PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, UnaryOperator, VariableDeclarator, }; use oxc_ast_visit::VisitMut; @@ -45,9 +46,9 @@ use oxc_ast_visit::walk_mut::{ use strum::IntoEnumIterator; use crate::utils::{ - ParsedStyleOrder, expression_to_style_order, get_str_by_property_key, + ParsedStyleOrder, StyleArguments, expression_to_style_order, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, jsx_expression_to_style_order, - unwrap_syntax_only, + style_arguments, unwrap_syntax_only, unwrap_syntax_only_mut, }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; @@ -305,6 +306,71 @@ impl<'a> DevupVisitor<'a> { (class_exprs, style_props) } + /// `((v) => typeof v === "number" ? v + unit : v)(value)`: how `StyleX` gives a + /// dynamic number its unit without evaluating `value` twice. + fn with_number_unit(&self, value: Expression<'a>, unit: &'static str) -> Expression<'a> { + if unit.is_empty() { + return value; + } + let param = || Expression::new_identifier(SPAN, "v", &self.ast); + let body = Expression::new_conditional_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, param(), &self.ast), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, &self.ast), + &self.ast, + ), + Expression::new_binary_expression( + SPAN, + param(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, unit, None, &self.ast), + &self.ast, + ), + param(), + &self.ast, + ); + let mut params = oxc_allocator::Vec::new_in(&self.ast); + params.push(FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(&self.ast), + BindingPattern::new_binding_identifier(SPAN, "v", &self.ast), + None, + None, + false, + None, + false, + false, + &self.ast, + )); + let arrow = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + params, + None::>>, + &self.ast, + ), + None::>>, + body.into(), + &self.ast, + ); + let mut arguments = oxc_allocator::Vec::new_in(&self.ast); + arguments.push(Argument::from(value)); + Expression::new_call_expression( + SPAN, + Expression::new_parenthesized_expression(SPAN, arrow, &self.ast), + None::>>, + arguments, + false, + &self.ast, + ) + } /// Resolve a dynamic namespace call like `styles.bar(h)` to (className, `style_props`). fn resolve_stylex_dynamic_call( &self, @@ -324,11 +390,12 @@ impl<'a> DevupVisitor<'a> { ); let mut props = Vec::with_capacity(info.css_vars.len()); - for (param_idx, var_name) in &info.css_vars { + for (param_idx, var_name, unit) in &info.css_vars { if let Some(arg) = call.arguments.get(*param_idx) && let Some(arg_expr) = arg.as_expression() { - let arg_expr = arg_expr.clone_in(self.ast.allocator()); + let arg_expr = + self.with_number_unit(arg_expr.clone_in(self.ast.allocator()), unit); props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, @@ -608,6 +675,24 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } fn visit_expression(&mut self, it: &mut Expression<'a>) { + // Emotion's `styled(tag, options)` is called for a component, so its second + // argument holds options rather than the rules of Devup UI's two-argument form + let factory = match it { + Expression::CallExpression(call) => Some(&mut call.callee), + Expression::TaggedTemplateExpression(tag) => Some(&mut tag.tag), + _ => None, + }; + if let Some(styled_name) = &self.styled_import + && let Some(factory) = factory + && let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory) + && call.arguments.len() == 2 + && call.arguments[1].as_expression().is_some_and(|options| { + matches!(unwrap_syntax_only(options), Expression::ObjectExpression(_)) + }) + && matches!(&call.callee, Expression::Identifier(ident) if ident.name == styled_name.as_str()) + { + call.arguments.truncate(1); + } walk_expression(self, it); // Handle styled function calls @@ -1015,6 +1100,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }; if let Some(util_type) = util_type { + let composed_classes = if matches!(util_type.as_ref(), UtilType::Css) + && let Some(StyleArguments { classes, rules }) = + style_arguments(&self.ast, &call.arguments) + { + call.arguments = + oxc_allocator::Vec::from_array_in([Argument::from(rules)], &self.ast); + classes + } else { + vec![] + }; if call.arguments.len() == 1 { let r = util_type.as_ref(); *it = if matches!(r, UtilType::Css) { @@ -1092,12 +1187,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &self.filename, ); // already set style order - self.styles.extend(styles.into_iter().flat_map(|mut ex| { - if let ExtractStyleProp::Static(css) = &mut ex { - css.set_style_order(style_order.unwrap_or(0)); - } - ex.into_extract() - })); + let style_order = style_order.unwrap_or(0); + self.styles.extend( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .map(|mut style| { + style.set_style_order(style_order); + style + }), + ); self.global_css_result(r.is_component()) } } else if call.arguments.len() == 2 @@ -1139,12 +1238,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &mut folded, &self.filename, ); - self.styles.extend(styles.into_iter().flat_map(|mut ex| { - if let ExtractStyleProp::Static(css) = &mut ex { - css.set_style_order(style_order.unwrap_or(0)); - } - ex.into_extract() - })); + let style_order = style_order.unwrap_or(0); + self.styles.extend( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .map(|mut style| { + style.set_style_order(style_order); + style + }), + ); *it = self.global_css_result(util_type.is_component()); } else { *it = match util_type.as_ref() { @@ -1154,6 +1257,17 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { global => self.global_css_result(global.is_component()), }; } + if !composed_classes.is_empty() { + let own = std::mem::replace( + it, + Expression::new_string_literal(SPAN, "", None, &self.ast), + ); + *it = merge_expression_for_class_name( + &self.ast, + composed_classes.into_iter().chain([own]), + ) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)); + } } } else if let Expression::TaggedTemplateExpression(tag) = it && let Expression::Identifier(ident) = &tag.tag @@ -1633,12 +1747,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { styles, style_order, } = extract_global_style_from_expression(&self.ast, expression, &self.filename); - self.styles.extend(styles.into_iter().flat_map(|mut ex| { - if let ExtractStyleProp::Static(css) = &mut ex { - css.set_style_order(style_order.unwrap_or(0)); - } - ex.into_extract() - })); + let style_order = style_order.unwrap_or(0); + self.styles.extend( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .map(|mut style| { + style.set_style_order(style_order); + style + }), + ); } elem.opening_element.attributes.remove(i); } diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs new file mode 100644 index 000000000..0f1268a81 --- /dev/null +++ b/libs/extractor/tests/vanilla_extract.rs @@ -0,0 +1,89 @@ +#![allow(clippy::unwrap_used)] + +use std::collections::HashMap; + +use extractor::{ExtractOption, extract}; + +fn extract_stylesheet(code: &str) -> String { + let output = extract( + "stylesheet.css.ts", + &format!("import {{ style, globalStyle, styleVariants, keyframes, fontFace, globalFontFace, createVar, fallbackVar, createContainer, layer, globalLayer, createTheme, createGlobalTheme, createThemeContract, createGlobalThemeContract, assignVars }} from '@devup-ui/react'\n{code}"), + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap(); + format!("{:?}\n{}", output.styles, output.code) +} + +// The library build other targets link is a separate copy from the unit-test +// build; these run every vanilla-extract API through it. +#[test] +fn font_faces_extract_through_the_library() { + let output = extract_stylesheet( + r"const body = fontFace({ src: 'local(a)' }, 'Body') +const icons = fontFace([{ src: 'local(b)' }, {}]) +const cyclic = {}; cyclic.self = cyclic +fontFace(cyclic) +globalFontFace('Inter', { src: 'local(Inter)' }) +export const text = style({ fontFamily: body, content: icons })", + ); + for family in ["Body-", "font-", "Inter", "local(b)"] { + assert!(output.contains(family), "{family} missing from {output}"); + } +} + +#[test] +fn an_unreadable_var_declaration_leaves_the_stylesheet_alone() { + let code = "import { createVar } from '@devup-ui/react'\nexport const v = createVar({ syntax: Symbol() })"; + let output = extract( + "broken.css.ts", + code, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap(); + assert!(output.styles.is_empty()); +} + +#[test] +fn vars_themes_and_layers_extract_through_the_library() { + let output = extract_stylesheet( + r"const plain = createVar() +const typed = createVar({ syntax: ['', ''], inherits: false, initialValue: '0px' }, 'size') +export const box = style({ vars: { [plain]: '1px' }, width: fallbackVar(typed, plain, '2px'), containerName: createContainer('side') }) +const contract = createThemeContract({ color: null }) +export const theme = createTheme(contract, { color: 'red' }) +export const [light, lightVars] = createTheme({ '@layer': layer('theme'), space: 4 }) +createGlobalTheme(':root', createGlobalThemeContract({ gap: 'gap' }), { gap: '1px' }) +export const assigned = style({ vars: assignVars(contract, { color: 'blue' }) }) +export const variants = styleVariants({ a: { color: 'red' } }, (rule) => [box, rule]) +globalStyle(`${variants.a} span`, { '@layer': { [globalLayer({ parent: 'base' }, 'x')]: { margin: 1 } } }) +export const spin = keyframes({ to: { opacity: 1 } }) +export const named = style({ content: `'${box}'` })", + ); + for expected in [ + "_box'\", level: 0, selector: None", + "@property --size-", + "var(--size-", + "container-name", + "theme-", + "property: \"--gap\", value: \"1px\"", + "value: \"4\", level: 0, selector: Some(Global(\".theme-", + "base.x", + ] { + assert!( + output.contains(expected), + "{expected} missing from {output}" + ); + } +} diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 695a0c2e6..eb71220eb 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -2,12 +2,15 @@ pub mod theme; use crate::theme::Theme; use css::{ + at_rule::{MediaCombination, combine_media_queries, query_order}, atom_hoist::{atom_hoist_threshold, is_atom_hoist}, file_map::canonical, file_routes::route_count_for_files, get_custom_shorthand_names, sheet_to_classname, - style_selector::{AtRuleKind, StyleSelector, get_selector_order, global_selector_order}, - theme_tokens::set_theme_token_levels, + style_selector::{ + AtRule, AtRuleKind, StyleSelector, get_selector_order, global_selector_order, write_at_rule, + }, + theme_tokens::{set_theme_token_levels, set_typography_keys}, utils::compile_regex, write_merge_selector, }; @@ -31,43 +34,50 @@ macro_rules! push_fmt { }}; } -/// Shared 5-field comparator for the decorate-sort-undecorate keyed tuples in -/// `create_style_with_layers`. Both the `global_keyed` (`bool` first field) and -/// `keyed` (`u8` first field) buckets share the shape -/// `(K, order: u8, selector_str: &str, prop: &StyleSheetProperty)` and sort by the -/// exact same chain: precomputed order key first (`bool`/`u8` both compare via -/// `Ord`), then selector string, then property, then value. Extracted so the two -/// call sites cannot drift; produces byte-identical ordering to the former inline -/// closures. -fn keyed_prop_cmp( - a: &(K, u8, &str, &StyleSheetProperty), - b: &(K, u8, &str, &StyleSheetProperty), -) -> std::cmp::Ordering { - a.0.cmp(&b.0) - .then_with(|| a.1.cmp(&b.1)) - .then_with(|| a.2.cmp(b.2)) - .then_with(|| a.3.property.cmp(&b.3.property)) - .then_with(|| a.3.value.cmp(&b.3.value)) +/// Plain rules first, then pseudo selectors in `SELECTOR_ORDER`. +fn selector_group(selector: Option<&str>) -> (u8, u8) { + selector.map_or((0, 0), |selector| (1, get_selector_order(selector))) } -fn global_prop_key(prop: &StyleSheetProperty) -> (bool, u8, &str, &StyleSheetProperty) { - match &prop.selector { - Some(StyleSelector::Global(selector, _)) => { - if let Some(i) = selector.find(':') { - ( - true, - global_selector_order(&selector[i..]), - selector.as_str(), - prop, - ) - } else { - (false, 0u8, selector.as_str(), prop) - } +/// Typography preset declarations come first so a declaration written directly +/// on the same selector wins, and each preset stays contiguous so its rules merge. +fn prop_cmp(a: &StyleSheetProperty, b: &StyleSheetProperty) -> std::cmp::Ordering { + b.typography.cmp(&a.typography).then_with(|| { + if a.typography { + a.class_name + .cmp(&b.class_name) + .then_with(|| a.property.cmp(&b.property)) + .then_with(|| a.value.cmp(&b.value)) + } else { + a.property + .cmp(&b.property) + .then_with(|| a.value.cmp(&b.value)) + .then_with(|| a.class_name.cmp(&b.class_name)) } - _ => (false, 0u8, "", prop), - } + }) +} + +/// (selector group, level, selector) +type RuleOrder<'a> = ((u8, u8), u8, &'a str); +/// (enclosing at-rules outermost first, selector group, level, selector) +type AtRuleOrder<'a> = (Vec<(u8, (u8, i64), &'a str)>, (u8, u8), u8, &'a str); + +/// The blocks a rule sits in: its breakpoint level and at-rule chain. +#[derive(Clone, Copy, PartialEq, Eq)] +struct Wrapper<'a> { + level: u8, + at_rule: Option<(&'a [AtRule], AtRuleKind, &'a str)>, } +/// Global selectors without a pseudo part first, then by `SELECTOR_ORDER`. +fn global_selector_group(selector: &str) -> (bool, u8) { + selector.find(':').map_or((false, 0), |i| { + (true, global_selector_order(&selector[i..])) + }) +} + +type GlobalProp<'a> = (u8, &'a str, &'a StyleSheetProperty); + #[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct StyleSheetProperty { @@ -82,6 +92,9 @@ pub struct StyleSheetProperty { /// CSS layer name (from vanilla-extract `layer()`) #[serde(rename = "l", skip_serializing_if = "Option::is_none")] pub layer: Option, + /// Declaration expanded from a conditional `typography` preset + #[serde(rename = "t", default, skip_serializing_if = "std::ops::Not::not")] + pub typography: bool, } #[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize)] @@ -117,13 +130,14 @@ impl Ord for StyleSheetProperty { } impl StyleSheetProperty { - fn write_extract(&self, css: &mut String) { - write_merge_selector(css, &self.class_name, self.selector.as_ref()); - css.push('{'); + fn same_rule(&self, other: &Self) -> bool { + self.class_name == other.class_name && self.selector == other.selector + } + + fn write_declaration(&self, css: &mut String) { css.push_str(&self.property); css.push(':'); css.push_str(&convert_theme_variable_value(&self.value)); - css.push('}'); } } @@ -209,7 +223,7 @@ pub struct StyleSheetCss { type PropertyMap = BTreeMap>>; type KeyframesMap = BTreeMap>>>; /// layer name -> Vec<(selector, property, value)> collected for `@layer` output. -type LayeredStyles = BTreeMap>; +type LayeredStyles = BTreeMap; fn deserialize_btree_map_u8<'de, D>( deserializer: D, @@ -291,9 +305,37 @@ impl StyleSheet { style_order: Option, filename: Option<&str>, layer: Option<&str>, + ) -> bool { + self.insert_property( + level, + style_order, + filename, + StyleSheetProperty { + class_name: class_name.to_string(), + property: property.to_string(), + value: value.to_string(), + selector: selector.cloned(), + layer: layer.map(ToString::to_string), + typography: false, + }, + ) + } + + fn insert_property( + &mut self, + level: u8, + style_order: Option, + filename: Option<&str>, + prop: StyleSheetProperty, ) -> bool { // register global css file for cache - if let Some(StyleSelector::Global(_, file)) = selector { + if let Some( + StyleSelector::Global(_, file) + | StyleSelector::At { + file: Some(file), .. + }, + ) = &prop.selector + { // Probe with the borrowed `&str` first so the owned `String` is only // allocated on first registration, not on repeat (HMR/multi-property) calls. // Matches the borrow-probe-first pattern in add_import/add_font_face/add_css. @@ -314,13 +356,7 @@ impl StyleSheet { .or_default() .entry(level) .or_default() - .insert(StyleSheetProperty { - class_name: class_name.to_string(), - property: property.to_string(), - value: value.to_string(), - selector: selector.cloned(), - layer: layer.map(ToString::to_string), - }) + .insert(prop) } pub fn add_import(&mut self, file: &str, import: &str) { @@ -414,12 +450,11 @@ impl StyleSheet { let bucket_empty = if let Some(prop_map) = self.properties.get_mut(&property_key) { for map in prop_map.values_mut() { for props in map.values_mut() { - props.retain(|prop| { - if let Some(StyleSelector::Global(_, f)) = prop.selector.as_ref() { - f != file - } else { - true - } + props.retain(|prop| match prop.selector.as_ref() { + Some( + StyleSelector::Global(_, f) | StyleSelector::At { file: Some(f), .. }, + ) => f != file, + _ => true, }); } // remove empty map @@ -443,6 +478,7 @@ impl StyleSheet { theme.get_length_token_levels(), theme.get_shadow_token_levels(), ); + set_typography_keys(theme.typography.keys().cloned().collect()); self.theme = theme; } @@ -466,6 +502,53 @@ impl StyleSheet { let bucket_scope = if single_css { None } else { Some(filename) }; for style in styles { match style { + // A conditional `typography` preset: its class is the atom, and the + // preset's declarations are emitted under the atom's selector. + ExtractStyleValue::Static(st) if st.property() == "typography" => { + let (StyleProperty::ClassName(class_name) + | StyleProperty::Variable { class_name, .. }) = st.extract(name_scope); + // `preset|property:level,...` lists the properties declared + // directly beside the preset and the breakpoint each starts at + let (preset, yielded) = st + .value() + .split_once('|') + .unwrap_or_else(|| (st.value(), "")); + let yielded: Vec<(&str, u8)> = yielded + .split(',') + .filter_map(|item| { + let (property, level) = item.split_once(':')?; + Some((property, level.parse().ok()?)) + }) + .collect(); + for (level, property, value) in + self.theme.typography_declarations(preset, st.level()) + { + if yielded + .iter() + .any(|(yielded, from)| *yielded == property && level >= *from) + { + continue; + } + if self.insert_property( + level, + st.style_order(), + bucket_scope, + StyleSheetProperty { + class_name: class_name.clone(), + property: property.to_string(), + value, + selector: st.selector().cloned(), + layer: st.layer().map(ToString::to_string), + typography: true, + }, + ) { + collected = true; + if st.style_order() == Some(0) { + updated_base_style = true; + } + } + } + } ExtractStyleValue::Static(st) => { let is_first_value = st.theme_token_resolution() == ThemeTokenResolution::FirstValue; @@ -741,248 +824,247 @@ impl StyleSheet { // deep-cloning every `StyleSheetProperty` (3 owned `String`s + `Option`). // Each `prop` here is a `&P`; `.borrow()` yields the `&StyleSheetProperty` all downstream // buckets already hold. Output is byte-identical. + /// Layered properties are written with the same rules as the others, each + /// layer on its own: into `layered_styles` when given, else as trailing + /// `@layer` blocks. fn create_style_with_layers>( &self, map: &BTreeMap>, mut layered_styles: Option<&mut LayeredStyles>, + ) -> String { + let mut css = self.create_layer_style(map, None); + let layers: BTreeSet<&str> = map + .values() + .flatten() + .filter_map(|prop| prop.borrow().layer.as_deref()) + .collect(); + for layer in layers { + let layer_css = self.create_layer_style(map, Some(layer)); + match layered_styles.as_deref_mut() { + Some(layered) => layered + .entry(layer.to_string()) + .or_default() + .push_str(&layer_css), + None => push_fmt!(&mut css, "@layer {layer}{{{layer_css}}}"), + } + } + css + } + + /// The properties of `map` in `layer` (`None`: in no layer) + fn create_layer_style>( + &self, + map: &BTreeMap>, + layer: Option<&str>, ) -> String { // Estimate ~64 bytes per property for pre-allocation let prop_count: usize = map.values().map(FxHashSet::len).sum(); let mut current_css = String::with_capacity(prop_count * 64); + let mut class_rules: Vec<(RuleOrder<'_>, &StyleSheetProperty)> = + Vec::with_capacity(prop_count); + let mut at_rules: Vec<(AtRuleOrder<'_>, Wrapper<'_>, &StyleSheetProperty)> = Vec::new(); + let mut global_props: Vec> = Vec::new(); for (level, props) in map { - // Single pass bucketing: the two prior `partition` calls always - // heap-allocated all four output `Vec`s even when the Global/At - // buckets stay empty (the common case). One scan pushing each - // `&prop` into one of three pre-declared `Vec`s leaves empty buckets - // as non-allocating `Vec::new()` and avoids the second full scan. - let mut global_props: Vec<_> = Vec::new(); - let mut at_rules: Vec<_> = Vec::new(); - // Plain (non-Global, non-At) selectors dominate: presize this bucket - // to `props.len()` to avoid its 1→4→8→… grow-reallocs. `global_props` - // / `at_rules` stay `Vec::new()` so empty buckets never allocate. - let mut sorted_props: Vec<&StyleSheetProperty> = Vec::with_capacity(props.len()); for prop in props { let prop: &StyleSheetProperty = prop.borrow(); - match prop.selector { - Some(StyleSelector::Global(_, _)) => global_props.push(prop), - Some(StyleSelector::At { .. }) => at_rules.push(prop), - _ => sorted_props.push(prop), + if prop.layer.as_deref() != layer { + continue; } - } - // Decorate-sort-undecorate for the Global bucket, mirroring the - // plain-selector `keyed` sort below. `global_props.sort()` compares via - // `StyleSheetProperty::cmp` → `StyleSelector::cmp`'s Global arm, which for - // EACH comparison re-runs `global_selector_order` (a `SELECTOR_ORDER` table - // walk) on BOTH operands — O(n log n) redundant re-scans of the same - // selector strings. Compute each prop's Global order key ONCE. The key - // `(has_colon, order, selector_str, property, value)` reproduces the Global - // arm byte-for-byte: equal selector strings ⇒ `Ordering::Equal` there, so - // `StyleSheetProperty::cmp` falls through to (property, value); no-colon - // props (`has_colon=false`) sort before colon props and tie on the selector - // string; colon props sort by (order, selector string). - // The common level has no Global selectors, so `global_props` is empty. - // Skip the three vector materializations (keyed `with_capacity`, `extend`, - // sorted `collect`) entirely in that case, mirroring the `at_rules.is_empty()` - // guard below; only decorate-sort-undecorate when there is actually work. - // An empty sort/collect yields an empty vec anyway, so output is byte-identical. - let global_props: Vec<&StyleSheetProperty> = if global_props.is_empty() { - Vec::new() - } else { - let mut global_keyed: Vec<(bool, u8, &str, &StyleSheetProperty)> = - Vec::with_capacity(global_props.len()); - global_keyed.extend(global_props.into_iter().map(global_prop_key)); - global_keyed.sort_by(keyed_prop_cmp); - global_keyed - .into_iter() - .map(|(_, _, _, prop)| prop) - .collect() - }; - // Decorate-sort-undecorate for the plain-selector bucket: sorting via - // `StyleSheetProperty::cmp` re-runs `get_selector_order` (a byte scan + - // linear `SELECTOR_ORDER` probe) on BOTH operands of every comparison, - // i.e. O(n log n) redundant scans of the same `Selector` strings. Here - // `sorted_props` holds only `None`/`Selector(_)` variants (Global/At were - // filtered out above), so compute each prop's order key ONCE into a keyed - // vec and sort that. The key `(is_some, order, selector_str, property, - // value)` reproduces `StyleSheetProperty::cmp` byte-for-byte: `None` props - // (is_some=0, order=0, selector_str="") sort by (property, value); `Selector` - // props (is_some=1) sort by (order, selector_str, property, value) — matching - // `StyleSelector::cmp`'s `get_selector_order` then string tie-break. - // The common level often has ONLY global/at props, leaving `sorted_props` - // empty. Skip the `with_capacity`/`extend`/`sort_by` triple entirely in that - // case, mirroring the `global_props`/`at_rules` guards above and below; an - // empty build+sort yields an empty vec anyway, so output is byte-identical. - let keyed: Vec<(u8, u8, &str, &StyleSheetProperty)> = if sorted_props.is_empty() { - Vec::new() - } else { - let mut keyed: Vec<(u8, u8, &str, &StyleSheetProperty)> = - Vec::with_capacity(sorted_props.len()); - keyed.extend(sorted_props.into_iter().map(|prop| match &prop.selector { - Some(StyleSelector::Selector(s)) => { - (1u8, get_selector_order(s), s.as_str(), prop) + match &prop.selector { + Some(StyleSelector::Global(selector, _)) => { + global_props.push((*level, selector.as_str(), prop)); } - _ => (0u8, 0u8, "", prop), - })); - keyed.sort_by(keyed_prop_cmp); - keyed - }; - // The common level has no `@`-rule atoms, so `at_rules` is empty. - // Skip the `sort()` no-op and the per-level `BTreeMap` construction - // entirely in that case; only regroup when there is actually work. - let at_rules: BTreeMap<(AtRuleKind, &String), Vec<_>> = if at_rules.is_empty() { - BTreeMap::new() - } else { - at_rules.sort(); - let mut map: BTreeMap<(AtRuleKind, &String), Vec<_>> = BTreeMap::new(); - for prop in at_rules { - if let Some(StyleSelector::At { kind, query, .. }) = &prop.selector { - map.entry((*kind, query)).or_default().push(prop); + Some(StyleSelector::At { + kind, + query, + selector, + outer, + .. + }) => { + let chain = outer + .iter() + .map(|rule| (rule.kind, rule.query.as_str())) + .chain(std::iter::once((*kind, query.as_str()))) + .map(|(kind, query)| (kind as u8, query_order(kind, query), query)) + .collect(); + let selector = selector.as_deref(); + at_rules.push(( + ( + chain, + selector_group(selector), + *level, + selector.unwrap_or(""), + ), + Wrapper { + level: *level, + at_rule: Some((outer.as_slice(), *kind, query.as_str())), + }, + prop, + )); } + Some(StyleSelector::Selector(selector)) => class_rules.push(( + (selector_group(Some(selector)), *level, selector.as_str()), + prop, + )), + None => class_rules.push(((selector_group(None), *level, ""), prop)), } - map - }; + } + } + if !global_props.is_empty() { + self.write_global_props(&mut current_css, global_props); + } - let break_point = if *level == 0 { - None - } else { - Some( - self.theme - .breakpoints - .get(*level as usize) - .copied() - .unwrap_or_else(|| self.theme.breakpoints.last().copied().unwrap_or(0)), + // Selector group (plain, then `SELECTOR_ORDER`) sorts before the breakpoint + // level, so `:active` still follows a `:hover` set at a wider breakpoint while + // one selector's responsive values keep ascending. At-rules follow every plain + // rule so a condition beats the breakpoint values it overrides; among + // themselves they order by condition, then selector group, then level. + class_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.1, b.1))); + at_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.2, b.2))); + + let mut open: Option<(Wrapper<'_>, Option)> = None; + let mut open_rule: Option<&StyleSheetProperty> = None; + for (wrapper, prop) in class_rules + .iter() + .map(|((_, level, _), prop)| { + ( + Wrapper { + level: *level, + at_rule: None, + }, + *prop, ) - }; - - if !global_props.is_empty() { - // Separate layered and non-layered global props. Only pay the - // partition + clone-into-map cost when the caller actually - // consumes the layered output (base/global path); callers that - // discard layers pass `None` and keep every global prop inline. - let non_layered_props = if let Some(layered_styles) = layered_styles.as_deref_mut() - { - let (layered_props, non_layered_props): (Vec<_>, Vec<_>) = global_props - .into_iter() - .partition(|prop| prop.layer.is_some()); - - // Collect layered props for later processing - for prop in layered_props { - if let Some(layer) = &prop.layer - && let Some(StyleSelector::Global(selector, _)) = &prop.selector - { - // Borrow-probe existing entry by layer.as_str() before cloning - let bucket = match layered_styles.get_mut(layer.as_str()) { - Some(bucket) => bucket, - None => layered_styles.entry(layer.clone()).or_default(), - }; - bucket.push(( - selector.clone(), - prop.property.clone(), - prop.value.clone(), - )); - } - } - non_layered_props + }) + .chain(at_rules.iter().map(|(_, wrapper, prop)| (*wrapper, *prop))) + { + if open.as_ref().is_none_or(|(current, _)| *current != wrapper) { + if open_rule.take().is_some() { + current_css.push('}'); + } + if let Some((_, Some(depth))) = open { + current_css.extend(std::iter::repeat_n('}', depth)); + } + let depth = self.open_wrapper(&mut current_css, wrapper); + open = Some((wrapper, depth)); + } + if let Some((_, Some(_))) = open { + if open_rule.is_some_and(|rule| rule.same_rule(prop)) { + current_css.push(';'); } else { - global_props - }; - - // Process non-layered global props as before - if !non_layered_props.is_empty() { - let mut selector_map: BTreeMap<_, Vec<_>> = BTreeMap::new(); - for prop in non_layered_props { - if let Some(StyleSelector::Global(selector, _)) = &prop.selector { - selector_map - .entry(selector) - .or_insert_with(|| Vec::with_capacity(1)) - .push(prop); - } - } - if let Some(break_point) = break_point { - push_fmt!(&mut current_css, "@media(min-width:{break_point}px){{"); - } - for (selector, props) in selector_map { - current_css.push_str(selector); - current_css.push('{'); - let mut first = true; - for prop in props { - if !first { - current_css.push(';'); - } - first = false; - current_css.push_str(&prop.property); - current_css.push(':'); - current_css.push_str(&prop.value); - } - current_css.push('}'); - } - if break_point.is_some() { + if open_rule.is_some() { current_css.push('}'); } + write_merge_selector( + &mut current_css, + &prop.class_name, + prop.selector.as_ref(), + ); + current_css.push('{'); + open_rule = Some(prop); } + prop.write_declaration(&mut current_css); } + } + if open_rule.is_some() { + current_css.push('}'); + } + if let Some((_, Some(depth))) = open { + current_css.extend(std::iter::repeat_n('}', depth)); + } + current_css + } - if !keyed.is_empty() { - if let Some(break_point) = break_point { - push_fmt!(&mut current_css, "@media(min-width:{break_point}px){{"); + fn break_point(&self, level: u8) -> u16 { + self.theme + .breakpoints + .get(level as usize) + .copied() + .unwrap_or_else(|| self.theme.breakpoints.last().copied().unwrap_or(0)) + } + + /// Open the blocks `wrapper` needs and return how many to close, or `None` + /// when the breakpoint and the media query can never hold together. + fn open_wrapper(&self, css: &mut String, wrapper: Wrapper<'_>) -> Option { + let mut rules: Vec<(AtRuleKind, Cow<'_, str>)> = + wrapper + .at_rule + .map_or_else(Vec::new, |(outer, kind, query)| { + outer + .iter() + .map(|rule| (rule.kind, Cow::Borrowed(rule.query.as_str()))) + .chain(std::iter::once((kind, Cow::Borrowed(query)))) + .collect() + }); + if wrapper.level > 0 { + let bp_query = format!("(min-width:{}px)", self.break_point(wrapper.level)); + // The breakpoint folds into the outermost `@media` when one query can + // express both; otherwise it wraps the whole chain. + let combined = match rules.first() { + Some((AtRuleKind::Media, query)) => combine_media_queries(&bp_query, query), + _ => MediaCombination::Nest, + }; + match combined { + MediaCombination::Merged(merged) => rules[0].1 = Cow::Owned(merged), + MediaCombination::Never => return None, + MediaCombination::Nest => { + rules.insert(0, (AtRuleKind::Media, Cow::Owned(bp_query))); } - for (_, _, _, prop) in &keyed { - prop.write_extract(&mut current_css); + } + } + for (kind, query) in &rules { + let _ = write_at_rule(css, *kind, query); + css.push('{'); + } + Some(rules.len()) + } + + fn write_global_props(&self, css: &mut String, mut global_props: Vec>) { + // Same order as class rules: selector group before breakpoint level, so a + // `:hover` set at a wider breakpoint still precedes `:active`. + global_props.sort_by(|a, b| { + global_selector_group(a.1) + .cmp(&global_selector_group(b.1)) + .then_with(|| a.0.cmp(&b.0)) + .then_with(|| a.1.cmp(b.1)) + .then_with(|| a.2.property.cmp(&b.2.property)) + .then_with(|| a.2.value.cmp(&b.2.value)) + }); + + let mut open_level: Option = None; + let mut open_selector: Option<&str> = None; + for (level, selector, prop) in global_props { + if open_level != Some(level) { + if open_selector.take().is_some() { + css.push('}'); } - if break_point.is_some() { - current_css.push('}'); + if open_level.is_some_and(|open| open > 0) { + css.push('}'); } + if level > 0 { + push_fmt!(css, "@media(min-width:{}px){{", self.break_point(level)); + } + open_level = Some(level); } - for ((kind, query), props) in at_rules { - if let Some(break_point) = break_point { - match kind { - AtRuleKind::Media => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px)and {query}{{" - ); - } - AtRuleKind::Supports => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px){{@supports{query}{{" - ); - } - AtRuleKind::Container => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px){{@container{query}{{" - ); - } - AtRuleKind::Layer => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px){{@layer {query}{{" - ); - } - } - for prop in props { - prop.write_extract(&mut current_css); - } - match kind { - AtRuleKind::Media => current_css.push('}'), - _ => current_css.push_str("}}"), - } - } else { - push_fmt!(&mut current_css, "@{kind}"); - if query.starts_with('(') { - push_fmt!(&mut current_css, "{query}{{"); - } else { - push_fmt!(&mut current_css, " {query}{{"); - } - for prop in props { - prop.write_extract(&mut current_css); - } - current_css.push('}'); + if open_selector == Some(selector) { + css.push(';'); + } else { + if open_selector.is_some() { + css.push('}'); } + css.push_str(selector); + css.push('{'); + open_selector = Some(selector); } + css.push_str(&prop.property); + css.push(':'); + css.push_str(&prop.value); + } + if open_selector.is_some() { + css.push('}'); + } + if open_level.is_some_and(|open| open > 0) { + css.push('}'); } - current_css } #[inline] @@ -1153,32 +1235,8 @@ impl StyleSheet { } css.push(';'); - // Generate styles wrapped in @layer blocks - for (layer_name, styles) in layered_styles { - // Group by selector - let mut selector_map: BTreeMap> = BTreeMap::new(); - for (selector, property, value) in styles { - selector_map - .entry(selector) - .or_default() - .push((property, value)); - } - - push_fmt!(&mut css, "@layer {layer_name}{{"); - for (selector, props) in selector_map { - css.push_str(&selector); - css.push('{'); - let mut first = true; - for (p, v) in props { - if !first { - css.push(';'); - } - first = false; - push_fmt!(&mut css, "{p}:{v}"); - } - css.push('}'); - } - css.push('}'); + for (layer_name, layer_css) in layered_styles { + push_fmt!(&mut css, "@layer {layer_name}{{{layer_css}}}"); } } // Atom hoisting: emit shared (hoisted) order!=0 atoms into the global @@ -1412,6 +1470,8 @@ mod tests { kind: AtRuleKind::Media, query: "(hover:hover)".to_string(), selector: None, + outer: vec![], + file: None, }; sheet.add_property( "atr", @@ -1491,8 +1551,8 @@ mod tests { set_file_routes(previous_routes); assert!( - css.contains("@layer o2{div{border-radius:9px}}"), - "layered global hoist must emit direct CSS: {css}" + css.contains("@layer o2{@layer components{div{border-radius:9px}}}"), + "layered global hoist must keep its layer: {css}" ); } @@ -2198,14 +2258,14 @@ mod tests { } #[test] - fn test_speech_selector() { + fn test_motion_reduce_selector() { let mut sheet = StyleSheet::default(); sheet.add_property( "test", "display", 0, "none", - Some(&"speech".into()), + Some(&"motion-reduce".into()), None, None, ); @@ -2241,6 +2301,8 @@ mod tests { kind: AtRuleKind::Media, query: "(min-width: 1024px)".to_string(), selector: Some("&:hover".to_string()), + outer: vec![], + file: None, }), None, None, @@ -2254,6 +2316,8 @@ mod tests { kind: AtRuleKind::Media, query: "(min-width: 1024px)".to_string(), selector: Some("&:hover".to_string()), + outer: vec![], + file: None, }), None, None, @@ -2274,6 +2338,8 @@ mod tests { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), selector: None, + outer: vec![], + file: None, }), None, None, @@ -2294,6 +2360,8 @@ mod tests { kind: AtRuleKind::Container, query: "(min-width: 768px)".to_string(), selector: None, + outer: vec![], + file: None, }), None, None, @@ -2868,6 +2936,31 @@ mod tests { assert_debug_snapshot!(css.split("*/").nth(1).unwrap()); } + #[test] + fn test_custom_layer_keeps_selector_order() { + let mut sheet = StyleSheet::default(); + for (selector, value) in [("a:active", "blue"), ("a:hover", "red"), ("a", "black")] { + sheet.add_property_with_layer( + "a", + "color", + 0, + value, + Some(&StyleSelector::Global( + selector.to_string(), + "links.css.ts".to_string(), + )), + Some(0), + None, + Some("ui"), + ); + } + let css = sheet.create_css(None, false); + assert!( + css.contains("@layer ui{a{color:black}a:hover{color:red}a:active{color:blue}}"), + "{css}" + ); + } + #[test] fn test_at_rules_with_breakpoints() { let mut sheet = StyleSheet::default(); @@ -2881,6 +2974,8 @@ mod tests { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), selector: None, + outer: vec![], + file: None, }), Some(0), None, @@ -2904,6 +2999,8 @@ mod tests { kind: AtRuleKind::Container, query: "(min-width: 400px)".to_string(), selector: None, + outer: vec![], + file: None, }), Some(0), None, @@ -2945,6 +3042,8 @@ mod tests { kind: AtRuleKind::Layer, query: "components".to_string(), selector: None, + outer: vec![], + file: None, }), Some(0), None, @@ -2975,6 +3074,7 @@ mod tests { value: value.to_string(), selector: None, layer: None, + typography: false, }; assert_eq!(make("color", "red").cmp(&make("color", "red")), Equal); assert!(make("color", "red") < make("color", "white")); @@ -2991,6 +3091,7 @@ mod tests { value: value.to_string(), selector, layer: None, + typography: false, }; let hover = || Some(StyleSelector::Selector("&:hover".to_string())); @@ -3008,29 +3109,11 @@ mod tests { } #[test] - fn test_global_prop_key_variants() { - let make = |selector: Option| StyleSheetProperty { - class_name: "a".to_string(), - property: "color".to_string(), - value: "red".to_string(), - selector, - layer: None, - }; - let pseudo = make(Some(StyleSelector::Global( - "a:hover".to_string(), - "test.tsx".to_string(), - ))); - let plain = make(Some(StyleSelector::Global( - "body".to_string(), - "test.tsx".to_string(), - ))); - let local = make(Some(StyleSelector::Selector("&:hover".to_string()))); - - assert!(global_prop_key(&pseudo).0); - assert_eq!(global_prop_key(&pseudo).2, "a:hover"); - assert!(!global_prop_key(&plain).0); - assert_eq!(global_prop_key(&plain).2, "body"); - assert_eq!(global_prop_key(&local).2, ""); + fn test_global_selector_group() { + assert_eq!(global_selector_group("body"), (false, 0)); + assert_eq!(global_selector_group("a:hover"), (true, 0)); + assert_eq!(global_selector_group("a:active"), (true, 3)); + assert_eq!(global_selector_group(":root"), (true, 0)); } #[test] @@ -3196,6 +3279,325 @@ mod tests { ); } + fn pipeline_css(theme: Theme, source: &str) -> String { + css::debug::set_debug(false); + css::set_prefix(None); + css::atom_hoist::set_atom_hoist(None); + reset_class_map(); + reset_file_map(); + let mut sheet = StyleSheet::default(); + sheet.set_theme(theme); + let output = extract( + "test.tsx", + &format!("import {{Box,css,styled,globalCss}} from '@devup-ui/core';\n{source}"), + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + }, + ) + .unwrap(); + sheet.update_styles(&output.styles, "test.tsx", true); + // Class names come from a process-wide counter; number them by first + // appearance so the expected CSS only pins down structure and order. + let mut names: Vec = vec![]; + compile_regex(r"\.([A-Za-z_][\w-]*)") + .replace_all( + sheet.create_css(None, false).split("*/").nth(1).unwrap(), + |caps: ®ex_lite::Captures| { + let index = names.iter().position(|n| *n == caps[1]).unwrap_or_else(|| { + names.push(caps[1].to_string()); + names.len() - 1 + }); + format!(".c{index}") + }, + ) + .into_owned() + } + + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_at_rule_pipeline() { + for (source, expected) in [ + // Conditions (at-rules) win over breakpoint values; pseudo states keep + // `SELECTOR_ORDER` across breakpoints. + ( + "", + ".c0{background:red}@media(min-width:768px){.c1{background:blue}}.c2:hover{background:green}", + ), + ( + "", + ".c0:hover{background:red}@media(min-width:768px){.c1:hover{background:blue}}.c2:active{background:green}", + ), + ( + "", + ".c0{transition:opacity .2s}@media(min-width:768px){.c1{transition:all .3s}}@media(prefers-reduced-motion:reduce){.c2{transition:none}}", + ), + ( + "", + ".c0{background:red}@media(min-width:768px){.c1{background:blue}}@supports(display:grid){.c2{background:green}}", + ), + ( + "", + "@media(min-width:500px){.c0{padding:4px}}@media(min-width:1000px){.c1{padding:8px}}@media(max-width:900px){.c2{padding:16px}}@media(max-width:300px){.c3{padding:12px}}", + ), + ( + "", + "@media(prefers-reduced-motion:reduce){.c0:hover{background:b}.c1:active{background:a}}", + ), + // Nesting in either direction keeps both the condition and the selector. + ( + "", + "@media print{.c0:hover{background:red}.c1:focus{color:red}}", + ), + ( + "", + "@media(prefers-reduced-motion:no-preference){.c0:hover{transform:scale(1.05)}:is([role=group],[data-group]):hover .c1{color:blue}:root[data-theme=dark] .c2{color:red}}", + ), + ( + "", + "@media(prefers-reduced-motion:reduce){.c0:hover{background:red}.c1::before{content:\"\"}}", + ), + ( + "", + ":root[data-theme=dark] .c0:hover{background:red}", + ), + // Nested at-rules fold into one query or nest; exclusive ones are dropped. + ( + "", + "@media print and (prefers-reduced-motion:reduce){.c0{background:red}}", + ), + ( + "", + "@media(prefers-reduced-motion:reduce){@supports(display:grid){.c0{background:red}}}", + ), + // Breakpoints combine with the query: media type first, lists distribute, + // `not print` becomes `screen`, anything else nests. + ( + "", + "@media print{.c0{background:red}}@media print and (min-width:768px){.c1{background:blue}}", + ), + ( + "", + "@media screen and (min-width:768px){.c0{color:red}}@media(min-width:768px)and (orientation:portrait),(min-width:768px)and (hover:none){.c1{background:blue}}", + ), + ( + "", + "@media(min-width:768px){@media not print and (color){.c0{background:blue}}}", + ), + ( + "", + "@media(min-width:768px){@supports not (display:grid){.c0{background:blue}}}", + ), + // Emotion-style object keys and template literals. + ( + "
", + ".c0:focus{color:red}@media print{.c1{color:blue}.c2:hover{color:green}}", + ), + ( + "
", + ".c0{transition:all .3s}.c1:hover span{color:blue}@media print{.c2:hover{color:red}}@media(prefers-reduced-motion:reduce){.c3{transition:none}}", + ), + ( + "", + "@media print and (prefers-reduced-motion:reduce){.c0{display:none}}", + ), + ( + "globalCss({ '@media (prefers-reduced-motion: reduce)': { '*': { transition: 'none' } }, _print: { body: { bg: 'white', _hover: { color: 'red' } } }, _media: { '(min-width: 768px)': { body: { m: 2 } } }, _screen: { _print: { body: { color: 'red' } } } })", + "@layer b;@layer b{@media(min-width:768px){body{margin:8px}}@media print{body{background:white}body:hover{color:red}}@media(prefers-reduced-motion:reduce){*{transition:none}}}", + ), + ( + "", + ".c0:hover{margin:4px}.c1:focus{margin:4px}@media print{.c2{background:red}}@media(hover:none){.c3{color:red}}", + ), + // Global pseudo rules keep `SELECTOR_ORDER` across breakpoints. + ( + "globalCss({ a: { _hover: { color: 'red' }, _active: { color: 'blue' }, _focus: { color: 'green' } } })", + "@layer b;@layer b{a:hover{color:red}a:focus{color:green}a:active{color:blue}}", + ), + ( + "globalCss({ a: { color: 'black', _hover: { color: [null, null, 'red'] }, _active: { color: 'blue' } } })", + "@layer b;@layer b{a{color:black}@media(min-width:768px){a:hover{color:red}}a:active{color:blue}}", + ), + ( + "globalCss({ a: { color: ['red', null, 'blue'] } })", + "@layer b;@layer b{a{color:red}@media(min-width:768px){a{color:blue}}}", + ), + ] { + assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}"); + } + } + + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_layer_pipeline() { + for (source, expected) in [ + // A layer keeps breakpoints, selectors and at-rules of its declarations. + ( + "globalCss({ '*': { '@layer': 'reset', margin: [0, null, 4], _hover: { color: ['red', null, 'blue'] }, _print: { color: 'black' } }, body: { color: 'white' } })", + "@layer b;@layer b{body{color:white}}@layer reset;@layer reset{*{margin:0}@media(min-width:768px){*{margin:16px}}*:hover{color:red}@media(min-width:768px){*:hover{color:blue}}@media print{*{color:black}}}", + ), + ( + "
", + ".c0{color:red}@layer base{.c1{color:blue}.c2{padding:4px}@media(min-width:768px){.c3{padding:8px}}.c4:hover{color:green}}@layer base.c5{.c6{margin:4px}}", + ), + ] { + assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}"); + } + } + + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_conditional_typography_pipeline() { + for (source, expected) in [ + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}", + ), + ( + "", + "@layer t;@media(prefers-reduced-motion:reduce){.c2{font-family:var(--heading);font-size:20px;font-weight:700}}@media(min-width:768px)and (prefers-reduced-motion:reduce){.c2{font-size:32px}}", + ), + ( + "", + "@layer t;@media(min-width:768px){.c2:hover{font-family:var(--heading);font-size:32px;font-weight:700}}", + ), + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}", + ), + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}", + ), + ("", "@layer t;"), + // A declaration written next to the preset wins over the preset's, at + // every breakpoint from the one it starts at. + ( + "", + "@layer t;.c2:hover{line-height:1.2}.c3:hover{font-size:11px}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-weight:700}.c3:hover{font-size:11px}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c2:hover{font-size:32px}}@media(min-width:992px){.c3:hover{font-size:11px}}", + ), + ( + "", + "@layer t;.c2{font-size:11px}@media(min-width:768px){.c3{font-family:var(--heading);font-weight:700}}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-weight:700}.c3:hover{font-size:var(--c)}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-size:20px;font-weight:700}.c3:hover{font-size:11px}@media(min-width:768px){.c4:hover{font-size:12px}}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-size:20px;font-weight:700}.c3:hover{font-size:11px}.c4:hover{line-height:1}.c5:hover{line-height:2}@media(min-width:768px){.c2:hover{font-size:32px}}", + ), + ( + "", + "@layer t;.c2{bottom:0}.c3{left:0}.c4{right:0}.c5{top:0}.c6:hover{line-height:1.2}.c7:hover{font-family:var(--heading);font-weight:700}.c8:hover{font-size:11px}", + ), + ( + "", + "@layer t;.c2:hover{line-height:1.2}.c3:hover{font-family:var(--heading);font-weight:700}.c4:hover{font-size:11px}", + ), + ( + "globalCss({ body: { typography: 'small' } })", + "@layer b,t;@layer b{body{font-size:12px;line-height:1.2}}", + ), + ( + "globalCss({ h1: { typography: 'title', fontSize: '13px' } })", + "@layer b,t;@layer b{h1{font-family:var(--heading);font-size:13px;font-weight:700}}", + ), + ] { + let mut theme = Theme::default(); + theme.add_typography( + "small", + vec![Some(Typography::new( + None, + Some("12px".to_string()), + None, + Some("1.2".to_string()), + None, + ))], + ); + theme.add_typography( + "title", + vec![ + Some(Typography::new( + Some("$heading".to_string()), + Some("20px".to_string()), + Some("700".to_string()), + None, + Some(" ".to_string()), + )), + None, + Some(Typography::new( + None, + Some("32px".to_string()), + None, + None, + None, + )), + ], + ); + let css = pipeline_css(theme, &format!("const size = 'small';\n{source}")); + // Drop the theme's own `.typo-*` layer; only the conditional atoms matter here. + let start = css.find("@layer t{").unwrap(); + let mut depth = 0; + let end = css[start..] + .char_indices() + .find_map(|(index, c)| { + match c { + '{' => depth += 1, + '}' => depth -= 1, + _ => {} + } + (c == '}' && depth == 0).then_some(start + index + 1) + }) + .unwrap(); + assert_eq!(format!("{}{}", &css[..start], &css[end..]), expected); + } + } + + #[test] + #[serial] + fn test_rm_global_css_drops_global_at_rules() { + let mut sheet = StyleSheet::default(); + let output = extract( + "global.tsx", + "import {globalCss} from '@devup-ui/core';globalCss({ body: { color: 'red', _motionReduce: { transition: 'none' } } })", + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + }, + ) + .unwrap(); + sheet.update_styles(&output.styles, "global.tsx", true); + assert!(sheet.create_css(None, false).contains("transition:none")); + + assert!(sheet.rm_global_css("global.tsx", true)); + let css = sheet.create_css(None, false); + assert!(!css.contains("transition:none"), "{css}"); + assert!(!css.contains("color:red"), "{css}"); + } + #[test] #[serial] fn test_dynamic_style_important_full_pipeline() { diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap b/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap index c57eb2315..851fa55b1 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -":is([role=group],[data-group]):focus-visible .test{background:blue}:is([role=group],[data-group]):focus-visible .test{background:red}" +":is([role=group],[data-group]):focus-visible .test{background:blue;background:red}" diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap index 3193a504f..3f9785c49 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -".test{border:1px solid}.test{border-color:red}" +".test{border:1px solid;border-color:red}" diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap index d9ef15491..6062adb5f 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:480px){.test{background:some}.test{background-color:red}}" +"@media(min-width:480px){.test{background:some;background-color:red}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap index d9ef15491..6062adb5f 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:480px){.test{background:some}.test{background-color:red}}" +"@media(min-width:480px){.test{background:some;background-color:red}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap index 77513dadb..252f09dc8 100644 --- a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap +++ b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap @@ -13,6 +13,7 @@ StyleSheet { value: "40px", selector: None, layer: None, + typography: false, }, }, }, diff --git a/libs/sheet/src/snapshots/sheet__tests__speech_selector.snap b/libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap similarity index 64% rename from libs/sheet/src/snapshots/sheet__tests__speech_selector.snap rename to libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap index a180ad3b7..4ac3f1f88 100644 --- a/libs/sheet/src/snapshots/sheet__tests__speech_selector.snap +++ b/libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media speech{.test{display:none}}" +"@media(prefers-reduced-motion:reduce){.test{display:none}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap b/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap index ca6c61257..88e3e2b12 100644 --- a/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap +++ b/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -".test{margin-bottom:40px}.test{margin-top:40px}@media print{.test{margin-left:40px}.test{margin-right:40px}}" +".test{margin-bottom:40px;margin-top:40px}@media print{.test{margin-left:40px;margin-right:40px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__print_selector.snap b/libs/sheet/src/snapshots/sheet__tests__print_selector.snap index 053d618c4..0cce6708d 100644 --- a/libs/sheet/src/snapshots/sheet__tests__print_selector.snap +++ b/libs/sheet/src/snapshots/sheet__tests__print_selector.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media print{.test{margin-bottom:40px}.test{margin-left:40px}.test{margin-right:40px}.test{margin-top:40px}}@media(min-width:480px)and print{.test{margin-bottom:40px}.test{margin-left:40px}.test{margin-right:40px}.test{margin-top:40px}}" +"@media print{.test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}}@media print and (min-width:480px){.test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap b/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap index 57a93b52f..b19be1fae 100644 --- a/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap +++ b/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:480px){:is([role=group],[data-group]):hover .test{margin-left:40px}:is([role=group],[data-group]):hover .test{margin-right:40px}}@media(min-width:768px){:is([role=group],[data-group]):hover .test{margin-left:50px}:is([role=group],[data-group]):hover .test{margin-right:50px}}" +"@media(min-width:480px){:is([role=group],[data-group]):hover .test{margin-left:40px;margin-right:40px}}@media(min-width:768px){:is([role=group],[data-group]):hover .test{margin-left:50px;margin-right:50px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap b/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap index b693d37d7..824a6032e 100644 --- a/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap +++ b/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width: 1024px){.test:hover{margin-bottom:40px}.test:hover{margin-top:40px}}" +"@media(min-width: 1024px){.test:hover{margin-bottom:40px;margin-top:40px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap b/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap index 2c1121290..c59953840 100644 --- a/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap +++ b/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@layer o1,o2;@layer o1{.test{margin-left:40px}.test{margin-right:40px}@media(min-width:480px){.test{margin-left:40px}.test{margin-left:44px}.test{margin-right:40px}.test{margin-right:44px}}}@layer o2{@media(min-width:480px){.test{margin-left:50px}.test{margin-right:50px}}}.test{margin-left:70px}.test{margin-right:70px}@media(min-width:480px){.test{margin-left:60px}.test{margin-right:60px}}" +"@layer o1,o2;@layer o1{.test{margin-left:40px;margin-right:40px}@media(min-width:480px){.test{margin-left:40px;margin-left:44px;margin-right:40px;margin-right:44px}}}@layer o2{@media(min-width:480px){.test{margin-left:50px;margin-right:50px}}}.test{margin-left:70px;margin-right:70px}@media(min-width:480px){.test{margin-left:60px;margin-right:60px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap index 1946f076e..f2d00072b 100644 --- a/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap +++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -".test{margin-left:41px}.test{margin-left:42px}.test{margin-right:41px}.test{margin-right:42px}:root[data-theme=light] .test{margin-left:50px}:root[data-theme=light] .test{margin-left:51px}:root[data-theme=light] .test{margin-right:50px}:root[data-theme=light] .test{margin-right:51px}" +".test{margin-left:41px;margin-left:42px;margin-right:41px;margin-right:42px}:root[data-theme=light] .test{margin-left:50px;margin-left:51px;margin-right:50px;margin-right:51px}" diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap index 771bbc05b..71c4134fb 100644 --- a/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap +++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -":root[data-theme=light] .test:hover{margin-left:50px}:root[data-theme=light] .test:hover{margin-right:50px}:root[data-theme=light] .test:active{margin-left:50px}:root[data-theme=light] .test:active{margin-right:50px}" +":root[data-theme=light] .test:hover{margin-left:50px;margin-right:50px}:root[data-theme=light] .test:active{margin-left:50px;margin-right:50px}" diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap index db1c61873..b576c552b 100644 --- a/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap +++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -":root[data-theme=dark] .test{margin-bottom:40px}:root[data-theme=dark] .test{margin-left:40px}:root[data-theme=dark] .test{margin-right:40px}:root[data-theme=dark] .test{margin-top:40px}:root[data-theme=light] .test{margin-left:50px}:root[data-theme=light] .test{margin-right:50px}" +":root[data-theme=dark] .test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}:root[data-theme=light] .test{margin-left:50px;margin-right:50px}" diff --git a/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap b/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap index a227ce36d..c051ab081 100644 --- a/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap +++ b/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:1600px){.test{margin-left:40px}.test{margin-right:40px}}" +"@media(min-width:1600px){.test{margin-left:40px;margin-right:40px}}" diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs index 9d7b11c62..29ff73962 100644 --- a/libs/sheet/src/theme.rs +++ b/libs/sheet/src/theme.rs @@ -606,6 +606,51 @@ impl Theme { default_variant_key(&self.colors).map(str::to_string) } + /// Declarations of typography preset `name` applied from breakpoint `level` + /// up: preset entries at or below `level` merge into `level`, wider ones + /// keep their own breakpoint. Values resolve like the `.typo-*` classes. + #[must_use] + pub fn typography_declarations( + &self, + name: &str, + level: u8, + ) -> Vec<(u8, &'static str, String)> { + let mut declarations: Vec<(u8, &'static str, String)> = vec![]; + let Some(typography) = self.typography.get(name) else { + return declarations; + }; + for (index, entry) in typography.0.iter().enumerate() { + let Some(entry) = entry else { + continue; + }; + let target = level.max(index as u8); + for (property, value) in [ + ("font-family", &entry.font_family), + ("font-size", &entry.font_size), + ("font-weight", &entry.font_weight), + ("line-height", &entry.line_height), + ("letter-spacing", &entry.letter_spacing), + ] { + let Some(value) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) else { + continue; + }; + let resolved = value.strip_prefix('$').map_or_else( + || optimize_value(value).into_owned(), + |token| format!("var(--{token})"), + ); + if let Some(existing) = declarations + .iter_mut() + .find(|(l, p, _)| *l == target && *p == property) + { + existing.2 = resolved; + } else { + declarations.push((target, property, resolved)); + } + } + } + declarations + } + #[must_use] pub fn get_length_token_levels(&self) -> BTreeMap> { token_levels(&self.length) diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts index 836570bde..dc0e1fa8f 100644 --- a/packages/bun-plugin/__regression__/css-emit.bun.ts +++ b/packages/bun-plugin/__regression__/css-emit.bun.ts @@ -68,3 +68,39 @@ await import(cssPath) } }, ) + +it('emits vanilla-extract styles through the full WASM engine', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + writeFileSync(join(cwd, 'bunfig.toml'), '') + writeFileSync( + join(cwd, 'styles.css.ts'), + `import { style } from '@devup-ui/react' +export const box = style({ width: '109px' }) +`, + ) + writeFileSync( + join(cwd, 'check.ts'), + `import { readFileSync } from 'node:fs' +import { expect } from 'bun:test' + +await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}) +const { box } = await import('./styles.css.ts') +expect(box).toBeTruthy() +expect(readFileSync('./df/devup-ui/devup-ui.css', 'utf-8')).toContain('width:109px') +`, + ) + const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/components/README.md b/packages/components/README.md index 72d9da7e0..d586eff94 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **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 | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. 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 result is effectively parity with Tailwind on this fixture. 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 diff --git a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap index 98996cba1..2771fb317 100644 --- a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap +++ b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap @@ -39,6 +39,23 @@ exports[`recommended export recommended config 1`] = ` }, "name": "no-duplicate-value", }, + "no-typography-token-prefix": { + "create": [Function: create], + "defaultOptions": [], + "meta": { + "docs": { + "description": "Disallow the \`$\` token prefix on typography keys.", + "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/no-typography-token-prefix", + }, + "fixable": "code", + "messages": { + "noTypographyTokenPrefix": "Typography keys take no \`$\` prefix. Use "{{name}}".", + }, + "schema": [], + "type": "problem", + }, + "name": "no-typography-token-prefix", + }, "no-useless-responsive": { "create": [Function: create], "defaultOptions": [], @@ -73,6 +90,23 @@ exports[`recommended export recommended config 1`] = ` }, "name": "no-useless-tailing-nulls", }, + "prefer-media-shorthand": { + "create": [Function: create], + "defaultOptions": [], + "meta": { + "docs": { + "description": "Prefer media shorthand props over their media queries.", + "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/prefer-media-shorthand", + }, + "fixable": "code", + "messages": { + "preferMediaShorthand": "Use the \`{{shorthand}}\` shorthand for this query.", + }, + "schema": [], + "type": "suggestion", + }, + "name": "prefer-media-shorthand", + }, "style-order-range": { "create": [Function: create], "defaultOptions": [], @@ -96,8 +130,10 @@ exports[`recommended export recommended config 1`] = ` "rules": { "@devup-ui/css-utils-literal-only": "error", "@devup-ui/no-duplicate-value": "error", + "@devup-ui/no-typography-token-prefix": "error", "@devup-ui/no-useless-responsive": "error", "@devup-ui/no-useless-tailing-nulls": "error", + "@devup-ui/prefer-media-shorthand": "warn", "@devup-ui/style-order-range": "error", }, }, diff --git a/packages/eslint-plugin/src/configs/recommended.ts b/packages/eslint-plugin/src/configs/recommended.ts index 3827b73a7..b36e20df4 100644 --- a/packages/eslint-plugin/src/configs/recommended.ts +++ b/packages/eslint-plugin/src/configs/recommended.ts @@ -1,8 +1,10 @@ import { cssUtilsLiteralOnly, noDuplicateValue, + noTypographyTokenPrefix, noUselessResponsive, noUselessTailingNulls, + preferMediaShorthand, styleOrderRange, } from '../rules' @@ -16,6 +18,8 @@ export default [ 'no-duplicate-value': noDuplicateValue, 'no-useless-responsive': noUselessResponsive, 'style-order-range': styleOrderRange, + 'no-typography-token-prefix': noTypographyTokenPrefix, + 'prefer-media-shorthand': preferMediaShorthand, }, }, }, @@ -25,6 +29,8 @@ export default [ '@devup-ui/no-duplicate-value': 'error', '@devup-ui/no-useless-responsive': 'error', '@devup-ui/style-order-range': 'error', + '@devup-ui/no-typography-token-prefix': 'error', + '@devup-ui/prefer-media-shorthand': 'warn', }, }, ] diff --git a/packages/eslint-plugin/src/rules/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/__tests__/index.test.ts index 16aa96261..624966813 100644 --- a/packages/eslint-plugin/src/rules/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/__tests__/index.test.ts @@ -10,6 +10,8 @@ describe('export index', () => { noDuplicateValue: expect.any(Object), noUselessResponsive: expect.any(Object), styleOrderRange: expect.any(Object), + noTypographyTokenPrefix: expect.any(Object), + preferMediaShorthand: expect.any(Object), }) }) }) diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 671847783..4f12cc9f4 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -1,5 +1,7 @@ export * from './css-utils-literal-only' export * from './no-duplicate-value' +export * from './no-typography-token-prefix' export * from './no-useless-responsive' export * from './no-useless-tailing-nulls' +export * from './prefer-media-shorthand' export * from './style-order-range' diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md new file mode 100644 index 000000000..a8d91d3a9 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md @@ -0,0 +1,39 @@ +# no-typography-token-prefix + +Disallow the `$` token prefix on `typography` values. + +## Rule Details + +`typography` takes the bare key of a `theme.typography` entry. Unlike color, +length, and shadow tokens it has no `$` prefix; `typography="$heading"` names a +preset that does not exist, so no style is applied. + +The rule checks string values of `typography` on Devup UI components and +utilities, including values inside responsive arrays, conditionals, and +selector objects. + +### Examples + +#### ❌ Incorrect + +```tsx +import { Box, css } from '@devup-ui/react' + +; +; +css({ _hover: { typography: '$title' } }) +``` + +#### ✅ Correct + +```tsx +import { Box, css } from '@devup-ui/react' + +; +; +css({ _hover: { typography: 'title' } }) +``` + +## Auto-fixable + +The `$` prefix is removed. diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts new file mode 100644 index 000000000..9c0395101 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts @@ -0,0 +1,56 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { noTypographyTokenPrefix } from '../index' + +describe('no-typography-token-prefix rule', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const imports = 'import { Box, css } from "@devup-ui/react";\n' + ruleTester.run('no-typography-token-prefix rule', noTypographyTokenPrefix, { + valid: [ + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}css({ color: '$primary', ['typography']: 1 })` }, + { code: `${imports}css({ '$heading': 1 })` }, + { code: `${imports}css({ typography: 1 })` }, + { code: `import { Box } from "other";\n` }, + { code: `const a = { typography: '$heading' }` }, + ], + invalid: [ + { + code: `${imports}`, + output: `${imports}`, + errors: [ + { messageId: 'noTypographyTokenPrefix', data: { name: 'heading' } }, + ], + }, + { + code: `${imports}`, + output: `${imports}`, + errors: [ + { messageId: 'noTypographyTokenPrefix' }, + { messageId: 'noTypographyTokenPrefix' }, + ], + }, + { + code: `${imports}css({ _hover: { typography: '$title' } })`, + output: `${imports}css({ _hover: { typography: 'title' } })`, + errors: [{ messageId: 'noTypographyTokenPrefix' }], + }, + { + code: `${imports}`, + output: `${imports}`, + errors: [{ messageId: 'noTypographyTokenPrefix' }], + }, + ], + }) +}) diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts new file mode 100644 index 000000000..31ae5ec2a --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts @@ -0,0 +1,97 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESTree, +} from '@typescript-eslint/utils' + +import { ImportStorage } from '../../utils/import-storage' +import { propertyKeyName } from '../../utils/property-key-name' + +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, +) + +/** Whether `literal` is a value of a `typography` prop rather than a condition. */ +function isTypographyValue( + child: TSESTree.Node, + parent: TSESTree.Node | undefined = child.parent, +): boolean { + switch (parent?.type) { + case AST_NODE_TYPES.JSXAttribute: + return ( + parent.name.type === AST_NODE_TYPES.JSXIdentifier && + parent.name.name === 'typography' + ) + case AST_NODE_TYPES.Property: + return parent.value === child && propertyKeyName(parent) === 'typography' + case AST_NODE_TYPES.ConditionalExpression: + return parent.test !== child && isTypographyValue(parent) + case AST_NODE_TYPES.JSXExpressionContainer: + case AST_NODE_TYPES.ArrayExpression: + case AST_NODE_TYPES.LogicalExpression: + return isTypographyValue(parent) + default: + return false + } +} + +export const noTypographyTokenPrefix = createRule({ + name: 'no-typography-token-prefix', + defaultOptions: [], + meta: { + schema: [], + messages: { + noTypographyTokenPrefix: + 'Typography keys take no `$` prefix. Use "{{name}}".', + }, + type: 'problem', + fixable: 'code', + docs: { + description: 'Disallow the `$` token prefix on typography keys.', + }, + }, + create(context) { + const importStorage = new ImportStorage() + let devupContext: + TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null + return { + ImportDeclaration(node) { + importStorage.addImportByDeclaration(node) + }, + CallExpression(node) { + if (!devupContext && importStorage.checkContextType(node) === 'UTIL') { + devupContext = node + } + }, + 'CallExpression:exit'(node) { + if (devupContext === node) devupContext = null + }, + JSXOpeningElement(node) { + if (importStorage.checkContextType(node) === 'COMPONENT') { + devupContext = node + } + }, + 'JSXOpeningElement:exit'(node) { + if (devupContext === node) devupContext = null + }, + Literal(node) { + if ( + !devupContext || + typeof node.value !== 'string' || + !node.value.startsWith('$') || + !isTypographyValue(node) + ) + return + const name = node.value.slice(1) + const quote = node.raw[0] + context.report({ + node, + messageId: 'noTypographyTokenPrefix', + data: { name }, + fix: (fixer) => fixer.replaceText(node, `${quote}${name}${quote}`), + }) + }, + } + }, +}) diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md new file mode 100644 index 000000000..93128c120 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md @@ -0,0 +1,48 @@ +# prefer-media-shorthand + +Prefer the media shorthand props over spelling out the same media query. + +## Rule Details + +`_media` entries and `'@media …'` keys whose query is exactly one of the +shorthands are reported. Whitespace and letter case in the query are ignored. + +| Query | Shorthand | +| ---------------------------------------- | ---------------- | +| `all` | `_all` | +| `print` | `_print` | +| `screen` | `_screen` | +| `(prefers-reduced-motion: reduce)` | `_motionReduce` | +| `(prefers-reduced-motion: no-preference)` | `_motionSafe` | +| `(orientation: portrait)` | `_portrait` | +| `(orientation: landscape)` | `_landscape` | +| `(prefers-contrast: more)` | `_contrastMore` | +| `(prefers-contrast: less)` | `_contrastLess` | +| `(forced-colors: active)` | `_forcedColors` | + +### Examples + +#### ❌ Incorrect + +```tsx +import { Box, css } from '@devup-ui/react' + +; +css({ '@media print': { color: 'black' } }) +``` + +#### ✅ Correct + +```tsx +import { Box, css } from '@devup-ui/react' + +; +css({ _print: { color: 'black' } }) +css({ _media: { '(min-width: 500px)': { p: 1 } } }) +``` + +## Auto-fixable + +A `_media` object holding only the matched query, and a static `'@media …'` +key, are rewritten to the shorthand. A `_media` object with other queries is +reported without a fix. diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts new file mode 100644 index 000000000..df0232e17 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts @@ -0,0 +1,76 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { preferMediaShorthand } from '../index' + +describe('prefer-media-shorthand rule', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const imports = 'import { Box, css } from "@devup-ui/react";\n' + ruleTester.run('prefer-media-shorthand rule', preferMediaShorthand, { + valid: [ + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}css({ '@media (hover: none)': { color: 'red' } })` }, + { code: `${imports}css({ [key]: { color: 'red' }, 1: 2, ...rest })` }, + { + code: `${imports}css({ _media: { [key]: { color: 'red' }, ...rest } })`, + }, + { code: `${imports}css({ '@mediaprint': { color: 'red' } })` }, + { code: `${imports}css(styles)` }, + { + code: `import { Box } from "other";\n`, + }, + { code: `css({ '@media print': { color: 'red' } })` }, + ], + invalid: [ + { + code: `${imports}`, + output: `${imports}`, + errors: [ + { + messageId: 'preferMediaShorthand', + data: { shorthand: '_motionReduce' }, + }, + ], + }, + { + code: `${imports}css({ _media: { print: { color: 'red' } } })`, + output: `${imports}css({ _print: { color: 'red' } })`, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + { + code: `${imports}css({ _media: { '(orientation:portrait)': { p: 1 }, '(min-width: 1px)': { p: 2 } } })`, + output: null, + errors: [ + { + messageId: 'preferMediaShorthand', + data: { shorthand: '_portrait' }, + }, + ], + }, + { + code: `${imports}css({ '@media (forced-colors: active)': { color: 'red' } })`, + output: `${imports}css({ _forcedColors: { color: 'red' } })`, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + { + code: `${imports}css({ ['@media screen']: { color: 'red' } })`, + output: null, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + { + code: `${imports}`, + output: `${imports}`, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + ], + }) +}) diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts new file mode 100644 index 000000000..c82d25d4a --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts @@ -0,0 +1,141 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESTree, +} from '@typescript-eslint/utils' + +import { ImportStorage } from '../../utils/import-storage' +import { propertyKeyName } from '../../utils/property-key-name' + +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, +) + +const MEDIA_SHORTHANDS = new Map([ + ['all', '_all'], + ['print', '_print'], + ['screen', '_screen'], + ['(prefers-reduced-motion:reduce)', '_motionReduce'], + ['(prefers-reduced-motion:no-preference)', '_motionSafe'], + ['(orientation:portrait)', '_portrait'], + ['(orientation:landscape)', '_landscape'], + ['(prefers-contrast:more)', '_contrastMore'], + ['(prefers-contrast:less)', '_contrastLess'], + ['(forced-colors:active)', '_forcedColors'], +]) + +const AT_MEDIA = /^@media(?=[\s(])/ + +function shorthandFor(query: string): string | undefined { + return MEDIA_SHORTHANDS.get(query.replace(/\s+/g, '').toLowerCase()) +} + +export const preferMediaShorthand = createRule({ + name: 'prefer-media-shorthand', + defaultOptions: [], + meta: { + schema: [], + messages: { + preferMediaShorthand: 'Use the `{{shorthand}}` shorthand for this query.', + }, + type: 'suggestion', + fixable: 'code', + docs: { + description: 'Prefer media shorthand props over their media queries.', + }, + }, + create(context) { + const importStorage = new ImportStorage() + let devupContext: + TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null + + function checkMediaRecord( + owner: TSESTree.Property | TSESTree.JSXAttribute, + record: TSESTree.Node, + ) { + if (record.type !== AST_NODE_TYPES.ObjectExpression) return + for (const prop of record.properties) { + if (prop.type !== AST_NODE_TYPES.Property) continue + const query = propertyKeyName(prop) + const shorthand = query === undefined ? undefined : shorthandFor(query) + if (!shorthand) continue + const value = context.sourceCode.getText(prop.value) + context.report({ + node: prop.key, + messageId: 'preferMediaShorthand', + data: { shorthand }, + fix: + record.properties.length === 1 + ? (fixer) => + fixer.replaceText( + owner, + owner.type === AST_NODE_TYPES.JSXAttribute + ? `${shorthand}={${value}}` + : `${shorthand}: ${value}`, + ) + : null, + }) + } + } + + return { + ImportDeclaration(node) { + importStorage.addImportByDeclaration(node) + }, + CallExpression(node) { + if ( + !devupContext && + importStorage.checkContextType(node) === 'UTIL' && + node.arguments.length === 1 && + node.arguments[0].type === AST_NODE_TYPES.ObjectExpression + ) { + devupContext = node + } + }, + 'CallExpression:exit'(node) { + if (devupContext === node) devupContext = null + }, + JSXOpeningElement(node) { + if (importStorage.checkContextType(node) === 'COMPONENT') { + devupContext = node + } + }, + 'JSXOpeningElement:exit'(node) { + if (devupContext === node) devupContext = null + }, + JSXAttribute(node) { + if ( + devupContext && + node.name.type === AST_NODE_TYPES.JSXIdentifier && + node.name.name === '_media' && + node.value?.type === AST_NODE_TYPES.JSXExpressionContainer + ) { + checkMediaRecord(node, node.value.expression) + } + }, + Property(node) { + if (!devupContext) return + const name = propertyKeyName(node) + if (name === undefined) return + if (name === '_media') { + checkMediaRecord(node, node.value) + return + } + const shorthand = AT_MEDIA.test(name) + ? shorthandFor(name.slice('@media'.length)) + : undefined + if (shorthand) { + context.report({ + node: node.key, + messageId: 'preferMediaShorthand', + data: { shorthand }, + fix: node.computed + ? null + : (fixer) => fixer.replaceText(node.key, shorthand), + }) + } + }, + } + }, +}) diff --git a/packages/eslint-plugin/src/utils/property-key-name.ts b/packages/eslint-plugin/src/utils/property-key-name.ts new file mode 100644 index 000000000..41f20250c --- /dev/null +++ b/packages/eslint-plugin/src/utils/property-key-name.ts @@ -0,0 +1,11 @@ +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils' + +export function propertyKeyName(node: TSESTree.Property): string | undefined { + if (node.key.type === AST_NODE_TYPES.Identifier && !node.computed) + return node.key.name + if ( + node.key.type === AST_NODE_TYPES.Literal && + typeof node.key.value === 'string' + ) + return node.key.value +} diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index 5775ace8b..facbc7247 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **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 | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. 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 result is effectively parity with Tailwind on this fixture. 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 diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 6a23d2555..62dd85c80 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -215,53 +215,6 @@ describe('coordinator', () => { coordinator.close() }) - it('uses the static vanilla transform on a production cache miss', async () => { - const source = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red' })` - const extractOutput = { - code: 'transformed code', - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } - codeExtractWithoutSourceMapSpy.mockReturnValue(extractOutput) - const coordinator = startCoordinator( - makeOptions({ sourceMap: false, staticVanillaExtract: true }), - ) - await new Promise((resolve) => setTimeout(resolve, 100)) - const port = parseInt( - (writeFileSyncSpy.mock.calls[0] as [string, string])[1], - ) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/styles.css.ts', - code: source, - resourcePath: join(process.cwd(), 'src', 'styles.css.ts'), - }), - ) - - expect(res.status).toBe(200) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/styles.css.ts', - `import { css } from '@devup-ui/react' -export const box = css({ color: 'red' })`, - '@devup-ui/react', - './../.tmp-coordinator-test/css', - false, - false, - true, - {}, - ) - expect(extractOutput.free).toHaveBeenCalledTimes(1) - coordinator.close() - }) - it('reuses byte-identical singleCss prewarm output', async () => { const source = 'const x = ' const options = makeOptions({ diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index c6ffe926a..a79904cea 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -193,13 +193,6 @@ describe('DevupUINextPlugin', () => { expect( selectWasmVariant({ files: ['src/theme.css.js'] } as StaticImportGraph), ).toBe('full') - expect( - selectWasmVariant( - { files: ['src/theme.css.ts'] } as StaticImportGraph, - ['src/theme.css.ts'], - true, - ), - ).toBe('lite') expect( selectWasmVariant({ files: ['src/page.tsx'] } as StaticImportGraph, [ 'node_modules/design-system/theme.css.ts', @@ -675,10 +668,9 @@ describe('DevupUINextPlugin', () => { prewarmedFiles: expect.any(Array), prewarmedOutputs: expect.any(Map), sourceMap: false, - staticVanillaExtract: false, }) }) - it('uses the lite extractor for a provably static vanilla style module', () => { + it('extracts vanilla-extract modules as written with the full engine', () => { setNodeEnv('production') process.env.TURBOPACK = '1' const filename = resolve('src/styles.css.ts') @@ -696,47 +688,6 @@ export const box = style({ color: 'red' })` String(path).endsWith('styles.css.ts') ? source : '{}', ) - try { - DevupUI({}) - - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/styles.css.ts', - `import { css } from '@devup-ui/react' -export const box = css({ color: 'red' })`, - '@devup-ui/react', - expect.any(String), - false, - false, - true, - expect.anything(), - ) - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ staticVanillaExtract: true }), - ) - } finally { - graphSpy.mockRestore() - compiledSpy.mockRestore() - } - }) - it('keeps the full extractor for dynamic vanilla style modules', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - const filename = resolve('src/styles.css.ts') - const source = `import { style } from '@vanilla-extract/css' -const color = getColor() -export const box = style({ color })` - const graphSpy = spyOn( - importGraphModule, - 'buildStaticImportGraph', - ).mockReturnValue(createSingleFileGraph(filename)) - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/styles.css.ts']) - readFileSyncSpy.mockImplementation((path: fs.PathOrFileDescriptor) => - String(path).endsWith('styles.css.ts') ? source : '{}', - ) - try { DevupUI({}) @@ -750,9 +701,6 @@ export const box = style({ color })` true, expect.anything(), ) - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ staticVanillaExtract: false }), - ) } finally { graphSpy.mockRestore() compiledSpy.mockRestore() @@ -959,7 +907,6 @@ export const box = style({ color })` prewarmedFiles: [], prewarmedOutputs: expect.any(Map), sourceMap: true, - staticVanillaExtract: false, }) expect(codeExtractSpy).not.toHaveBeenCalled() expect(codeExtractWithoutSourceMapSpy).not.toHaveBeenCalled() diff --git a/packages/next-plugin/src/__tests__/static-vanilla.test.ts b/packages/next-plugin/src/__tests__/static-vanilla.test.ts deleted file mode 100644 index 16da2ad98..000000000 --- a/packages/next-plugin/src/__tests__/static-vanilla.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { transformStaticVanillaExtract } from '../static-vanilla' - -describe('transformStaticVanillaExtract', () => { - it('converts static style modules without evaluating JavaScript', () => { - const source = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red', padding: [4, null, 8] }) -export const hover = style({ selectors: { '&:hover': { opacity: 0.5 } } })` - - expect( - transformStaticVanillaExtract( - 'src/styles.css.ts', - source, - '@devup-ui/react', - ), - ).toBe(`import { css } from '@devup-ui/react' -export const box = css({ color: 'red', padding: [4, null, 8] }) -export const hover = css({ selectors: { '&:hover': { opacity: 0.5 } } })`) - }) - - it('rejects executable values and extra module statements', () => { - const dynamic = `import { style } from '@vanilla-extract/css' -const color = getColor() -export const box = style({ color })` - const spread = `import { style } from '@vanilla-extract/css' -export const box = style({ ...base, color: 'red' })` - - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - dynamic, - '@devup-ui/react', - ), - ).toBeUndefined() - expect( - transformStaticVanillaExtract('styles.css.ts', spread, '@devup-ui/react'), - ).toBeUndefined() - expect( - transformStaticVanillaExtract('styles.ts', dynamic, '@devup-ui/react'), - ).toBeUndefined() - }) - - it('preserves comments and aliases in the static subset', () => { - const source = `/* import */ import { style as makeStyle } from '@vanilla-extract/css' -export const box = makeStyle({ - // responsive values remain source text - padding: [4, null, 8], -})` - - expect( - transformStaticVanillaExtract('styles.css.js', source, 'custom-package'), - ).toContain('export const box = css({') - }) - - it('rejects unsupported imports and malformed trailing comments', () => { - const multipleImports = `import { style, globalStyle } from '@vanilla-extract/css' -export const box = style({ color: 'red' })` - const unterminatedComment = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red' }) /*` - - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - multipleImports, - '@devup-ui/react', - ), - ).toBeUndefined() - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - unterminatedComment, - '@devup-ui/react', - ), - ).toBeUndefined() - }) -}) diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 71bf53476..a6f6b4370 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -5,7 +5,6 @@ import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { elapsedMs, profileStart, reportProfile } from './profile' -import { transformStaticVanillaExtract } from './static-vanilla' import type { DevupWasm } from './wasm' export interface CoordinatorOptions { @@ -48,8 +47,6 @@ export interface CoordinatorOptions { prewarmedOutputs?: Map /** Generate transform source maps. Defaults to true for existing callers. */ sourceMap?: boolean - /** Production-only static `.css.ts` fast path selected with the lite WASM. */ - staticVanillaExtract?: boolean /** * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic @@ -377,7 +374,6 @@ export function startCoordinator(options: CoordinatorOptions): { getCss, } = wasm const prewarmedOutputs = options.prewarmedOutputs ?? new Map() - const staticVanillaExtract = options.staticVanillaExtract ?? false const extract = options.sourceMap === false ? codeExtractWithoutSourceMap : codeExtract @@ -473,9 +469,7 @@ export function startCoordinator(options: CoordinatorOptions): { : takeExtractOutput( extract( filename, - (staticVanillaExtract - ? transformStaticVanillaExtract(filename, code, libPackage) - : undefined) ?? code, + code, libPackage, relCssDir, singleCss, diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index b520692ec..64d97b32e 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -31,7 +31,6 @@ import { } from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' -import { transformStaticVanillaExtract } from './static-vanilla' import { loadWasm, loadWebpackPlugin } from './wasm' /** Options accepted by the Next.js integration. */ @@ -118,11 +117,9 @@ export function resetTurboSetupCacheForTesting(): void { export function selectWasmVariant( graph: StaticImportGraph | undefined, candidateFiles: string[] = graph?.files ?? [], - staticVanillaExtract = false, ): 'lite' | 'full' { return graph && - (staticVanillaExtract || - !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename))) + !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename)) ? 'lite' : 'full' } @@ -248,31 +245,7 @@ export function DevupUI( }) : [] const candidateCollectMs = elapsedMs(candidateCollectStartedAt) - const staticVanillaSources = new Map< - string, - { code: string; source: string } - >() - const vanillaCandidates = wasmCandidateFiles.filter((filename) => - /\.css\.(?:ts|js)$/.test(filename), - ) - let staticVanillaExtract = - !watch && !sourceMap && vanillaCandidates.length > 0 - for (const filename of vanillaCandidates) { - if (!staticVanillaExtract) break - const source = readFileSync(resolve(process.cwd(), filename), 'utf-8') - const code = transformStaticVanillaExtract(filename, source, libPackage) - if (code === undefined) { - staticVanillaExtract = false - staticVanillaSources.clear() - break - } - staticVanillaSources.set(filename, { code, source }) - } - const wasmVariant = selectWasmVariant( - staticGraph, - wasmCandidateFiles, - staticVanillaExtract, - ) + const wasmVariant = selectWasmVariant(staticGraph, wasmCandidateFiles) const wasm = loadWasm(wasmVariant === 'lite') const { codeExtract, @@ -452,9 +425,7 @@ export function DevupUI( ).replaceAll('\\', '/')}` const readStartedAt = prewarmStartedAt === undefined ? undefined : performance.now() - const preparedVanilla = staticVanillaSources.get(filename) - const source = - preparedVanilla?.source ?? readFileSync(resourcePath, 'utf-8') + const source = readFileSync(resourcePath, 'utf-8') if (readStartedAt !== undefined) { prewarmReadMs += performance.now() - readStartedAt prewarmSourceBytes += Buffer.byteLength(source) @@ -464,7 +435,7 @@ export function DevupUI( const output = takeExtractOutput( extract( filename, - preparedVanilla?.code ?? source, + source, libPackage, relCssDir, singleCss, @@ -544,7 +515,6 @@ export function DevupUI( prewarmedFiles, prewarmedOutputs, sourceMap, - staticVanillaExtract, }) // Cleanup on exit diff --git a/packages/next-plugin/src/static-vanilla.ts b/packages/next-plugin/src/static-vanilla.ts deleted file mode 100644 index 3bdd237c4..000000000 --- a/packages/next-plugin/src/static-vanilla.ts +++ /dev/null @@ -1,194 +0,0 @@ -function isIdentifierChar(char: string | undefined): boolean { - return char !== undefined && /[$\w]/.test(char) -} - -class StaticVanillaParser { - private index = 0 - private valid = true - - constructor( - private readonly source: string, - private readonly packageName: string, - ) {} - - parse(): string | undefined { - if (!this.word('import') || !this.char('{')) return undefined - const imported = this.identifier() - if (imported !== 'style') return undefined - let local = imported - if (this.word('as')) { - const alias = this.identifier() - if (alias === undefined) return undefined - local = alias - } - if ( - !this.char('}') || - !this.word('from') || - this.string() !== '@vanilla-extract/css' - ) { - return undefined - } - this.char(';') - - const declarations: string[] = [] - while (!this.done()) { - if (!this.word('export') || !this.word('const')) return undefined - const name = this.identifier() - if (name === undefined || !this.char('=') || !this.word(local)) { - return undefined - } - if (!this.char('(')) return undefined - this.skipTrivia() - const valueStart = this.index - if (!this.staticValue()) return undefined - const value = this.source.slice(valueStart, this.index) - if (!this.char(')')) return undefined - this.char(';') - declarations.push(`export const ${name} = css(${value})`) - } - - if (declarations.length === 0) return undefined - return `import { css } from '${this.packageName}'\n${declarations.join('\n')}` - } - - private staticValue(): boolean { - this.skipTrivia() - const char = this.source[this.index] - if (char === '{') return this.object() - if (char === '[') return this.array() - if (char === '"' || char === "'") return this.string() !== undefined - if (char === '+' || char === '-') this.index++ - const number = this.source - .slice(this.index) - .match(/^(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i) - if (number) { - this.index += number[0].length - return true - } - return this.word('true') || this.word('false') || this.word('null') - } - - private object(): boolean { - if (!this.char('{')) return false - if (this.char('}')) return true - while (true) { - this.skipTrivia() - const char = this.source[this.index] - const key = - char === '"' || char === "'" - ? this.string() - : (this.identifier() ?? this.numberKey()) - if (key === undefined || !this.char(':') || !this.staticValue()) { - return false - } - if (this.char('}')) return true - if (!this.char(',')) return false - if (this.char('}')) return true - } - } - - private array(): boolean { - if (!this.char('[')) return false - if (this.char(']')) return true - while (true) { - if (!this.staticValue()) return false - if (this.char(']')) return true - if (!this.char(',')) return false - if (this.char(']')) return true - } - } - - private numberKey(): string | undefined { - this.skipTrivia() - const match = this.source.slice(this.index).match(/^\d+(?:\.\d+)?/) - if (!match) return undefined - this.index += match[0].length - return match[0] - } - - private identifier(): string | undefined { - this.skipTrivia() - const match = this.source.slice(this.index).match(/^[$A-Z_a-z][$\w]*/) - if (!match) return undefined - this.index += match[0].length - return match[0] - } - - private string(): string | undefined { - this.skipTrivia() - const quote = this.source[this.index] - if (quote !== '"' && quote !== "'") return undefined - const start = ++this.index - while (this.index < this.source.length) { - const char = this.source[this.index++] - if (char === '\\') { - this.index++ - } else if (char === quote) { - return this.source.slice(start, this.index - 1) - } else if (char === '\n' || char === '\r') { - return undefined - } - } - return undefined - } - - private word(value: string): boolean { - this.skipTrivia() - if ( - !this.source.startsWith(value, this.index) || - isIdentifierChar(this.source[this.index - 1]) || - isIdentifierChar(this.source[this.index + value.length]) - ) { - return false - } - this.index += value.length - return true - } - - private char(value: string): boolean { - this.skipTrivia() - if (this.source[this.index] !== value) return false - this.index++ - return true - } - - private done(): boolean { - this.skipTrivia() - return this.valid && this.index === this.source.length - } - - private skipTrivia(): void { - while (this.index < this.source.length) { - if (/\s/.test(this.source[this.index])) { - this.index++ - } else if (this.source.startsWith('//', this.index)) { - const newline = this.source.indexOf('\n', this.index + 2) - this.index = newline === -1 ? this.source.length : newline + 1 - } else if (this.source.startsWith('/*', this.index)) { - const end = this.source.indexOf('*/', this.index + 2) - if (end === -1) { - this.valid = false - this.index = this.source.length - } else { - this.index = end + 2 - } - } else { - break - } - } - } -} - -/** - * Convert the deliberately small, statically provable subset of vanilla - * `style()` modules into Devup `css()` calls. Any executable expression or - * additional statement returns undefined and keeps the existing Boa path. - */ -export function transformStaticVanillaExtract( - filename: string, - source: string, - packageName: string, -): string | undefined { - if (!/\.css\.(?:ts|js)$/.test(filename)) return undefined - return new StaticVanillaParser(source, packageName).parse() -} diff --git a/packages/react/AGENTS.md b/packages/react/AGENTS.md index b9011b401..2807aa5f5 100644 --- a/packages/react/AGENTS.md +++ b/packages/react/AGENTS.md @@ -68,7 +68,7 @@ src/ ### Theme Tokens ```tsx // Color token - // Typography preset + // Typography preset ``` ## ANTI-PATTERNS diff --git a/packages/react/README.md b/packages/react/README.md index bc91e68a5..ede5459d6 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **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 | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. 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 result is effectively parity with Tailwind on this fixture. 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 diff --git a/packages/react/src/types/props/__tests__/index.test-d.ts b/packages/react/src/types/props/__tests__/index.test-d.ts index 309493e4b..32bc57f14 100644 --- a/packages/react/src/types/props/__tests__/index.test-d.ts +++ b/packages/react/src/types/props/__tests__/index.test-d.ts @@ -128,6 +128,30 @@ describe('index', () => { `, }) }) + it('media shorthands and at-rule keys', () => { + assertType>({ + _motionReduce: { transition: 'none' }, + _motionSafe: { _hover: { transform: 'scale(1.05)' } }, + _portrait: { flexDir: 'column' }, + _landscape: [{ flexDir: 'row' }, null, { flexDir: 'column' }], + _contrastMore: { borderColor: 'black' }, + _contrastLess: { borderColor: 'gray' }, + _forcedColors: { borderColor: 'CanvasText' }, + _print: { display: 'none' }, + _screen: { display: 'block' }, + _all: { color: 'black' }, + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, + '@supports (display: grid)': { display: 'grid' }, + '@container sidebar (min-width: 400px)': { p: 2 }, + _hover: { + _motionReduce: { transform: 'none' }, + '@media print': { color: 'black' }, + }, + }) + + expectTypeOf>().not.toHaveProperty('_speech') + }) + it('DevupComponentAdditionalProps', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars function Foo({ children: _ }: { children?: string; c: string }) { diff --git a/packages/react/src/types/props/selector/index.ts b/packages/react/src/types/props/selector/index.ts index 891a13b1f..81d19ad0c 100644 --- a/packages/react/src/types/props/selector/index.ts +++ b/packages/react/src/types/props/selector/index.ts @@ -71,13 +71,30 @@ export type AtRuleRecord = Partial< Record> > +/** Props that wrap styles in a fixed `@media` query, e.g. `_motionReduce`. */ +export type MediaShorthand = + | '_print' + | '_screen' + | '_all' + | '_portrait' + | '_landscape' + | '_motionReduce' + | '_motionSafe' + | '_contrastMore' + | '_contrastLess' + | '_forcedColors' + +export type MediaShorthandProps = { + [K in MediaShorthand]?: SelectorProps +} + +/** At-rule keys carrying their condition, e.g. `'@media print'`. */ +export type AtRuleKey = + `@media ${string}` | `@supports ${string}` | `@container ${string}` + export interface DevupSelectorProps - extends SimpleSelectorProps, AdvancedSelectorProps { - // media query - _print?: SelectorProps - _screen?: SelectorProps - _speech?: SelectorProps - _all?: SelectorProps + extends SimpleSelectorProps, AdvancedSelectorProps, MediaShorthandProps { + [key: AtRuleKey]: SelectorProps | undefined // at-rules (underscore prefix) _container?: AtRuleRecord diff --git a/packages/react/src/utils/__tests__/global-css.test-d.ts b/packages/react/src/utils/__tests__/global-css.test-d.ts index 0be126b4e..5175a9987 100644 --- a/packages/react/src/utils/__tests__/global-css.test-d.ts +++ b/packages/react/src/utils/__tests__/global-css.test-d.ts @@ -12,4 +12,17 @@ describe('globalCss', () => { }, }) }) + + it('top-level at-rules', () => { + assertType({ + '@media (prefers-reduced-motion: reduce)': { + '*, *::before, *::after': { transition: 'none' }, + }, + _motionReduce: { html: { scrollBehavior: 'auto' } }, + _print: { body: { bg: 'white' } }, + _media: { '(min-width: 768px)': { body: { m: 2 } } }, + '@supports': { '(display: grid)': { main: { display: 'grid' } } }, + body: { _motionReduce: { transition: 'none' } }, + }) + }) }) diff --git a/packages/react/src/utils/global-css.ts b/packages/react/src/utils/global-css.ts index c35d0ad3e..dd86476c8 100644 --- a/packages/react/src/utils/global-css.ts +++ b/packages/react/src/utils/global-css.ts @@ -1,10 +1,12 @@ import type { DevupCommonProps } from '../types/props' import type { AdvancedSelector, + AtRuleKey, CamelCase, DevupSelectorProps, DevupThemeSelectorProps, ExtractSelector, + MediaShorthand, SimpleSelector, } from '../types/props/selector' @@ -36,6 +38,19 @@ export type GlobalCssProps = { [ K in `${keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap | '.' | '*' | '#' | ':' | '['}${string}` ]?: DevupCommonProps & DevupSelectorProps & DevupThemeSelectorProps +} & { + // Top-level at-rules wrap whole selector maps: `'@media print': { body: … }`. + [K in AtRuleKey | MediaShorthand]?: GlobalCssProps +} & { + [ + K in + | '_media' + | '_supports' + | '_container' + | '@media' + | '@supports' + | '@container' + ]?: Record } interface FontFaceProps { diff --git a/packages/reset-css/README.md b/packages/reset-css/README.md index 72d9da7e0..d586eff94 100644 --- a/packages/reset-css/README.md +++ b/packages/reset-css/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **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 | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. 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 result is effectively parity with Tailwind on this fixture. 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 diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 9f00bf49c..404fc2997 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **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 | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. 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 result is effectively parity with Tailwind on this fixture. 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 diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 166b4784b..a943c028c 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **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 | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. 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 result is effectively parity with Tailwind on this fixture. 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 diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 83029a18e..343ead564 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **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 | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. 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 result is effectively parity with Tailwind on this fixture. 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