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_changed_constants.json b/.changepacks/changepack_log_changed_constants.json new file mode 100644 index 000000000..ef64e5d19 --- /dev/null +++ b/.changepacks/changepack_log_changed_constants.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "An object, array or enum constant that code the build sees changes (a member assignment, delete, ++, push, sort, Object.assign, or handing it to a function the build does not know) is no longer read as its declared value: elements read its members at runtime, css(), styled() and element spreads taking it whole are build errors naming where it is changed, and values computed at build time do not read it. Values computed at build time cannot read window, document, navigator, location, process or globalThis properties the build does not define, so the CSS never holds the build environment's answer", + "date": "2026-09-29T00: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..dfacbaf27 --- /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 `@devup-ui/wasm` 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..118e781bd --- /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 evaluates every `.css.ts` module instead of taking a separate static fast path, so development and production builds produce the same output", + "date": "2026-09-27T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_module_loader_constants.json b/.changepacks/changepack_log_module_loader_constants.json new file mode 100644 index 000000000..365bb171c --- /dev/null +++ b/.changepacks/changepack_log_module_loader_constants.json @@ -0,0 +1,14 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch", + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Load the modules a file imports: a .css.ts stylesheet can import other stylesheets and ordinary modules (an imported stylesheet exports the names its own CSS uses and stays imported), and a string or number declared with const in an imported module stays a static class instead of a CSS variable. The plugins resolve imports like the bundler (relative paths, tsconfig paths) through setModuleResolver and watch the files read (Output.dependencies). styled-components .attrs() (objects and functions) and .withConfig() compile, a file may import styled from several libraries, and conditional style arguments (cond && { ... }, ternaries) merge per property in css() and styled. Dynamic values inside '@layer' stay in the layer, and a layered declaration no longer shares a class with the same unlayered one. A declaration written only on a condition now wins over a typography preset, which sits in a cascade layer below direct declarations. Emotion's number-to-px conversion reaches conditional branches, and a side-effect import in a .css.ts file is kept. Imports also resolve to packages (exports conditions, module, main) and CommonJS modules, import cycles behave as in ES modules, value || { ... } and value ?? { ... } compose, styled composes classes like css(), and a style argument that cannot be known at build time is a build error instead of a silently missing style. A string or number const at the top of the file itself is inlined too, with templates and arithmetic over constants folded; css(), globalCss(), keyframes() and stylex.create() report a value known only at runtime as a build error instead of emitting an unset CSS variable; template interpolations of globalCss, createGlobalStyle and keyframes are written into the CSS, a template mixin (${mixin}, ${p => cond && mixin}) composes its class, and an unknown interpolation in a selector or property name is a build error; Next.js keeps the files a prewarmed extraction read so their changes rebuild; the css-utils-literal-only lint rule accepts the constants the build inlines; the webpack plugin prewarms with the module resolver, so a file merged into another file's CSS keeps the classes its loader emits; StyleX variables, constants and themes imported from another module resolve to the names that module generates, and every namespace or default StyleX import is recognized; a .css.ts file that throws while evaluated reports the exception instead of emitting runtime style() calls; constant inlining skips scope analysis when no style reads a constant; build errors carry file:line:column and all of them are reported; StyleX defineVars and createTheme values may depend on @media, @supports and @container conditions (optionally wrapped in types.*()), emitted as one block per set of conditions after the unconditional values with nested conditions last, in the defining file and in the files importing it; a StyleX variable or theme value that is not static is a build error; Math.* calls over constants fold at build time; a style value computed from constants when the module runs (darken(0.1, PRIMARY), a helper call) is computed at build time and becomes a static class; StyleX create(), defineVars(), defineConsts(), createTheme(), keyframes(), positionTry(), viewTransitionClass() and include() report what they cannot read (spreads, computed keys, non-object arguments) as located build errors instead of printing or dropping it, and stylex.props()/attrs() join styles compiled elsewhere (a style prop, a spread) at runtime; object, array and TypeScript enum constants (declared in the file or imported) read as if written in place wherever styles are read (css(base), {...base}, _hover: hover, space[2], Size.M, ), a later property replacing an earlier one as in JavaScript, both in style objects and among JSX props; styles the build cannot read where an object is expected (a spread of an unknown object, a selector given a non-object, a computed key, a non-object globalCss/keyframes argument) are build errors instead of silently missing, a single css()/styled argument that is not a rule object composes as a class, a JSX spread of an object literal keeps its non-style props, a member read such as theme.colors[key] becomes a CSS variable instead of vanishing, and an index or key a spread may supply is read at runtime; values computed from constants through a const or an import, spreads, shorthand properties, computed keys and conditions are computed at build time, with stand-ins for the style packages and for modules the build cannot load; a TypeScript enum no longer crashes stylesheet evaluation; a binding css() or styled() composes that holds rules the module computes (const base = makeStyles(); css(base)) applies those rules, and a class computed in its place is never read as CSS text; build errors name what the build accepts (constants, and values computed from them); source maps point at the code as written when imports were rewritten or values computed in its place; an imported stylesheet runs once per script it evaluates to, and each module is stripped of TypeScript once instead of on every evaluation importing it; css-utils-literal-only accepts what module functions, imports and built-ins compute from constants, and reports Date, Math.random and functions only known at runtime. A runtime value an element holds is written as it is, where it was optimized as CSS text (a string passed to a function lost digits, --i became var(--i)), and every value an element can hold reaches it: await, new, assignments, sequences and tagged templates were dropped. globalCss() used as a value gives undefined instead of invalid code, a condition known only at runtime is a build error in globalCss(), keyframes() and (it was dropped), and a condition the build knows picks its branch. A JSX spread, a selector prop and a spread, selector or computed key in a styled object compute what the module computes, as do css(...parts) and a template argument of css(); a namespace or object of known classes no longer runs the module; a stylesheet that loops forever fails instead of hanging the build; and css-utils-literal-only accepts the parameters of a callback written in the value. A component with a non-literal as (as={Link}, as={cond ? \"a\" : \"b\"}) is replaced where it stands: in JSX children it replaced its parent element or a later expression and left the component in the output, and in lists it lost its key inside a fragment. A spread whose value may change when read again (, a hook) is evaluated once, with the props and children written before it in order, where it was evaluated for the spread, the className and the style; and a literal spread no longer reads className or style it does not write.", + "date": "2026-09-27T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_remove_lite_engine.json b/.changepacks/changepack_log_remove_lite_engine.json new file mode 100644 index 000000000..0010fe3c8 --- /dev/null +++ b/.changepacks/changepack_log_remove_lite_engine.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Remove the lite engine: `@devup-ui/wasm` ships one build that evaluates stylesheets and computed style values, the `@devup-ui/wasm/lite` export is gone, and the Next.js plugin always loads `@devup-ui/wasm` instead of choosing an engine from the project's files", + "date": "2026-09-29T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_runtime_value_gaps.json b/.changepacks/changepack_log_runtime_value_gaps.json new file mode 100644 index 000000000..a86d168b2 --- /dev/null +++ b/.changepacks/changepack_log_runtime_value_gaps.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Close the gaps left in reading changed objects and the build environment: an object a namespace import or another object holds that code changes is a build error where taken whole instead of silently composed as a class; CommonJS modules, changes to elements in for...of loops and forEach/map callbacks, and methods using this count as changes; export default, module.exports, Object.freeze, Array.from, Object.keys and element props (except ref) only read what they are given, so token objects stay static classes; notes tell a change apart from handing an object on. Values computed at build time never read Intl or locale methods (toLocaleString, toLocaleUpperCase, localeCompare), directly or through a variable or an import, and a value reading a statement that threw or a module the build cannot load is not computed instead of taking a branch on a stand-in. A module checking the environment at its top still gives its other exports, a top-level console call no longer stops a module or a .css.ts stylesheet, and a computed value only reads the modules its code reads. content, font-size-adjust and math-depth keep their numbers instead of scaling them to px. css-utils-literal-only reports a constant or an import the file changes", + "date": "2026-09-29T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_strict_build_time_values.json b/.changepacks/changepack_log_strict_build_time_values.json new file mode 100644 index 000000000..32277fee5 --- /dev/null +++ b/.changepacks/changepack_log_strict_build_time_values.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Only run build-time code whose result is certain: this file's const, function and enum declarations computing from literals and constants with exact built-ins. Imports are read as the literal, object or array their module declares and another module's code never runs. Other globals, Math.random and approximate Math functions, **, toString with a radix, locale and Unicode methods, this, new, classes, regular expressions, try, getters, async code, generators, JSX, methods chosen at runtime and functions writing module-level bindings are not run: elements keep such values as CSS variables, and css(), globalCss(), keyframes() and styled() report a build error, including parts they compose from such bindings. Constant conditions and &&, || and ?? choose their side in style arguments and props only, so application code keeps reading its bindings. Objects holding booleans, undefined or functions are no longer taken as changed. transition and animation durations and delays are milliseconds instead of spacing, and counters keep their numbers. css-utils-literal-only follows the same rules: it reports calls of imported functions and of functions the build does not run, changing elements in for...of loops and forEach/map callbacks, methods using this, and objects handed to unknown code", + "date": "2026-09-29T00:00:00.000Z" +} diff --git a/Cargo.lock b/Cargo.lock index 59af8ade9..43ee28ccf 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", @@ -735,6 +738,7 @@ dependencies = [ "oxc_codegen", "oxc_parser", "oxc_semantic", + "oxc_sourcemap", "oxc_span", "oxc_syntax", "oxc_transformer", @@ -2380,6 +2384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "libc", "num-conv", "num_threads", @@ -2624,6 +2629,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..293ef7d0a 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`, `counterReset`/`counterIncrement`/`counterSet`, and the rest of the unitless list vanilla-extract uses. Times are milliseconds, never scaled: `transitionDuration={300}`, `transitionDelay`, `animationDuration` and `animationDelay` give `300ms`. + +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 @@ -238,8 +257,14 @@ Available: `_groupHover`, `_groupFocus`, `_groupActive`, `_groupDisabled`. // Conditional -> preserved // className={isActive ? "a" : "b"} + +// Imported const -> static (resolved like the bundler: relative paths, tsconfig paths, packages; ESM or CommonJS) +import { PRIMARY } from "./tokens" // export const PRIMARY = "red" + // className="a"; a `let`, call or package import stays a variable ``` +A `.css.ts` file may import other stylesheets and modules (import cycles behave as in ES modules); an imported stylesheet exports the same names its own CSS uses. styled-components `.attrs()` (objects and functions) and `.withConfig()` compile, and `css(base, cond && { ... })` / `styled.div(base, cond ? a : b)` / `value || { ... }` / `value ?? { ... }` merge per property. A style argument that cannot be known at build time is a build error. StyleX `defineVars` / `defineConsts` / `createTheme` values imported from a `.stylex.ts` file resolve to the names that file generates, and variable/theme values may be condition objects (`{ default, [DARK]: ... }`, `@media` / `@supports` / `@container`). A `.css.ts` file that throws while evaluated is a build error carrying the exception. Build errors are reported all at once as `file:line:column: message`. + ### Responsive + Pseudo Combined ```tsx @@ -314,7 +339,7 @@ const spin = keyframes({ from: { transform: "rotate(0)" }, to: { transform: "rot ### Dynamic Values with Custom Components -`css()` only accepts **static values**. For dynamic values on custom components, use ``: +`css()`, `globalCss()`, `keyframes()` and `stylex.create()` only accept values known at build time - literals, theme tokens, imported constants and module-level `const`s (templates, arithmetic and `Math.*` calls over them fold). Object, array and enum constants read as if written in place (`css(base)`, `{ ...base, color: 'red' }`, `_hover: hover`, `space[2]`, `Size.M`, ``), a later property replacing an earlier one. The build only runs code whose result is certain: `const`s, functions and enums this file declares, computing from literals and constants with exact built-ins (`String`, `Number`, `JSON`, `Object`, `Array`, string/array methods, `Math.abs/ceil/floor/round/trunc/sign/max/min/sqrt/fround/imul/clz32` and `Math` constants) - `const double = (n) => n * 2; css({ w: double(SIZE) })` is static. Imports are only read as the literal, object or array their module declares; code of another module never runs (`darken(0.1, PRIMARY)` with an imported `darken` is not computed). Not run: other globals (`window`, `Date`, `Intl`, ...), `Math.random` and approximate `Math` functions (`sin`, `pow`, ...), `**`, `toString(radix)`, `toLocale*`/`localeCompare`/`normalize`, `this`, `new`, classes, regex, `try`, getters, async/generators, JSX, `obj[key]()`, and functions writing module-level bindings - elements keep such values as CSS variables, while `css()`/`globalCss()`/`keyframes()`/`styled()` report a build error. An object, array or enum constant that visible code changes (member assignment, `delete`, `++`, `push`/`sort`, `Object.assign`, changing elements in `for...of`/`forEach`, a method using `this`, passing it to an unknown function) is not a constant: elements read its members at runtime, and `css(obj)`, `styled.div(obj)` or `{...obj}` on an element is a build error naming where it changes. JSX props (except `ref`), `export default`, `module.exports` and `Object.freeze` only read it. Never mutate objects styles read; use theme tokens or props for values that change. A value known only at runtime (a prop, state, a parameter), and styles written where the build cannot read an object (a spread of an unknown object, `_hover={x}`, a computed key), are a **build error**. For dynamic values on custom components, use ``: ```tsx // WRONG - css() cannot handle dynamic values @@ -365,7 +390,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) @@ -548,6 +573,7 @@ One rule explains `Dynamic Values = CSS Variables`, `$token Scope` and |------|--------| | `` | Static class | | `` | Static class per value - **preferred** | +| `` where `PRIMARY` is a module-level or imported `const` string/number | Static class | | `` where `colors` is declared elsewhere | CSS variable | | `` | CSS variable (genuinely dynamic - correct) | | `const s = { a: css({ ... }) }` then `className={s[v]}` | Neither - see below | diff --git a/apps/landing/src/app/(detail)/docs/api/box/page.mdx b/apps/landing/src/app/(detail)/docs/api/box/page.mdx index 748acafb2..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/style-props/page.mdx b/apps/landing/src/app/(detail)/docs/api/style-props/page.mdx index 42b613b78..319509b93 100644 --- a/apps/landing/src/app/(detail)/docs/api/style-props/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/style-props/page.mdx @@ -10,6 +10,8 @@ export const metadata = { The attributes defined in Style Property correspond to those defined in Props, allowing them to be used directly as component props. +A number given to a length is on the spacing scale (`p={4}` is `16px`). Times are milliseconds, never scaled: `transitionDuration={300}`, `transitionDelay`, `animationDuration` and `animationDelay` give `300ms`. Unitless properties such as `lineHeight`, `zIndex`, `opacity` and the counters (`counterReset`, `counterIncrement`, `counterSet`) keep the number as written. + ## Animation diff --git a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx index 9133ae7f7..fb7b8b524 100644 --- a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx @@ -83,6 +83,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/devup/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..376f818a6 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. That holds for a property set only on a condition too (`fontSize={small ? '12px' : undefined}`): preset declarations sit in a cascade layer below the ones written directly, so whichever applies wins: ```tsx const example = ( - + 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..cb96ca9fa 100644 --- a/apps/landing/src/app/(detail)/docs/features/page.mdx +++ b/apps/landing/src/app/(detail)/docs/features/page.mdx @@ -62,6 +62,25 @@ const generated =
// .a { background-color: var(--a); } ``` +A value imported from another module stays static when that module declares it with `const` — a string or number literal, a member of an object literal (`as const` included), an alias of another constant, a re-export or a default export: + +```tsx +// tokens.ts +export const PRIMARY = 'red' +export const colors = { brand: '#123456' } as const + +// App.tsx +import { colors, PRIMARY } from './tokens' +const example = + +// Devup UI generates: +const generated =
+``` + +The same holds for a `const` declared at the top of the file itself, and templates or arithmetic over such constants fold into one value (`` `${SIZE * 2}px` ``). + +Imports are resolved like the bundler resolves them — relative paths, `tsconfig` `paths` and packages (`exports`, `module`, `main`) — and a change to the module rebuilds the files reading it. CommonJS modules count too when they assign the export once (`exports.PRIMARY = 'red'`, `module.exports = { ... }`). Anything that can change or is computed — a `let`, a property assigned twice, a function call — stays a CSS variable. + ## Conditional Expressions Ternary operators and logical expressions are statically analyzed. @@ -162,7 +181,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. @@ -212,6 +231,25 @@ const cardStyles = css({ const example = ``` +`css()`, `globalCss()` and `keyframes()` have no element to set a CSS variable on, so their values must be known at build time — literals, theme tokens or constants. Constants may be objects, arrays and TypeScript enums, declared in the file or imported: `css(baseStyles)`, `{ ...baseStyles, color: 'red' }`, `_hover: hoverStyles`, `space[2]`, `Size.M`, and `` all read as if written in place, a later property replacing an earlier one as in JavaScript. `Math` calls over them (`Math.max(SIZE, 20)`, `Math.round(x)`, `Math.PI`) fold like arithmetic. + +The build also runs code, but only code whose result is certain: what this file declares — `const`s, functions and enums — computing from literals and constants with exact built-ins (`String`, `Number`, `JSON`, `Object`, `Array`, string and array methods such as `'a'.toUpperCase()` or `[1, 2].map(double)`, and the exact `Math` functions `abs`, `ceil`, `floor`, `round`, `trunc`, `sign`, `max`, `min`, `sqrt`, `fround`, `imul`, `clz32` and its constants). `const double = (n) => n * 2; css({ w: double(SIZE) })` becomes a static class. An import is only read as the value its module declares — a literal, or an object or array of them — and the code of another module never runs, so `darken(0.1, PRIMARY)` with an imported `darken` is not computed. Anything less certain is not run: other globals (`window`, `Date`, `Intl`, `IntersectionObserver`...), `Math.random` and the approximate `Math` functions (`sin`, `pow`, `exp`...), `**`, `toString` with a radix, locale and Unicode methods (`toLocaleString`, `localeCompare`, `normalize`), `this`, `new`, classes, regular expressions, `try`, getters, `async` code and generators, JSX, a method chosen at runtime (`obj[key]()`), and a function writing a module-level binding. An element then keeps such a value as a CSS variable, as it keeps any runtime value; `css()`, `globalCss()`, `keyframes()` and `styled()` have no element for it, so it is a build error naming it — move the value to a style prop, or make it a constant. + +An object, array or enum constant reads as written only while no code the build sees changes it: the file using it, the module declaring it and the modules re-exporting it, CommonJS modules included. Assigning a member, `delete`, `++`, a method such as `push` or `sort`, `Object.assign`, changing its elements in a `for...of` loop or a `forEach`/`map` callback, calling a method of it that uses `this`, or handing it to a function the build does not know (or to an object that is changed) makes it a value only known at runtime — an element reads its members as CSS variables, and `css()`, `styled()` or a spread taking it whole, directly or through a namespace import or an object holding it, is a build error that names where it is changed or handed on. What only reads it changes nothing: handing on a string or number it holds (`darken(colors.primary)`), keeping it in a `const`, `export default` or `module.exports` nothing changes, `Object.freeze`, `Object.keys` or `Array.from` over its primitives, and element props — React components must not change their props, so `` only reads it; `ref={obj}` is the exception React assigns to. A module the build does not see can still change an imported object, so keep objects styles read unchanged at runtime, and use theme tokens or props for values that change. + +A binding that `css()` or `styled()` composes reads the same way: with `const base = makeStyles()`, `css(base)`, `css(...parts)` and `styled.div(base)` apply the rules the module computes, while a class held in a binding (`const base = css({ ... })`, a class imported from a stylesheet) still composes as a class. Elements take such objects too — ``, `` — as do a spread, a selector or a computed key in a `styled` object; values an element holds at runtime stay CSS variables. Source maps point at the code as written, not at the values computed in its place. + +A condition the build knows picks its branch (`const DARK = true; css({ color: DARK ? 'white' : 'black' })`). `globalCss()`, `keyframes()` and `` have no class to switch, so a condition known only at runtime is a build error there, where `css()` and style props switch classes. + +A value known only at runtime — a prop, a state, a parameter — is a build error naming it, as is styles the build cannot read where an object is expected: a spread of an unknown object, a selector such as `_hover` given something other than an object, a computed key. For a runtime value, use a style prop (``); any expression works there, `await` and `new` included, and runs as written. A JSX spread of an unknown object (``) still passes its props at runtime. + +Build errors point at the code they are about, and a file with several problems reports them all at once, in source order: + +``` +src/App.tsx:6:18: `css()` cannot use `width` at build time: its values must be literals, theme tokens or constants, or be computed from them +src/App.tsx:9:27: `globalCss()` cannot use `color` at build time: its values must be literals, theme tokens or constants, or be computed from them +``` + ## globalCss() API Define global styles that are extracted at build time. 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..858e464cd 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,27 @@ 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. + +A style argument can be conditional — `cond && { ... }`, `cond ? { ... } : { ... }`, `value || { ... }` or `value ?? { ... }`, in `styled` as in `css`. Each property it sets becomes a per-property ternary over what came before (`color: cond ? 'blue' : 'red'`), so the result does not depend on the order atomic classes land in the stylesheet; a conditional class joins as `cond ? cls : ''`, and the left side of `||` / `??` joins only when it is a string, as the libraries skip other values. Classes passed beside rule objects compose in `styled` as in `css`. + +A style argument that cannot be known at build time — a function call the build cannot run, a spread or a computed key it cannot compute — is a build error naming it, rather than a silently missing style; a spread, a selector value or a computed key the module computes (`styled.div({ ...cardStyles(), _hover: hoverStyles })`) is computed, while the other values stay CSS variables set at runtime. An object constant, declared in the file or imported, reads as if written in place: `styled.div(baseStyles)`, `styled.div({ ...baseStyles, color: 'red' })`. A single argument that is not a rule object composes as a class, as several do; one holding rules the module computes (`const base = makeStyles()`, `styled.div(base)`) applies those rules. In the object form a value may read the theme (`(p) => p.theme.brand`); any other function of the props is a build error — write it in a template (`${(p) => p.color}`), where it becomes a CSS variable. + +Inside a template, an interpolation standing where a declaration would is a mixin: `${mixin}` and `${(p) => p.active && mixin}` compose their class with the component's. A literal or constant is written into the CSS wherever it stands — a value, a CSS snippet, a selector. Any other interpolation in a selector or a property name is a build error, as are runtime values in `css`, `keyframes` and global styles, which have no element to set a CSS variable on. + +`.attrs()` and `.withConfig()` chain onto any factory. Attrs are merged over the props in order, as styled-components does — an object is spread, a function is called with the props; `.withConfig()` only tunes runtime behavior, so it is dropped: + +```tsx +const Button = styled.button + .attrs({ type: 'button' }) + .attrs((props) => ({ disabled: props.loading })) + .withConfig({ displayName: 'Button' })` + color: red; +` +``` + +A file may import `styled` from several of these libraries at once; every import compiles. + Namespace imports work too. `@emotion/styled` and `styled-components` export a single callable, so the namespace binding _is_ that value and it is bound straight to the Devup UI export: ```tsx @@ -94,3 +115,4 @@ Emotion's `` behaves the same way: the `styles` prop is e | `ServerStyleSheet`, `StyleSheetManager` | inert — Devup UI already emits a real stylesheet, so there is nothing to collect | | `isStyledComponent` | always `false` | | `ClassNames`, `CacheProvider` | no equivalent; stays on its own package with a build warning | +| `.attrs()`, `.withConfig()` | attrs merged over props; `withConfig` dropped | diff --git a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx index 2dc60054b..c454cf3fa 100644 --- a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx @@ -33,6 +33,8 @@ const el =
Computed access such as `colors[color]` is resolved against the object the `create()` call was rewritten into, so every generated atom stays reachable. `stylex.attrs()` behaves the same but emits `class` instead of `className`. +Numbers get the unit StyleX gives them: `fontSize: 16` is `16px`, `transitionDuration: 300` is `300ms`, and unitless properties such as `lineHeight` stay as written. A number passed to a dynamic style gets the same unit at runtime. + Accepted argument shapes: ```tsx @@ -76,6 +78,63 @@ const el =
`createThemeContract()` produces the same references without publishing a `:root` block, and `defineConsts()` inlines its values into the `create()` calls that read them. +A variable or theme value can depend on at-rules, with an optional `types.*()` wrapper. Each set of conditions becomes one block after the unconditional values, and nested conditions come after the ones they refine, so the more specific value wins: + +```tsx +const DARK = '@media (prefers-color-scheme: dark)' + +const colors = stylex.defineVars({ + text: { default: 'black', [DARK]: 'white' }, + accent: stylex.types.color({ default: 'blue', [DARK]: 'lightblue' }), +}) +const dracula = stylex.createTheme(colors, { + text: { default: 'purple', [DARK]: 'pink' }, +}) +``` + +```css +:root { + --a: black; + --b: blue; +} +@media (prefers-color-scheme: dark) { + :root { + --a: white; + --b: lightblue; + } +} +.c { + --a: purple; +} +@media (prefers-color-scheme: dark) { + .c { + --a: pink; + } +} +``` + +Conditions are `default`, `@media`, `@supports` and `@container` keys, and `null` leaves a variable unset under its condition. A value that is not static is a build error naming it; pseudo-class keys count as not static, because a variable is set on `:root` or a theme class, not on the element being styled. + +As StyleX requires, variables usually live in a `.stylex.ts` file and are imported where they are used. Imports resolve like the bundler resolves them, and the importing file reads the same custom properties, theme classes and constants that the defining file generates: + +```tsx +// tokens.stylex.ts +export const colors = stylex.defineVars({ primary: 'blue' }) +export const spacing = stylex.defineConsts({ gap: '8px' }) + +// Button.tsx +import { colors, spacing } from './tokens.stylex' + +const dark = stylex.createTheme(colors, { primary: 'navy' }) +const styles = stylex.create({ + box: { color: colors.primary, marginTop: spacing.gap }, +}) +``` + +A value in `create()` that this file's own functions compute from constants with exact built-ins, such as `double(SIZE)`, is computed at build time; another module's code never runs, and code whose result is not certain (`Date`, `Math.random`, `Math.sin`, locale methods, `new`, `this`...) is not run. A value only known at runtime, such as a prop or a state, is a build error naming it, as is anything `create()`, `defineVars()`, `defineConsts()`, `createTheme()`, `keyframes()`, `positionTry()` or `viewTransitionClass()` cannot read (a spread, a computed key, a non-object argument). Pass runtime values through a dynamic style (`(color) => ({ color })`), which becomes a CSS variable instead. + +`stylex.props()` accepts styles compiled elsewhere — a `style` prop a parent passes, a spread, another module's namespaces — and joins their class names at runtime; namespaces of this file resolve at build time. + ## At-rules ```tsx diff --git a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx index bb86dca45..b127bf339 100644 --- a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx @@ -9,7 +9,7 @@ export const metadata = { ## Stylesheet files -Inside `.css.ts` / `.css.js` — the only place vanilla-extract allows its APIs — Devup UI evaluates the module and replaces every call with its generated output. The whole surface works there: `style`, `globalStyle`, `keyframes`, `styleVariants`, `fontFace`, `createVar`, `fallbackVar`, `createTheme`, `createThemeContract`, `layer`, `createContainer`. +Inside `.css.ts` / `.css.js` — the only place vanilla-extract allows its APIs — Devup UI evaluates the module and replaces every call with its generated output. The whole surface works there: `style`, `globalStyle`, `keyframes`, `styleVariants`, `fontFace`, `globalFontFace`, `createVar`, `fallbackVar`, `createTheme`, `createGlobalTheme`, `createThemeContract`, `createGlobalThemeContract`, `assignVars`, `layer`, `globalLayer`, `createContainer`. Generated names — custom properties, containers, layers, theme classes, font families — are unique to each stylesheet, and layers keep the order they are declared in. ```ts // theme.css.ts @@ -20,9 +20,27 @@ export const light = createTheme(vars, { colors: { bg: 'white' } }) export const box = style({ background: vars.colors.bg }) ``` +Numbers keep vanilla-extract's meaning, here and in ordinary modules: `padding: 8` is `8px`, unitless properties such as `lineHeight` or `zIndex` stay as written, and times such as `transitionDuration: 300` are `300ms`. + +A stylesheet can import other stylesheets and ordinary modules — ES modules or CommonJS — resolved like the bundler resolves them (relative paths, `tsconfig` `paths` and packages). An imported stylesheet is extracted the way the bundler extracts it, so the class names and custom properties it exports are the ones its own CSS uses, and the importer keeps importing it so that CSS is loaded: + +```ts +// button.css.ts +import { style } from '@vanilla-extract/css' + +import { base, vars } from './theme.css' +import { PRIMARY } from './tokens' + +export const button = style([base, { color: PRIMARY, margin: vars.space }]) +``` + +A change to an imported file rebuilds the stylesheets importing it. Import cycles behave as in ES modules: they work as long as neither side reads the other before it has been evaluated, and reading too early is a build error naming the binding and the module. + +A stylesheet that throws while it is evaluated — an import that does not resolve, a property read on `undefined` — is a build error carrying that exception, rather than output that still calls `style()` at runtime. + ## Ordinary modules -`style` and `globalStyle` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts: +`style`, `globalStyle` and `keyframes` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts, and `style([base, { ... }])` keeps composing `base`: ```tsx import { globalStyle, style } from '@vanilla-extract/css' diff --git a/apps/landing/src/app/(detail)/docs/overview/page.mdx b/apps/landing/src/app/(detail)/docs/overview/page.mdx index 9c337acd6..b186af1b8 100644 --- a/apps/landing/src/app/(detail)/docs/overview/page.mdx +++ b/apps/landing/src/app/(detail)/docs/overview/page.mdx @@ -235,7 +235,7 @@ Turbopack values are medians of six cold builds in alternating order. Build size
-The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ### Get Started diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index 9b61daaf9..fa769a310 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -13,14 +13,10 @@ categories = ["development-tools", "wasm", "web-programming"] [lib] crate-type = ["cdylib"] -[features] -default = ["vanilla-extract"] -vanilla-extract = ["extractor/vanilla-extract", "sheet/vanilla-extract"] - [dependencies] wasm-bindgen = "0.2.128" -extractor = { path = "../../libs/extractor", default-features = false } -sheet = { path = "../../libs/sheet", default-features = false } +extractor = { path = "../../libs/extractor" } +sheet = { path = "../../libs/sheet" } css = { path = "../../libs/css" } rustc-hash = "2" @@ -39,6 +35,11 @@ serde-wasm-bindgen = "0.6.5" # `getrandom_backend="wasm_js"` cfg requires on wasm32 targets. # Keep this major version in sync with boa_engine's transitive getrandom. getrandom = { version = "0.4", features = ["wasm_js"] } +# Not used directly. The extractor evaluates `.css.ts` modules and computed +# style values with Boa, whose `Context::default()` reads the clock. Without the +# `js` feature that clock is `std::time::Instant`, which panics on +# wasm32-unknown-unknown. +boa_engine = { version = "0.22", features = ["js"] } [dev-dependencies] wasm-bindgen-test = "0.3.78" diff --git a/bindings/devup-ui-wasm/package.json b/bindings/devup-ui-wasm/package.json index a4b56e0af..c9c61e979 100644 --- a/bindings/devup-ui-wasm/package.json +++ b/bindings/devup-ui-wasm/package.json @@ -19,7 +19,7 @@ ], "version": "1.0.81", "scripts": { - "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && wasm-pack build --target nodejs --out-dir ./pkg/lite --out-name index --no-default-features && node script.js", + "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && node script.js", "test": "wasm-pack test --node" }, "publishConfig": { @@ -33,12 +33,7 @@ "pkg/index.js", "pkg/package.json", "pkg/index_bg.wasm", - "pkg/index_bg.wasm.d.ts", - "pkg/lite/index.d.ts", - "pkg/lite/index.js", - "pkg/lite/package.json", - "pkg/lite/index_bg.wasm", - "pkg/lite/index_bg.wasm.d.ts" + "pkg/index_bg.wasm.d.ts" ], "type": "module", "exports": { @@ -46,11 +41,6 @@ "types": "./pkg/index.d.ts", "import": "./pkg/index.js", "require": "./pkg/index.js" - }, - "./lite": { - "types": "./pkg/lite/index.d.ts", - "import": "./pkg/lite/index.js", - "require": "./pkg/lite/index.js" } }, "types": "./pkg/index.d.ts" diff --git a/bindings/devup-ui-wasm/script.js b/bindings/devup-ui-wasm/script.js index 9fafac1be..27b3139cc 100644 --- a/bindings/devup-ui-wasm/script.js +++ b/bindings/devup-ui-wasm/script.js @@ -2,4 +2,3 @@ import { writeFileSync } from 'node:fs' // support mjs config writeFileSync('pkg/package.json', JSON.stringify({}), 'utf8') -writeFileSync('pkg/lite/package.json', JSON.stringify({}), 'utf8') diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 6e7a0b735..4560410f3 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -3,9 +3,13 @@ use css::file_map::{ canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map, }; use extractor::extract_style::extract_style_value::ExtractStyleValue; -use extractor::{ExtractOption, ImportAlias, extract, extract_without_source_map, has_devup_ui}; +use extractor::{ + ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, + extract_without_source_map, has_devup_ui, +}; use rustc_hash::FxHashSet; use sheet::StyleSheet; +use std::cell::RefCell; use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; use std::sync::{LazyLock, Mutex}; @@ -20,6 +24,10 @@ enum SourceMapMode { Skip, } +thread_local! { + static MODULE_RESOLVER: RefCell> = const { RefCell::new(None) }; +} + fn with_style_sheet(f: F) -> R where F: FnOnce(&StyleSheet) -> R, @@ -52,9 +60,11 @@ pub struct Output { css_file: Option, updated_base_style: bool, css: Option, + dependencies: Vec, } #[wasm_bindgen] impl Output { + #[allow(clippy::too_many_arguments)] fn new( code: String, styles: FxHashSet, @@ -63,6 +73,7 @@ impl Output { filename: String, css_file: Option, import_main_css: bool, + dependencies: Vec, ) -> Self { // Use the bucket identity (single-importer collapse) so the sheet's CSS // naming + property bucket + emitted chunk match the canonical class names @@ -83,6 +94,7 @@ impl Output { code, map, css_file, + dependencies, updated_base_style: updated_base_style || default_collected, css: { if !collected && !default_collected { @@ -134,6 +146,13 @@ impl Output { pub fn css(&self) -> Option { self.css.clone() } + + /// Files read through the module resolver, for the bundler to watch + #[wasm_bindgen(getter, js_name = "dependencies")] + #[must_use] + pub fn dependencies(&self) -> Vec { + self.dependencies.clone() + } } #[wasm_bindgen(js_name = "setDebug")] @@ -337,6 +356,7 @@ pub fn code_extract_internal( import_main_css_in_css, import_aliases, SourceMapMode::Generate, + None, ) } @@ -361,6 +381,35 @@ pub fn code_extract_without_source_map_internal( import_main_css_in_css, import_aliases, SourceMapMode::Skip, + None, + ) +} + +/// [`code_extract_internal`] reading the modules a file imports through +/// `resolver` +#[allow(clippy::too_many_arguments)] +pub fn code_extract_with_modules_internal( + filename: &str, + code: &str, + package: &str, + css_dir: String, + single_css: bool, + import_main_css_in_code: bool, + import_main_css_in_css: bool, + import_aliases: HashMap, + resolver: &ModuleResolver, +) -> Result { + code_extract_internal_impl( + filename, + code, + package, + css_dir, + single_css, + import_main_css_in_code, + import_main_css_in_css, + import_aliases, + SourceMapMode::Generate, + Some(resolver), ) } @@ -375,6 +424,7 @@ fn code_extract_internal_impl( import_main_css_in_css: bool, import_aliases: HashMap, source_map: SourceMapMode, + resolver: Option<&ModuleResolver>, ) -> Result { let option = ExtractOption { package: package.to_string(), @@ -383,9 +433,16 @@ fn code_extract_internal_impl( import_main_css: import_main_css_in_code, import_aliases, }; - let extracted = match source_map { - SourceMapMode::Generate => extract(filename, code, option), - SourceMapMode::Skip => extract_without_source_map(filename, code, option), + let extracted = match (resolver, source_map) { + (Some(resolver), mode) => extract_with_modules( + filename, + code, + option, + matches!(mode, SourceMapMode::Generate), + resolver, + ), + (None, SourceMapMode::Generate) => extract(filename, code, option), + (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), }; match extracted { @@ -397,11 +454,77 @@ fn code_extract_internal_impl( filename.to_string(), output.css_file, import_main_css_in_css, + output.dependencies, )), Err(error) => Err(error.to_string()), } } +/// Set how the imports of the files being extracted are resolved. +/// +/// `(specifier, importer) => ({ path, code })`, or nothing when they cannot be. +/// Imported stylesheets are then evaluated, and imported constants inlined. +#[wasm_bindgen(js_name = "setModuleResolver")] +#[cfg(not(tarpaulin_include))] +pub fn set_module_resolver(resolver: Option) { + MODULE_RESOLVER.with_borrow_mut(|current| *current = resolver); +} + +#[cfg(not(tarpaulin_include))] +fn call_module_resolver( + resolver: &js_sys::Function, + specifier: &str, + importer: &str, +) -> Option { + let module = resolver + .call2(&JsValue::NULL, &specifier.into(), &importer.into()) + .ok()?; + let field = |name: &str| { + js_sys::Reflect::get(&module, &name.into()) + .ok()? + .as_string() + }; + Some(ResolvedModule { + path: field("path")?, + code: field("code")?, + }) +} + +/// Extract with the resolver set by `setModuleResolver`, if any +#[cfg(not(tarpaulin_include))] +#[allow(clippy::too_many_arguments)] +fn code_extract_js( + filename: &str, + code: &str, + package: &str, + css_dir: String, + single_css: bool, + import_main_css_in_code: bool, + import_main_css_in_css: bool, + import_aliases: JsValue, + source_map: SourceMapMode, +) -> Result { + let import_aliases = import_aliases_from_js(import_aliases)?; + let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| { + move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer) + }); + code_extract_internal_impl( + filename, + code, + package, + css_dir, + single_css, + import_main_css_in_code, + import_main_css_in_css, + import_aliases, + source_map, + resolver + .as_ref() + .map(|resolver| resolver as &ModuleResolver), + ) + .map_err(js_error) +} + #[cfg(not(tarpaulin_include))] fn import_aliases_from_js( import_aliases: JsValue, @@ -433,7 +556,7 @@ pub fn code_extract( import_main_css_in_css: bool, import_aliases: JsValue, ) -> Result { - code_extract_internal( + code_extract_js( filename, code, package, @@ -441,9 +564,9 @@ pub fn code_extract( single_css, import_main_css_in_code, import_main_css_in_css, - import_aliases_from_js(import_aliases)?, + import_aliases, + SourceMapMode::Generate, ) - .map_err(js_error) } #[cfg(not(tarpaulin_include))] @@ -459,7 +582,7 @@ pub fn code_extract_without_source_map( import_main_css_in_css: bool, import_aliases: JsValue, ) -> Result { - code_extract_without_source_map_internal( + code_extract_js( filename, code, package, @@ -467,9 +590,9 @@ pub fn code_extract_without_source_map( single_css, import_main_css_in_code, import_main_css_in_css, - import_aliases_from_js(import_aliases)?, + import_aliases, + SourceMapMode::Generate, ) - .map_err(js_error) } /// Internal function to register theme (testable without `JsValue`) @@ -1052,6 +1175,37 @@ mod tests { assert_debug_snapshot!(get_css(None, false).unwrap().split("*/").nth(1).unwrap()); } + #[test] + #[serial] + fn test_code_extract_with_modules() { + { + let mut sheet = GLOBAL_STYLE_SHEET.lock().unwrap(); + *sheet = StyleSheet::default(); + } + let resolver = |specifier: &str, _: &str| { + (specifier == "./tokens").then(|| ResolvedModule { + path: "/src/tokens.ts".to_string(), + code: "export const PRIMARY = 'red'".to_string(), + }) + }; + { + let output = code_extract_with_modules_internal( + "/src/App.tsx", + "import { Box } from '@devup-ui/react';\nimport { PRIMARY } from './tokens';\nexport const a = ;", + "@devup-ui/react", + "@devup-ui/react".to_string(), + true, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap(); + assert_eq!(output.dependencies(), ["/src/tokens.ts"]); + assert!(!output.code().contains("--"), "{}", output.code()); + } + } + #[test] #[serial] fn deserialize_theme() { @@ -1393,6 +1547,7 @@ mod tests { "test.tsx".to_string(), Some("devup-ui-0.css".to_string()), false, + Vec::new(), ); // Test getters @@ -1419,6 +1574,7 @@ mod tests { "test.tsx".to_string(), None, false, + Vec::new(), ); // Test updated_base_style getter @@ -1481,6 +1637,7 @@ mod tests { "test.tsx".to_string(), Some("devup-ui.css".to_string()), true, // import_main_css = true + Vec::new(), ); assert!(output.css().is_some()); @@ -1519,6 +1676,7 @@ mod tests { "test.tsx".to_string(), None, false, + Vec::new(), ); // The updated_base_style should be true because global CSS was removed diff --git a/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/optimize_value.rs b/libs/css/src/optimize_value.rs index 7bf2beac6..1a1175b51 100644 --- a/libs/css/src/optimize_value.rs +++ b/libs/css/src/optimize_value.rs @@ -546,6 +546,9 @@ mod tests { #[case("rgb(2147483648,0,0)", "rgb(2147483648,0,0)")] // f32 parse failure for alpha (".") → falls back to original #[case("rgba(255,0,0,.)", "rgba(255,0,0,.)")] + // whitespace other than a space between values + #[case("10px\t20px", "10px 20px")] + #[case("1px\n2px", "1px 2px")] fn test_optimize_value(#[case] input: &str, #[case] expected: &str) { assert_eq!(optimize_value(input), expected); } diff --git a/libs/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/Cargo.toml b/libs/extractor/Cargo.toml index 4464920b4..33565e1c7 100644 --- a/libs/extractor/Cargo.toml +++ b/libs/extractor/Cargo.toml @@ -11,10 +11,6 @@ categories = ["development-tools", "wasm", "web-programming"] [lints] workspace = true -[features] -default = ["vanilla-extract"] -vanilla-extract = ["dep:boa_engine"] - [dependencies] oxc_parser = "0.151.0" oxc_syntax = "0.151.0" @@ -25,12 +21,13 @@ oxc_ast_visit = "0.151.0" oxc_codegen = "0.151.0" oxc_transformer = "0.151.0" oxc_semantic = "0.151.0" +oxc_sourcemap = "8" css = { path = "../css" } phf = "0.14" strum = "0.28.0" strum_macros = "0.28.0" serde_json = "1.0" -boa_engine = { version = "0.22", optional = true } +boa_engine = "0.22" rustc-hash = "2" smallvec = "1" diff --git a/libs/extractor/benches/extract_benchmark.rs b/libs/extractor/benches/extract_benchmark.rs index b17a0f085..46eadf327 100644 --- a/libs/extractor/benches/extract_benchmark.rs +++ b/libs/extractor/benches/extract_benchmark.rs @@ -91,8 +91,8 @@ export const button = style([base, interactive, { background: 'blue', color: 'wh export const danger = style([button, { background: 'red', color: 'white' }]) export const ghost = style([base, { background: 'transparent', color: 'blue' }])"; -const CSS_TEMPLATE_INPUT: &str = r"import { css } from '@devup-ui/react' -const cls = css` +const CSS_TEMPLATE_INPUT: &str = r"import { styled } from '@devup-ui/react' +const Cls = styled.div` color: red; background-color: blue; padding: 4px; @@ -108,8 +108,8 @@ const cls = css` // `found_placeholders` scan, reverse-position decorate-sort, per-placeholder // `${expr}` rebuild) that the single-interpolation `CSS_TEMPLATE_INPUT` only // shallowly covers. -const CSS_MULTI_TEMPLATE_INPUT: &str = r"import { css } from '@devup-ui/react' -const cls = css` +const CSS_MULTI_TEMPLATE_INPUT: &str = r"import { styled } from '@devup-ui/react' +const Cls = styled.div` margin: ${topGap}px ${sideGap}px ${bottomGap}px ${sideGap}px; padding: ${padY}px ${padX}px; color: ${textColor}; diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs new file mode 100644 index 000000000..74fffd767 --- /dev/null +++ b/libs/extractor/src/build_time_values.rs @@ -0,0 +1,1078 @@ +//! Values the styles of a file compute from constants with code the file +//! declares, such as `css({ w: double(SIZE) })`: the APIs taking them have no +//! element to set a runtime value on, so the build runs that code and writes +//! what it gives in their place. Only code whose every step gives the same +//! value on every page and every build runs; anything else is left to the +//! runtime. + +use std::collections::{BTreeMap, BTreeSet}; + +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + ArrayExpressionElement, Declaration, ExportDefaultDeclarationKind, Expression, + ImportDeclarationSpecifier, ObjectPropertyKind, Program, Statement, VariableDeclarationKind, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::{GetSpan, SourceType, Span}; +use oxc_syntax::operator::LogicalOperator; +use oxc_syntax::reference::ReferenceId; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::stylex::StylexFunction; +use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only}; +use crate::{ExtractOption, ModuleResolver}; + +/// What code reads outside itself +#[derive(Clone, Default)] +struct Closure { + /// The top-level statements it reads, directly or through each other + statements: BTreeSet, + /// The imports it reads, by local name, each as the value the build knows + imports: BTreeMap, +} + +/// An expression whose reads the build can run +struct Found { + span: Span, + closure: Closure, + /// The key of the shorthand property it is written as, which the value + /// written in its place needs + shorthand: Option, + /// A part `css()` or `styled()` composes, which a class computed in its + /// place would turn into CSS text + rules_only: bool, +} + +const UTILS: [&str; 4] = ["css", "globalCss", "keyframes", "createGlobalStyle"]; + +/// Whether `code` has a style value that only running the module computes: +/// one that the constants it reads do not give once inlined +#[cfg(test)] +pub(crate) fn has_build_time_values( + filename: &str, + code: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, +) -> bool { + let (code, _) = crate::import_alias_visit::transform_import_aliases_with_edits( + code, + filename, + &option.package, + &option.import_aliases, + ); + let allocator = Allocator::default(); + let Some(mut program) = parse(&allocator, filename, &code) else { + return false; + }; + let inlined = crate::imported_constants::inline_constants( + &oxc_ast::builder::AstBuilder::new(&allocator), + &mut program, + filename, + option, + resolver, + ); + let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); + !find( + &program, + &|source| source.starts_with(option.package.as_str()), + &inlined.unknown, + &|name| changes.is_changed(name), + &|name| changes.known(name), + ) + .is_empty() +} + +/// The globals the build runs: plain data and the functions over it that every +/// engine computes alike +const GLOBALS: [&str; 18] = [ + "undefined", + "NaN", + "Infinity", + "Math", + "String", + "Number", + "Boolean", + "Array", + "Object", + "JSON", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURIComponent", + "decodeURIComponent", + "encodeURI", + "decodeURI", +]; + +/// The members of `Math` every engine gives exactly; the others are +/// approximations that may differ in their last digits +const EXACT_MATH: [&str; 20] = [ + "abs", "ceil", "floor", "round", "trunc", "sign", "max", "min", "sqrt", "fround", "imul", + "clz32", "PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2", +]; + +/// Members giving what the locale, the Unicode data of the engine or chance +/// make them +const UNCERTAIN_MEMBERS: [&str; 8] = [ + "toLocaleString", + "toLocaleDateString", + "toLocaleTimeString", + "toLocaleUpperCase", + "toLocaleLowerCase", + "localeCompare", + "normalize", + "random", +]; + +fn parse<'a>(allocator: &'a Allocator, filename: &str, code: &'a str) -> Option> { + let source_type = SourceType::from_path(filename).ok()?; + let parsed = Parser::new(allocator, code, source_type).parse(); + (!parsed.fatal_error).then_some(parsed.program) +} + +fn find( + program: &Program<'_>, + is_style: &dyn Fn(&str) -> bool, + unknown: &crate::imported_constants::Unknown, + is_changed: &dyn Fn(&str) -> bool, + known: &dyn Fn(&str) -> Option, +) -> Vec { + let scoping = SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(); + let mut finder = Finder::new(program, &scoping, is_style, unknown, is_changed, known); + finder.visit_program(program); + finder.found +} + +/// A top-level binding: the statement declaring it, and whether the module +/// can run it without the style packages +struct Binding { + statement: usize, + usable: bool, +} + +struct Finder<'s, 'a> { + scoping: &'s Scoping, + statements: &'s [Statement<'a>], + bindings: FxHashMap, + /// Style APIs imported by name, and the namespaces holding them + apis: FxHashSet, + namespaces: FxHashSet, + stylex_namespaces: FxHashSet, + /// `css` and `styled` imported by name, which compose their arguments + css: FxHashSet, + styled: FxHashSet, + /// What the style packages other than `StyleX` are imported as by name + components: FxHashSet, + /// Bindings whose value only running the module gives + unknown: &'s crate::imported_constants::Unknown, + /// Whether a binding holds an object or array code changes, which the + /// module would compute from as declared + is_changed: &'s dyn Fn(&str) -> bool, + /// The value of an import as JavaScript source, when the build knows all + /// of it; the module it comes from never runs + known: &'s dyn Fn(&str) -> Option, + closures: FxHashMap>, + found: Vec, +} + +fn is_evaluated_stylex(name: &str) -> bool { + StylexFunction::from_export_name(name).is_some_and(|function| function.requirement().is_some()) +} + +impl<'s, 'a> Finder<'s, 'a> { + fn new( + program: &'s Program<'a>, + scoping: &'s Scoping, + is_style: &dyn Fn(&str) -> bool, + unknown: &'s crate::imported_constants::Unknown, + is_changed: &'s dyn Fn(&str) -> bool, + known: &'s dyn Fn(&str) -> Option, + ) -> Self { + let mut finder = Self { + scoping, + statements: &program.body, + bindings: FxHashMap::default(), + apis: FxHashSet::default(), + namespaces: FxHashSet::default(), + stylex_namespaces: FxHashSet::default(), + css: FxHashSet::default(), + styled: FxHashSet::default(), + components: FxHashSet::default(), + unknown, + is_changed, + known, + closures: FxHashMap::default(), + found: Vec::new(), + }; + for (index, statement) in program.body.iter().enumerate() { + finder.bind(index, statement, is_style); + } + finder + } + + fn bind(&mut self, statement: usize, node: &Statement<'a>, is_style: &dyn Fn(&str) -> bool) { + let mut declare = |symbol: Option, usable: bool| { + if let Some(symbol) = symbol { + self.bindings.insert(symbol, Binding { statement, usable }); + } + }; + let declaration = match node { + Statement::ImportDeclaration(import) => { + let source = import.source.value.as_str(); + let stylex = source == crate::STYLEX_PACKAGE; + let style = stylex || is_style(source); + for specifier in import.specifiers.iter().flatten() { + let symbol = specifier.local().symbol_id.get(); + let (usable, api, namespace) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + let name = specifier.imported.name(); + ( + !specifier.import_kind.is_type(), + if stylex { + is_evaluated_stylex(&name) + } else { + UTILS.contains(&name.as_str()) + }, + false, + ) + } + _ => (true, false, true), + }; + declare(symbol, usable && !style && !import.import_kind.is_type()); + if let Some(symbol) = symbol.filter(|_| style) { + if api { + self.apis.insert(symbol); + } else if namespace && stylex { + self.stylex_namespaces.insert(symbol); + } else if namespace { + self.namespaces.insert(symbol); + } + if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier + && !stylex + { + self.components.insert(symbol); + match specifier.imported.name().as_str() { + "css" => { + self.css.insert(symbol); + } + "styled" => { + self.styled.insert(symbol); + } + _ => {} + } + } + } + } + return; + } + Statement::ExportDeclaration(export) => &export.declaration, + Statement::ExportDefaultDeclaration(export) => { + match &export.declaration { + ExportDefaultDeclarationKind::FunctionDeclaration(function) => { + declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true); + } + ExportDefaultDeclarationKind::ClassDeclaration(class) => { + declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false); + } + _ => {} + } + return; + } + node => match node.as_declaration() { + Some(declaration) => declaration, + None => return, + }, + }; + match declaration { + Declaration::VariableDeclaration(declaration) => { + for declarator in &declaration.declarations { + for identifier in declarator.id.get_binding_identifiers() { + declare( + identifier.symbol_id.get(), + declaration.kind == VariableDeclarationKind::Const, + ); + } + } + } + Declaration::FunctionDeclaration(function) => { + declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true); + } + Declaration::ClassDeclaration(class) => { + declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false); + } + Declaration::TSEnumDeclaration(declaration) => { + declare(declaration.id.symbol_id.get(), !declaration.declare); + } + _ => {} + } + } + + fn symbol(&self, expression: &Expression<'_>) -> Option { + let Expression::Identifier(identifier) = expression else { + return None; + }; + self.scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id() + } + + /// Whether `callee` is a style API with no element to set a runtime value on + fn is_api(&self, callee: &Expression<'_>) -> bool { + match callee { + Expression::Identifier(_) => { + self.symbol(callee).is_some_and(|s| self.apis.contains(&s)) + } + Expression::StaticMemberExpression(member) => { + self.symbol(&member.object).is_some_and(|symbol| { + let name = member.property.name.as_str(); + (self.namespaces.contains(&symbol) && UTILS.contains(&name)) + || (self.stylex_namespaces.contains(&symbol) && is_evaluated_stylex(name)) + }) + } + _ => false, + } + } + + fn is_css(&self, callee: &Expression<'_>) -> bool { + if let Expression::StaticMemberExpression(member) = callee { + return member.property.name == "css"; + } + self.symbol(callee) + .is_some_and(|symbol| self.css.contains(&symbol)) + } + + /// Whether calling `callee` gives a `styled` component its styles: + /// `styled.div(...)`, `styled(Link)(...)`, `styled.div.attrs({})(...)` + fn is_styled(&self, callee: &Expression<'_>) -> bool { + match unwrap_syntax_only(callee) { + Expression::StaticMemberExpression(member) => self.is_styled_function(&member.object), + Expression::CallExpression(call) => match unwrap_syntax_only(&call.callee) { + Expression::StaticMemberExpression(member) + if matches!(member.property.name.as_str(), "attrs" | "withConfig") => + { + self.is_styled(&member.object) + } + callee => self.is_styled_function(callee), + }, + _ => false, + } + } + + /// Whether `name` is a component of the style packages: ``, `` + fn is_component(&self, name: &oxc_ast::ast::JSXElementName<'_>) -> bool { + let mut object = match name { + oxc_ast::ast::JSXElementName::IdentifierReference(identifier) => { + return self + .symbol_of(identifier) + .is_some_and(|symbol| self.components.contains(&symbol)); + } + oxc_ast::ast::JSXElementName::MemberExpression(member) => &member.object, + _ => return false, + }; + loop { + match object { + oxc_ast::ast::JSXMemberExpressionObject::MemberExpression(member) => { + object = &member.object; + } + oxc_ast::ast::JSXMemberExpressionObject::IdentifierReference(identifier) => { + return self + .symbol_of(identifier) + .is_some_and(|symbol| self.namespaces.contains(&symbol)); + } + oxc_ast::ast::JSXMemberExpressionObject::ThisExpression(_) => return false, + } + } + } + + fn symbol_of(&self, identifier: &oxc_ast::ast::IdentifierReference<'_>) -> Option { + self.scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id() + } + + fn is_styled_function(&self, expression: &Expression<'_>) -> bool { + self.symbol(expression) + .is_some_and(|symbol| self.styled.contains(&symbol)) + } + + /// The parts `css()` and `styled()` compose: rule objects, whose values + /// are read when `rules` is set, and classes, which only rules the module + /// computes replace + fn parts(&mut self, expression: &Expression<'a>, rules: bool) { + match unwrap_syntax_only(expression) { + Expression::ObjectExpression(object) => { + if rules { + self.values(expression, None); + return; + } + // Values an element holds at runtime stay; what gives it styles + // is computed: spreads, selectors and computed keys + for property in &object.properties { + match property { + ObjectPropertyKind::SpreadProperty(spread) => { + self.parts(&spread.argument, false); + } + ObjectPropertyKind::ObjectProperty(property) => { + if property.computed + && let Some(key) = property.key.as_expression() + { + self.values(key, None); + } + if property + .key + .static_name() + .is_some_and(|key| key.starts_with('_')) + { + self.parts(&property.value, false); + } + } + } + } + } + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + self.parts(element, rules); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.parts(&conditional.consequent, rules); + self.parts(&conditional.alternate, rules); + } + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.parts(&logical.left, rules); + } + self.parts(&logical.right, rules); + } + // CSS text, whose values are read as `values` reads them + Expression::TemplateLiteral(_) => { + if rules { + self.values(expression, None); + } + } + Expression::NullLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::StringLiteral(_) => {} + inner => { + if binding_root(inner).is_none() || self.unknown.read_by(inner) { + self.candidate(expression, None, true); + } + } + } + } + + /// The values `expression` holds: itself, or those of the object, array or + /// condition it writes + fn values(&mut self, expression: &Expression<'a>, shorthand: Option<&str>) { + match unwrap_syntax_only(expression) { + Expression::ObjectExpression(object) => { + for property in &object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if property.computed + && let Some(key) = property.key.as_expression() + { + self.values(key, None); + } + let key = property.key.static_name(); + self.values( + &property.value, + key.as_deref().filter(|_| property.shorthand), + ); + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.values(&spread.argument, None); + } + } + } + } + Expression::ArrayExpression(array) => { + for element in &array.elements { + match element { + ArrayExpressionElement::SpreadElement(spread) => { + self.values(&spread.argument, None); + } + element => { + if let Some(element) = element.as_expression() { + self.values(element, None); + } + } + } + } + } + Expression::NullLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) => {} + inner if get_string_by_literal_expression(inner).is_some() => {} + inner => { + if self.candidate(expression, shorthand, false) { + return; + } + // A condition only known at runtime still chooses between + // values the module computes + match inner { + Expression::ConditionalExpression(conditional) => { + self.values(&conditional.consequent, None); + self.values(&conditional.alternate, None); + } + Expression::LogicalExpression(logical) => { + self.values(&logical.left, None); + self.values(&logical.right, None); + } + _ => {} + } + } + } + } + + fn candidate( + &mut self, + expression: &Expression<'a>, + shorthand: Option<&str>, + rules_only: bool, + ) -> bool { + let span = expression.span(); + let mut reads = Reads::new(self.scoping); + reads.visit_expression(expression); + if reads.impure { + return false; + } + let Some(closure) = self.closure(span, &reads.references) else { + return false; + }; + self.found.push(Found { + span, + closure, + shorthand: shorthand.map(str::to_string), + rules_only, + }); + true + } + + /// What code at `span` reads outside itself, `None` when it reads a + /// binding the build does not run: one declared other than as a `const`, + /// a function or an enum, code it does not run, an object code changes, + /// or an import whose value it does not know whole + fn closure(&mut self, span: Span, references: &[ReferenceId]) -> Option { + let mut closure = Closure::default(); + for reference in references { + let Some(symbol) = self.scoping.get_reference(*reference).symbol_id() else { + continue; + }; + if span.contains_inclusive(self.scoping.symbol_span(symbol)) { + continue; + } + let name = self.scoping.symbol_name(symbol); + let statement = match self.bindings.get(&symbol) { + Some(Binding { + statement, + usable: true, + }) if !(self.is_changed)(name) => *statement, + _ => return None, + }; + if matches!(self.statements[statement], Statement::ImportDeclaration(_)) { + closure + .imports + .insert(name.to_string(), (self.known)(name)?); + continue; + } + closure.statements.insert(statement); + let inner = self.statement_closure(statement)?; + closure.statements.extend(inner.statements); + closure.imports.extend(inner.imports); + } + Some(closure) + } + + fn statement_closure(&mut self, index: usize) -> Option { + if let Some(closure) = self.closures.get(&index) { + return closure.clone(); + } + // Read while it is computed, so statements reading each other stop + self.closures.insert(index, Some(Closure::default())); + let statement = &self.statements[index]; + let mut reads = Reads::new(self.scoping); + reads.visit_statement(statement); + let closure = if reads.impure { + None + } else { + self.closure(statement.span(), &reads.references) + }; + self.closures.insert(index, closure.clone()); + closure + } +} + +impl<'a> Visit<'a> for Finder<'_, 'a> { + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + let (api, styled) = (self.is_api(&call.callee), self.is_styled(&call.callee)); + if api || styled { + let composes = styled || self.is_css(&call.callee); + for argument in &call.arguments { + let argument = match argument { + oxc_ast::ast::Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }; + if composes { + self.parts(argument, api); + } else { + self.values(argument, None); + } + } + } + walk::walk_call_expression(self, call); + } + + fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) { + if self.is_component(&element.name) { + for attribute in &element.attributes { + match attribute { + oxc_ast::ast::JSXAttributeItem::SpreadAttribute(spread) => { + self.parts(&spread.argument, false); + } + oxc_ast::ast::JSXAttributeItem::Attribute(attribute) => { + if let oxc_ast::ast::JSXAttributeName::Identifier(name) = &attribute.name + && name.name.starts_with('_') + && let Some(oxc_ast::ast::JSXAttributeValue::ExpressionContainer( + container, + )) = &attribute.value + && let Some(value) = container.expression.as_expression() + { + self.parts(value, false); + } + } + } + } + } + walk::walk_jsx_opening_element(self, element); + } + + fn visit_tagged_template_expression( + &mut self, + tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>, + ) { + if self.is_api(&tagged.tag) { + for expression in &tagged.quasi.expressions { + self.values(expression, None); + } + } + walk::walk_tagged_template_expression(self, tagged); + } +} + +/// The bindings code reads, and whether the build runs it +struct Reads<'s> { + scoping: &'s Scoping, + references: Vec, + /// Does what the build does not run: reads what only the code around it, + /// the page or the process knows (`this`, globals such as `window` or + /// `Date`), chance, the locale or results engines only approximate; uses + /// more than plain data and functions (`new`, classes, regular + /// expressions, `try`, getters, async code, JSX); or writes a binding + /// outside itself + impure: bool, +} + +impl<'s> Reads<'s> { + const fn new(scoping: &'s Scoping) -> Self { + Self { + scoping, + references: Vec::new(), + impure: false, + } + } + + fn symbol(&self, identifier: &oxc_ast::ast::IdentifierReference<'_>) -> Option { + self.scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id() + } + + fn is_global(&self, expression: &Expression<'_>, name: &str) -> bool { + matches!(expression, Expression::Identifier(identifier) + if identifier.name == name && self.symbol(identifier).is_none()) + } + + fn member(&mut self, object: &Expression<'_>, name: &str) { + self.impure |= UNCERTAIN_MEMBERS.contains(&name) + || (self.is_global(object, "Math") && !EXACT_MATH.contains(&name)); + } + + /// Whether `expression`, the object of a member written, is a top-level + /// binding or a member of one + fn is_outside(&self, expression: &Expression<'_>) -> bool { + let mut expression = expression; + loop { + match unwrap_syntax_only(expression) { + Expression::StaticMemberExpression(member) => expression = &member.object, + Expression::ComputedMemberExpression(member) => expression = &member.object, + Expression::Identifier(identifier) => { + return self.symbol(identifier).is_some_and(|symbol| { + self.scoping.symbol_scope_id(symbol) == self.scoping.root_scope_id() + }); + } + _ => return false, + } + } + } +} + +impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + if let Some(reference) = identifier.reference_id.get() { + let reference_data = self.scoping.get_reference(reference); + self.impure |= reference_data.symbol_id().map_or_else( + || !GLOBALS.contains(&identifier.name.as_str()), + |symbol| { + reference_data.is_write() + && self.scoping.symbol_scope_id(symbol) == self.scoping.root_scope_id() + }, + ); + self.references.push(reference); + } + } + + fn visit_static_member_expression( + &mut self, + member: &oxc_ast::ast::StaticMemberExpression<'a>, + ) { + self.member(&member.object, member.property.name.as_str()); + walk::walk_static_member_expression(self, member); + } + + fn visit_computed_member_expression( + &mut self, + member: &oxc_ast::ast::ComputedMemberExpression<'a>, + ) { + if let Some(key) = get_string_by_literal_expression(&member.expression) { + self.member(&member.object, &key); + } + walk::walk_computed_member_expression(self, member); + } + + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + // A method chosen at runtime may be any, and `toString` with a radix + // is only approximated + self.impure |= match unwrap_syntax_only(&call.callee) { + Expression::ComputedMemberExpression(member) => { + get_string_by_literal_expression(&member.expression).is_none() + } + Expression::StaticMemberExpression(member) => { + member.property.name == "toString" && !call.arguments.is_empty() + } + _ => false, + }; + walk::walk_call_expression(self, call); + } + + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + self.impure |= assignment.operator == oxc_syntax::operator::AssignmentOperator::Exponential + || assignment + .left + .as_simple_assignment_target() + .and_then(|target| target.as_member_expression()) + .is_some_and(|member| self.is_outside(member.object())); + walk::walk_assignment_expression(self, assignment); + } + + fn visit_update_expression(&mut self, update: &oxc_ast::ast::UpdateExpression<'a>) { + self.impure |= update + .argument + .as_member_expression() + .is_some_and(|member| self.is_outside(member.object())); + walk::walk_update_expression(self, update); + } + + fn visit_unary_expression(&mut self, unary: &oxc_ast::ast::UnaryExpression<'a>) { + self.impure |= unary.operator == oxc_syntax::operator::UnaryOperator::Delete + && self.is_outside(&unary.argument); + walk::walk_unary_expression(self, unary); + } + + fn visit_binary_expression(&mut self, binary: &oxc_ast::ast::BinaryExpression<'a>) { + self.impure |= binary.operator == oxc_syntax::operator::BinaryOperator::Exponential; + walk::walk_binary_expression(self, binary); + } + + fn visit_object_property(&mut self, property: &oxc_ast::ast::ObjectProperty<'a>) { + self.impure |= property.kind != oxc_ast::ast::PropertyKind::Init; + walk::walk_object_property(self, property); + } + + fn visit_binding_property(&mut self, property: &oxc_ast::ast::BindingProperty<'a>) { + self.impure |= property + .key + .static_name() + .is_some_and(|key| UNCERTAIN_MEMBERS.contains(&key.as_ref())); + walk::walk_binding_property(self, property); + } + + fn visit_function( + &mut self, + function: &oxc_ast::ast::Function<'a>, + flags: oxc_syntax::scope::ScopeFlags, + ) { + self.impure |= function.r#async || function.generator; + walk::walk_function(self, function, flags); + } + + fn visit_arrow_function_expression( + &mut self, + arrow: &oxc_ast::ast::ArrowFunctionExpression<'a>, + ) { + self.impure |= arrow.r#async; + walk::walk_arrow_function_expression(self, arrow); + } + + fn visit_this_expression(&mut self, _: &oxc_ast::ast::ThisExpression) { + self.impure = true; + } + + fn visit_super(&mut self, _: &oxc_ast::ast::Super) { + self.impure = true; + } + + fn visit_import_meta(&mut self, _: &oxc_ast::ast::ImportMeta) { + self.impure = true; + } + + fn visit_new_target(&mut self, _: &oxc_ast::ast::NewTarget) { + self.impure = true; + } + + fn visit_new_expression(&mut self, _: &oxc_ast::ast::NewExpression<'a>) { + self.impure = true; + } + + fn visit_class(&mut self, _: &oxc_ast::ast::Class<'a>) { + self.impure = true; + } + + fn visit_reg_exp_literal(&mut self, _: &oxc_ast::ast::RegExpLiteral<'a>) { + self.impure = true; + } + + fn visit_try_statement(&mut self, _: &oxc_ast::ast::TryStatement<'a>) { + self.impure = true; + } + + fn visit_await_expression(&mut self, _: &oxc_ast::ast::AwaitExpression<'a>) { + self.impure = true; + } + + fn visit_yield_expression(&mut self, _: &oxc_ast::ast::YieldExpression<'a>) { + self.impure = true; + } + + fn visit_import_expression(&mut self, _: &oxc_ast::ast::ImportExpression<'a>) { + self.impure = true; + } + + fn visit_jsx_element(&mut self, _: &oxc_ast::ast::JSXElement<'a>) { + self.impure = true; + } + + fn visit_jsx_fragment(&mut self, _: &oxc_ast::ast::JSXFragment<'a>) { + self.impure = true; + } + + // Types are erased before the code runs, so what they name is not read + fn visit_ts_type(&mut self, _: &oxc_ast::ast::TSType<'a>) {} +} + +/// `code` with what its style values compute written in their place, the +/// replacements made, and the files read; `None` when running the code it +/// reads computes none of them as a string, a finite number, or a plain +/// object or array of those +pub(crate) fn evaluate( + code: &str, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + unknown: &crate::imported_constants::Unknown, +) -> Option<( + String, + Vec, + BTreeSet, +)> { + let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?; + values.sort_unstable_by_key(|(span, _)| span.start); + let mut result = String::with_capacity(code.len()); + let mut edits = Vec::with_capacity(values.len()); + let mut copied = 0; + for (span, literal) in values { + let (start, end) = (span.start as usize, span.end as usize); + result.push_str(&code[copied..start]); + result.push_str(&literal); + edits.push((start, end, literal.len())); + copied = end; + } + result.push_str(&code[copied..]); + Some((result, edits, dependencies)) +} + +/// A value's source text by the span of the code computing it +type Replacement = (Span, String); + +/// Run before the values: the code checked to read nothing that differs +/// between builds or pages, nothing does even if the check missed it +/// (reading `Date`, `Math.random`, the environment or the locale throws), and +/// the source text of a value the build can read. A statement that throws is +/// recorded, and a value reading it is not computed +const PRELUDE: &str = r#"delete globalThis.Date; +Math.random = undefined; +for (const name of ["window", "self", "document", "navigator", "location", "history", "localStorage", "sessionStorage", "matchMedia", "screen", "innerWidth", "innerHeight", "devicePixelRatio", "process", "global", "Deno", "Bun", "Intl"]) Object.defineProperty(globalThis, name, { get() { throw new ReferenceError(`${name} is only known at runtime`); }, configurable: true }); +for (const [prototype, names] of [[Object.prototype, ["toLocaleString"]], [Number.prototype, ["toLocaleString"]], [BigInt.prototype, ["toLocaleString"]], [Array.prototype, ["toLocaleString"]], [String.prototype, ["localeCompare", "toLocaleUpperCase", "toLocaleLowerCase", "normalize"]]]) for (const name of names) Object.defineProperty(prototype, name, { value() { throw new ReferenceError(`${name} depends on the locale`); }, configurable: true, writable: true }); +Object.setPrototypeOf(globalThis, new Proxy(Object.getPrototypeOf(globalThis), { get(target, key, receiver) { if (typeof key === "string" && !(key in target)) throw new ReferenceError(`${key} is only known at runtime`); return Reflect.get(target, key, receiver); } })); +const __failed__ = (() => { const fail = () => { throw new ReferenceError("its value threw"); }; return new Proxy(function () {}, { get: fail, apply: fail, construct: fail, getPrototypeOf: fail }); })(); +const __failed_statements__ = new Set(); +const __try__ = (compute, statement) => { try { return compute(); } catch { __failed_statements__.add(statement); return __failed__; } }; +const __literal__ = (value) => { + const plain = (item) => item === undefined || item === null || typeof item === "string" || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item)) + || (Array.isArray(item) && item.every(plain)) + || (typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype && Object.getOwnPropertySymbols(item).length === 0 && !Object.prototype.hasOwnProperty.call(item, "__proto__") && Object.values(item).every(plain)); + if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined; + return value !== undefined && plain(value) ? JSON.stringify(value) : undefined; +}; +"#; + +fn compute( + code: &str, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + unknown: &crate::imported_constants::Unknown, +) -> Option<(Vec, BTreeSet)> { + use std::fmt::Write; + + use boa_engine::{Context, JsObject, Source}; + + let allocator = Allocator::default(); + let program = parse(&allocator, filename, code)?; + let is_style = |source: &str| { + source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source) + }; + let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); + let found = find( + &program, + &is_style, + unknown, + &|name| changes.is_changed(name), + &|name| changes.known(name), + ); + if found.is_empty() { + return None; + } + let statements: BTreeSet = found + .iter() + .flat_map(|found| found.closure.statements.iter().copied()) + .collect(); + let imports: BTreeMap<&str, &str> = found + .iter() + .flat_map(|found| &found.closure.imports) + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect(); + // The modules imports come from never run: their values are written as + // the build knows them + let mut module = String::new(); + for (name, value) in imports { + let _ = writeln!(module, "const {name} = {value};"); + } + for index in statements { + let statement = &program.body[index]; + let declaration = match statement { + Statement::VariableDeclaration(declaration) => Some(declaration), + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(declaration) => Some(declaration), + _ => None, + }, + _ => None, + }; + if let Some(declaration) = declaration { + // A binding whose value throws is one reading it throws for, + // leaving the others + for declarator in &declaration.declarations { + let Some(init) = &declarator.init else { + continue; + }; + let _ = writeln!( + module, + "{} {}__try__(() => ({}), {index});", + declaration.kind.as_str(), + &code[declarator.span.start as usize..init.span().start as usize], + &code[init.span().start as usize..init.span().end as usize], + ); + } + continue; + } + let span = match statement { + Statement::ExportDeclaration(export) => export.declaration.span(), + Statement::ExportDefaultDeclaration(export) => export.declaration.span(), + statement => statement.span(), + }; + module.push_str(&code[span.start as usize..span.end as usize]); + module.push('\n'); + } + // Each runs apart, so one that throws leaves the others + for (index, found) in found.iter().enumerate() { + let statements: Vec = found + .closure + .statements + .iter() + .map(ToString::to_string) + .collect(); + let _ = writeln!( + module, + "const __value_{index}__ = (() => {{ if ([{}].some((statement) => __failed_statements__.has(statement))) return undefined; try {{ return __literal__(({})); }} catch {{ return undefined; }} }})();", + statements.join(", "), + &code[found.span.start as usize..found.span.end as usize] + ); + } + let names: Vec = (0..found.len()) + .map(|index| format!("__value_{index}__")) + .collect(); + let _ = writeln!(module, "[{}];", names.join(", ")); + + let script = crate::vanilla_extract::strip_typescript(&module, filename); + let mut context = Context::default(); + context + .runtime_limits_mut() + .set_loop_iteration_limit(crate::module_loader::LOOP_ITERATION_LIMIT); + let values = context + .eval(Source::from_bytes(format!("{PRELUDE}{script}").as_bytes())) + .ok()?; + let values = values.as_object().filter(JsObject::is_array)?; + let mut computed = Vec::new(); + for (index, found) in found.into_iter().enumerate() { + let Some(literal) = values + .get(index, &mut context) + .ok()? + .as_string() + .map(|literal| literal.to_std_string_escaped()) + else { + continue; + }; + // A part joined gives rules, or a class as a string + if found.rules_only && !literal.starts_with(['{', '[', '"']) { + continue; + } + computed.push(( + found.span, + match found.shorthand { + Some(key) => format!("{key}: {literal}"), + None => literal, + }, + )); + } + (!computed.is_empty()).then_some((computed, changes.dependencies())) +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index be4b5e863..f0d67200b 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -2,19 +2,23 @@ use std::borrow::Cow; use std::collections::BTreeMap; use std::fmt::Write as _; -use crate::utils::{get_str_by_property_key, get_string_by_literal_expression, wrap_direct_call}; +use crate::utils::{ + get_str_by_property_key, get_string_by_literal_expression, readable_code, runtime_value_error, + wrap_direct_call, +}; use css::{ + at_rule::split_at_rule_key, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}, rm_css_comment::rm_css_comment, - style_selector::{AtRuleKind, StyleSelector}, + style_selector::StyleSelector, }; use oxc_allocator::Allocator; -use oxc_span::SPAN; +use oxc_span::{GetSpan, SPAN}; use crate::utils::expression_to_code; use oxc_ast::ast::BindingPattern; use oxc_ast::ast::Expression; -use oxc_ast::ast::TemplateLiteral; +use oxc_ast::ast::{TemplateElement, TemplateLiteral}; use oxc_ast::builder::AstBuilder; use crate::extract_style::{ @@ -127,6 +131,80 @@ pub fn css_to_style_literal( level: u8, selector: &Option, ) -> Vec { + css_to_style_template(css, level, selector).styles +} + +/// The declarations of a CSS template, and the interpolations that are not the +/// value of one +#[derive(Debug, Default)] +pub struct TemplateStyles { + pub styles: Vec, + /// Indexes of the interpolations standing where a declaration would, such + /// as a mixin + pub statements: Vec, + /// Indexes of the interpolations in a selector or a property name + pub unplaced: Vec, +} + +enum Place { + Value, + Statement, + Other, +} + +/// Where an interpolation stands, from the CSS written before and after it +fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { + let head = &before[before.rfind([';', '{', '}']).map_or(0, |index| index + 1)..]; + let rest: String = after.iter().map(|quasi| quasi.value.raw.as_str()).collect(); + let end = rest.find([';', '{', '}']); + if end.is_some_and(|end| rest.as_bytes()[end] == b'{') { + return Place::Other; + } + if head.contains(':') { + Place::Value + } else if head.trim().is_empty() + && rest + .chars() + .next() + .is_none_or(|c| c.is_whitespace() || c == ';') + { + Place::Statement + } else { + Place::Other + } +} + +/// The CSS text of a template whose interpolations must all be known at build +/// time, as `api` has no element to set a runtime value on; the error is at +/// the offset of the first that is not +pub(crate) fn template_css_text( + css: &TemplateLiteral<'_>, + api: &str, +) -> Result { + let mut text = String::new(); + for (index, quasi) in css.quasis.iter().enumerate() { + text.push_str(&quasi.value.raw); + if let Some(expression) = css.expressions.get(index) { + let value = get_string_by_literal_expression(expression) + .map(Cow::into_owned) + .or_else(|| theme_var_reference(expression)) + .ok_or_else(|| { + ( + expression.span().start, + runtime_value_error(api, &readable_code(expression)), + ) + })?; + text.push_str(&value); + } + } + Ok(text) +} + +pub fn css_to_style_template( + css: &TemplateLiteral<'_>, + level: u8, + selector: &Option, +) -> TemplateStyles { let mut styles = vec![]; // If there are no expressions, just process quasis as static CSS @@ -138,8 +216,13 @@ pub fn css_to_style_literal( .map(CssToStyleResult::Static), ); } - return styles; + return TemplateStyles { + styles, + ..TemplateStyles::default() + }; } + let mut statements = vec![]; + let mut unplaced = vec![]; // Process template literal with expressions // Template literal format: `text ${expr1} text ${expr2} text` @@ -169,8 +252,20 @@ pub fn css_to_style_literal( for (i, quasi) in css.quasis.iter().enumerate() { combined_css.push_str(&quasi.value.raw); - // Add expression placeholder if not the last quasi + // Add expression placeholder if not the last quasi. Only a value keeps a + // placeholder: anything else is written in when known, or reported if i < css.expressions.len() { + let place = interpolation_place(&combined_css, &css.quasis[i + 1..]); + if !matches!(place, Place::Value) { + if let Some(text) = get_string_by_literal_expression(&css.expressions[i]) { + combined_css.push_str(&text); + } else if matches!(place, Place::Statement) { + statements.push(i); + } else { + unplaced.push(i); + } + continue; + } // Use a unique placeholder format that CSS parser won't modify. // Build the placeholder once into the reused scratch buffer, push it // into `combined_css`, then clone it as the owned map key. @@ -418,241 +513,20 @@ pub fn css_to_style_literal( } } - styles + TemplateStyles { + styles, + statements, + unplaced, + } } -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 +538,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 +806,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 +881,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 +904,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 +938,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 +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: "(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 +1016,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 +1070,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 +1108,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 +1127,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 +1138,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 +1152,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 +1163,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 +1390,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 +1413,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 +1447,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 +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: "(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 +1525,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 +1579,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 +1620,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..3388d2198 100644 --- a/libs/extractor/src/extract_style/constant.rs +++ b/libs/extractor/src/extract_style/constant.rs @@ -1,6 +1,16 @@ use phf::phf_set; -pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { +/// Properties whose numbers are unitless in CSS, so they are kept as written +/// instead of being scaled to `px`. Includes every property vanilla-extract +/// leaves unitless, so numbers from `.css.ts` files keep their meaning, +/// `content`, which never takes a length, and the counters. +pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { + "content", + "counter-increment", + "counter-reset", + "counter-set", + "font-size-adjust", + "math-depth", "opacity", "flex", "z-index", @@ -12,6 +22,7 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "flex-grow", "flex-shrink", "order", + "grid-area", "grid-column", "grid-column-start", "grid-column-end", @@ -21,5 +32,40 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "animation-iteration-count", "tab-size", "moz-tab-size", - "-webkit-line-clamp" + "-webkit-line-clamp", + "webkit-line-clamp", + "border-image", + "border-image-outset", + "border-image-slice", + "border-image-width", + "box-flex", + "box-flex-group", + "box-ordinal-group", + "column-count", + "columns", + "initial-letter", + "max-lines", + "orphans", + "widows", + "zoom", + "fill-opacity", + "flood-opacity", + "mask-border", + "mask-border-outset", + "mask-border-slice", + "mask-border-width", + "shape-image-threshold", + "stop-opacity", + "stroke-dashoffset", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width" +}; + +/// Properties taking a time, whose numbers are milliseconds +pub(crate) static TIME_PROPERTIES: phf::Set<&str> = phf_set! { + "transition-duration", + "transition-delay", + "animation-duration", + "animation-delay", }; diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index 414aae1d2..f09b03b07 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -1,7 +1,6 @@ use std::fmt::{Debug, Formatter}; use css::{ - optimize_value::optimize_value, sheet_to_classname, sheet_to_variable_name, style_selector::{StyleSelector, optimize_selector}, }; @@ -23,6 +22,8 @@ pub struct ExtractDynamicStyle { /// Whether the value had `!important` that was stripped from the identifier important: bool, + + pub(crate) layer: Option, } impl Debug for ExtractDynamicStyle { @@ -36,6 +37,9 @@ impl Debug for ExtractDynamicStyle { if self.important { s.field("important", &self.important); } + if let Some(layer) = &self.layer { + s.field("layer", layer); + } s.finish() } } @@ -79,6 +83,22 @@ fn strip_important(identifier: String) -> (String, bool) { (identifier, false) } +/// `identifier`, the code the element runs, without the statement's semicolon; +/// only a template holding the whole CSS value loses the `;` ending it. The +/// code is not optimized as CSS, as that would rewrite the literals it passes on +fn runtime_code(identifier: &str) -> String { + let code = identifier.trim(); + let code = code.strip_suffix(';').unwrap_or(code); + match code + .strip_prefix('`') + .and_then(|code| code.strip_suffix('`')) + .filter(|value| value.ends_with(';')) + { + Some(value) => format!("`{}`", value.trim_end_matches(';')), + None => code.to_string(), + } +} + impl ExtractDynamicStyle { /// create a new `ExtractDynamicStyle` pub fn new( @@ -89,8 +109,7 @@ impl ExtractDynamicStyle { ) -> Self { // `optimize_value` returns `Cow`; `strip_important` takes ownership (and // the struct stores the `String`), so materialize the owned form here. - let optimized = optimize_value(identifier).into_owned(); - let (identifier, important) = strip_important(optimized); + let (identifier, important) = strip_important(runtime_code(identifier)); Self { property: property.to_string(), level, @@ -98,6 +117,7 @@ impl ExtractDynamicStyle { selector: selector.map(optimize_selector), style_order: None, important, + layer: None, } } @@ -124,11 +144,15 @@ impl ExtractDynamicStyle { pub const fn important(&self) -> bool { self.important } + + pub fn layer(&self) -> Option<&str> { + self.layer.as_deref() + } } impl ExtractStyleProperty for ExtractDynamicStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { - let selector = self.selector.as_ref().map(StyleSelector::as_class_str); + let selector = super::class_selector(self.selector.as_ref(), self.layer()); StyleProperty::Variable { class_name: sheet_to_classname( self.property.as_str(), diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs index 91ad74822..1adc78be6 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -10,7 +10,9 @@ use css::{ use crate::{ extract_style::{ - ExtractStyleProperty, constant::MAINTAIN_VALUE_PROPERTIES, style_property::StyleProperty, + ExtractStyleProperty, + constant::{MAINTAIN_VALUE_PROPERTIES, TIME_PROPERTIES}, + style_property::StyleProperty, }, utils::{convert_value, gcd}, }; @@ -65,22 +67,28 @@ impl ExtractStaticStyle { // immediately re-copied by `optimize_value` (which takes `&str` and owns its // own result). Only the aspect-ratio reduction and the `convert_value` branch // must own; both already produce owned `String`s. Byte-identical output. - let normalized: Cow = if MAINTAIN_VALUE_PROPERTIES.contains(property) { - if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') { - if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| { - Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?)) - }) { - let gcd = gcd(a, b); - Cow::Owned(format!("{}/{}", a / gcd, b / gcd)) + let normalized: Cow = + if MAINTAIN_VALUE_PROPERTIES.contains(property) || property.starts_with("--") { + if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') { + if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| { + Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?)) + }) { + let gcd = gcd(a, b); + Cow::Owned(format!("{}/{}", a / gcd, b / gcd)) + } else { + Cow::Borrowed(value) + } } else { Cow::Borrowed(value) } + } else if TIME_PROPERTIES.contains(property) { + // A time is written in `ms`, never on the spacing scale + value.parse::().map_or(Cow::Borrowed(value), |number| { + Cow::Owned(format!("{}ms", crate::utils::js_number_string(number))) + }) } else { - Cow::Borrowed(value) - } - } else { - convert_value(value) - }; + convert_value(value) + }; // `optimize_value` returns `Cow` (borrowed when no optimization pass // fires); this constructor stores an owned `String`, so materialize it // here — a borrowed result costs exactly the one copy it always did. @@ -144,6 +152,13 @@ impl ExtractStaticStyle { self.layer.as_deref() } + /// The selector part of the class name key, holding the layer so a layered + /// declaration never shares a class with an unlayered one + #[must_use] + pub fn class_selector(&self) -> Option> { + super::class_selector(self.selector.as_ref(), self.layer.as_deref()) + } + #[must_use] pub const fn property(&self) -> &str { self.property.as_str() @@ -177,7 +192,7 @@ impl ExtractStaticStyle { impl ExtractStyleProperty for ExtractStaticStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { - let s = self.selector.as_ref().map(StyleSelector::as_class_str); + let s = self.class_selector(); // `self.value` is already the result of `optimize_value(convert_value(..))` // (computed in the constructors), so re-running convert_value + optimize_value // here is redundant. Only the multi-css optimization is not applied at construction. @@ -210,6 +225,14 @@ mod tests { assert_eq!(style.selector(), None); assert_eq!(style.style_order(), None); assert_eq!(style.layer(), None); + assert_eq!( + ExtractStaticStyle::new("--columns", "4", 0, None).value(), + "4" + ); + assert_eq!( + ExtractStaticStyle::new("padding", "4", 0, None).value(), + "16px" + ); } #[test] diff --git a/libs/extractor/src/extract_style/mod.rs b/libs/extractor/src/extract_style/mod.rs index 05d4646de..e101f8bbb 100644 --- a/libs/extractor/src/extract_style/mod.rs +++ b/libs/extractor/src/extract_style/mod.rs @@ -9,6 +9,22 @@ pub mod extract_style_value; pub mod style_property; use crate::extract_style::style_property::StyleProperty; +use css::style_selector::StyleSelector; +use std::borrow::Cow; + +fn class_selector<'a>( + selector: Option<&'a StyleSelector>, + layer: Option<&str>, +) -> Option> { + let selector = selector.map(StyleSelector::as_class_str); + match layer { + Some(layer) => Some(Cow::Owned(format!( + "{}@layer {layer}", + selector.as_deref().unwrap_or_default() + ))), + None => selector, + } +} pub trait ExtractStyleProperty { /// extract style properties diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index 15ba1c04f..e82d4a73a 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, + unreadable, unreadable_key, 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,59 @@ 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); } + } else { + styles.push(unreadable_key(&o.key, false)); } } 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, + } else { + styles.extend(unreadable(expression).styles); } } diff --git a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs index 7d3d19b1c..ae468ce3d 100644 --- a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs +++ b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs @@ -3,9 +3,14 @@ use crate::{ extract_style::{extract_keyframes::ExtractKeyframes, extract_style_value::ExtractStyleValue}, extractor::{ ExtractResult, KeyframesExtractResult, - extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, + extract_style_from_expression::{ + LiteralHandling, extract_style_from_expression, unreadable, + }, + }, + utils::{ + fixed_value, get_string_by_property_key, readable_code, runtime_value, + unwrap_syntax_only_mut, }, - utils::get_string_by_property_key, }; use oxc_ast::{ ast::{Expression, ObjectPropertyKind}, @@ -17,35 +22,58 @@ pub fn extract_keyframes_from_expression<'a>( expression: &mut Expression<'a>, ) -> KeyframesExtractResult { let mut keyframes = ExtractKeyframes::default(); - - if let Expression::ObjectExpression(obj) = expression { - for p in &mut obj.properties { - if let ObjectPropertyKind::ObjectProperty(o) = p - && let Some(name) = get_string_by_property_key(&o.key) - { - let ExtractResult { styles, .. } = extract_style_from_expression( - ast_builder, - None, - &mut o.value, - 0, - &None, - LiteralHandling::ExpandResponsiveThemeToken, - ); - - let mut styles = styles - .into_iter() - .filter_map(|s| match s { - ExtractStyleProp::Static(ExtractStyleValue::Static(s)) => Some(s), - _ => None, - }) - .collect::>(); - styles.sort_by(|a, b| a.property().cmp(b.property())); - keyframes.keyframes.insert( - name.parse::().map(|v| format!("{v}%")).unwrap_or(name), - styles, - ); + let Expression::ObjectExpression(obj) = unwrap_syntax_only_mut(expression) else { + return KeyframesExtractResult { + keyframes, + runtime_value: runtime_value(&unreadable(expression).styles), + }; + }; + let mut runtime = None; + for p in &mut obj.properties { + let o = match p { + ObjectPropertyKind::ObjectProperty(o) => o, + ObjectPropertyKind::SpreadProperty(spread) => { + runtime = + runtime.or_else(|| Some(format!("...{}", readable_code(&spread.argument)))); + continue; } - } + }; + let Some(name) = get_string_by_property_key(&o.key) else { + runtime = runtime.or_else(|| { + Some(format!( + "[{}]", + o.key + .as_expression() + .map_or_else(String::new, readable_code) + )) + }); + continue; + }; + let ExtractResult { styles, .. } = extract_style_from_expression( + ast_builder, + None, + &mut o.value, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + runtime = runtime.or_else(|| fixed_value(&styles)); + + let mut styles = styles + .into_iter() + .filter_map(|s| match s { + ExtractStyleProp::Static(ExtractStyleValue::Static(s)) => Some(s), + _ => None, + }) + .collect::>(); + styles.sort_by(|a, b| a.property().cmp(b.property())); + keyframes.keyframes.insert( + name.parse::().map(|v| format!("{v}%")).unwrap_or(name), + styles, + ); + } + KeyframesExtractResult { + keyframes, + runtime_value: runtime, } - KeyframesExtractResult { keyframes } } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index cc4a9311b..27150f096 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -12,26 +12,28 @@ use crate::{ utils::{ expression_to_code, get_number_by_literal_expression, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_same_expression, - unwrap_syntax_only_mut, + readable_code, unwrap_syntax_only, unwrap_syntax_only_mut, }, }; use css::{ - 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}; use oxc_ast::{ ast::{ - BinaryOperator, Expression, LogicalOperator, ObjectPropertyKind, Str, TemplateElement, - TemplateElementValue, UnaryOperator, + BinaryOperator, Expression, LogicalOperator, ObjectExpression, ObjectPropertyKind, + PropertyKey, Str, TemplateElement, TemplateElementValue, UnaryOperator, }, builder::AstBuilder, }; -use oxc_span::SPAN; +use oxc_span::{GetSpan, SPAN}; +use std::collections::BTreeMap; const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"]; @@ -41,6 +43,112 @@ pub enum LiteralHandling { KeepSingleClass, } +/// `expression` where styles are expected but the build cannot read them: +/// nothing for a value that holds no styles (`null`, `false`, `undefined`) +pub(crate) fn unreadable<'a>(expression: &Expression<'a>) -> ExtractResult<'a> { + let holds_nothing = match unwrap_syntax_only(expression) { + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => true, + Expression::Identifier(identifier) => { + IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) + } + Expression::UnaryExpression(unary) => unary.operator == UnaryOperator::Void, + _ => false, + }; + ExtractResult { + styles: if holds_nothing { + vec![] + } else { + vec![ExtractStyleProp::Unreadable { + offset: expression.span().start, + code: readable_code(expression), + prop: false, + }] + }, + ..ExtractResult::default() + } +} + +/// Whether `expression` is truthy, when it is a literal: a condition the +/// build knows picks its branch there, as once constants are inlined +fn literal_truthiness(expression: &Expression<'_>) -> Option { + match unwrap_syntax_only(expression) { + Expression::BooleanLiteral(literal) => Some(literal.value), + Expression::NullLiteral(_) => Some(false), + Expression::NumericLiteral(literal) => { + Some(literal.value != 0.0 && !literal.value.is_nan()) + } + Expression::StringLiteral(literal) => Some(!literal.value.is_empty()), + Expression::Identifier(identifier) if identifier.name == "undefined" => Some(false), + _ => None, + } +} + +/// Merge the object literals spread into `object` in place, a later property +/// replacing an earlier one of the same key as in the object JavaScript builds +pub(crate) fn flatten_spreads<'a>(ast_builder: &AstBuilder<'a>, object: &mut ObjectExpression<'a>) { + if !object.properties.iter().any(|property| { + matches!(property, ObjectPropertyKind::SpreadProperty(spread) + if matches!(unwrap_syntax_only(&spread.argument), Expression::ObjectExpression(_))) + }) { + return; + } + let properties = std::mem::replace( + &mut object.properties, + oxc_allocator::Vec::new_in(ast_builder), + ); + for property in properties { + match property { + ObjectPropertyKind::SpreadProperty(mut spread) => { + if let Expression::ObjectExpression(inner) = + unwrap_syntax_only_mut(&mut spread.argument) + { + flatten_spreads(ast_builder, inner); + let spread_properties = std::mem::replace( + &mut inner.properties, + oxc_allocator::Vec::new_in(ast_builder), + ); + for property in spread_properties { + push_replacing(&mut object.properties, property); + } + } else { + object + .properties + .push(ObjectPropertyKind::SpreadProperty(spread)); + } + } + property @ ObjectPropertyKind::ObjectProperty(_) => { + push_replacing(&mut object.properties, property); + } + } + } +} + +fn push_replacing<'a>( + properties: &mut oxc_allocator::Vec<'a, ObjectPropertyKind<'a>>, + property: ObjectPropertyKind<'a>, +) { + if let ObjectPropertyKind::ObjectProperty(added) = &property + && let Some(key) = get_str_by_property_key(&added.key) + { + properties.retain(|existing| { + !matches!(existing, ObjectPropertyKind::ObjectProperty(existing) + if get_str_by_property_key(&existing.key).as_deref() == Some(key.as_ref())) + }); + } + properties.push(property); +} + +pub(crate) fn unreadable_key<'a>(key: &PropertyKey<'_>, prop: bool) -> ExtractStyleProp<'a> { + ExtractStyleProp::Unreadable { + offset: key.span().start, + code: format!( + "[{}]", + key.as_expression().map_or_else(String::new, readable_code) + ), + prop, + } +} + fn create_static_styles<'a>( name: &str, value: &str, @@ -112,11 +220,18 @@ pub fn extract_style_from_expression<'a>( let mut props = None; return match expression { Expression::ObjectExpression(obj) => { + flatten_spreads(ast_builder, obj); let mut props_styles: Vec> = vec![]; let mut tag = None; for idx in (0..obj.properties.len()).rev() { let mut prop = obj.properties.remove(idx); if !match &mut prop { + ObjectPropertyKind::ObjectProperty(prop) + if get_str_by_property_key(&prop.key).is_none() => + { + props_styles.push(unreadable_key(&prop.key, true)); + false + } ObjectPropertyKind::ObjectProperty(prop) => { if let Some(name) = get_str_by_property_key(&prop.key) && !is_special_property(&name) @@ -216,7 +331,39 @@ pub fn extract_style_from_expression<'a>( .collect(), ..ExtractResult::default() }, - _ => ExtractResult::default(), + Expression::StringLiteral(text) => ExtractResult { + styles: css_to_style(&text.value, level, selector) + .into_iter() + .map(|ex| ExtractStyleProp::Static(ExtractStyleValue::Static(ex))) + .collect(), + ..ExtractResult::default() + }, + _ => unreadable(expression), + }; + } + + 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() }; } @@ -242,76 +389,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 +415,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 +472,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 +496,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 @@ -438,36 +547,12 @@ pub fn extract_style_from_expression<'a>( } } } else { + // `name` is `None` only under a selector (`_hover: value`), which takes + // styles rather than a value a CSS variable could hold match expression { - Expression::UnaryExpression(un) => ExtractResult { - // `name` is None only when this was reached through the `_xxx` - // selector recursion (see line 324). In that case the value - // cannot be statically extracted as a dynamic style because - // the pseudo-selector has no CSS property slot to bind a - // CSS variable to, so we return an empty result and let the - // caller drop the attribute (see issue with `_hover={var}`). - styles: if un.operator == UnaryOperator::Void { - vec![] - } else if let Some(name) = name { - vec![dynamic_style( - ast_builder, - name, - expression, - level, - selector, - )] - } else { - vec![] - }, - ..ExtractResult::default() - }, - // Each variant is kept on its own line so per-line coverage - // tools (tarpaulin on CI) can attribute the hit to the exact - // pattern being exercised. The body is flattened to a single - // `Option::map().unwrap_or_default()` chain to avoid an extra - // if/else branch region — `name == None` happens only under - // `_xxx={...}` pseudo-selector recursion, where no dynamic_style - // can be emitted because the selector has no CSS property slot. + Expression::UnaryExpression(un) if un.operator == UnaryOperator::Void => { + ExtractResult::default() + } // `styled.div({ color: (p) => p.theme.brand })` — the object spelling of // the template interpolation, resolved to the same build-time `var()`. Expression::ArrowFunctionExpression(_) => match (name, theme_var_reference(expression)) @@ -482,13 +567,17 @@ pub fn extract_style_from_expression<'a>( ), ..ExtractResult::default() }, - _ => ExtractResult::default(), + _ => unreadable(expression), }, - Expression::BinaryExpression(_) + // Each variant is kept on its own line so per-line coverage + // tools (tarpaulin on CI) can attribute the hit to the exact + // pattern being exercised. + Expression::UnaryExpression(_) + | Expression::BinaryExpression(_) | Expression::StaticMemberExpression(_) | Expression::ChainExpression(_) - | Expression::CallExpression(_) => name - .map(|name| ExtractResult { + | Expression::CallExpression(_) => match name { + Some(name) => ExtractResult { styles: vec![dynamic_style( ast_builder, name, @@ -497,140 +586,190 @@ pub fn extract_style_from_expression<'a>( selector, )], ..ExtractResult::default() - }) - .unwrap_or_default(), + }, + None => unreadable(expression), + }, Expression::ComputedMemberExpression(mem) => { extract_style_from_member_expression(ast_builder, name, mem, level, selector) } - Expression::TemplateLiteral(_) => ExtractResult { - // `typo == true` implies `name == Some("typography")` (set at - // line 337 inside an `if let Some(name) = name` block), so the - // typo branch is safe. The non-typo branch must handle the - // `name.is_none()` case (pseudo-selector recursion) by - // returning empty styles. - styles: if typo { - vec![ExtractStyleProp::Expression { - expression: Expression::new_template_literal( - SPAN, - oxc_allocator::Vec::from_array_in( - [ - TemplateElement::new( - SPAN, - TemplateElementValue { - raw: Str::from("typo-"), - cooked: None, - }, - false, - ast_builder, - ), - TemplateElement::new( - SPAN, - TemplateElementValue { - raw: Str::from(""), - cooked: None, - }, - true, - ast_builder, - ), - ], - ast_builder, - ), - oxc_allocator::Vec::from_array_in( - [expression.clone_in(ast_builder.allocator())], + Expression::TemplateLiteral(_) => { + let Some(name) = name else { + return unreadable(expression); + }; + ExtractResult { + styles: if typo + && let Some(style) = + conditional_typography(ast_builder, expression, level, selector) + { + vec![style] + } else if typo { + vec![ExtractStyleProp::Expression { + expression: Expression::new_template_literal( + SPAN, + oxc_allocator::Vec::from_array_in( + [ + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from("typo-"), + cooked: None, + }, + false, + ast_builder, + ), + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from(""), + cooked: None, + }, + true, + ast_builder, + ), + ], + ast_builder, + ), + oxc_allocator::Vec::from_array_in( + [expression.clone_in(ast_builder.allocator())], + ast_builder, + ), ast_builder, ), + styles: vec![], + }] + } else { + vec![dynamic_style( ast_builder, - ), - styles: vec![], - }] - } else if let Some(name) = name { - vec![dynamic_style( - ast_builder, - name, - expression, - level, - selector, - )] - } else { - vec![] - }, - ..ExtractResult::default() - }, + name, + expression, + level, + selector, + )] + }, + ..ExtractResult::default() + } + } Expression::Identifier(identifier) => { - // When `name` is `None` we are inside a pseudo-selector - // recursion (e.g. `_hover={someIdentifier}`). In that case - // the identifier is a black box (it may come from another - // module) and we cannot statically extract a style from it, - // so we skip extraction gracefully instead of panicking. The - // pseudo-selector attribute will be stripped by the visitor - // like any other non-extracted style prop. + let Some(name) = name else { + return unreadable(expression); + }; if IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) { ExtractResult::default() - } else if let Some(name) = name { - if typo { - ExtractResult { - styles: vec![ExtractStyleProp::Expression { - expression: Expression::new_conditional_expression( + } 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 typo { + ExtractResult { + styles: vec![ExtractStyleProp::Expression { + expression: Expression::new_conditional_expression( + SPAN, + Expression::new_identifier( + SPAN, + identifier.name.as_str(), + ast_builder, + ), + Expression::new_template_literal( SPAN, - Expression::new_identifier( - SPAN, - identifier.name.as_str(), + oxc_allocator::Vec::from_array_in( + [ + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from("typo-"), + cooked: None, + }, + false, + ast_builder, + ), + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from(""), + cooked: None, + }, + true, + ast_builder, + ), + ], ast_builder, ), - Expression::new_template_literal( - SPAN, - oxc_allocator::Vec::from_array_in( - [ - TemplateElement::new( - SPAN, - TemplateElementValue { - raw: Str::from("typo-"), - cooked: None, - }, - false, - ast_builder, - ), - TemplateElement::new( - SPAN, - TemplateElementValue { - raw: Str::from(""), - cooked: None, - }, - true, - ast_builder, - ), - ], - ast_builder, - ), - oxc_allocator::Vec::from_array_in( - [expression.clone_in(ast_builder.allocator())], - ast_builder, - ), + oxc_allocator::Vec::from_array_in( + [expression.clone_in(ast_builder.allocator())], ast_builder, ), - Expression::new_string_literal(SPAN, "", None, ast_builder), ast_builder, ), - styles: vec![], - }], - ..ExtractResult::default() - } - } else { - ExtractResult { - styles: vec![dynamic_style( + Expression::new_string_literal(SPAN, "", None, ast_builder), ast_builder, - name, - expression, - level, - selector, - )], - ..ExtractResult::default() - } + ), + styles: vec![], + }], + ..ExtractResult::default() } } else { - ExtractResult::default() + ExtractResult { + styles: vec![dynamic_style( + ast_builder, + name, + expression, + level, + selector, + )], + ..ExtractResult::default() + } } } + Expression::LogicalExpression(logical) + if literal_truthiness(&logical.left).is_some() => + { + let nullish = matches!( + unwrap_syntax_only(&logical.left), + Expression::NullLiteral(_) + ) || matches!(unwrap_syntax_only(&logical.left), Expression::Identifier(identifier) if identifier.name == "undefined"); + let takes_right = match logical.operator { + LogicalOperator::And => literal_truthiness(&logical.left) == Some(true), + LogicalOperator::Or => literal_truthiness(&logical.left) == Some(false), + LogicalOperator::Coalesce => nullish, + }; + extract_style_from_expression( + ast_builder, + name, + if takes_right { + &mut logical.right + } else { + &mut logical.left + }, + level, + selector, + literal_handling, + ) + } + Expression::ConditionalExpression(conditional) + if literal_truthiness(&conditional.test).is_some() => + { + let consequent = literal_truthiness(&conditional.test) == Some(true); + extract_style_from_expression( + ast_builder, + name, + if consequent { + &mut conditional.consequent + } else { + &mut conditional.alternate + }, + level, + selector, + literal_handling, + ) + } Expression::LogicalExpression(logical) => { let res = Some(Box::new(ExtractStyleProp::StaticArray( extract_style_from_expression( @@ -766,6 +905,7 @@ pub fn extract_style_from_expression<'a>( } } Expression::ObjectExpression(obj) => { + flatten_spreads(ast_builder, obj); let mut props = vec![]; let params = obj.properties.iter().find_map(|p| { if let ObjectPropertyKind::ObjectProperty(o) = p @@ -811,24 +951,43 @@ pub fn extract_style_from_expression<'a>( }); for p in &mut obj.properties { - if let ObjectPropertyKind::ObjectProperty(o) = p - && let Some(key_name) = o.key.name() - && key_name != "params" - { - for name in disassemble_property(&key_name) { - let name: &str = &name; + let o = match p { + ObjectPropertyKind::ObjectProperty(o) => o, + ObjectPropertyKind::SpreadProperty(spread) => { props.extend( extract_style_from_expression( ast_builder, - Some(name), - &mut o.value, + None, + &mut spread.argument, level, &selector, literal_handling, ) .styles, ); + continue; } + }; + let Some(key_name) = o.key.name() else { + props.push(unreadable_key(&o.key, false)); + continue; + }; + if key_name == "params" { + continue; + } + for name in disassemble_property(&key_name) { + let name: &str = &name; + props.extend( + extract_style_from_expression( + ast_builder, + Some(name), + &mut o.value, + level, + &selector, + literal_handling, + ) + .styles, + ); } } ExtractResult { @@ -836,9 +995,333 @@ pub fn extract_style_from_expression<'a>( ..ExtractResult::default() } } - _ => ExtractResult::default(), + _ => match name { + None => unreadable(expression), + Some(_) if matches!(expression, Expression::NullLiteral(_)) => { + ExtractResult::default() + } + Some(_) + if matches!( + expression, + Expression::FunctionExpression(_) | Expression::ClassExpression(_) + ) => + { + unreadable(expression) + } + // A sequence keeps its parentheses, as its code is written into + // the element's style object, where a comma ends the property + Some(name) if matches!(expression, Expression::SequenceExpression(_)) => { + let code = expression_to_code(expression); + ExtractResult { + styles: vec![ExtractStyleProp::Static(ExtractStyleValue::Dynamic( + ExtractDynamicStyle::new( + name, + level, + &format!("({})", code.trim_end().trim_end_matches(';')), + selector.clone(), + ), + ))], + ..ExtractResult::default() + } + } + // Any other value is only known at runtime: `await`, `new`, an + // assignment, a tagged template + Some(name) => ExtractResult { + styles: vec![dynamic_style( + ast_builder, + name, + expression, + level, + selector, + )], + ..ExtractResult::default() + }, + }, + } + } +} + +/// Put every static declaration of `props` in `layer`, nesting the layer one +/// already sits in +pub(crate) fn place_in_layer(props: &mut [ExtractStyleProp<'_>], layer: &str) { + let nest = |inner: &mut Option| { + *inner = Some(match inner.take() { + Some(inner) => format!("{layer}.{inner}"), + None => layer.to_string(), + }); + }; + for prop in props { + match prop { + ExtractStyleProp::Static(ExtractStyleValue::Static(style)) => nest(&mut style.layer), + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) => nest(&mut style.layer), + ExtractStyleProp::StaticArray(props) => place_in_layer(props, layer), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for branch in [consequent, alternate].into_iter().flatten() { + place_in_layer(std::slice::from_mut(branch.as_mut()), layer); + } + } + ExtractStyleProp::Enum { map, .. } => { + for props in map.values_mut() { + place_in_layer(props, layer); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + place_in_layer(std::slice::from_mut(prop.as_mut()), layer); + } + } + // A `typography` preset is shared by every use, so it stays out of layers + ExtractStyleProp::Static(_) + | ExtractStyleProp::Expression { .. } + | ExtractStyleProp::Unreadable { .. } => {} + } + } +} + +const TYPOGRAPHY_PROPERTIES: [&str; 5] = [ + "font-family", + "font-size", + "font-weight", + "line-height", + "letter-spacing", +]; + +/// `(selector, typography property)` -> breakpoint its declaration starts at +type Declared = BTreeMap<(Option, &'static str), u8>; + +/// Typography properties `props` always declare, per selector +fn declared_from(props: &[ExtractStyleProp<'_>]) -> Declared { + let mut declared = Declared::new(); + for prop in props { + let found = match prop { + ExtractStyleProp::Static(value) => { + let (property, level, selector) = match value { + ExtractStyleValue::Static(style) => { + (style.property(), style.level(), style.selector()) + } + ExtractStyleValue::Dynamic(style) => { + (style.property(), style.level(), style.selector()) + } + _ => continue, + }; + let Some(property) = TYPOGRAPHY_PROPERTIES.into_iter().find(|p| *p == property) + else { + continue; + }; + Declared::from([((selector.cloned(), property), level)]) + } + ExtractStyleProp::StaticArray(props) => declared_from(props), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => declared_by_every([consequent, alternate].map(|branch| { + branch.as_deref().map_or_else(Declared::new, |branch| { + declared_from(std::slice::from_ref(branch)) + }) + })), + ExtractStyleProp::Enum { map, .. } => { + declared_by_every(map.values().map(|props| declared_from(props))) + } + ExtractStyleProp::MemberExpression { map, .. } => declared_by_every( + map.values() + .map(|prop| declared_from(std::slice::from_ref(prop.as_ref()))), + ), + ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => continue, + }; + for (key, level) in found { + declared + .entry(key) + .and_modify(|from| *from = (*from).min(level)) + .or_insert(level); + } + } + declared +} + +/// What every alternative declares, from the widest breakpoint one starts at +fn declared_by_every(alternatives: impl IntoIterator) -> Declared { + let mut alternatives = alternatives.into_iter(); + let Some(mut common) = alternatives.next() else { + return Declared::new(); + }; + for alternative in alternatives { + common = common + .into_iter() + .filter_map(|(key, level)| alternative.get(&key).map(|other| (key, level.max(*other)))) + .collect(); + } + common +} + +/// A `typography` preset gives way to the declarations written beside it: under +/// the same selector it skips each of their properties from the breakpoint the +/// written one starts at, so a wider breakpoint of the preset cannot win. +pub(crate) fn yield_typography(props: &mut [ExtractStyleProp<'_>]) { + let declared = declared_from(props); + if !declared.is_empty() { + skip_declared(props, &declared); + } +} + +fn skip_declared(props: &mut [ExtractStyleProp<'_>], declared: &Declared) { + for prop in props { + match prop { + ExtractStyleProp::Static(ExtractStyleValue::Static(style)) + if style.property() == "typography" => + { + let skipped: Vec = declared + .iter() + .filter(|((selector, _), _)| selector.as_ref() == style.selector()) + .map(|((_, property), level)| format!("{property}:{level}")) + .collect(); + if !skipped.is_empty() { + style.value = format!("{}|{}", style.value, skipped.join(",")); + } + } + ExtractStyleProp::StaticArray(props) => skip_declared(props, declared), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for branch in [consequent, alternate].into_iter().flatten() { + skip_declared(std::slice::from_mut(branch.as_mut()), declared); + } + } + ExtractStyleProp::Enum { map, .. } => { + for props in map.values_mut() { + skip_declared(props, declared); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + skip_declared(std::slice::from_mut(prop.as_mut()), declared); + } + } + ExtractStyleProp::Static(_) + | ExtractStyleProp::Expression { .. } + | ExtractStyleProp::Unreadable { .. } => {} + } + } +} + +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>( @@ -886,6 +1369,16 @@ mod tests { use oxc_parser::Parser; use oxc_span::SourceType; + #[test] + fn test_declared_by_every() { + assert!(declared_by_every(std::iter::empty()).is_empty()); + let declared = declared_by_every([ + Declared::from([((None, "font-size"), 0), ((None, "line-height"), 1)]), + Declared::from([((None, "font-size"), 2)]), + ]); + assert_eq!(declared, Declared::from([((None, "font-size"), 2)])); + } + #[test] fn test_extract_selector_object_styles() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs index c5c494d63..b52e3169e 100644 --- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs @@ -8,7 +8,7 @@ use crate::{ }, utils::{ get_number_by_literal_expression, get_str_by_property_key, - get_string_by_literal_expression, get_string_by_property_key, + get_string_by_literal_expression, get_string_by_property_key, readable_code, }, }; use css::style_selector::StyleSelector; @@ -43,6 +43,32 @@ pub(super) fn extract_style_from_member_expression<'a>( mem.object = inner; } + // With a spread in it, the literal as written gives what a position it + // does not spell out holds + let spread = match &mem.object { + Expression::ArrayExpression(array) => array + .elements + .iter() + .any(|element| matches!(element, ArrayExpressionElement::SpreadElement(_))), + Expression::ObjectExpression(object) => object + .properties + .iter() + .any(|property| matches!(property, ObjectPropertyKind::SpreadProperty(_))), + _ => false, + } + .then(|| mem.object.clone_in(ast_builder.allocator())); + let runtime = |whole: &Expression<'a>, offset: u32| { + runtime_member( + ast_builder, + name, + whole.clone_in(ast_builder.allocator()), + mem_expression, + offset, + level, + selector, + ) + }; + if let Expression::ArrayExpression(array) = &mut mem.object && !array.elements.is_empty() { @@ -50,12 +76,13 @@ pub(super) fn extract_style_from_member_expression<'a>( if num < 0f64 { return ExtractResult::default(); } - let mut etc = None; + // Only the elements before the first spread sit at a fixed index let selected_index = (num.fract() == 0.0).then_some(num as usize); for (idx, p) in array.elements.iter_mut().enumerate() { - if let ArrayExpressionElement::SpreadElement(sp) = p { - etc = Some(sp.argument.clone_in(ast_builder.allocator())); - } else if Some(idx) == selected_index + if matches!(p, ArrayExpressionElement::SpreadElement(_)) { + break; + } + if Some(idx) == selected_index && let Some(p) = p.as_expression_mut() { return extract_style_from_expression( @@ -68,58 +95,21 @@ pub(super) fn extract_style_from_member_expression<'a>( ); } } - // If `name` is None (pseudo-selector recursion) we cannot emit a - // dynamic_style because there is no CSS property slot to bind to. - // Fall back to an empty result in that case. return ExtractResult { - props: None, - styles: etc - .zip(name) - .map(|(etc, name)| { - vec![dynamic_style( - ast_builder, - name, - &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( - SPAN, - etc, - mem_expression.clone_in(ast_builder.allocator()), - false, - ast_builder, - )), - level, - selector, - )] - }) - .unwrap_or_default(), - tag: None, - style_order: None, - style_vars: None, + styles: spread + .iter() + .map(|whole| runtime(whole, mem.span.start)) + .collect(), + ..ExtractResult::default() }; } let mut map = BTreeMap::new(); for (idx, p) in array.elements.iter_mut().enumerate() { - if let ArrayExpressionElement::SpreadElement(sp) = p { - // Skip spread elements entirely when `name` is None — we - // can't synthesize a dynamic style without a prop name. - if let Some(name) = name { - map.insert( - idx.to_string(), - Box::new(dynamic_style( - ast_builder, - name, - &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( - SPAN, - sp.argument.clone_in(ast_builder.allocator()), - mem_expression.clone_in(ast_builder.allocator()), - false, - ast_builder, - )), - level, - selector, - )), - ); - } + if let ArrayExpressionElement::SpreadElement(sp) = p + && let Some(whole) = &spread + { + map.insert(idx.to_string(), Box::new(runtime(whole, sp.span.start))); } else if let Some(p) = p.as_expression_mut() { map.insert( idx.to_string(), @@ -147,48 +137,40 @@ pub(super) fn extract_style_from_member_expression<'a>( { let mut map = BTreeMap::new(); if let Some(k) = get_string_by_literal_expression(mem_expression) { - let mut etc = None; - for p in &mut obj.properties { - if let ObjectPropertyKind::ObjectProperty(o) = p { - if let Some(property_name) = get_str_by_property_key(&o.key) - && property_name == k - { - return ExtractResult { - styles: extract_style_from_expression( - ast_builder, - name, - &mut o.value, - level, - selector, - LiteralHandling::ExpandResponsiveThemeToken, - ) - .styles, - ..ExtractResult::default() - }; - } - } else if let ObjectPropertyKind::SpreadProperty(sp) = p { - etc = Some(sp.argument.clone_in(ast_builder.allocator())); - } + // The last property written for the key gives it, unless a spread + // after it may replace it + let written = obj.properties.iter().rposition(|p| { + matches!(p, ObjectPropertyKind::ObjectProperty(o) + if get_str_by_property_key(&o.key).as_deref() == Some(k.as_ref())) + }); + let replaced = obj.properties[written.map_or(0, |index| index + 1)..] + .iter() + .any(|p| matches!(p, ObjectPropertyKind::SpreadProperty(_))); + if replaced && let Some(whole) = &spread { + return ExtractResult { + styles: vec![runtime(whole, mem.span.start)], + ..ExtractResult::default() + }; } - - match (etc, name) { - (Some(etc), Some(name)) => ret.push(dynamic_style( - ast_builder, - name, - &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( - SPAN, - etc, - mem_expression.clone_in(ast_builder.allocator()), - false, - ast_builder, - )), - level, - selector, - )), - // No spread fallback, or no prop name (pseudo-selector - // recursion): return empty instead of panicking. - _ => return ExtractResult::default(), + for p in obj.properties.iter_mut().rev() { + if let ObjectPropertyKind::ObjectProperty(o) = p + && get_str_by_property_key(&o.key).as_deref() == Some(k.as_ref()) + { + return ExtractResult { + styles: extract_style_from_expression( + ast_builder, + name, + &mut o.value, + level, + selector, + LiteralHandling::ExpandResponsiveThemeToken, + ) + .styles, + ..ExtractResult::default() + }; + } } + return ExtractResult::default(); } for p in &mut obj.properties { @@ -215,21 +197,16 @@ pub(super) fn extract_style_from_member_expression<'a>( expression: mem_expression.clone_in(ast_builder.allocator()), map, }); - } else if let Expression::Identifier(_) = &mut mem.object - && let Some(name) = name - { - // When `name` is None we are in a pseudo-selector recursion and - // cannot emit a dynamic_style — skip gracefully. - ret.push(dynamic_style( + } else if !matches!( + &mem.object, + Expression::ArrayExpression(_) | Expression::ObjectExpression(_) + ) { + ret.push(runtime_member( ast_builder, name, - &Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( - SPAN, - mem.object.clone_in(ast_builder.allocator()), - mem_expression.clone_in(ast_builder.allocator()), - false, - ast_builder, - )), + mem.object.clone_in(ast_builder.allocator()), + mem_expression, + mem.span.start, level, selector, )); @@ -240,3 +217,31 @@ pub(super) fn extract_style_from_member_expression<'a>( ..ExtractResult::default() } } + +/// `object[key]` known only at runtime: a CSS variable for a property, and +/// under a selector, which takes styles, what the build cannot read +fn runtime_member<'a>( + ast_builder: &AstBuilder<'a>, + name: Option<&str>, + object: Expression<'a>, + key: &Expression<'a>, + offset: u32, + level: u8, + selector: &Option, +) -> ExtractStyleProp<'a> { + let member = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( + SPAN, + object, + key.clone_in(ast_builder.allocator()), + false, + ast_builder, + )); + match name { + Some(name) => dynamic_style(ast_builder, name, &member, level, selector), + None => ExtractStyleProp::Unreadable { + offset, + code: readable_code(&member), + prop: false, + }, + } +} diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 492264a49..e9d0d34c0 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -3,26 +3,31 @@ use rustc_hash::FxHashMap; use crate::{ ExtractStyleProp, component::ExportVariableKind, - css_utils::css_to_style_literal, + css_utils::{TemplateStyles, css_to_style_template}, extract_style::extract_style_value::ExtractStyleValue, extractor::{ ExtractResult, extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, }, - gen_class_name::gen_class_names, + gen_class_name::{gen_class_names, merge_expression_for_class_name}, gen_style::gen_styles, - utils::{merge_object_expressions, unwrap_syntax_only, wrap_array_filter}, + utils::{ + STYLE_OBJECT, StyleArguments, build_time_error, merge_object_expressions, reads_directly, + style_arguments, uncomposable_error, unplaced_error, unreadable_styles, unwrap_syntax_only, + unwrap_syntax_only_mut, wrap_array_filter, wrap_direct_call, + }, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::{ ast::{ Argument, BindingPattern, BindingProperty, BindingRestElement, CallExpression, Expression, FormalParameter, FormalParameterKind, FormalParameters, JSXAttributeItem, JSXAttributeName, - JSXAttributeValue, JSXElementName, JSXOpeningElement, PropertyKey, Str, + JSXAttributeValue, JSXElementName, JSXOpeningElement, ObjectPropertyKind, PropertyKey, Str, }, builder::AstBuilder, }; -use oxc_span::SPAN; +use oxc_span::{GetSpan, SPAN}; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; fn extract_base_tag_and_class_name( input: &Expression<'_>, @@ -103,7 +108,35 @@ pub fn extract_style_from_styled<'a>( expression: &mut Expression<'a>, split_filename: Option<&str>, imports: &FxHashMap, -) -> (ExtractResult<'a>, Expression<'a>) { + attrs: &[Expression<'a>], +) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { + let mut composed_classes = Vec::new(); + let mut errors = Vec::new(); + if let Expression::CallExpression(call) = expression + && extract_base_tag_and_class_name(&call.callee, imports) + .0 + .is_some() + { + match style_arguments(ast_builder, &call.arguments) { + Some(StyleArguments { classes, rules }) => { + call.arguments = + oxc_allocator::Vec::from_array_in([Argument::from(rules)], ast_builder); + composed_classes = classes; + } + None if !reads_directly(&call.arguments) => { + errors.push((call.span.start, uncomposable_error(&call.arguments))); + call.arguments = oxc_allocator::Vec::from_array_in( + [Argument::from(Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + ast_builder, + ))], + ast_builder, + ); + } + None => {} + } + } let (result, new_expr) = if let Expression::TaggedTemplateExpression(tag) = expression && let (Some(tag_name), default_class_name) = extract_base_tag_and_class_name(&tag.tag, imports) @@ -112,7 +145,15 @@ pub fn extract_style_from_styled<'a>( // Check if tag is styled.div or styled(...) // Extract CSS from template literal - let styles = css_to_style_literal(&tag.quasi, 0, &None); + let TemplateStyles { + styles, + statements, + unplaced, + } = css_to_style_template(&tag.quasi, 0, &None); + for index in unplaced { + let expression = &tag.quasi.expressions[index]; + errors.push((expression.span().start, unplaced_error(expression))); + } let mut props_styles: Vec> = styles .into_iter() .map(|ex| ExtractStyleProp::Static(ex.into())) @@ -122,12 +163,49 @@ pub fn extract_style_from_styled<'a>( props_styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); } - let class_name = gen_class_names(ast_builder, &mut props_styles, None, split_filename); - let styled_component = create_styled_component( + let mixins = statements.into_iter().map(|index| { + let mixin = &tag.quasi.expressions[index]; + if matches!( + unwrap_syntax_only(mixin), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + // A mixin returns a class, or `false` when its condition fails + Expression::new_logical_expression( + SPAN, + wrap_direct_call( + ast_builder, + mixin, + &[Expression::new_identifier(SPAN, "rest", ast_builder)], + ), + LogicalOperator::Or, + Expression::new_string_literal(SPAN, "", None, ast_builder), + ast_builder, + ) + } else { + mixin.clone_in(ast_builder.allocator()) + } + }); + let class_name = merge_expression_for_class_name( ast_builder, - &tag_name, - &class_name, - &gen_styles(ast_builder, &props_styles, None), + mixins + .collect::>() + .into_iter() + .chain(gen_class_names( + ast_builder, + &mut props_styles, + None, + split_filename, + )), + ); + let styled_component = apply_attrs( + ast_builder, + create_styled_component( + ast_builder, + &tag_name, + &class_name, + &gen_styles(ast_builder, &props_styles, None), + ), + attrs, ); let result = ExtractResult { @@ -170,16 +248,35 @@ pub fn extract_style_from_styled<'a>( &None, LiteralHandling::ExpandResponsiveThemeToken, ); + let mut unreadable = Vec::new(); + unreadable_styles(&styles, true, &mut unreadable); + errors.extend( + unreadable + .into_iter() + .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))), + ); if let Some(default_class_name) = default_class_name { styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); } - let class_name = gen_class_names(ast_builder, &mut styles, style_order, split_filename); - let styled_component = create_styled_component( + let class_name = merge_expression_for_class_name( + ast_builder, + composed_classes.into_iter().chain(gen_class_names( + ast_builder, + &mut styles, + style_order, + split_filename, + )), + ); + let styled_component = apply_attrs( ast_builder, - &tag_name, - &class_name, - &gen_styles(ast_builder, &styles, None), + create_styled_component( + ast_builder, + &tag_name, + &class_name, + &gen_styles(ast_builder, &styles, None), + ), + attrs, ); let result = ExtractResult { @@ -197,6 +294,174 @@ pub fn extract_style_from_styled<'a>( ( result.unwrap_or_else(ExtractResult::default), new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), + errors, + ) +} + +/// The name the attrs wrapper binds props to, chosen not to shadow what the +/// attrs expressions read +const ATTRS_PROPS: &str = "__devupProps"; + +/// Strip styled-components' `.attrs()` / `.withConfig()` off a styled factory +/// such as `styled.div.attrs(a).withConfig(c)`, returning the attrs in the +/// order they apply. `withConfig` only tunes runtime behavior, so it is dropped. +pub fn take_styled_modifiers<'a>( + ast_builder: &AstBuilder<'a>, + factory: &mut Expression<'a>, + is_styled: impl Fn(&str) -> bool, +) -> Vec> { + let mut attrs = Vec::new(); + if !is_modified_styled(factory, is_styled) { + return attrs; + } + while let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory) + && let CallExpression { + callee, arguments, .. + } = &mut **call + && let Expression::StaticMemberExpression(member) = unwrap_syntax_only_mut(callee) + && matches!(member.property.name.as_str(), "attrs" | "withConfig") + { + let placeholder = || Expression::new_null_literal(SPAN, ast_builder); + if member.property.name == "attrs" + && let Some(argument) = arguments[0].as_expression_mut() + { + attrs.push(std::mem::replace(argument, placeholder())); + } + let object = std::mem::replace(&mut member.object, placeholder()); + *factory = object; + } + attrs.reverse(); + attrs +} + +fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool { + let mut expression = unwrap_syntax_only(expression); + let mut modified = false; + while let Some(object) = modifier_object(expression) { + expression = unwrap_syntax_only(object); + modified = true; + } + modified + && match expression { + Expression::StaticMemberExpression(member) => { + matches!(&member.object, Expression::Identifier(ident) if is_styled(&ident.name)) + } + Expression::CallExpression(call) => { + matches!(&call.callee, Expression::Identifier(ident) if is_styled(&ident.name)) + } + _ => false, + } +} + +fn modifier_object<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression<'a>> { + if let Expression::CallExpression(call) = expression + && let [argument] = call.arguments.as_slice() + && argument.is_expression() + && let Expression::StaticMemberExpression(member) = unwrap_syntax_only(&call.callee) + && matches!(member.property.name.as_str(), "attrs" | "withConfig") + { + Some(&member.object) + } else { + None + } +} + +/// Wrap `component` so the attrs are merged over its props first: an object is +/// spread, anything else is called with the props when it is a function, as +/// styled-components does +fn apply_attrs<'a>( + ast_builder: &AstBuilder<'a>, + component: Expression<'a>, + attrs: &[Expression<'a>], +) -> Expression<'a> { + if attrs.is_empty() { + return component; + } + let props = || Expression::new_identifier(SPAN, ATTRS_PROPS, ast_builder); + let mut merged = props(); + for attr in attrs { + let attr = attr.clone_in(ast_builder.allocator()); + if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { + merged = spread_objects(ast_builder, merged, attr); + continue; + } + let called = wrap_direct_call(ast_builder, &attr, &[props()]); + let resolved = if matches!( + unwrap_syntax_only(&attr), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + called + } else { + let is_function = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Typeof, + attr.clone_in(ast_builder.allocator()), + ast_builder, + ), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "function", None, ast_builder), + ast_builder, + ); + Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) + }; + let step = props_arrow(ast_builder, spread_objects(ast_builder, props(), resolved)); + merged = wrap_direct_call(ast_builder, &step, &[merged]); + } + props_arrow( + ast_builder, + wrap_direct_call(ast_builder, &component, &[merged]), + ) +} + +fn spread_objects<'a>( + ast_builder: &AstBuilder<'a>, + first: Expression<'a>, + second: Expression<'a>, +) -> Expression<'a> { + let mut properties = oxc_allocator::Vec::with_capacity_in(2, ast_builder); + properties.push(ObjectPropertyKind::new_spread_property( + SPAN, + first, + ast_builder, + )); + properties.push(ObjectPropertyKind::new_spread_property( + SPAN, + second, + ast_builder, + )); + Expression::new_object_expression(SPAN, properties, ast_builder) +} + +fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expression<'a> { + let parameter = FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + BindingPattern::new_binding_identifier(SPAN, ATTRS_PROPS, ast_builder), + None::>>, + None::>>, + false, + None, + false, + false, + ast_builder, + ); + let params = FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::from_iter_in([parameter], ast_builder), + None::>>, + ast_builder, + ); + Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + params, + None::>>, + body.into(), + ast_builder, ) } diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 568ec9b42..69ff713a8 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -3,17 +3,23 @@ use crate::extract_style::extract_dynamic_style::ExtractDynamicStyle; use crate::extract_style::extract_static_style::ExtractStaticStyle; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::stylex::{ - SelectorPart, StylexIncludeRef, decompose_value_conditions, is_first_that_works_call, - is_include_call_static, is_types_call, normalize_stylex_property, + DecomposedStyle, SelectorPart, StylexIncludeRef, decompose_value_conditions, + dynamic_number_suffix, is_include_call_static, normalize_stylex_property, stylex_value, }; -use crate::utils::get_string_by_literal_expression; use css::optimize_value::optimize_value; use css::sheet_to_variable_name; use css::style_selector::StyleSelector; -use oxc_ast::ast::{BindingPattern, Expression, ObjectPropertyKind}; +use oxc_ast::ast::{ + Argument, ArrowFunctionExpression, BindingPattern, Expression, ObjectExpression, + ObjectPropertyKind, SpreadElement, +}; +use oxc_span::GetSpan; use rustc_hash::FxHashMap; -use crate::utils::{get_str_by_property_key, get_string_by_property_key}; +use crate::utils::{ + build_time_error, get_str_by_property_key, get_string_by_property_key, key_error, + readable_code, runtime_value_error, spread_error, +}; /// Construct a static style directly — bypass `convert_value()` to avoid devup-ui /// spacing transformations. `StyleX` values are raw CSS, only `optimize_value()`. @@ -35,24 +41,34 @@ fn raw_static_style<'a>( /// Flatten an object literal of literal-valued properties into kebab-cased CSS /// declarations, the shape `positionTry` and `viewTransitionClass` bodies take. -pub fn extract_stylex_declarations(value: &Expression<'_>) -> Vec<(String, String)> { - let Expression::ObjectExpression(obj) = value else { - return vec![]; - }; - obj.properties - .iter() - .filter_map(|prop| { - let ObjectPropertyKind::ObjectProperty(prop) = prop else { - return None; - }; - let name = get_str_by_property_key(&prop.key)?; - let value = get_string_by_literal_expression(&prop.value)?; - Some(( - normalize_stylex_property(name.as_ref()), - optimize_value(&value).into_owned(), - )) - }) - .collect() +pub fn extract_stylex_declarations( + api: &str, + object: &ObjectExpression<'_>, + errors: &mut Vec<(u32, String)>, +) -> Vec<(String, String)> { + let mut declarations = vec![]; + for property in &object.properties { + let property = match property { + ObjectPropertyKind::ObjectProperty(property) => property, + ObjectPropertyKind::SpreadProperty(spread) => { + errors.push(spread_error(api, spread)); + continue; + } + }; + let Some(name) = get_str_by_property_key(&property.key) else { + errors.push(key_error(api, &property.key)); + continue; + }; + let name = normalize_stylex_property(name.as_ref()); + match stylex_value(&name, &property.value) { + Some(value) => declarations.push((name, optimize_value(&value).into_owned())), + None => errors.push(( + property.value.span().start, + runtime_value_error(api, &readable_code(&property.value)), + )), + } + } + declarations } /// Resolve a `vars.key` member access against the contracts `stylex.defineVars()` @@ -96,253 +112,228 @@ static SHORTHAND_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { "padding-block", }; +type Leaf<'l> = dyn Fn(&str, &Expression<'_>) -> Option + 'l; + /// Extract styles from a `stylex.create()` call's argument (`ObjectExpression`). /// -/// Handles static string/number values (Phase 1) and value-level conditions (Phase 2). +/// Handles static string/number values (Phase 1) and value-level conditions (Phase 2); +/// what cannot be read at build time is reported in `errors`. /// /// Returns a Vec of `(namespace_name, style_props, css_vars, include_refs)` tuples. Each namespace /// corresponds to a top-level key in the `stylex.create({...})` argument. #[allow(clippy::type_complexity)] pub fn extract_stylex_namespace_styles<'a>( - expression: &mut Expression<'a>, + obj: &ObjectExpression<'_>, keyframe_names: &FxHashMap, var_refs: &FxHashMap, + errors: &mut Vec<(u32, String)>, ) -> Vec<( String, Vec>, - Option>, + Option>, Vec, )> { - let Expression::ObjectExpression(obj) = expression else { - return vec![]; + // A keyframes name or a `defineVars` member reads as the value it stands for + let leaf = |property: &str, value: &Expression<'_>| { + if let Expression::Identifier(ident) = value + && let Some(name) = keyframe_names.get(ident.name.as_str()) + { + return Some(name.clone()); + } + var_reference(value, var_refs) + .map(str::to_string) + .or_else(|| stylex_value(property, value).map(std::borrow::Cow::into_owned)) }; let mut result = vec![]; - for prop in &obj.properties { - let ObjectPropertyKind::ObjectProperty(prop) = prop else { - // Phase 4c: Spread not supported at namespace level - if matches!(prop, ObjectPropertyKind::SpreadProperty(_)) { - eprintln!( - "[stylex] ERROR: Object spread is not allowed at the namespace level of stylex.create()." - ); + let prop = match prop { + ObjectPropertyKind::ObjectProperty(prop) => prop, + ObjectPropertyKind::SpreadProperty(spread) => { + errors.push(spread_error("stylex.create", spread)); + continue; } - continue; }; - let Some(ns_name) = get_string_by_property_key(&prop.key) else { - // Phase 4c: Computed namespace keys not supported - if prop.computed { - eprintln!( - "[stylex] ERROR: Computed namespace keys are not allowed in stylex.create()." - ); - } + errors.push(key_error("stylex.create", &prop.key)); continue; }; - - // Phase 4b: Arrow function (dynamic namespace) - if let Expression::ArrowFunctionExpression(arrow) = &prop.value { - if let Some((styles, css_vars)) = - extract_stylex_dynamic_namespace(arrow, keyframe_names) - { + match &prop.value { + Expression::ArrowFunctionExpression(arrow) => { + let Some((styles, css_vars)) = + extract_stylex_dynamic_namespace(arrow, &leaf, errors) + else { + errors.push(( + prop.value.span().start, + build_time_error( + "stylex.create", + &readable_code(&prop.value), + "a dynamic style is an arrow function with plain parameters returning an object literal", + ), + )); + continue; + }; result.push((ns_name, styles, Some(css_vars), vec![])); - } else { - result.push((ns_name, vec![], None, vec![])); } - continue; + Expression::ObjectExpression(ns_obj) => { + let (styles, include_refs) = extract_stylex_namespace(ns_obj, &leaf, errors); + result.push((ns_name, styles, None, include_refs)); + } + Expression::NullLiteral(_) => result.push((ns_name, vec![], None, vec![])), + value => errors.push(( + value.span().start, + build_time_error( + "stylex.create", + &readable_code(value), + "a namespace is an object of styles or an arrow function returning one", + ), + )), } + } + result +} - let Expression::ObjectExpression(ns_obj) = &prop.value else { - // Non-object namespace value (e.g., null): push empty styles - result.push((ns_name, vec![], None, vec![])); - continue; - }; - - let mut styles = vec![]; - let mut include_refs = vec![]; - - for style_prop in &ns_obj.properties { - let ObjectPropertyKind::ObjectProperty(style_prop) = style_prop else { - // Check for stylex.include() spread - if let ObjectPropertyKind::SpreadProperty(spread) = style_prop - && let Expression::CallExpression(call) = &spread.argument - && is_include_call_static(&call.callee) - { - // Parse include(base.member) - if let Some(Expression::StaticMemberExpression(member)) = - call.arguments.first().and_then(|arg| arg.as_expression()) - && let Expression::Identifier(ident) = &member.object - { - include_refs.push(StylexIncludeRef { - var_name: ident.name.to_string(), - member_name: member.property.name.to_string(), - }); - } - } else if matches!(style_prop, ObjectPropertyKind::SpreadProperty(_)) { - eprintln!( - "[stylex] ERROR: Object spread is not allowed in stylex.create() namespaces. Define all properties explicitly." - ); +/// The styles and `include()` references of one static namespace +fn extract_stylex_namespace<'a>( + namespace: &ObjectExpression<'_>, + leaf: &Leaf<'_>, + errors: &mut Vec<(u32, String)>, +) -> (Vec>, Vec) { + let mut styles = vec![]; + let mut include_refs = vec![]; + for style_prop in &namespace.properties { + let style_prop = match style_prop { + ObjectPropertyKind::ObjectProperty(style_prop) => style_prop, + ObjectPropertyKind::SpreadProperty(spread) => { + match include(spread) { + Some(Ok(include_ref)) => include_refs.push(include_ref), + Some(Err(error)) => errors.push(error), + None => errors.push(spread_error("stylex.create", spread)), } continue; - }; + } + }; + let Some(prop_name) = get_str_by_property_key(&style_prop.key) else { + errors.push(key_error("stylex.create", &style_prop.key)); + continue; + }; - let Some(prop_name) = get_str_by_property_key(&style_prop.key) else { - // Phase 4c: Computed property keys not supported - if style_prop.computed { - eprintln!( - "[stylex] ERROR: Computed property keys are not allowed in stylex.create(). Use static string keys instead." - ); - } + // Phase 2: pseudo-element / pseudo-class top-level keys + if prop_name.starts_with(':') { + let Expression::ObjectExpression(inner_obj) = &style_prop.value else { + errors.push(( + style_prop.value.span().start, + build_time_error( + "stylex.create", + &readable_code(&style_prop.value), + "a pseudo-class or pseudo-element key takes an object of styles", + ), + )); continue; }; - - // Phase 2: pseudo-element / pseudo-class top-level keys - if prop_name.starts_with("::") || prop_name.starts_with(':') { - let Expression::ObjectExpression(inner_obj) = &style_prop.value else { + let parent_selectors = [SelectorPart::Pseudo(prop_name.to_string())]; + for inner_prop in &inner_obj.properties { + let inner_prop = match inner_prop { + ObjectPropertyKind::ObjectProperty(inner_prop) => inner_prop, + ObjectPropertyKind::SpreadProperty(spread) => { + errors.push(spread_error("stylex.create", spread)); + continue; + } + }; + let Some(inner_name) = get_str_by_property_key(&inner_prop.key) else { + errors.push(key_error("stylex.create", &inner_prop.key)); continue; }; - for inner_prop in &inner_obj.properties { - let ObjectPropertyKind::ObjectProperty(inner_prop) = inner_prop else { - continue; - }; - let Some(inner_name) = get_str_by_property_key(&inner_prop.key) else { - continue; - }; - let inner_css_property = normalize_stylex_property(inner_name.as_ref()); - let parent_selectors = vec![SelectorPart::Pseudo(prop_name.to_string())]; - for decomposed in decompose_value_conditions( - &inner_css_property, + push_decomposed( + &mut styles, + decompose_value_conditions( + &normalize_stylex_property(inner_name.as_ref()), &inner_prop.value, &parent_selectors, - ) { - if let Some(css_value) = decomposed.value { - styles.push(raw_static_style( - decomposed.property, - &css_value, - decomposed.selector, - )); - } - } - } - continue; - } - - let css_property = normalize_stylex_property(prop_name.as_ref()); - - if SHORTHAND_PROPERTIES.contains(css_property.as_str()) { - eprintln!( - "[stylex] WARNING: Shorthand property '{css_property}' may cause unexpected specificity issues. Consider using longhand properties (e.g., 'marginTop', 'paddingLeft')." + leaf, + errors, + ), ); } + continue; + } - // Phase 4a: Resolve keyframe variable references (e.g., animationName: fadeIn) - if let Expression::Identifier(ident) = &style_prop.value - && let Some(anim_name) = keyframe_names.get(ident.name.as_str()) - { - styles.push(raw_static_style(css_property, anim_name, None)); - continue; - } - - // Resolve `defineVars` members (e.g., color: colors.primary) - if let Some(reference) = var_reference(&style_prop.value, var_refs) { - styles.push(raw_static_style(css_property, reference, None)); - continue; - } - - // Phase 1: static string/number values - let css_value = if let Some(s) = get_string_by_literal_expression(&style_prop.value) { - s - } else if matches!(&style_prop.value, Expression::ObjectExpression(_)) { - // Phase 2: value-level conditions - for decomposed in decompose_value_conditions(&css_property, &style_prop.value, &[]) - { - if let Some(css_value) = decomposed.value { - styles.push(raw_static_style( - decomposed.property, - &css_value, - decomposed.selector, - )); - } - } - continue; - } else if let Expression::CallExpression(call) = &style_prop.value - && is_first_that_works_call(&call.callee) - { - // firstThatWorks('a', 'b', 'c'): last arg is least preferred, first is most preferred. - // CSS fallback: output in reverse order (least preferred first, most preferred last). - for arg in call.arguments.iter().rev() { - if let Some(arg_expr) = arg.as_expression() - && let Some(s) = get_string_by_literal_expression(arg_expr) - { - styles.push(raw_static_style(css_property.clone(), &s, None)); - } - } - continue; - } else if let Expression::CallExpression(call) = &style_prop.value - && is_types_call(&call.callee) - // stylex.types.length('100px') → extract inner value '100px' - && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) - { - let css_value = if let Some(s) = get_string_by_literal_expression(inner) { - s - } else { - continue; // Can't resolve inner value - }; - styles.push(raw_static_style(css_property, &css_value, None)); - continue; - } else { - // Phase 4c: Non-static values in create() are not supported - if !matches!(&style_prop.value, Expression::NullLiteral(_)) { - eprintln!( - "[stylex] ERROR: Non-static value for property '{prop_name}' in stylex.create(). Only string literals, numbers, null, objects (conditions), firstThatWorks(), types.*(), and arrow functions are allowed." - ); - } - continue; - }; - - styles.push(raw_static_style(css_property, &css_value, None)); + let css_property = normalize_stylex_property(prop_name.as_ref()); + if SHORTHAND_PROPERTIES.contains(css_property.as_str()) { + eprintln!( + "[stylex] WARNING: Shorthand property '{css_property}' may cause unexpected specificity issues. Consider using longhand properties (e.g., 'marginTop', 'paddingLeft')." + ); } + push_decomposed( + &mut styles, + decompose_value_conditions(&css_property, &style_prop.value, &[], leaf, errors), + ); + } + (styles, include_refs) +} - result.push((ns_name, styles, None, include_refs)); +fn push_decomposed(styles: &mut Vec>, decomposed: Vec) { + for style in decomposed { + if let Some(value) = style.value { + styles.push(raw_static_style(style.property, &value, style.selector)); + } } +} - result +/// `...stylex.include(base.member)`: `None` when the spread is not an +/// `include()` call +fn include(spread: &SpreadElement<'_>) -> Option> { + let Expression::CallExpression(call) = &spread.argument else { + return None; + }; + if !is_include_call_static(&call.callee) { + return None; + } + if let Some(Expression::StaticMemberExpression(member)) = + call.arguments.first().and_then(Argument::as_expression) + && let Expression::Identifier(ident) = &member.object + { + return Some(Ok(StylexIncludeRef { + var_name: ident.name.to_string(), + member_name: member.property.name.to_string(), + offset: spread.span.start, + })); + } + Some(Err(( + spread.span.start, + build_time_error( + "stylex.include", + &readable_code(&spread.argument), + "it takes a namespace such as `styles.base`", + ), + ))) } /// Extract styles from a dynamic `StyleX` namespace (arrow function). -/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to CSS variable name. +/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to a CSS variable +/// name and the unit a number passed for it gets; `None` when the function is +/// not an arrow with plain parameters returning an object literal. #[allow(clippy::type_complexity)] fn extract_stylex_dynamic_namespace<'a>( - arrow: &oxc_ast::ast::ArrowFunctionExpression<'a>, - keyframe_names: &FxHashMap, -) -> Option<(Vec>, Vec<(usize, String)>)> { + arrow: &ArrowFunctionExpression<'_>, + leaf: &Leaf<'_>, + errors: &mut Vec<(u32, String)>, +) -> Option<( + Vec>, + Vec<(usize, String, &'static str)>, +)> { // 1. Extract parameter names - let param_names: Vec = arrow - .params - .items - .iter() - .filter_map(|param| { - if let BindingPattern::BindingIdentifier(ident) = ¶m.pattern { - Some(ident.name.to_string()) - } else { - None - } - }) - .collect(); - - if param_names.is_empty() { - return None; + let mut param_names = vec![]; + for param in &arrow.params.items { + let BindingPattern::BindingIdentifier(ident) = ¶m.pattern else { + return None; + }; + param_names.push(ident.name.to_string()); } // 2. Get body ObjectExpression from expression body: (x) => ({ ... }) - // Unwrap ParenthesizedExpression since Oxc preserves parens for `(x) => ({...})`. - let body_expr = arrow.body.as_expression()?; - let inner = if let Expression::ParenthesizedExpression(paren) = body_expr { - &paren.expression - } else { - body_expr - }; - let Expression::ObjectExpression(body_obj) = inner else { + let Expression::ObjectExpression(body_obj) = arrow.body.as_expression()?.without_parentheses() + else { return None; }; @@ -351,11 +342,15 @@ fn extract_stylex_dynamic_namespace<'a>( let mut css_vars = vec![]; for prop in &body_obj.properties { - let ObjectPropertyKind::ObjectProperty(prop) = prop else { - continue; + let prop = match prop { + ObjectPropertyKind::ObjectProperty(prop) => prop, + ObjectPropertyKind::SpreadProperty(spread) => { + errors.push(spread_error("stylex.create", spread)); + continue; + } }; - let Some(prop_name) = get_string_by_property_key(&prop.key) else { + errors.push(key_error("stylex.create", &prop.key)); continue; }; let css_property = normalize_stylex_property(&prop_name); @@ -373,25 +368,23 @@ fn extract_stylex_dynamic_namespace<'a>( if let Some(param_idx) = is_dynamic { // Dynamic property: generate CSS variable let var_name = sheet_to_variable_name(&css_property, 0, None); - css_vars.push((param_idx, var_name)); + css_vars.push((param_idx, var_name, dynamic_number_suffix(&css_property))); let param_name = ¶m_names[param_idx]; styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic( ExtractDynamicStyle::new(&css_property, 0, param_name, None), ))); - } else { - // Static property: resolve keyframe references or literal values - if let Expression::Identifier(ident) = &prop.value - && let Some(anim_name) = keyframe_names.get(ident.name.as_str()) - { - styles.push(raw_static_style(css_property, anim_name, None)); - continue; - } - let css_value = if let Some(s) = get_string_by_literal_expression(&prop.value) { - s - } else { - continue; - }; - styles.push(raw_static_style(css_property, &css_value, None)); + continue; + } + match leaf(&css_property, &prop.value) { + Some(value) => styles.push(raw_static_style(css_property, &value, None)), + None => errors.push(( + prop.value.span().start, + build_time_error( + "stylex.create", + &readable_code(&prop.value), + "a dynamic style's value is one of its parameters or a static value; compute it before passing it", + ), + )), } } diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index 45e32e199..1b47b9788 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -36,4 +36,6 @@ pub struct GlobalExtractResult<'a> { #[derive(Debug)] pub struct KeyframesExtractResult { pub keyframes: ExtractKeyframes, + /// A value only known at runtime, which keyframes cannot hold + pub runtime_value: Option, } diff --git a/libs/extractor/src/gen_class_name.rs b/libs/extractor/src/gen_class_name.rs index 3101e5667..3b274b7fd 100644 --- a/libs/extractor/src/gen_class_name.rs +++ b/libs/extractor/src/gen_class_name.rs @@ -1,5 +1,6 @@ use crate::ExtractStyleProp; use crate::extract_style::style_property::StyleProperty; +use crate::extractor::extract_style_from_expression::yield_typography; use crate::prop_modify_utils::convert_class_name; use crate::utils::is_same_expression; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -16,6 +17,7 @@ pub fn gen_class_names<'a>( style_order: Option, filename: Option<&str>, ) -> Option> { + yield_typography(style_props); merge_expression_for_class_name( ast_builder, style_props @@ -126,6 +128,7 @@ fn gen_class_name<'a>( ExtractStyleProp::Expression { expression, .. } => { Some(expression.clone_in(ast_builder.allocator())) } + ExtractStyleProp::Unreadable { .. } => None, // direct select ExtractStyleProp::MemberExpression { map, expression } => { let exp = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 259bf357f..158d0f420 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,14 +8,178 @@ //! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` use crate::ImportAlias; -use crate::utils::is_vanilla_extract_file; +use crate::utils::{ + get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number, +}; use oxc_allocator::Allocator; -use oxc_ast::ast::{ImportDeclarationSpecifier, ModuleExportName}; +use oxc_ast::ast::{ + Argument, CallExpression, Expression, ImportDeclarationSpecifier, JSXAttributeItem, + JSXAttributeValue, JSXElementName, JSXOpeningElement, ModuleExportName, ObjectPropertyKind, + Statement, +}; +use oxc_ast_visit::{ + Visit, + walk::{walk_call_expression, walk_jsx_opening_element}, +}; use oxc_parser::Parser; -use oxc_span::SourceType; +use oxc_span::{GetSpan, SourceType}; use std::borrow::Cow; use std::collections::HashMap; +/// Where a style function takes its rules +#[derive(Clone, Copy)] +enum RulesAt { + Argument(usize), + EveryArgument, +} + +/// Numbers in rules written against vanilla-extract, Emotion or styled-components +/// outside a stylesheet. Those calls become Devup UI's, which read a number as its +/// spacing scale, so it is rewritten to the `px` string the library makes of it. +#[derive(Default)] +struct LibraryNumbers<'n> { + /// Local name of each style function and where its rules are + calls: Vec<(&'n str, RulesAt)>, + /// Local names that build styled components + styled: Vec<&'n str>, + /// Local names of components taking rules in `styles` (Emotion's `Global`) + components: Vec<&'n str>, + replacements: Vec<(usize, usize, String)>, +} + +impl LibraryNumbers<'_> { + /// `styled.div`, `styled(tag)`, `styled(tag, options)` and their + /// `.attrs()` / `.withConfig()`: whatever a call of it passes are rules + fn is_styled_factory(&self, callee: &Expression) -> bool { + match callee { + Expression::StaticMemberExpression(member) => { + matches!(&member.object, Expression::Identifier(root) if self.styled.contains(&root.name.as_str())) + } + Expression::CallExpression(call) => match &call.callee { + Expression::Identifier(root) => self.styled.contains(&root.name.as_str()), + Expression::StaticMemberExpression(member) => { + matches!(member.property.name.as_str(), "attrs" | "withConfig") + && self.is_styled_factory(&member.object) + } + _ => false, + }, + _ => false, + } + } + + fn pixelify(&mut self, rules: &Expression) { + match rules { + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + self.pixelify(element); + } + } + } + Expression::ObjectExpression(object) => { + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(key) = get_str_by_property_key(&property.key) + { + self.pixelify_value(&key, &property.value); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.pixelify(&conditional.consequent); + self.pixelify(&conditional.alternate); + } + Expression::LogicalExpression(logical) => self.pixelify(&logical.right), + Expression::ParenthesizedExpression(inner) => self.pixelify(&inner.expression), + _ => {} + } + } + + fn pixelify_value(&mut self, key: &str, value: &Expression) { + if let Some(number) = js_number_literal(value) { + if number != 0.0 && !keeps_bare_number(key) { + let span = value.span(); + self.replacements.push(( + span.start as usize, + span.end as usize, + format!("\"{number}px\""), + )); + } + return; + } + match value { + Expression::ObjectExpression(_) => { + if key != "vars" { + self.pixelify(value); + } + } + Expression::ConditionalExpression(conditional) => { + self.pixelify_value(key, &conditional.consequent); + self.pixelify_value(key, &conditional.alternate); + } + Expression::LogicalExpression(logical) => self.pixelify_value(key, &logical.right), + Expression::ParenthesizedExpression(inner) => { + self.pixelify_value(key, &inner.expression); + } + _ => {} + } + } +} + +impl<'a> Visit<'a> for LibraryNumbers<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let rules_at = match &call.callee { + Expression::Identifier(callee) => self + .calls + .iter() + .find(|(name, _)| *name == callee.name.as_str()) + .map(|(_, rules_at)| *rules_at), + callee => self + .is_styled_factory(callee) + .then_some(RulesAt::EveryArgument), + }; + let rules: Vec<&Expression<'a>> = match rules_at { + Some(RulesAt::Argument(index)) => call + .arguments + .get(index) + .and_then(Argument::as_expression) + .into_iter() + .collect(), + Some(RulesAt::EveryArgument) => call + .arguments + .iter() + .filter_map(Argument::as_expression) + .collect(), + None => vec![], + }; + for rules in rules { + self.pixelify(rules); + } + walk_call_expression(self, call); + } + + fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { + if let JSXElementName::IdentifierReference(name) = &element.name + && self.components.contains(&name.name.as_str()) + { + for attribute in &element.attributes { + if let JSXAttributeItem::Attribute(attribute) = attribute + && attribute + .name + .as_identifier() + .is_some_and(|name| name.name == "styles") + && let Some(JSXAttributeValue::ExpressionContainer(container)) = + &attribute.value + && let Some(rules) = container.expression.as_expression() + { + self.pixelify(rules); + } + } + } + walk_jsx_opening_element(self, element); + } +} + /// Map an aliased package's export onto the `@devup-ui/react` export that implements the /// same behaviour, so the extractor consumes the call and drops the import entirely — no /// dependency on either package survives. @@ -55,6 +219,19 @@ fn devup_equivalent(source: &str, imported: &str) -> Option } } +#[cfg(test)] +pub fn transform_import_aliases<'a>( + code: &'a str, + filename: &str, + package: &str, + import_aliases: &HashMap, +) -> Cow<'a, str> { + transform_import_aliases_with_edits(code, filename, package, import_aliases).0 +} + +/// A replacement of `code[start..end]` by text of `length` bytes +pub type Edit = (usize, usize, usize); + /// Transform source code by rewriting aliased imports to the target package /// /// # Arguments @@ -64,16 +241,18 @@ fn devup_equivalent(source: &str, imported: &str) -> Option /// * `import_aliases` - Map of source package → alias configuration /// /// # Returns -/// The transformed source code, or the original code if no transformations were needed -pub fn transform_import_aliases<'a>( +/// The transformed source code, or the original code if no transformations were +/// needed, and the replacements made in order, so a position in the result maps +/// back to the source +pub fn transform_import_aliases_with_edits<'a>( code: &'a str, filename: &str, package: &str, import_aliases: &HashMap, -) -> Cow<'a, str> { +) -> (Cow<'a, str>, Vec) { // Quick check: if no aliases match, return original code if import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)) { - return Cow::Borrowed(code); + return (Cow::Borrowed(code), Vec::new()); } let allocator = Allocator::default(); @@ -87,9 +266,10 @@ pub fn transform_import_aliases<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); + let mut numbers = LibraryNumbers::default(); for stmt in &program.body { - if let oxc_ast::ast::Statement::ImportDeclaration(import_decl) = stmt { + if let Statement::ImportDeclaration(import_decl) = stmt { let source_value = import_decl.source.value.as_str(); if let Some(alias) = import_aliases.get(source_value) { @@ -97,21 +277,89 @@ pub fn transform_import_aliases<'a>( let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); transformations.push((span.start as usize, span.end as usize, new_import)); + if !redirect_every_name { + for specifier in import_decl.specifiers.iter().flatten() { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(spec) => { + let local = spec.local.name.as_str(); + match (source_value, imported_name(&spec.imported).as_ref()) { + ("@vanilla-extract/css", "style" | "keyframes") => { + numbers.calls.push((local, RulesAt::Argument(0))); + } + ("@vanilla-extract/css", "globalStyle") => { + numbers.calls.push((local, RulesAt::Argument(1))); + } + ( + "@emotion/react" | "styled-components", + "css" | "keyframes", + ) => numbers.calls.push((local, RulesAt::EveryArgument)), + ("@emotion/react", "Global") => numbers.components.push(local), + _ => {} + } + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(spec) + if matches!( + source_value, + "@emotion/styled" | "styled-components" + ) => + { + numbers.styled.push(spec.local.name.as_str()); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) + if matches!( + source_value, + "@emotion/styled" | "styled-components" + ) => + { + numbers.styled.push(spec.local.name.as_str()); + } + _ => {} + } + } + } } } } + if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) { + numbers.visit_program(&program); + transformations.append(&mut numbers.replacements); + transformations.sort_unstable_by_key(|(start, ..)| *start); + } // Apply transformations in reverse order to preserve positions if transformations.is_empty() { - return Cow::Borrowed(code); + return (Cow::Borrowed(code), Vec::new()); } + let edits = transformations + .iter() + .map(|(start, end, replacement)| (*start, *end, replacement.len())) + .collect(); let mut result = code.to_string(); for (start, end, replacement) in transformations.into_iter().rev() { result.replace_range(start..end, &replacement); } - Cow::Owned(result) + (Cow::Owned(result), edits) +} + +/// The source offset of `offset` in code `edits` made; an offset inside a +/// replacement maps to where the replaced text began +#[must_use] +pub fn source_offset(edits: &[Edit], offset: usize) -> usize { + let (mut added, mut removed) = (0, 0); + for &(start, end, length) in edits { + let replaced_at = start + added - removed; + if offset < replaced_at { + break; + } + if offset < replaced_at + length { + return start; + } + added += length; + removed += end - start; + } + offset + removed - added } /// Pick the name a specifier should import from the target package, or `None` to leave it @@ -338,6 +586,26 @@ mod tests { aliases } + #[test] + fn test_source_offset() { + // `ab` at 2 became 5 bytes and `cdef` at 10 became 1 byte + let edits = [(2, 4, 5), (10, 14, 1)]; + for (offset, source) in [ + (0, 0), + (1, 1), + (2, 2), + (6, 2), + (7, 4), + (12, 9), + (13, 10), + (14, 14), + (20, 20), + ] { + assert_eq!(source_offset(&edits, offset), source, "{offset}"); + } + assert_eq!(source_offset(&[], 7), 7); + } + fn styled_components_alias() -> HashMap { let mut aliases = HashMap::new(); aliases.insert( @@ -501,6 +769,70 @@ export const container = style({ background: 'red' })", )); } + #[test] + fn test_emotion_numbers_become_px() { + let code = r"import styled from '@emotion/styled' +import { Global, ThemeProvider } from '@emotion/react' +foo.bar.baz({ top: 1 }) +make()()({ top: 1 }) +;(() => 1)() +styled.div({ top: 1, p: 2 }) +styled.div(cond && { top: 1 }, (flag ? { left: 2 } : { right: 3 }), { bottom: cond ? 4 : (5), left: (cond ? 7 : 8), vars: flag && { x: 6 } }) +export const A = () => <>
"; + let mut aliases = combined_aliases(); + aliases.insert("@emotion/react".to_string(), ImportAlias::NamedToNamed); + assert_eq!( + transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases), + r#"import { styled } from '@devup-ui/react'; +import { Global, ThemeProvider } from '@devup-ui/react/compat'; +foo.bar.baz({ top: 1 }) +make()()({ top: 1 }) +;(() => 1)() +styled.div({ top: "1px", p: 2 }) +styled.div(cond && { top: "1px" }, (flag ? { left: "2px" } : { right: "3px" }), { bottom: cond ? "4px" : "5px", left: (cond ? "7px" : "8px"), vars: flag && { x: 6 } }) +export const A = () => <>
"# + ); + } + + #[test] + fn test_vanilla_extract_numbers_become_px_outside_stylesheets() { + let code = r"import { style as s, globalStyle, keyframes, styleVariants } from '@vanilla-extract/css' +export const a = s({ padding: 8, top: -2, left: (1.5), width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: 4 } } }) +export const b = s([a, { right: 3 }], 'debug') +globalStyle('body', { margin: 2 }) +keyframes({ from: { width: 10, opacity: 0 }, '50%': { width: 20 } }) +styleVariants({ small: { padding: 1 } }) +other({ padding: 8 }) +s()"; + assert_eq!( + transform_import_aliases( + code, + "test.tsx", + "@devup-ui/react", + &vanilla_extract_alias() + ), + r#"import { css as s, globalCss as globalStyle, keyframes } from '@devup-ui/react'; import { styleVariants } from '@vanilla-extract/css'; +export const a = s({ padding: "8px", top: "-2px", left: "1.5px", width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: "4px" } } }) +export const b = s([a, { right: "3px" }], 'debug') +globalStyle('body', { margin: "2px" }) +keyframes({ from: { width: "10px", opacity: 0 }, '50%': { width: "20px" } }) +styleVariants({ small: { padding: 1 } }) +other({ padding: 8 }) +s()"# + ); + let stylesheet = + "import { style } from '@vanilla-extract/css'\nexport const a = style({ padding: 8 })"; + assert!( + transform_import_aliases( + stylesheet, + "a.css.ts", + "@devup-ui/react", + &vanilla_extract_alias() + ) + .ends_with("style({ padding: 8 })") + ); + } + #[test] fn test_multiple_imports_same_file() { assert_snapshot!(transform_import_aliases( diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs new file mode 100644 index 000000000..a600d1deb --- /dev/null +++ b/libs/extractor/src/imported_constants.rs @@ -0,0 +1,1981 @@ +//! Imported constants inlined where styles read them, so a value another +//! module declares with `const` becomes a static class instead of a CSS +//! variable set at runtime. + +use std::cell::Cell; +use std::collections::BTreeSet; +use std::rc::Rc; + +use oxc_allocator::{Allocator, FromIn, GetAllocator}; +use oxc_ast::ast::{ + Argument, ArrayExpressionElement, Expression, ImportDeclarationSpecifier, JSXAttributeItem, + JSXElementName, ObjectPropertyKind, Program, Statement, Str, VariableDeclarationKind, +}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; +use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::{SPAN, SourceType}; +use oxc_syntax::number::NumberBase; +use oxc_syntax::operator::BinaryOperator; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::stylex::StylexFunction; +use crate::{ExtractOption, ModuleResolver}; + +#[derive(Clone, Debug)] +enum Constant { + String(String), + Number(f64), + Null, + Bool(bool), + Undefined, + /// A function an object literal holds: styles only call it, which only + /// running the module does + Function, + /// The properties known of an object, a module namespace or an enum + Object(Rc>), + /// An object literal every property of which is known, in source order + Record(Rc>), + Array(Rc>), + /// `StyleX` custom properties by key, which read as their `var()` + Vars(Rc>), + /// The class a `StyleX` theme applies + Theme(String), + /// What another style API gives: a class, a component or a keyframes + /// name, never rules + Style, + /// An object or array code changes, or a value read from one + Changed(Rc), +} + +impl Constant { + /// Whether code can change what it holds + const fn is_mutable(&self) -> bool { + matches!(self, Self::Object(_) | Self::Record(_) | Self::Array(_)) + } + + /// Whether this value or one it holds is a function + fn has_function(&self) -> bool { + match self { + Self::Function => true, + Self::Object(object) => object.values().any(Self::has_function), + Self::Record(entries) => entries.iter().any(|(_, value)| value.has_function()), + Self::Array(values) => values.iter().any(Self::has_function), + _ => false, + } + } + + /// This value as JavaScript source, when every part of it is known + fn js_literal(&self) -> Option { + match self { + Self::String(text) => serde_json::to_string(text).ok(), + Self::Number(number) => Some(crate::utils::js_number_string(*number)), + Self::Null => Some("null".to_string()), + Self::Bool(value) => Some(value.to_string()), + Self::Undefined => Some("undefined".to_string()), + Self::Record(entries) => { + let mut properties = Vec::with_capacity(entries.len()); + for (key, value) in entries.iter() { + properties.push(format!( + "{}: {}", + serde_json::to_string(key).ok()?, + value.js_literal()? + )); + } + Some(format!("({{ {} }})", properties.join(", "))) + } + Self::Array(values) => { + let values: Option> = values.iter().map(Self::js_literal).collect(); + Some(format!("[{}]", values?.join(", "))) + } + _ => None, + } + } + + /// Where code changes this value or one it holds + fn change(&self) -> Option> { + match self { + Self::Changed(change) => Some(change.clone()), + Self::Object(object) => object.values().find_map(Self::change), + Self::Record(entries) => entries.iter().find_map(|(_, value)| value.change()), + Self::Array(values) => values.iter().find_map(Self::change), + _ => None, + } + } + + /// Whether what `path` leads to (`None` for any key) holds nothing code can + /// change + fn reaches_only_primitives(&self, path: &[Option]) -> bool { + match path.split_first() { + None => !self.is_mutable(), + Some((Some(key), rest)) => match member_of(self, key) { + Some(member) => member.reaches_only_primitives(rest), + // A key the build knows is missing reads `undefined` + None => !matches!(self, Self::Object(_)), + }, + Some((None, rest)) => match self { + Self::Record(entries) => entries + .iter() + .all(|(_, value)| value.reaches_only_primitives(rest)), + Self::Array(values) => values + .iter() + .all(|value| value.reaches_only_primitives(rest)), + value => !value.is_mutable(), + }, + } + } +} + +/// Where code changes the object or array a binding holds, or hands it to code +/// that may change it +#[derive(Debug)] +pub(crate) struct Change { + /// The binding, as the module changing it names it + pub name: String, + pub site: ChangeSite, + /// Handed on rather than changed there + pub handed: bool, +} + +#[derive(Debug)] +pub(crate) enum ChangeSite { + /// An offset in the file extracted + Here(u32), + /// `file:line:column` in a module it imports + In(String), +} + +/// What inlining found: the files read, and the `StyleX` values imported from +/// other modules, by the name the program binds them to +#[derive(Default)] +pub(crate) struct Inlined { + pub dependencies: BTreeSet, + pub stylex_vars: FxHashMap>, + pub stylex_themes: FxHashMap, + pub unknown: Unknown, + pub changed: Changed, +} + +/// Bindings styles read that hold an object or array code changes, whole or +/// in some member +#[derive(Default, Clone)] +pub(crate) struct Changed { + whole: FxHashMap>, + holding: FxHashMap, +} + +impl Changed { + pub(crate) fn is_empty(&self) -> bool { + self.whole.is_empty() && self.holding.is_empty() + } + + /// Whether `expression` (`x`, `x.y` or `x[y]`) reads, whole or in part, an + /// object or array code changes + pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool { + let mut path = Vec::new(); + let mut expression = expression; + let name = loop { + match expression { + Expression::StaticMemberExpression(member) => { + path.push(Some(member.property.name.as_str())); + expression = &member.object; + } + Expression::ComputedMemberExpression(member) => { + path.push(None); + expression = &member.object; + } + Expression::Identifier(identifier) => break identifier.name.as_str(), + _ => return false, + } + }; + if self.whole.contains_key(name) { + return true; + } + let Some(mut value) = self.holding.get(name) else { + return false; + }; + for key in path.iter().rev() { + let Some(key) = key else { + return true; + }; + match member_value(value, key) { + Some(member) => value = member, + None => return false, + } + } + value.change().is_some() + } + + /// The changes of the bindings `message` names, by the name the module + /// changing them uses + pub(crate) fn named_in(&self, message: &str) -> Vec> { + let is_part = |c: char| c.is_alphanumeric() || c == '_' || c == '$'; + let mut changes: Vec> = self + .whole + .iter() + .map(|(name, change)| (name, Some(change.clone()))) + .chain( + self.holding + .iter() + .map(|(name, value)| (name, value.change())), + ) + .filter(|(name, _)| { + message.match_indices(name.as_str()).any(|(index, _)| { + !message[..index].ends_with(is_part) + && !message[index + name.len()..].starts_with(is_part) + }) + }) + .filter_map(|(_, change)| change) + .collect(); + changes.sort_unstable_by(|a, b| a.name.cmp(&b.name)); + changes.dedup_by(|a, b| Rc::ptr_eq(a, b)); + changes + } +} + +/// Module-level bindings styles read whose value only running the module +/// gives, whole or in part +#[derive(Default, Clone)] +pub(crate) struct Unknown { + names: FxHashSet, + /// Objects and namespaces the build knows some members of + partial: FxHashMap, +} + +impl Unknown { + pub(crate) fn is_empty(&self) -> bool { + self.names.is_empty() && self.partial.is_empty() + } + + /// Whether `expression` (`x`, `x.y.z`, `x[y]` or a call of one) reads what + /// only running the module gives + pub(crate) fn read_by(&self, expression: &Expression<'_>) -> bool { + let mut path = Vec::new(); + let mut expression = expression; + loop { + match expression { + Expression::StaticMemberExpression(member) => { + path.push(member.property.name.as_str()); + expression = &member.object; + } + Expression::ComputedMemberExpression(member) => { + return crate::utils::binding_root(&member.object).is_some_and(|name| { + self.names.contains(name) || self.partial.contains_key(name) + }); + } + Expression::CallExpression(call) => return self.read_by(&call.callee), + Expression::Identifier(identifier) => { + let name = identifier.name.as_str(); + if self.names.contains(name) { + return true; + } + let Some(mut value) = self.partial.get(name) else { + return false; + }; + for key in path.iter().rev() { + match member_value(value, key) { + Some(member) => value = member, + None => break, + } + } + return matches!(value, Constant::Object(_)) || value.has_function(); + } + _ => return false, + } + } + } +} + +fn member_value<'c>(value: &'c Constant, key: &str) -> Option<&'c Constant> { + match value { + Constant::Object(object) => object.get(key), + Constant::Record(entries) => entries + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value), + _ => None, + } +} + +enum Imported { + Named(String), + Namespace, +} + +/// Inline the primitive constants `program` reads in styles, its own +/// module-level `const`s and those it imports, returning the files read +pub(crate) fn inline_constants<'a>( + ast_builder: &AstBuilder<'a>, + program: &mut Program<'a>, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, +) -> Inlined { + let is_style_package = + |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; + let mut style_roots = FxHashSet::default(); + let mut apis = StyleApis::default(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement + && is_style_package(&import.source.value) + { + let stylex = import.source.value == crate::STYLEX_PACKAGE; + for specifier in import.specifiers.iter().flatten() { + let local = specifier.local().name.as_str(); + style_roots.insert(local); + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + if takes_style_objects(stylex, &specifier.imported.name()) { + apis.functions.insert(local); + } + } + _ => { + apis.namespaces.insert(local, stylex); + } + } + } + } + } + if style_roots.is_empty() { + return Inlined::default(); + } + let mut read = StyleReads { + style_roots: &style_roots, + names: FxHashSet::default(), + depth: 0, + }; + read.visit_program(program); + if read.names.is_empty() { + return Inlined::default(); + } + let mut modules = Modules { + resolver, + option, + exports: FxHashMap::default(), + loading: Vec::new(), + }; + let mut symbols: FxHashMap = FxHashMap::default(); + let mut inlined = Inlined::default(); + let (scoping, reads_math) = { + let mut scope = ModuleScope::new(filename, program, None); + scope + .style_names + .extend(style_roots.iter().map(ToString::to_string)); + let mut bindings: FxHashMap<&str, Vec<&Cell>>> = FxHashMap::default(); + for statement in &program.body { + let declaration = match statement { + Statement::ImportDeclaration(import) => { + if !is_style_package(&import.source.value) { + scope.import(import); + for specifier in import.specifiers.iter().flatten() { + let local = specifier.local(); + bindings + .entry(local.name.as_str()) + .or_default() + .push(&local.symbol_id); + } + } else if import.source.value != crate::STYLEX_PACKAGE { + scope.style_imports.extend( + import + .specifiers + .iter() + .flatten() + .map(|specifier| specifier.local().name.to_string()), + ); + } + continue; + } + Statement::VariableDeclaration(declaration) => declaration, + Statement::ExportDeclaration(export) => match &export.declaration { + oxc_ast::ast::Declaration::VariableDeclaration(declaration) => declaration, + oxc_ast::ast::Declaration::TSEnumDeclaration(declaration) => { + scope.declare_enum(declaration); + bindings + .entry(declaration.id.name.as_str()) + .or_default() + .push(&declaration.id.symbol_id); + continue; + } + _ => continue, + }, + Statement::TSEnumDeclaration(declaration) => { + scope.declare_enum(declaration); + bindings + .entry(declaration.id.name.as_str()) + .or_default() + .push(&declaration.id.symbol_id); + continue; + } + _ => continue, + }; + scope.declare(declaration); + for declarator in &declaration.declarations { + if let oxc_ast::ast::BindingPattern::BindingIdentifier(identifier) = &declarator.id + { + bindings + .entry(identifier.name.as_str()) + .or_default() + .push(&identifier.symbol_id); + } + } + } + // Scoping is only worth building when a style reads a name that may + // hold a constant + let reads_math = read.names.contains("Math") && !scope.binds("Math"); + if !reads_math && !read.names.iter().any(|name| scope.binds(name)) { + return Inlined::default(); + } + let scoping = SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(); + for name in &read.names { + let bound = scope.binds(name); + let constant = scope.lookup(&mut modules, name); + match &constant { + Some(Constant::Changed(change)) => { + inlined.changed.whole.insert(name.clone(), change.clone()); + continue; + } + Some(constant) if constant.change().is_some() => { + inlined + .changed + .holding + .insert(name.clone(), constant.clone()); + } + _ => {} + } + match &constant { + None if bound => { + inlined.unknown.names.insert(name.clone()); + } + Some(object) + if bound + && (matches!(object, Constant::Object(_)) || object.has_function()) => + { + inlined.unknown.partial.insert(name.clone(), object.clone()); + } + _ => {} + } + let Some(constant) = constant else { + continue; + }; + match &constant { + Constant::Vars(vars) => { + inlined + .stylex_vars + .insert(name.clone(), vars.as_ref().clone()); + } + Constant::Theme(class) => { + inlined.stylex_themes.insert(name.clone(), class.clone()); + } + _ => {} + } + for symbol in bindings.get(name.as_str()).into_iter().flatten() { + symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone()))); + } + } + (scoping, reads_math) + }; + inlined.dependencies = modules.exports.into_keys().collect(); + if !symbols.is_empty() || reads_math { + Inline { + ast_builder, + scoping: &scoping, + symbols: &symbols, + style_roots: &style_roots, + apis: &apis, + objects: false, + styles: false, + } + .visit_program(program); + } + inlined +} + +/// Tells which top-level bindings of a program hold, whole or in some member, +/// an object or array code changes, reading only the modules they come from +pub(crate) struct ChangeCheck<'p, 'a, 'r> { + scope: std::cell::RefCell>, + modules: std::cell::RefCell>, +} + +impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { + pub(crate) fn new( + program: &'p Program<'a>, + filename: &'p str, + option: &'r ExtractOption, + resolver: Option<&'r ModuleResolver>, + ) -> Self { + let mut scope = ModuleScope::new(filename, program, None); + for statement in &program.body { + match statement { + Statement::ImportDeclaration(import) => scope.import(import), + Statement::VariableDeclaration(declaration) => { + scope.declare(declaration); + } + Statement::ExportDeclaration(export) => match &export.declaration { + oxc_ast::ast::Declaration::VariableDeclaration(declaration) => { + scope.declare(declaration); + } + oxc_ast::ast::Declaration::TSEnumDeclaration(declaration) => { + scope.declare_enum(declaration); + } + _ => {} + }, + Statement::TSEnumDeclaration(declaration) => { + scope.declare_enum(declaration); + } + _ => {} + } + } + Self { + scope: std::cell::RefCell::new(scope), + modules: std::cell::RefCell::new(Modules { + resolver, + option, + exports: FxHashMap::default(), + loading: Vec::new(), + }), + } + } + + /// The value of `name` as JavaScript source, when the build knows all of + /// it and no code changes it + pub(crate) fn known(&self, name: &str) -> Option { + let mut modules = self.modules.borrow_mut(); + self.scope + .borrow_mut() + .lookup(&mut modules, name)? + .js_literal() + } + + /// The modules read for the values of imports + pub(crate) fn dependencies(&self) -> BTreeSet { + self.modules.borrow().exports.keys().cloned().collect() + } + + pub(crate) fn is_changed(&self, name: &str) -> bool { + let mut modules = self.modules.borrow_mut(); + let mut scope = self.scope.borrow_mut(); + scope.change(&mut modules, name).is_some() + || scope + .lookup(&mut modules, name) + .is_some_and(|value| value.change().is_some()) + } +} + +/// The local names of the style APIs that read style objects at build time +#[derive(Default)] +struct StyleApis<'s> { + functions: FxHashSet<&'s str>, + /// Namespace and default imports, `true` for `StyleX` + namespaces: FxHashMap<&'s str, bool>, +} + +fn takes_style_objects(stylex: bool, export: &str) -> bool { + if stylex { + StylexFunction::from_export_name(export) + .is_some_and(|function| function.requirement().is_some()) + } else { + matches!( + export, + "css" | "globalCss" | "keyframes" | "createGlobalStyle" | "styled" + ) + } +} + +impl StyleApis<'_> { + /// Whether calling `callee` reads its arguments as style objects: + /// `css(...)`, `styled.div(...)`, `styled(Link).attrs(...)`, + /// `stylex.create(...)` + fn reads(&self, callee: &Expression<'_>) -> bool { + let mut expression = callee; + let mut member = None; + loop { + match crate::utils::unwrap_syntax_only(expression) { + Expression::Identifier(identifier) => { + let name = identifier.name.as_str(); + return self.functions.contains(name) + || self.namespaces.get(name).is_some_and(|stylex| { + member.is_some_and(|member| takes_style_objects(*stylex, member)) + }); + } + Expression::StaticMemberExpression(inner) => { + member = Some(inner.property.name.as_str()); + expression = &inner.object; + } + Expression::CallExpression(call) => { + member = None; + expression = &call.callee; + } + _ => return false, + } + } + } +} +/// Names read inside the props of the package's components and the arguments +/// of its functions +struct StyleReads<'s> { + style_roots: &'s FxHashSet<&'s str>, + names: FxHashSet, + depth: usize, +} + +/// Whether `expression` is a style API: a root the package gives, or a member +/// or call of one +fn is_style_root(style_roots: &FxHashSet<&str>, expression: &Expression<'_>) -> bool { + match expression { + Expression::Identifier(identifier) => style_roots.contains(identifier.name.as_str()), + Expression::StaticMemberExpression(member) => is_style_root(style_roots, &member.object), + Expression::CallExpression(call) => is_style_root(style_roots, &call.callee), + _ => false, + } +} + +impl StyleReads<'_> { + fn is_style_root(&self, expression: &Expression<'_>) -> bool { + is_style_root(self.style_roots, expression) + } + + fn reading(&mut self, style: bool, walk: impl FnOnce(&mut Self) -> T) -> T { + self.depth += usize::from(style); + let result = walk(self); + self.depth -= usize::from(style); + result + } +} + +impl<'a> Visit<'a> for StyleReads<'_> { + fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + if self.depth > 0 { + self.names.insert(identifier.name.to_string()); + } + } + + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + self.visit_expression(&call.callee); + let style = self.is_style_root(&call.callee); + self.reading(style, |reads| { + for argument in &call.arguments { + reads.visit_argument(argument); + } + }); + } + + fn visit_tagged_template_expression( + &mut self, + tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>, + ) { + self.visit_expression(&tagged.tag); + let style = self.is_style_root(&tagged.tag); + self.reading(style, |reads| reads.visit_template_literal(&tagged.quasi)); + } + + fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) { + let style = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + self.reading(style, |reads| { + for attribute in &element.attributes { + match attribute { + JSXAttributeItem::Attribute(attribute) => { + if let Some(value) = &attribute.value { + reads.visit_jsx_attribute_value(value); + } + } + JSXAttributeItem::SpreadAttribute(spread) => { + reads.visit_expression(&spread.argument); + } + } + } + }); + } +} + +/// The name `` or `` starts with +pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { + match name { + JSXElementName::IdentifierReference(identifier) => Some(identifier.name.as_str()), + JSXElementName::MemberExpression(member) => { + let mut object = &member.object; + while let oxc_ast::ast::JSXMemberExpressionObject::MemberExpression(inner) = object { + object = &inner.object; + } + match object { + oxc_ast::ast::JSXMemberExpressionObject::IdentifierReference(identifier) => { + Some(identifier.name.as_str()) + } + _ => None, + } + } + _ => None, + } +} + +/// The constant exports of the modules read, by path +struct Modules<'r> { + resolver: Option<&'r ModuleResolver>, + option: &'r ExtractOption, + exports: FxHashMap>>, + loading: Vec, +} + +impl Modules<'_> { + fn exports( + &mut self, + specifier: &str, + importer: &str, + ) -> Option>> { + let module = (self.resolver?)(specifier, importer)?; + if let Some(exports) = self.exports.get(&module.path) { + return Some(exports.clone()); + } + if self.loading.contains(&module.path) { + return None; + } + self.loading.push(module.path.clone()); + let exports = Rc::new(self.read(&module.path, &module.code)); + self.loading.pop(); + self.exports.insert(module.path, exports.clone()); + Some(exports) + } + + fn read(&mut self, path: &str, code: &str) -> FxHashMap { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts()); + let program = Parser::new(&allocator, code, source_type).parse().program; + let mut scope = ModuleScope::new(path, &program, Some(code)); + let mut exports = FxHashMap::default(); + let mut exported: Vec<(String, String)> = Vec::new(); + let mut commonjs = CommonJs::new(&program); + for statement in &program.body { + match statement { + Statement::ImportDeclaration(import) => scope.import(import), + Statement::VariableDeclaration(declaration) => { + scope.require(declaration); + scope.declare(declaration); + } + Statement::ExpressionStatement(statement) => { + commonjs.assign(&mut scope, self, &statement.expression); + } + Statement::ExportDeclaration(export) => match &export.declaration { + oxc_ast::ast::Declaration::VariableDeclaration(declaration) => { + for name in scope.declare(declaration) { + exported.push((name.clone(), name)); + } + } + oxc_ast::ast::Declaration::TSEnumDeclaration(declaration) => { + let name = scope.declare_enum(declaration); + exported.push((name.clone(), name)); + } + _ => {} + }, + Statement::TSEnumDeclaration(declaration) => { + scope.declare_enum(declaration); + } + Statement::ExportNamedDeclaration(export) => { + for specifier in &export.specifiers { + exported.push(( + specifier.exported.name().to_string(), + specifier.local.name().to_string(), + )); + } + } + Statement::ExportFromDeclaration(export) => { + if let Some(from) = self.exports(&export.source.value, path) { + for specifier in &export.specifiers { + if let Some(constant) = from.get(specifier.local.name().as_str()) { + exports.insert( + specifier.exported.name().to_string(), + constant.clone(), + ); + } + } + } + } + Statement::ExportAllDeclaration(export) => { + if let Some(from) = self.exports(&export.source.value, path) { + match &export.exported { + Some(exported) => { + exports.insert(exported.name().to_string(), Constant::Object(from)); + } + None => { + for (name, constant) in from.iter() { + if name != "default" { + exports + .entry(name.clone()) + .or_insert_with(|| constant.clone()); + } + } + } + } + } + } + Statement::ExportDefaultDeclaration(export) => { + if let Some(expression) = export.declaration.as_expression() + && let Some(constant) = scope.evaluate(self, expression) + { + exports.insert("default".to_string(), constant); + } + } + _ => {} + } + } + for (exported, local) in exported { + if let Some(constant) = scope.lookup(self, &local) { + exports.insert(exported, constant); + } + } + commonjs.finish(&mut exports); + exports + } +} +/// The exports of a `CommonJS` module. A property counts only when the module +/// assigns it once, as anything assigned again may change after it is read. +#[derive(Default)] +struct CommonJs { + /// Assignments to each exported property anywhere in the module, `*` for + /// `module.exports` itself; `void 0` placeholders do not count + writes: FxHashMap, + exports: FxHashMap, + whole: Option, + es_module: bool, + seen: bool, +} + +impl CommonJs { + fn new(program: &Program<'_>) -> Self { + let mut commonjs = Self::default(); + commonjs.visit_program(program); + commonjs + } + + fn assign( + &mut self, + scope: &mut ModuleScope<'_, '_>, + modules: &mut Modules<'_>, + expression: &Expression<'_>, + ) { + if let Expression::CallExpression(call) = expression + && is_define_es_module(call) + { + self.es_module = true; + return; + } + let Expression::AssignmentExpression(assignment) = expression else { + return; + }; + let Some(key) = assignment + .left + .as_simple_assignment_target() + .and_then(|target| target.as_member_expression()) + .and_then(commonjs_target) + else { + return; + }; + self.seen = true; + if key == "__esModule" { + self.es_module = true; + return; + } + if self.writes.get(&key) != Some(&1) { + return; + } + let Some(value) = scope.evaluate(modules, &assignment.right) else { + return; + }; + if key == "*" { + // What `module.exports` holds becomes the module's exports + self.whole = Some(match value { + Constant::Record(entries) => { + Constant::Object(Rc::new(entries.iter().cloned().collect())) + } + value => value, + }); + } else { + self.exports.insert(key, value); + } + } + + fn finish(self, exports: &mut FxHashMap) { + if !self.seen { + return; + } + let mut properties = FxHashMap::default(); + if let Some(Constant::Object(object)) = &self.whole { + for (key, value) in object.iter() { + if !self.writes.contains_key(key) { + properties.insert(key.clone(), value.clone()); + } + } + } + properties.extend(self.exports); + let default = if self.es_module { + properties.get("default").cloned() + } else { + match self.whole { + Some(Constant::Object(_)) | None => Some(Constant::Object(Rc::new( + properties + .clone() + .into_iter() + .filter(|(key, _)| key != "default") + .collect(), + ))), + primitive => primitive, + } + }; + exports.extend(properties); + if let Some(default) = default { + exports.insert("default".to_string(), default); + } + } +} + +impl<'a> Visit<'a> for CommonJs { + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + let mut value = &assignment.right; + while let Expression::AssignmentExpression(inner) = value { + value = &inner.right; + } + let placeholder = matches!(value, Expression::UnaryExpression(unary) + if unary.operator == oxc_syntax::operator::UnaryOperator::Void); + if !placeholder + && let Some(key) = assignment + .left + .as_simple_assignment_target() + .and_then(|target| target.as_member_expression()) + .and_then(commonjs_target) + { + *self.writes.entry(key).or_default() += 1; + } + walk::walk_assignment_expression(self, assignment); + } +} + +/// `exports.x` / `module.exports.x` -> `x`, `module.exports` -> `*` +fn commonjs_target(member: &oxc_ast::ast::MemberExpression<'_>) -> Option { + let key = member.static_property_name()?; + let object = member.object(); + if matches!(object, Expression::Identifier(identifier) if identifier.name == "module") { + return (key == "exports").then(|| "*".to_string()); + } + let is_exports = match object { + Expression::Identifier(identifier) => identifier.name == "exports", + Expression::StaticMemberExpression(inner) => { + inner.property.name == "exports" + && matches!(&inner.object, Expression::Identifier(identifier) if identifier.name == "module") + } + _ => false, + }; + is_exports.then(|| key.to_string()) +} + +/// `Object.defineProperty(exports, '__esModule', ...)` +fn is_define_es_module(call: &oxc_ast::ast::CallExpression<'_>) -> bool { + matches!(&call.callee, Expression::StaticMemberExpression(callee) + if callee.property.name == "defineProperty" + && matches!(&callee.object, Expression::Identifier(object) if object.name == "Object")) + && matches!(call.arguments.first(), Some(Argument::Identifier(target)) if target.name == "exports") + && matches!(call.arguments.get(1), Some(Argument::StringLiteral(key)) if key.value == "__esModule") +} + +/// The top-level constants and imports of a module, each constant evaluated +/// when first read +struct ModuleScope<'p, 'a> { + path: &'p str, + program: &'p Program<'a>, + /// The code of an imported module, where changes are located as + /// `file:line:column`; the file extracted keeps offsets + source: Option<&'p str>, + locals: FxHashMap, + declarations: FxHashMap>, + imports: FxHashMap, + style_imports: FxHashSet, + /// Style APIs besides the imports, which never run what they are given + style_names: FxHashSet, + uses: Option>>>, + changes: FxHashMap>>, +} + +impl<'p, 'a> ModuleScope<'p, 'a> { + fn new(path: &'p str, program: &'p Program<'a>, source: Option<&'p str>) -> Self { + Self { + path, + program, + source, + locals: FxHashMap::default(), + declarations: FxHashMap::default(), + imports: FxHashMap::default(), + style_imports: FxHashSet::default(), + style_names: FxHashSet::default(), + uses: None, + changes: FxHashMap::default(), + } + } + + fn is_style_api(&self, modules: &Modules<'_>, callee: &Expression<'_>) -> bool { + let mut expression = callee; + let name = loop { + match expression { + Expression::Identifier(identifier) => break identifier.name.as_str(), + Expression::StaticMemberExpression(member) => expression = &member.object, + Expression::CallExpression(call) => expression = &call.callee, + _ => return false, + } + }; + self.style_imports.contains(name) + || self.imports.get(name).is_some_and(|(source, _)| { + source != crate::STYLEX_PACKAGE + && (source.starts_with(modules.option.package.as_str()) + || modules.option.import_aliases.contains_key(source)) + }) + } + + fn is_style_import(&self, option: &ExtractOption, name: &str) -> bool { + self.style_imports.contains(name) + || self.style_names.contains(name) + || self.imports.get(name).is_some_and(|(source, _)| { + source == crate::STYLEX_PACKAGE + || source.starts_with(option.package.as_str()) + || option.import_aliases.contains_key(source) + }) + } + + fn site(&self, name: &str, at: u32, handed: bool) -> Rc { + Rc::new(Change { + name: name.to_string(), + site: match self.source { + Some(code) => ChangeSite::In(crate::locate(self.path, code, at as usize)), + None => ChangeSite::Here(at), + }, + handed, + }) + } + + /// Where code changes the object or array `name` holds, directly or + /// through the `const` it is put in; handing on a value the build does + /// not know, such as a function, does not count. A namespace import can + /// only have its members changed + fn change(&mut self, modules: &mut Modules<'_>, name: &str) -> Option> { + if let Some(change) = self.changes.get(name) { + return change.clone(); + } + self.changes.insert(name.to_string(), None); + let uses = if let Some(uses) = &self.uses { + uses.clone() + } else { + let option = modules.option; + let uses = Rc::new(crate::mutations::uses(self.program, &|name| { + self.is_style_import(option, name) + })); + self.uses = Some(uses.clone()); + uses + }; + let namespace = matches!(self.imports.get(name), Some((_, Imported::Namespace))); + let value = self.lookup_raw(modules, name); + let mut change = None; + for found in uses.get(name).into_iter().flatten() { + change = match found { + crate::mutations::Use::Changes { at, depth } => { + (!namespace || *depth > 1).then(|| self.site(name, *at, false)) + } + crate::mutations::Use::Calls { at, path } => (!namespace + && value + .as_ref() + .is_some_and(|value| !value.reaches_only_primitives(path))) + .then(|| self.site(name, *at, true)), + crate::mutations::Use::Escapes { at, path, into } => { + let mut path = path.clone(); + if namespace && path.is_empty() { + path.push(None); + } + if value + .as_ref() + .is_none_or(|value| value.reaches_only_primitives(&path)) + { + continue; + } + match into { + Some(into) => self.change(modules, into), + None => Some(self.site(name, *at, true)), + } + } + }; + if change.is_some() { + break; + } + } + self.changes.insert(name.to_string(), change.clone()); + change + } + + fn binds(&self, name: &str) -> bool { + self.declarations.contains_key(name) + || self.imports.contains_key(name) + || self.locals.contains_key(name) + } + + fn is_global_math(&self, expression: &Expression<'_>) -> bool { + matches!(expression, Expression::Identifier(identifier) + if identifier.name == "Math" && !self.binds("Math")) + } + + /// Record the members of an enum whose values are literals, up to the + /// first that is computed, returning its name + fn declare_enum(&mut self, declaration: &oxc_ast::ast::TSEnumDeclaration<'_>) -> String { + let name = declaration.id.name.to_string(); + if declaration.declare { + return name; + } + let mut members = FxHashMap::default(); + let mut next = Some(0.0); + for member in &declaration.body.members { + let value = match &member.initializer { + None => next.map(Constant::Number), + Some(Expression::StringLiteral(literal)) => { + Some(Constant::String(literal.value.to_string())) + } + Some(initializer) => { + crate::utils::js_number_literal(initializer).map(Constant::Number) + } + }; + let Some(value) = value else { + break; + }; + next = match &value { + Constant::Number(number) => Some(number + 1.0), + _ => None, + }; + members.insert(member.id.static_name().to_string(), value); + } + self.locals + .insert(name.clone(), Constant::Object(Rc::new(members))); + name + } + + fn import(&mut self, import: &oxc_ast::ast::ImportDeclaration<'_>) { + for specifier in import.specifiers.iter().flatten() { + let imported = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + Imported::Named(specifier.imported.name().to_string()) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => { + Imported::Named("default".to_string()) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => Imported::Namespace, + }; + self.imports.insert( + specifier.local().name.to_string(), + (import.source.value.to_string(), imported), + ); + } + } + /// `const x = require('m')` and `const { a, b: c } = require('m')` + fn require(&mut self, declaration: &oxc_ast::ast::VariableDeclaration<'_>) { + for declarator in &declaration.declarations { + let Some(Expression::CallExpression(call)) = &declarator.init else { + continue; + }; + let (Expression::Identifier(callee), [Argument::StringLiteral(source)]) = + (&call.callee, call.arguments.as_slice()) + else { + continue; + }; + if callee.name != "require" { + continue; + } + let source = source.value.to_string(); + match &declarator.id { + oxc_ast::ast::BindingPattern::BindingIdentifier(identifier) => { + self.imports + .insert(identifier.name.to_string(), (source, Imported::Namespace)); + } + oxc_ast::ast::BindingPattern::ObjectPattern(pattern) => { + for property in &pattern.properties { + if let Some(key) = property.key.static_name() + && let Some(local) = property.value.get_identifier_name() + { + self.imports.insert( + local.to_string(), + (source.clone(), Imported::Named(key.to_string())), + ); + } + } + } + _ => {} + } + } + } + + /// Record the `const`s of `declaration`, returning the names it binds + fn declare(&mut self, declaration: &'p oxc_ast::ast::VariableDeclaration<'a>) -> Vec { + let mut names = Vec::new(); + if declaration.kind != VariableDeclarationKind::Const { + return names; + } + for declarator in &declaration.declarations { + if let Some(name) = declarator.id.get_identifier_name() + && let Some(init) = &declarator.init + { + self.declarations.insert(name.to_string(), init); + names.push(name.to_string()); + } + } + names + } + + /// What `name` holds, or where code changes it when it is an object or + /// array the module changes + fn lookup(&mut self, modules: &mut Modules<'_>, name: &str) -> Option { + let value = self.lookup_raw(modules, name)?; + if value.is_mutable() + && let Some(change) = self.change(modules, name) + { + return Some(Constant::Changed(change)); + } + Some(value) + } + + fn lookup_raw(&mut self, modules: &mut Modules<'_>, name: &str) -> Option { + if let Some(constant) = self.locals.get(name) { + return Some(constant.clone()); + } + // Taken out while it is evaluated, so a constant reading itself stops + if let Some(init) = self.declarations.remove(name) + && let Some(constant) = self.evaluate(modules, init) + { + self.locals.insert(name.to_string(), constant.clone()); + return Some(constant); + } + let (source, imported) = self.imports.get(name)?; + let exports = modules.exports(source, self.path)?; + match imported { + Imported::Named(export) => exports.get(export).cloned(), + Imported::Namespace => Some(Constant::Object(exports)), + } + } + + fn stylex_function(&self, callee: &Expression<'_>) -> Option { + let (name, member) = match callee { + Expression::Identifier(identifier) => (identifier.name.as_str(), None), + Expression::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(object) => { + (object.name.as_str(), Some(member.property.name.as_str())) + } + _ => return None, + }, + _ => return None, + }; + let export = match (self.imports.get(name)?, member) { + ((source, _), _) if source != crate::STYLEX_PACKAGE => return None, + ((_, Imported::Named(export)), None) => export.as_str(), + ((_, Imported::Namespace), Some(export)) => export, + ((_, Imported::Named(export)), Some(member)) if export == "default" => member, + _ => return None, + }; + StylexFunction::from_export_name(export) + } + + /// A value `StyleX` gives when this module's own extraction reads it, with + /// the names that extraction generates + fn evaluate_stylex( + &mut self, + modules: &mut Modules<'_>, + call: &oxc_ast::ast::CallExpression<'_>, + ) -> Option { + let function = self.stylex_function(&call.callee)?; + let split_filename = crate::css_bucket(self.path, modules.option); + match (function, call.arguments.as_slice()) { + ( + function @ (StylexFunction::DefineVars | StylexFunction::CreateThemeContract), + [Argument::ObjectExpression(object)], + ) => { + let mut vars = FxHashMap::default(); + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(key) = self.property_key(modules, &property.key) + && (function == StylexFunction::CreateThemeContract + || self.variable_value(modules, &property.value)) + { + let variable = crate::stylex::define_vars_variable( + self.path, + &key, + split_filename.as_deref(), + ); + vars.insert(key, variable); + } + } + Some(Constant::Vars(Rc::new(vars))) + } + (StylexFunction::DefineConsts, [Argument::ObjectExpression(object)]) => { + let mut values = FxHashMap::default(); + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(key) = self.property_key(modules, &property.key) + && let Some(value) = self.literal_text(modules, &property.value) + { + values.insert(key, Constant::String(value)); + } + } + Some(Constant::Object(Rc::new(values))) + } + ( + StylexFunction::CreateTheme, + [ + Argument::Identifier(contract), + Argument::ObjectExpression(_), + ], + ) => matches!(self.lookup(modules, &contract.name)?, Constant::Vars(_)).then(|| { + Constant::Theme(crate::stylex::create_theme_class( + self.path, + &contract.name, + split_filename.as_deref(), + )) + }), + _ => None, + } + } + + fn property_key( + &mut self, + modules: &mut Modules<'_>, + key: &oxc_ast::ast::PropertyKey<'_>, + ) -> Option { + match crate::utils::get_string_by_property_key(key) { + Some(key) => Some(key), + None => self.literal_text(modules, key.as_expression()?), + } + } + + /// Whether the module's own extraction reads `value` as the value of a + /// `StyleX` variable, as [`crate::stylex::variable_values`] reads it once + /// constants are inlined + fn variable_value(&mut self, modules: &mut Modules<'_>, value: &Expression<'_>) -> bool { + let value = crate::stylex::unwrap_types_call(value); + if matches!(value, Expression::NullLiteral(_)) + || self.literal_text(modules, value).is_some() + { + return true; + } + let Expression::ObjectExpression(object) = value else { + return false; + }; + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return false; + }; + let Some(key) = self.property_key(modules, &property.key) else { + return false; + }; + if (key != "default" && css::at_rule::split_at_rule_key(&key).is_none()) + || !self.variable_value(modules, &property.value) + { + return false; + } + } + true + } + + /// `value` as the text the module's own extraction reads it as, when that + /// extraction knows it + fn literal_text( + &mut self, + modules: &mut Modules<'_>, + value: &Expression<'_>, + ) -> Option { + match crate::utils::get_string_by_literal_expression(value) { + Some(text) => Some(text.into_owned()), + None => js_string(&self.evaluate(modules, value)?), + } + } + + fn evaluate( + &mut self, + modules: &mut Modules<'_>, + expression: &Expression<'_>, + ) -> Option { + match expression { + Expression::StringLiteral(literal) => Some(Constant::String(literal.value.to_string())), + Expression::NumericLiteral(literal) => Some(Constant::Number(literal.value)), + Expression::UnaryExpression(unary) + if unary.operator == oxc_syntax::operator::UnaryOperator::UnaryNegation => + { + match self.evaluate(modules, &unary.argument)? { + Constant::Number(number) => Some(Constant::Number(-number)), + _ => None, + } + } + Expression::TemplateLiteral(template) => { + let mut values = Vec::with_capacity(template.expressions.len()); + for expression in &template.expressions { + values.push(self.evaluate(modules, expression)?); + } + fold_template(template, &values) + } + Expression::BinaryExpression(binary) => { + let left = self.evaluate(modules, &binary.left)?; + let right = self.evaluate(modules, &binary.right)?; + fold_binary(binary.operator, &left, &right) + } + Expression::NullLiteral(_) => Some(Constant::Null), + Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)), + Expression::Identifier(identifier) + if identifier.name == "undefined" && !self.binds("undefined") => + { + Some(Constant::Undefined) + } + Expression::ObjectExpression(object) => Some(self.object(modules, object)), + Expression::ArrayExpression(array) => { + let mut values = Vec::with_capacity(array.elements.len()); + for element in &array.elements { + match element { + ArrayExpressionElement::SpreadElement(spread) => { + let Constant::Array(spread) = + self.evaluate(modules, &spread.argument)? + else { + return None; + }; + values.extend(spread.iter().cloned()); + } + element => values.push(self.evaluate(modules, element.as_expression()?)?), + } + } + Some(Constant::Array(Rc::new(values))) + } + Expression::ComputedMemberExpression(member) => { + let key = js_string(&self.evaluate(modules, &member.expression)?)?; + member_of(&self.evaluate(modules, &member.object)?, &key) + } + Expression::Identifier(identifier) => self.lookup(modules, &identifier.name), + Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => { + math_constant(member.property.name.as_str()) + } + Expression::StaticMemberExpression(member) => member_of( + &self.evaluate(modules, &member.object)?, + member.property.name.as_str(), + ), + Expression::CallExpression(call) => match &call.callee { + Expression::StaticMemberExpression(callee) + if self.is_global_math(&callee.object) => + { + let mut arguments = Vec::with_capacity(call.arguments.len()); + for argument in &call.arguments { + arguments.push(self.evaluate(modules, argument.as_expression()?)?); + } + fold_math(callee.property.name.as_str(), &arguments) + } + callee if self.is_style_api(modules, callee) => Some(Constant::Style), + _ => self.evaluate_stylex(modules, call), + }, + Expression::TaggedTemplateExpression(tagged) + if self.is_style_api(modules, &tagged.tag) => + { + Some(Constant::Style) + } + Expression::TSAsExpression(inner) => self.evaluate(modules, &inner.expression), + Expression::TSSatisfiesExpression(inner) => self.evaluate(modules, &inner.expression), + Expression::ParenthesizedExpression(inner) => self.evaluate(modules, &inner.expression), + _ => None, + } + } + + /// Every property of `object` when all are known, the known ones otherwise; + /// a key written twice holds its last value at its first place, as in + /// JavaScript, and what an unknown spread or key may replace is unknown + fn object( + &mut self, + modules: &mut Modules<'_>, + object: &oxc_ast::ast::ObjectExpression<'_>, + ) -> Constant { + let mut entries: Vec<(String, Constant)> = Vec::with_capacity(object.properties.len()); + let mut complete = true; + for property in &object.properties { + let (key, value) = match property { + ObjectPropertyKind::ObjectProperty(property) + if property.kind == oxc_ast::ast::PropertyKind::Init => + { + let key = self.property_key(modules, &property.key); + let value = if property.method + || matches!( + crate::utils::unwrap_syntax_only(&property.value), + Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + ) { + Some(Constant::Function) + } else { + self.evaluate(modules, &property.value) + }; + (key, value) + } + ObjectPropertyKind::SpreadProperty(spread) => { + if let Some(Constant::Record(spread)) = self.evaluate(modules, &spread.argument) + { + for (key, value) in spread.iter() { + set_entry(&mut entries, key.clone(), value.clone()); + } + continue; + } + (None, None) + } + ObjectPropertyKind::ObjectProperty(property) => { + (self.property_key(modules, &property.key), None) + } + }; + match (key, value) { + (Some(key), Some(value)) if key != "__proto__" => { + set_entry(&mut entries, key, value); + } + (Some(key), _) => { + complete = false; + entries.retain(|(existing, _)| *existing != key); + } + (None, _) => { + complete = false; + entries.clear(); + } + } + } + if complete { + Constant::Record(Rc::new(entries)) + } else { + Constant::Object(Rc::new(entries.into_iter().collect())) + } + } +} + +fn set_entry(entries: &mut Vec<(String, Constant)>, key: String, value: Constant) { + match entries.iter_mut().find(|(existing, _)| *existing == key) { + Some(entry) => entry.1 = value, + None => entries.push((key, value)), + } +} + +/// `value` as JavaScript turns it into a string +fn js_string(value: &Constant) -> Option { + match value { + Constant::String(text) => Some(text.clone()), + Constant::Number(number) => Some(crate::utils::js_number_string(*number)), + Constant::Null => Some("null".to_string()), + Constant::Bool(value) => Some(value.to_string()), + Constant::Undefined => Some("undefined".to_string()), + _ => None, + } +} + +fn member_of(object: &Constant, key: &str) -> Option { + match object { + Constant::Object(object) => object.get(key).cloned(), + Constant::Record(entries) => entries + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value.clone()), + Constant::Array(values) => key + .parse::() + .ok() + .filter(|index| index.to_string() == key) + .and_then(|index| values.get(index).cloned()), + Constant::Vars(vars) => vars + .get(key) + .map(|variable| Constant::String(format!("var({variable})"))), + _ => None, + } +} + +fn fold_template( + template: &oxc_ast::ast::TemplateLiteral<'_>, + values: &[Constant], +) -> Option { + let mut text = String::new(); + for (index, quasi) in template.quasis.iter().enumerate() { + text.push_str(quasi.value.cooked.as_deref()?); + if let Some(value) = values.get(index) { + text.push_str(&js_string(value)?); + } + } + Some(Constant::String(text)) +} + +fn math_constant(name: &str) -> Option { + use std::f64::consts; + let value = match name { + "PI" => consts::PI, + "E" => consts::E, + "LN2" => consts::LN_2, + "LN10" => consts::LN_10, + "LOG2E" => consts::LOG2_E, + "LOG10E" => consts::LOG10_E, + "SQRT2" => consts::SQRT_2, + "SQRT1_2" => consts::FRAC_1_SQRT_2, + _ => return None, + }; + Some(Constant::Number(value)) +} + +/// `Math.{name}(...arguments)`, folded only where every engine computes the +/// same result, so the CSS never depends on the platform that builds it +fn fold_math(name: &str, arguments: &[Constant]) -> Option { + let mut numbers = Vec::with_capacity(arguments.len()); + for argument in arguments { + let Constant::Number(number) = argument else { + return None; + }; + numbers.push(*number); + } + let first = numbers.first().copied(); + let value = match name { + "abs" => first?.abs(), + "ceil" => first?.ceil(), + "floor" => first?.floor(), + "trunc" => first?.trunc(), + "sqrt" => first?.sqrt(), + "sign" => { + let x = first?; + if x > 0.0 { + 1.0 + } else if x < 0.0 { + -1.0 + } else { + x + } + } + // JavaScript rounds a half up, toward +Infinity, where Rust rounds it + // away from zero; `x - floor(x)` is exact for every double + "round" => { + let x = first?; + let floor = x.floor(); + if x - floor >= 0.5 { floor + 1.0 } else { floor } + } + "max" => numbers.iter().copied().fold(f64::NEG_INFINITY, f64::max), + "min" => numbers.iter().copied().fold(f64::INFINITY, f64::min), + "pow" => exact_power(first?, *numbers.get(1)?)?, + _ => return None, + }; + value.is_finite().then_some(Constant::Number(value)) +} + +/// An integer raised to a whole power, when the result is an exact integer +fn exact_power(base: f64, exponent: f64) -> Option { + const EXACT: f64 = 9_007_199_254_740_992.0; + if base.fract() != 0.0 || exponent.fract() != 0.0 || !(0.0..=64.0).contains(&exponent) { + return None; + } + let mut result = 1.0_f64; + for _ in 0..exponent as u8 { + result *= base; + if result.abs() > EXACT { + return None; + } + } + Some(result) +} + +fn fold_binary(operator: BinaryOperator, left: &Constant, right: &Constant) -> Option { + let number = match (operator, left, right) { + (BinaryOperator::Addition, Constant::Number(a), Constant::Number(b)) => a + b, + (BinaryOperator::Subtraction, Constant::Number(a), Constant::Number(b)) => a - b, + (BinaryOperator::Multiplication, Constant::Number(a), Constant::Number(b)) => a * b, + (BinaryOperator::Division, Constant::Number(a), Constant::Number(b)) => a / b, + (BinaryOperator::Remainder, Constant::Number(a), Constant::Number(b)) => a % b, + (BinaryOperator::Addition, Constant::String(_), _) + | (BinaryOperator::Addition, _, Constant::String(_)) => { + return Some(Constant::String(js_string(left)? + &js_string(right)?)); + } + _ => return None, + }; + number.is_finite().then_some(Constant::Number(number)) +} +/// Replaces reads of constants where styles read them: primitives in the +/// arguments of style APIs and in style props, objects and arrays where they +/// are read as style objects, as a copy elsewhere would change their identity. +/// Other code keeps reading the binding itself +struct Inline<'s, 'a> { + ast_builder: &'s AstBuilder<'a>, + scoping: &'s Scoping, + symbols: &'s FxHashMap, + style_roots: &'s FxHashSet<&'s str>, + apis: &'s StyleApis<'s>, + /// Inside what the build reads as style objects + objects: bool, + /// Inside the arguments of a style API or a style prop + styles: bool, +} + +impl<'a> Inline<'_, 'a> { + fn constant(&self, expression: &Expression<'a>) -> Option { + match expression { + Expression::Identifier(identifier) => { + let reference = identifier.reference_id.get()?; + let symbol = self.scoping.get_reference(reference).symbol_id()?; + self.symbols.get(&symbol).cloned() + } + Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => { + math_constant(member.property.name.as_str()) + } + Expression::StaticMemberExpression(member) => member_of( + &self.constant(&member.object)?, + member.property.name.as_str(), + ), + Expression::CallExpression(call) => { + let Expression::StaticMemberExpression(callee) = &call.callee else { + return None; + }; + if !self.is_global_math(&callee.object) { + return None; + } + let arguments: Option> = call + .arguments + .iter() + .map(|argument| self.operand(argument.as_expression()?)) + .collect(); + fold_math(callee.property.name.as_str(), &arguments?) + } + Expression::ComputedMemberExpression(member) => { + let key = js_string(&self.operand(&member.expression)?)?; + member_of(&self.constant(&member.object)?, &key) + } + // Folded only when they read a constant, leaving other code as written + Expression::TemplateLiteral(template) + if template + .expressions + .iter() + .any(|e| self.constant(e).is_some()) => + { + let values: Option> = template + .expressions + .iter() + .map(|expression| self.operand(expression)) + .collect(); + fold_template(template, &values?) + } + Expression::BinaryExpression(binary) + if self.constant(&binary.left).is_some() + || self.constant(&binary.right).is_some() => + { + fold_binary( + binary.operator, + &self.operand(&binary.left)?, + &self.operand(&binary.right)?, + ) + } + Expression::UnaryExpression(unary) + if unary.operator == oxc_syntax::operator::UnaryOperator::UnaryNegation => + { + match self.constant(&unary.argument)? { + Constant::Number(number) => Some(Constant::Number(-number)), + _ => None, + } + } + Expression::ParenthesizedExpression(inner) => self.constant(&inner.expression), + _ => None, + } + } + + /// A constant read or a literal + fn operand(&self, expression: &Expression<'a>) -> Option { + self.constant(expression).or_else(|| match expression { + Expression::StringLiteral(literal) => Some(Constant::String(literal.value.to_string())), + Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)), + Expression::NullLiteral(_) => Some(Constant::Null), + _ => crate::utils::js_number_literal(expression).map(Constant::Number), + }) + } + + /// Whether a condition reading `expression` holds, when it is a constant + /// read or a literal + fn holds(&self, expression: &Expression<'a>) -> Option { + Some(match self.operand(expression)? { + Constant::String(text) => !text.is_empty(), + Constant::Number(number) => number != 0.0 && !number.is_nan(), + Constant::Null | Constant::Undefined => false, + Constant::Bool(value) => value, + _ => true, + }) + } + + /// The side of a condition or of `&&`, `||` or `??` a constant or literal + /// chooses, taken out of `expression` + fn chosen(&self, expression: &mut Expression<'a>) -> Option> { + use oxc_allocator::TakeIn; + + let allocator = self.ast_builder; + match expression { + Expression::ConditionalExpression(conditional) => { + Some(if self.holds(&conditional.test)? { + conditional.consequent.take_in(allocator) + } else { + conditional.alternate.take_in(allocator) + }) + } + Expression::LogicalExpression(logical) => { + let left = self.operand(&logical.left)?; + let keep_left = match logical.operator { + oxc_syntax::operator::LogicalOperator::And => !self.holds(&logical.left)?, + oxc_syntax::operator::LogicalOperator::Or => self.holds(&logical.left)?, + oxc_syntax::operator::LogicalOperator::Coalesce => { + !matches!(left, Constant::Null | Constant::Undefined) + } + }; + Some(if keep_left { + logical.left.take_in(allocator) + } else { + logical.right.take_in(allocator) + }) + } + _ => None, + } + } + + fn is_global_math(&self, expression: &Expression<'a>) -> bool { + matches!(expression, Expression::Identifier(identifier) + if identifier.name == "Math" + && identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .is_none()) + } + + fn literal(&self, constant: &Constant) -> Option> { + let builder = self.ast_builder; + match constant { + Constant::String(value) => Some(Expression::new_string_literal( + SPAN, + Str::from_in(value.as_str(), builder.allocator()), + None, + builder, + )), + Constant::Number(value) => Some(Expression::new_numeric_literal( + SPAN, + *value, + None, + NumberBase::Decimal, + builder, + )), + Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), + Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Record(entries) if self.objects => { + let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); + for (key, value) in entries.iter() { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + oxc_ast::ast::PropertyKey::StringLiteral( + oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(key.as_str(), builder.allocator()), + None, + builder, + ), + ), + self.literal(value)?, + false, + false, + false, + builder, + )); + } + Some(Expression::new_object_expression(SPAN, properties, builder)) + } + Constant::Array(values) if self.objects => { + let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); + for value in values.iter() { + elements.push(self.literal(value)?.into()); + } + Some(Expression::new_array_expression(SPAN, elements, builder)) + } + _ => None, + } + } + + fn reading_objects(&mut self, objects: bool, visit: impl FnOnce(&mut Self) -> T) -> T { + let outer = std::mem::replace(&mut self.objects, objects); + let result = visit(self); + self.objects = outer; + result + } + + fn reading_styles(&mut self, styles: bool, visit: impl FnOnce(&mut Self) -> T) -> T { + let outer = self.styles; + self.styles |= styles; + let result = visit(self); + self.styles = outer; + result + } +} + +impl<'a> VisitMut<'a> for Inline<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if self.styles { + if let Some(literal) = self + .constant(expression) + .and_then(|constant| self.literal(&constant)) + { + *expression = literal; + return; + } + if let Some(chosen) = self.chosen(expression) { + *expression = chosen; + self.visit_expression(expression); + return; + } + } + walk_mut::walk_expression(self, expression); + } + + fn visit_tagged_template_expression( + &mut self, + tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>, + ) { + self.visit_expression(&mut tagged.tag); + let styles = is_style_root(self.style_roots, &tagged.tag); + self.reading_styles(styles, |inline| { + inline.visit_template_literal(&mut tagged.quasi); + }); + } + + fn visit_member_expression(&mut self, member: &mut oxc_ast::ast::MemberExpression<'a>) { + self.reading_objects(false, |inline| { + walk_mut::walk_member_expression(inline, member); + }); + } + + fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { + self.visit_expression(&mut call.callee); + let objects = self.apis.reads(&call.callee); + let styles = is_style_root(self.style_roots, &call.callee); + self.reading_styles(styles, |inline| { + inline.reading_objects(objects, |inline| { + for argument in &mut call.arguments { + inline.visit_argument(argument); + } + }); + }); + } + + fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { + let styled = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + for attribute in &mut element.attributes { + let objects = styled + && match attribute { + JSXAttributeItem::Attribute(attribute) => { + matches!(&attribute.name, oxc_ast::ast::JSXAttributeName::Identifier(name) + if !css::is_special_property::is_special_property(&name.name) + && !matches!(name.name.as_str(), "as" | "props" | "styleVars")) + } + JSXAttributeItem::SpreadAttribute(_) => true, + }; + self.reading_styles(objects, |inline| { + inline + .reading_objects(objects, |inline| inline.visit_jsx_attribute_item(attribute)); + }); + } + } + + fn visit_object_property(&mut self, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + walk_mut::walk_object_property(self, property); + if property.shorthand && !matches!(property.value, Expression::Identifier(_)) { + property.shorthand = false; + } + } +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 62018cfa3..1b30729f7 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,4 +1,5 @@ mod as_visit; +mod build_time_values; mod component; mod css_utils; pub mod extract_style; @@ -6,12 +7,15 @@ mod extractor; mod gen_class_name; mod gen_style; mod import_alias_visit; +mod imported_constants; +mod module_loader; +mod mutations; mod prop_modify_utils; +mod source_map; mod stylex; mod tailwind; mod util_type; mod utils; -#[cfg(feature = "vanilla-extract")] mod vanilla_extract; mod visit; use crate::extract_style::extract_style_value::ExtractStyleValue; @@ -23,7 +27,6 @@ use oxc_ast_visit::VisitMut; use oxc_codegen::{Codegen, CodegenOptions}; use oxc_parser::{Parser, ParserReturn}; use oxc_span::SourceType; -#[cfg(feature = "vanilla-extract")] use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; use std::collections::{BTreeMap, HashMap}; @@ -63,6 +66,14 @@ pub enum ExtractStyleProp<'a> { map: BTreeMap>>, expression: Expression<'a>, }, + /// Styles written where the build cannot read them, reported as an error; + /// `prop` for a computed key among an element's props, which the element + /// takes as it is at runtime + Unreadable { + offset: u32, + code: String, + prop: bool, + }, } impl<'a> ExtractStyleProp<'a> { @@ -101,6 +112,11 @@ impl<'a> ExtractStyleProp<'a> { expression: expression.clone_in(alloc), } } + ExtractStyleProp::Unreadable { offset, code, prop } => ExtractStyleProp::Unreadable { + offset: *offset, + code: code.clone(), + prop: *prop, + }, } } @@ -137,6 +153,7 @@ impl<'a> ExtractStyleProp<'a> { .values() .flat_map(|s| s.iter().flat_map(ExtractStyleProp::extract)) .collect(), + ExtractStyleProp::Unreadable { .. } => vec![], } } @@ -177,6 +194,7 @@ impl<'a> ExtractStyleProp<'a> { .into_values() .flat_map(|s| s.into_iter().flat_map(ExtractStyleProp::into_extract)) .collect(), + ExtractStyleProp::Unreadable { .. } => vec![], } } } @@ -191,8 +209,22 @@ pub struct ExtractOutput { pub map: Option, pub css_file: Option, + + /// Files read through the module resolver, for the bundler to watch + pub dependencies: Vec, +} + +/// A module the bundler resolved an import to +pub struct ResolvedModule { + pub path: String, + pub code: String, } +/// Resolves `(specifier, importer)` the way the bundler does; `None` when it +/// cannot +pub type ModuleResolver = dyn Fn(&str, &str) -> Option; + +#[derive(Clone)] pub struct ExtractOption { pub package: String, pub css_dir: String, @@ -219,7 +251,7 @@ pub fn extract( code: &str, option: ExtractOption, ) -> Result> { - extract_with_source_map(filename, code, option, true) + extract_with_source_map(filename, code, option, true, None) } pub fn extract_without_source_map( @@ -227,7 +259,18 @@ pub fn extract_without_source_map( code: &str, option: ExtractOption, ) -> Result> { - extract_with_source_map(filename, code, option, false) + extract_with_source_map(filename, code, option, false, None) +} + +/// [`extract`] that reads the modules a file imports through `resolver` +pub fn extract_with_modules( + filename: &str, + code: &str, + option: ExtractOption, + source_map: bool, + resolver: &ModuleResolver, +) -> Result> { + extract_with_source_map(filename, code, option, source_map, Some(resolver)) } fn extract_with_source_map( @@ -235,11 +278,25 @@ fn extract_with_source_map( code: &str, option: ExtractOption, source_map: bool, + resolver: Option<&ModuleResolver>, +) -> Result> { + extract_source(filename, code, None, option, source_map, resolver) +} + +/// `evaluated` is the source `code` was computed from, with the layers of +/// edits, last made first, that map `code` back to it +fn extract_source( + filename: &str, + code: &str, + evaluated: Option<(&str, &[&[import_alias_visit::Edit]])>, + option: ExtractOption, + source_map: bool, + resolver: Option<&ModuleResolver>, ) -> Result> { // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` - let transformed_code = import_alias_visit::transform_import_aliases( + let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits( code, filename, &option.package, @@ -257,58 +314,74 @@ fn extract_with_source_map( code: code.to_string(), map: None, css_file: None, + dependencies: Vec::new(), }); } + let mut dependencies = std::collections::BTreeSet::new(); + let mut evaluation_error = None; // Step 3: Handle vanilla-extract style files (.css.ts, .css.js) // `processed_code` is Some only when vanilla-extract generation succeeded; // otherwise the untouched `transformed_code` is parsed directly (no copy). - #[cfg(feature = "vanilla-extract")] let processed_code: Option = if utils::is_vanilla_extract_file(filename) { // Use transformed code (with imports already pointing to @devup-ui/react) - match vanilla_extract::execute_vanilla_extract(&transformed_code, &option.package, filename) - { - Ok(collected) => { - // 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, - )) + match vanilla_extract::execute_stylesheet(&transformed_code, filename, &option, resolver) { + Ok((collected, imports)) => { + dependencies = imports.dependencies; + // Keyframes names are generated, so extract the referenced ones + // first and substitute their names into the styles using them. + let referenced = vanilla_extract::referenced_keyframes(&collected); + 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, - )) - } + )? + }; + let code = vanilla_extract::collected_styles_to_code_with_keyframes( + &collected, + &option.package, + &keyframes_names, + ); + Some(if code.is_empty() { + code + } else { + imports + .kept_imports + .iter() + .map(|specifier| { + format!("import {};\n", vanilla_extract::json_string(specifier)) + }) + .chain([code]) + .collect() + }) + } + // A stylesheet another one imports must give its own values, and an + // import cycle read too early fails as it does in ES modules, so both + // are reported rather than hidden behind plain extraction + Err(error) + if module_loader::loading_for_stylesheet() + || error.contains(module_loader::IMPORT_CYCLE) => + { + return Err(error.into()); + } + // Plain extraction still compiles Devup UI's own APIs; the error is + // reported when calls it cannot compile remain + Err(error) => { + evaluation_error = Some(error); + None } - // Fall back to treating as regular file if execution fails - Err(_) => None, } } else { None }; - #[cfg(not(feature = "vanilla-extract"))] - let processed_code: Option = None; // For vanilla-extract files, if no styles were collected, return early if processed_code.as_deref() == Some("") { return Ok(ExtractOutput { @@ -316,6 +389,7 @@ fn extract_with_source_map( code: code.to_string(), map: None, css_file: None, + dependencies: dependencies.into_iter().collect(), }); } @@ -342,6 +416,18 @@ fn extract_with_source_map( if fatal_error { return Err("Parser panicked".into()); } + let inlined = if processed_code.is_none() { + imported_constants::inline_constants( + &oxc_ast::builder::AstBuilder::new(&allocator), + &mut program, + filename, + &option, + resolver, + ) + } else { + imported_constants::Inlined::default() + }; + dependencies.extend(inlined.dependencies); let mut visitor = DevupVisitor::new( &allocator, filename, @@ -349,7 +435,52 @@ fn extract_with_source_map( css_files, if global { None } else { Some(bucket) }, ); + visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); + visitor.unknown_bindings(&inlined.unknown); + visitor.changed_bindings(inlined.changed.clone()); visitor.visit_program(&mut program); + if let Some(error) = evaluation_error + && imports_uncompiled(&program, &option.package) + { + return Err(error.into()); + } + // Run the code a value computes, or tell rules the module computes from a + // class it composes + if (!visitor.errors.is_empty() || visitor.composes_unknown) + && evaluated.is_none() + && !utils::is_vanilla_extract_file(filename) + && let Some((computed, value_edits, read)) = build_time_values::evaluate( + &transformed_code, + filename, + &option, + resolver, + &inlined.unknown, + ) + { + let mut output = extract_source( + filename, + &computed, + Some((code, &[value_edits.as_slice(), alias_edits.as_slice()])), + option, + source_map, + resolver, + )?; + let mut files: std::collections::BTreeSet = + output.dependencies.into_iter().collect(); + files.extend(read); + output.dependencies = files.into_iter().collect(); + return Ok(output); + } + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .collect(); + visitor.errors.append(&mut visitor.unknown_parts); + if !visitor.errors.is_empty() { + let mut message = located_errors(filename, source, &edits, visitor.errors); + message += &changed_notes(&message, filename, source, &edits, &inlined.changed); + return Err(message.into()); + } let codegen_options = if source_map { CodegenOptions { source_map_path: Some(PathBuf::from(filename)), @@ -359,12 +490,22 @@ fn extract_with_source_map( CodegenOptions::default() }; let result = Codegen::new().with_options(codegen_options).build(&program); + // A stylesheet's output is generated, so its map stays on that code + let map = result.map.map(|map| { + if processed_code.is_some() || edits.iter().all(|edits| edits.is_empty()) { + map + } else { + source_map::remap(map, code_to_parse, source, &edits) + } + .to_json_string() + }); Ok(ExtractOutput { styles: visitor.styles, code: result.code, - map: result.map.map(|m| m.to_json_string()), + map, css_file: Some(css_file), + dependencies: dependencies.into_iter().collect(), }) } @@ -386,6 +527,96 @@ fn main_css_path(css_dir: &str) -> String { /// kept for parse/sourcemap. Global (shared-chunk) files are emitted like /// single-css: into devup-ui.css with prefix-less global naming, so styles /// shared across routes ship once. +/// Whether `program` still imports a value from `package` that extraction did +/// not compile away +fn imports_uncompiled(program: &oxc_ast::ast::Program<'_>, package: &str) -> bool { + program.body.iter().any(|statement| { + matches!(statement, oxc_ast::ast::Statement::ImportDeclaration(import) + if import.source.value == package + && !import.import_kind.is_type() + && import.specifiers.iter().flatten().any(|specifier| matches!( + specifier, + oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named) + if !named.import_kind.is_type() + ))) + }) +} + +/// `errors` in source order, one per line, each led by `filename:line:column` +/// of the code it is about; each layer of `edits`, last made first, maps the +/// offsets back to `source` +fn located_errors( + filename: &str, + source: &str, + edits: &[&[import_alias_visit::Edit]], + mut errors: Vec<(u32, String)>, +) -> String { + errors.sort_unstable(); + errors.dedup(); + errors + .into_iter() + .map(|(offset, message)| { + let offset = edits.iter().fold(offset as usize, |offset, edits| { + import_alias_visit::source_offset(edits, offset) + }); + format!("{}: {message}", locate(filename, source, offset)) + }) + .collect::>() + .join("\n") +} + +/// `filename:line:column` of `offset` in `source` +fn locate(filename: &str, source: &str, offset: usize) -> String { + let before = source.get(..offset).unwrap_or(source); + let line_start = before.rfind('\n').map_or(0, |index| index + 1); + format!( + "{filename}:{}:{}", + before.matches('\n').count() + 1, + before[line_start..].chars().count() + 1 + ) +} + +/// A line for each binding `message` names that code changes, telling where +fn changed_notes( + message: &str, + filename: &str, + source: &str, + edits: &[&[import_alias_visit::Edit]], + changed: &imported_constants::Changed, +) -> String { + let mut notes = String::new(); + for change in changed.named_in(message) { + let location = match &change.site { + imported_constants::ChangeSite::Here(offset) => { + let offset = edits.iter().fold(*offset as usize, |offset, edits| { + import_alias_visit::source_offset(edits, offset) + }); + locate(filename, source, offset) + } + imported_constants::ChangeSite::In(location) => location.clone(), + }; + let what = if change.handed { + "is handed here to code that may change it" + } else { + "is changed here" + }; + let _ = std::fmt::Write::write_fmt( + &mut notes, + format_args!( + "\n{location}: `{}` {what}, so the build cannot read it as a constant", + change.name + ), + ); + } + notes +} + +/// The file name generated names of `filename` are scoped by, `None` when its +/// CSS goes to the shared sheet +fn css_bucket(filename: &str, option: &ExtractOption) -> Option { + (!(option.single_css || is_global(filename))).then(|| canonical(filename)) +} + fn resolve_css_target(filename: &str, option: &ExtractOption) -> (String, bool, String) { let bucket = canonical(filename); let global = option.single_css || is_global(filename); @@ -403,7 +634,6 @@ fn resolve_css_target(filename: &str, option: &ExtractOption) -> (String, bool, /// Extract class names from generated code for specific style names /// Used for two-pass vanilla-extract processing to resolve selector references -#[cfg(feature = "vanilla-extract")] fn extract_class_map_from_code( filename: &str, partial_code: &str, @@ -509,6 +739,7 @@ mod tests { use std::collections::BTreeSet; use super::*; + use crate::build_time_values::has_build_time_values; use css::class_map::reset_class_map; use css::file_map::reset_file_map; use insta::assert_debug_snapshot; @@ -7747,12 +7978,10 @@ globalCss() reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_eq!( extract( "test.tsx", - r#"import { globalCss } from "@devup-ui/core"; -globalCss(1) -"#, + "import { globalCss } from \"@devup-ui/core\";\nglobalCss(1)\n", ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), @@ -7761,8 +7990,10 @@ globalCss(1) import_aliases: HashMap::new() } ) - .unwrap() - )); + .unwrap_err() + .to_string(), + "test.tsx:2:1: `globalCss()` cannot use `1` at build time: its values must be literals, theme tokens or constants, or be computed from them" + ); } #[test] @@ -8879,7 +9110,7 @@ keyframes(...{ keyframes({ from: { opacity: 0 }, [true]: { opacity: 0.5 }, - to: { opacity: 1, color: dy } + to: { opacity: 1 } }) "#, ExtractOption { @@ -8894,6 +9125,206 @@ keyframes({ )); } + #[test] + #[serial] + fn test_runtime_values_where_no_element_holds_them() { + for (code, error) in [ + ( + "import { css } from '@devup-ui/react';\nexport const a = css({ color: x });", + "`css()` cannot use `x`", + ), + ( + "import { globalCss } from '@devup-ui/react';\nglobalCss({ body: { color: x } });", + "`globalCss()` cannot use `x`", + ), + ( + "import { globalCss } from '@devup-ui/react';\nglobalCss('body', { color: x });", + "`globalCss()` cannot use `x`", + ), + ( + "import { keyframes } from '@devup-ui/react';\nkeyframes({ from: { opacity: 0 }, to: { color: dy } });", + "`keyframes()` cannot use `dy`", + ), + ( + "import * as stylex from '@stylexjs/stylex';\nexport const spin = stylex.keyframes({ to: { color: dy } });", + "`stylex.keyframes()` cannot use `dy`", + ), + ] { + reset_class_map(); + reset_file_map(); + let message = extract("test.tsx", code, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!( + message.starts_with("test.tsx:2:") && message.contains(error), + "{message}" + ); + assert!( + message.ends_with( + "at build time: its values must be literals, theme tokens or constants, or be computed from them" + ), + "{message}" + ); + } + } + + #[test] + #[serial] + fn test_template_interpolations() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import { css, globalCss, keyframes, styled } from '@devup-ui/react'; +const C = 'red'; +const O = 0.5; +const SEL = '.x'; +const SNIPPET = 'display: flex;'; +const mixin = css`display: grid;`; +globalCss`body { color: ${C}; padding: ${4}px; }`; +globalCss`a { color: ${(p) => p.theme.colors.link}; }`; +export const k = keyframes`from { opacity: ${O} } to { opacity: 1 }`; +export const c = css`${SNIPPET} color: ${C};`; +export const d = css`${mixin}; margin: 0;`; +export const A = styled.div`${mixin} color: red; ${(p) => p.active && mixin}`; +export const B = styled.div`${SEL} & { color: ${C}; }`;", + ExtractOption::default(), + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_template_interpolations_known_only_at_runtime() { + for (code, error) in [ + ( + "import { globalCss } from '@devup-ui/react';\nglobalCss`body { color: ${x}; }`;", + "`globalCss()` cannot use `x` at build time", + ), + ( + "import { keyframes } from '@devup-ui/react';\nexport const k = keyframes`from { opacity: ${x} }`;", + "`keyframes()` cannot use `x` at build time", + ), + ( + "import { css } from '@devup-ui/react';\nexport const c = css`color: ${x};`;", + "`css()` cannot use `x` at build time", + ), + ( + "import { css } from '@devup-ui/react';\nexport const c = css`${x}: red;`;", + "Cannot place `x` at build time", + ), + ( + "import { styled } from '@devup-ui/react';\nexport const A = styled.div`${Other}:hover & { color: red; }`;", + "Cannot place `Other` at build time", + ), + ( + "import { styled } from '@devup-ui/react';\nexport const A = styled.div`& ${x} { color: red; }`;", + "Cannot place `x` at build time", + ), + ] { + reset_class_map(); + reset_file_map(); + let message = extract("test.tsx", code, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!( + message.starts_with("test.tsx:2:") && message.contains(error), + "{message}" + ); + } + } + + #[test] + #[serial] + fn test_errors_are_located_and_all_reported() { + reset_class_map(); + reset_file_map(); + let message = extract( + "src/App.tsx", + "import {\n css,\n keyframes,\n} from '@emotion/react';\nimport { globalCss } from '@devup-ui/react';\nexport const a = css({ padding: 8 }); export const b = css({ color: x });\nglobalCss`body { color: ${y}; }`;\nexport const k = keyframes({ to: { opacity: z } });\nexport const c = css({ color: x });", + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert_eq!( + message.lines().collect::>(), + [ + "src/App.tsx:6:56: `css()` cannot use `x` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "src/App.tsx:7:27: `globalCss()` cannot use `y` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "src/App.tsx:8:18: `keyframes()` cannot use `z` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "src/App.tsx:9:18: `css()` cannot use `x` at build time: its values must be literals, theme tokens or constants, or be computed from them", + ] + ); + } + + #[test] + #[serial] + fn test_inline_local_constants_edges() { + reset_class_map(); + reset_file_map(); + let output = extract( + "test.tsx", + r"import { Box } from '@devup-ui/react'; +export function helper() {} +const OBJ = { a: 1 }; +export const a = ;", + ExtractOption::default(), + ) + .unwrap(); + let mut values: Vec = output + .styles + .iter() + .map(|style| format!("{style:?}")) + .collect(); + values.sort(); + let values = values.join("\n"); + assert!(values.contains("\"Infinitypx\""), "{values}"); + assert!(values.contains("\"-Infinitypx\""), "{values}"); + assert!(output.code.contains("OBJ"), "{}", output.code); + } + + #[test] + #[serial] + fn test_inline_local_constants() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import { Box, css, keyframes, globalCss } from '@devup-ui/react'; +const SIZE = 4; +const UNIT = `${SIZE}px`; +const DOUBLE = SIZE * 2; +const HALF = SIZE / 2 - 1; +const LABEL = 'a' + SIZE; +const TINY = 0.0000001; +const HUGE = 1e21; +const ZERO = -0; +const PX = 'px'; +const SELF = SELF; +let mutable = 1; +export const a = ; +export const b = css({ padding: `${SIZE * 2}px`, margin: SIZE - 1, width: SIZE * SIZE, height: (SIZE) }); +export const k = keyframes({ from: { opacity: SIZE / 8 } }); +globalCss({ body: { padding: UNIT } }); +export const c = ;", + ExtractOption::default(), + ) + .unwrap() + )); + } + #[test] #[serial] fn extract_keyframs_literal() { @@ -9790,7 +10221,7 @@ keyframes({ extract( "test.tsx", r"import {styled} from '@devup-ui/core' - const color = 'red'; + let color = 'red'; const StyledDiv = styled.div` color: ${color}; background: blue; @@ -9815,8 +10246,8 @@ keyframes({ extract( "test.tsx", r#"import {styled} from '@devup-ui/core' - const primaryColor = 'blue'; - const padding = '16px'; + let primaryColor = 'blue'; + let padding = '16px'; const StyledDiv = styled("div")` color: ${primaryColor}; padding: ${padding}; @@ -9840,8 +10271,8 @@ keyframes({ extract( "test.tsx", r#"import {styled} from '@devup-ui/core' - const primaryColor = 'blue'; - const padding = '16px'; + let primaryColor = 'blue'; + let padding = '16px'; const StyledDiv = styled("div")({ bg: primaryColor, padding }) "#, ExtractOption { @@ -9862,8 +10293,8 @@ keyframes({ extract( "test.tsx", r"import {styled} from '@devup-ui/core' - const primaryColor = 'blue'; - const padding = '16px'; + let primaryColor = 'blue'; + let padding = '16px'; const StyledDiv = styled.div({ bg: primaryColor, padding }) ", ExtractOption { @@ -9977,7 +10408,7 @@ keyframes({ extract( "test.tsx", r"import {styled, Box} from '@devup-ui/core' - const fontSize = '18px'; + let fontSize = '18px'; const StyledComponent = styled(Box)` font-size: ${fontSize}; color: ${props => props.color || 'black'}; @@ -10002,8 +10433,8 @@ keyframes({ extract( "test.tsx", r"import {styled} from '@devup-ui/core' - const margin = '10px'; - const padding = '20px'; + let margin = '10px'; + let padding = '20px'; const StyledDiv = styled.div` margin: ${margin}; padding: ${padding}; @@ -10568,8 +10999,8 @@ globalCss({ extract( "test.tsx", r#"import {Box} from '@devup-ui/core' -const colors = { primary: "blue", secondary: "red" }; -const key = "primary"; +let colors = { primary: "blue", secondary: "red" }; +let key = "primary"; "#, ExtractOption { @@ -10746,7 +11177,7 @@ const b = false; extract( "test.tsx", r#"import {Box} from '@devup-ui/core' -const color = "red"; +let color = "red"; "#, ExtractOption { @@ -10891,8 +11322,8 @@ const className = DevUI.css({ bg: "red" }) extract( "test.tsx", r#"import {Box} from '@devup-ui/core' -const variant = "primary"; -const colors = { primary: "blue", secondary: "red" }; +let variant = "primary"; +let colors = { primary: "blue", secondary: "red" }; "#, ExtractOption { @@ -10917,7 +11348,7 @@ const colors = { primary: "blue", secondary: "red" }; extract( "test.tsx", r#"import {Box} from '@devup-ui/core' -const dynamicColor = "blue"; +let dynamicColor = "blue"; "#, ExtractOption { @@ -10991,9 +11422,9 @@ const extraProps = { onClick: () => {} }; extract( "test.tsx", r#"import {Box} from '@devup-ui/core' -const color = "red"; -const padding = 10; -const margin = 5; +let color = "red"; +let padding = 10; +let margin = 5; "#, ExtractOption { @@ -11019,7 +11450,7 @@ const margin = 5; extract( "test.tsx", r#"import {Box} from '@devup-ui/core' -const color = "red"; +let color = "red"; "#, ExtractOption { @@ -11077,14 +11508,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 +12416,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 { styled } from '@devup-ui/react' +const A = styled.div({ '@layer': { base: { + color: cond ? 'red' : 'blue', + positioning: pos, + bg: { a: 'red', b: 'blue' }[key], + typography: typo, + width: w, + p: [1, 2], + '@layer': { inner: { m: 1 } }, +} } }) +", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + } + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_vanilla_extract_layer_records() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "layers.css.ts", + r"import { layer, style, globalStyle } from '@vanilla-extract/css' +const reset = layer('reset') +export const components = layer('components') +globalStyle('a', { color: 'blue', '@layer': { [reset]: { color: 'inherit', padding: 0 } } }) +export const button = style({ color: 'red', '@layer': { [components]: { color: 'green', padding: 4 } } }) +", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed + )]) + } + ) + .unwrap() + )); + } + #[test] #[serial] fn test_vanilla_extract_layer() { @@ -13101,6 +13592,36 @@ globalCss({ assert!(!output.code.is_empty()); } + #[test] + #[serial] + fn test_stylesheet_evaluation_errors() { + reset_class_map(); + reset_file_map(); + let error = extract( + "broken.css.ts", + "import { type StyleRule, style } from '@devup-ui/react';\nconst tokens = { brand: 'red' };\nexport const a = style({ color: tokens.accent.toUpperCase() });", + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!( + error.starts_with("JS execution error: TypeError"), + "{error}" + ); + + // Devup UI's own APIs still compile when evaluation fails + reset_class_map(); + reset_file_map(); + let output = extract( + "own.css.ts", + "import type { DevupProps } from '@devup-ui/react';\nimport { css } from '@devup-ui/react';\nexport const a = css({ color: 'red' });", + ExtractOption::default(), + ) + .unwrap(); + assert!(!output.code.contains("css("), "{}", output.code); + } + #[test] #[serial] fn test_vanilla_extract_execution_fallback() { @@ -14755,6 +15276,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() { @@ -15727,86 +16374,33 @@ const result = stylex.props(styles.bar(myH, myW));", // StyleX Phase 4c: Diagnostic error/warning tests // ========================================== - #[test] - #[serial] - fn test_stylex_error_computed_key() { - reset_class_map(); - reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const key = 'color'; -const styles = stylex.create({ - base: { - [key]: 'red', - }, -});", - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - }, - ) - .unwrap() - )); - } - - #[test] - #[serial] - fn test_stylex_error_spread_in_namespace() { - reset_class_map(); - reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const shared = { color: 'red' }; -const styles = stylex.create({ - base: { - ...shared, - fontSize: '16px', - }, -});", - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - }, - ) - .unwrap() - )); - } - #[test] #[serial] fn test_stylex_error_non_static_value() { - reset_class_map(); - reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ - base: { - color: someVariable, - fontSize: '16px', - }, -});", - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - }, - ) - .unwrap() - )); + for (value, shown) in [ + ("someVariable", "someVariable"), + ("someFunc()", "someFunc()"), + ("theme.colors.primary", "theme.colors.primary"), + ] { + reset_class_map(); + reset_file_map(); + let message = extract( + "test.tsx", + &format!( + "import stylex from '@stylexjs/stylex';\nconst styles = stylex.create({{ base: {{ color: {value}, fontSize: '16px' }} }});" + ), + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert_eq!( + message, + format!( + "test.tsx:2:47: `stylex.create()` cannot use `{shown}` at build time: its values must be literals, theme tokens or constants, or be computed from them" + ) + ); + } } #[test] @@ -15836,32 +16430,6 @@ const styles = stylex.create({ )); } - #[test] - #[serial] - fn test_stylex_error_destructuring_create() { - reset_class_map(); - reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const { base } = stylex.create({ - base: { - color: 'red', - }, -});", - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - }, - ) - .unwrap() - )); - } - #[test] #[serial] fn test_stylex_coexist_with_box() { @@ -16155,14 +16723,14 @@ const styles = stylex.create({ base: { fontSize: stylex.types.length(16) } });", #[test] #[serial] - fn test_stylex_types_unresolvable() { + fn test_stylex_dynamic_empty_params() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: { fontSize: stylex.types.length(someVar) } });", +const styles = stylex.create({ base: () => ({ color: 'red' }) });", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16177,14 +16745,14 @@ const styles = stylex.create({ base: { fontSize: stylex.types.length(someVar) } #[test] #[serial] - fn test_stylex_pseudo_non_object_value() { + fn test_stylex_dynamic_numeric_value() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: { ':hover': 'invalid' } });", +const styles = stylex.create({ base: (x) => ({ fontSize: 16, height: x }) });", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16199,14 +16767,32 @@ const styles = stylex.create({ base: { ':hover': 'invalid' } });", #[test] #[serial] - fn test_stylex_non_object_namespace_value() { + fn test_stylex_dynamic_keyframe_ref() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const fadeIn = stylex.keyframes({ from: { opacity: '0' }, to: { opacity: '1' } }); +const styles = stylex.create({ base: (dur) => ({ animationName: fadeIn, animationDuration: dur }) });", + ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() }, + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_stylex_props_conditional_consequent_only() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: 'not-an-object', active: { color: 'blue' } });", +const styles = stylex.create({ active: { color: 'red' } }); +const el =
;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16221,15 +16807,15 @@ const styles = stylex.create({ base: 'not-an-object', active: { color: 'blue' } #[test] #[serial] - fn test_stylex_spread_at_namespace_level() { + fn test_stylex_props_conditional_alternate_only() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const other = {}; -const styles = stylex.create({ ...other, base: { color: 'red' } });", +const styles = stylex.create({ fallback: { color: 'gray' } }); +const el =
;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16244,15 +16830,15 @@ const styles = stylex.create({ ...other, base: { color: 'red' } });", #[test] #[serial] - fn test_stylex_computed_namespace_key() { + fn test_stylex_props_conditional_none_none() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const key = 'base'; -const styles = stylex.create({ [key]: { color: 'red' } });", +const styles = stylex.create({ base: { color: 'red' } }); +const el =
;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16267,14 +16853,15 @@ const styles = stylex.create({ [key]: { color: 'red' } });", #[test] #[serial] - fn test_stylex_dynamic_empty_params() { + fn test_stylex_props_falsy_literals() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: () => ({ color: 'red' }) });", +const styles = stylex.create({ base: { color: 'red' } }); +const el =
;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16289,14 +16876,15 @@ const styles = stylex.create({ base: () => ({ color: 'red' }) });", #[test] #[serial] - fn test_stylex_dynamic_block_body() { + fn test_stylex_props_unresolvable_arg() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: (x) => { return { color: x }; } });", +const styles = stylex.create({ base: { color: 'red' } }); +const el =
;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16311,14 +16899,15 @@ const styles = stylex.create({ base: (x) => { return { color: x }; } });", #[test] #[serial] - fn test_stylex_dynamic_numeric_value() { + fn test_stylex_props_dynamic_as_non_call() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: (x) => ({ fontSize: 16, height: x }) });", +const styles = stylex.create({ base: (x) => ({ color: x }) }); +const el =
;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16333,15 +16922,15 @@ const styles = stylex.create({ base: (x) => ({ fontSize: 16, height: x }) });", #[test] #[serial] - fn test_stylex_dynamic_keyframe_ref() { + fn test_stylex_include_dynamic_target() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const fadeIn = stylex.keyframes({ from: { opacity: '0' }, to: { opacity: '1' } }); -const styles = stylex.create({ base: (dur) => ({ animationName: fadeIn, animationDuration: dur }) });", +const base = stylex.create({ dynamic: (x) => ({ color: x, fontSize: '14px' }) }); +const composed = stylex.create({ fancy: { ...stylex.include(base.dynamic), backgroundColor: 'blue' } });", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() }, ) .unwrap() @@ -16350,15 +16939,14 @@ const styles = stylex.create({ base: (dur) => ({ animationName: fadeIn, animatio #[test] #[serial] - fn test_stylex_props_conditional_consequent_only() { + fn test_stylex_import_unknown_named() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ active: { color: 'red' } }); -const el =
;", + r"import { create, unknownFunction } from '@stylexjs/stylex'; +const styles = create({ base: { color: 'red' } });", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16371,17 +16959,25 @@ const el =
;", )); } + // ========================================== + // Coverage tests: extract_style_from_stylex.rs + // ========================================== + + // ========================================== + // Coverage tests: stylex.rs + // ========================================== + + /// Lines 66-67: Named import `types.X()` #[test] #[serial] - fn test_stylex_props_conditional_alternate_only() { + fn test_stylex_named_import_types() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ fallback: { color: 'gray' } }); -const el =
;", + r"import { create, types } from '@stylexjs/stylex'; +const styles = create({ base: { width: types.length('100px') } });", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16394,17 +16990,22 @@ const el =
;", )); } + // ========================================== + // Coverage tests: visit.rs + // ========================================== + + /// Line 188: Empty className in static namespace — styles.empty where empty has no properties #[test] #[serial] - fn test_stylex_props_conditional_none_none() { + fn test_stylex_props_empty_namespace() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: { color: 'red' } }); -const el =
;", +const styles = stylex.create({ empty: {} }); +const el =
;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16417,9 +17018,10 @@ const el =
;", )); } + /// Line 198: styles.nonexistent — member not found in namespace map #[test] #[serial] - fn test_stylex_props_falsy_literals() { + fn test_stylex_props_nonexistent_member() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( @@ -16427,7 +17029,7 @@ const el =
;", "test.tsx", r"import stylex from '@stylexjs/stylex'; const styles = stylex.create({ base: { color: 'red' } }); -const el =
;", +const el =
;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16440,9 +17042,10 @@ const el =
;", )); } + /// Line 208: None from `LogicalExpression` when right side can't resolve #[test] #[serial] - fn test_stylex_props_unresolvable_arg() { + fn test_stylex_props_logical_unresolvable_right() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( @@ -16450,7 +17053,7 @@ const el =
;", "test.tsx", r"import stylex from '@stylexjs/stylex'; const styles = stylex.create({ base: { color: 'red' } }); -const el =
;", +const el =
;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16463,17 +17066,18 @@ const el =
;", )); } + /// Line 332: include with empty `class_name_str` — include a namespace that has no properties #[test] #[serial] - fn test_stylex_props_dynamic_as_non_call() { + fn test_stylex_include_empty_namespace() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: (x) => ({ color: x }) }); -const el =
;", +const base = stylex.create({ empty: {} }); +const composed = stylex.create({ test: { ...stylex.include(base.empty), color: 'red' } });", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16486,18 +17090,26 @@ const el =
;", )); } + /// Include-only namespace with no own styles — `class_name_str` starts empty + /// Covers: visit.rs line 332 (`class_name_str` = `included_class` when empty) #[test] #[serial] - fn test_stylex_include_dynamic_target() { + fn test_stylex_include_only_no_own_styles() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r"import stylex from '@stylexjs/stylex'; -const base = stylex.create({ dynamic: (x) => ({ color: x, fontSize: '14px' }) }); -const composed = stylex.create({ fancy: { ...stylex.include(base.dynamic), backgroundColor: 'blue' } });", - ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() }, +const base = stylex.create({ root: { color: 'red' } }); +const composed = stylex.create({ combined: { ...stylex.include(base.root) } });", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + }, ) .unwrap() )); @@ -16505,329 +17117,415 @@ const composed = stylex.create({ fancy: { ...stylex.include(base.dynamic), backg #[test] #[serial] - fn test_stylex_import_unknown_named() { + fn test_length_token_extraction() { + // Test $token on gap prop reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import { create, unknownFunction } from '@stylexjs/stylex'; -const styles = create({ base: { color: 'red' } });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - - // ========================================== - // Coverage tests: extract_style_from_stylex.rs - // ========================================== - /// Line 24: Non-object argument to `stylex.create()` - #[test] - #[serial] - fn test_stylex_create_non_object_arg() { + // Test $token on padding/margin shortcuts reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r#"import stylex from '@stylexjs/stylex'; -const styles = stylex.create("not-object");"#, + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - /// Lines 100, 103: Non-ObjectProperty / no string key inside pseudo inner properties - #[test] - #[serial] - fn test_stylex_pseudo_inner_spread_and_computed() { + // Test $token on multiple spacing props reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const other = { fontSize: '14px' }; -const styles = stylex.create({ base: { ':hover': { ...other, [Symbol()]: 'val', color: 'red' } } });", - ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() }, + r#"import {Flex} from '@devup-ui/react' + + "#, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + } ) .unwrap() )); } - /// Lines 232, 236: Non-ObjectProperty / no key in dynamic body (spread and computed) #[test] #[serial] - fn test_stylex_dynamic_body_spread_and_computed() { + fn test_shadow_token_extraction() { + // Test $token on boxShadow prop reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const other = { fontSize: '14px' }; -const styles = stylex.create({ base: (x) => ({ ...other, [Symbol()]: x, color: 'red' }) });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - /// Line 269: Unresolvable value in dynamic namespace (not a param, not string, not number) - #[test] - #[serial] - fn test_stylex_dynamic_unresolvable_value() { + // Test shadow token with other props reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const someVar = getSomething(); -const styles = stylex.create({ base: (x) => ({ color: x, fontSize: someVar }) });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); } - // ========================================== - // Coverage tests: stylex.rs - // ========================================== - - /// Line 48: false from `is_include_call_static` — spread with non-include call #[test] #[serial] - fn test_stylex_spread_non_include_call() { + fn test_responsive_length_token_literal_vs_array() { + use css::theme_tokens::set_theme_token_levels; + + let mut length = BTreeMap::new(); + length.insert("containerX".to_string(), vec![0, 2]); + set_theme_token_levels(length, BTreeMap::new()); + + // String literal: w="$containerX" → expands to multiple breakpoint classes reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -function notInclude() { return {}; } -const styles = stylex.create({ base: { ...notInclude(), color: 'red' } });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - /// Lines 66-67: Named import `types.X()` - #[test] - #[serial] - fn test_stylex_named_import_types() { + // Expression: w={"$containerX"} → also expands (same as string literal) reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import { create, types } from '@stylexjs/stylex'; -const styles = create({ base: { width: types.length('100px') } });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - /// Line 70: false from `is_types_call` — call expression value that's not types or firstThatWorks - #[test] - #[serial] - fn test_stylex_non_types_non_ftw_call() { + // Array with single element: w={["$containerX"]} → single class, base value only reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -function someFunc() { return 'red'; } -const styles = stylex.create({ base: { color: someFunc() } });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - /// Line 183: types with unresolvable inside condition object - #[test] - #[serial] - fn test_stylex_types_unresolvable_in_condition() { + // Mixed array: w={["1px", null, "$containerX"]} → token inside array stays single per slot reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const someVar = getSomething(); -const styles = stylex.create({ base: { fontSize: { default: stylex.types.length(someVar) } } });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); } - /// Line 188: Non-object/non-string/non-number/non-null/non-call value in decompose (array expression) #[test] #[serial] - fn test_stylex_array_in_condition_object() { + fn test_responsive_shadow_token_literal_vs_array() { + use css::theme_tokens::set_theme_token_levels; + + let mut shadow = BTreeMap::new(); + shadow.insert("card".to_string(), vec![0, 3]); + set_theme_token_levels(BTreeMap::new(), shadow); + + // String literal: boxShadow="$card" → expands to multiple breakpoint classes reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: { opacity: { default: [1, 2] } } });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - /// Line 195: Non-ObjectProperty inside condition object (spread) - #[test] - #[serial] - fn test_stylex_spread_in_condition_object() { + // Expression: boxShadow={"$card"} → also expands (same as string literal) reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const spreadObj = { ':hover': 'blue' }; -const styles = stylex.create({ base: { color: { ...spreadObj, default: 'red' } } });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - /// Line 198: No string key in condition object (computed key) - #[test] - #[serial] - fn test_stylex_computed_key_in_condition_object() { + // Array with single element: boxShadow={["$card"]} → single class, base value only reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const key = ':hover'; -const styles = stylex.create({ base: { color: { [key]: 'blue', default: 'red' } } });", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - - // ========================================== - // Coverage tests: visit.rs - // ========================================== - /// Line 188: Empty className in static namespace — styles.empty where empty has no properties - #[test] - #[serial] - fn test_stylex_props_empty_namespace() { + // Mixed array: boxShadow={["none", null, null, "$card"]} → token inside array stays single per slot reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ empty: {} }); -const el =
;", + r#"import {Box} from '@devup-ui/react' + + "#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); } - /// Line 198: styles.nonexistent — member not found in namespace map #[test] #[serial] - fn test_stylex_props_nonexistent_member() { + fn extract_pseudo_selector_with_non_literal_value_graceful() { + // Minimal regression tests for the panic that ERROR.tsx surfaced. + // + // When a pseudo-selector prop (`_hover`, `_active`, `_focus`, ...) + // receives anything other than an inline object literal, the + // extractor used to `unwrap()` a None prop-name inside the + // `_xxx` selector recursion path and panic. The fix is to return + // an empty ExtractResult from the non-literal branches of + // `extract_style_from_expression` and + // `extract_style_from_member_expression` when `name` is `None`. + // + // Each case below must NOT panic (used to panic before the fix). + // Formerly the un-extractable pseudo-selector attribute was dropped + // from the generated code (no class, no runtime style — devup-ui is + // fully static, so there is no runtime fallback to fall back + // to) + let cases: &[(&str, &str)] = &[ + ( + "identifier", + r"import {Box} from '@devup-ui/react' +const hoverStyle = { opacity: 1 }; +export const A = () => ; +", + ), + ( + "call expression", + r"import {Box} from '@devup-ui/react' +declare const getHover: () => object; +export const A = () => ; +", + ), + ( + "member expression", + r"import {Box} from '@devup-ui/react' +declare const styles: { hover: object }; +export const A = () => ; +", + ), + ( + "binary expression", + r"import {Box} from '@devup-ui/react' +declare const a: any; declare const b: any; +export const A = () => ; +", + ), + ( + "template literal", + r"import {Box} from '@devup-ui/react' +declare const x: string; +export const A = () => ; +", + ), + ( + "unary expression", + r"import {Box} from '@devup-ui/react' +declare const v: any; +export const A = () => ; +", + ), + ( + "computed member expression (array index)", + r"import {Box} from '@devup-ui/react' +declare const arr: any[]; +export const A = () => ; +", + ), + ]; + + for (label, src) in cases { + reset_class_map(); + reset_file_map(); + let result = std::panic::catch_unwind(|| { + extract( + "test.tsx", + src, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + }); + // A constant object is inlined; anything else is a located error + match result { + Ok(Ok(output)) => { + assert_eq!(*label, "identifier"); + assert_eq!(output.styles.len(), 1); + } + Ok(Err(e)) => { + assert!(e.to_string().contains("`` cannot use"), "{label}: {e}"); + } + Err(panic_payload) => { + let msg = panic_payload + .downcast_ref::<&'static str>() + .map(|s| (*s).to_string()) + .or_else(|| panic_payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "".to_string()); + panic!("[FAIL] {label}: extract panicked: {msg}"); + } + } + } + } + + #[test] + #[serial] + fn extract_pseudo_selector_with_identifier_snapshot() { + // Snapshot-locks the current behavior for the minimal ERROR.tsx + // reduction so future refactors can't silently regress either + // direction (re-introducing the panic, or over-eagerly turning the + // `_hover={ident}` attribute into something surprising). reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: { color: 'red' } }); -const el =
;", + r#"import {Box} from '@devup-ui/react' +const hoverStyle = { opacity: 1 }; +export const A = () => ; +"#, ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -16840,121 +17538,274 @@ const el =
;", )); } - /// Line 208: None from `LogicalExpression` when right side can't resolve + // Coverage for extract_style_from_expression.rs:206 — the + // `Expression::BinaryExpression | StaticMemberExpression | CallExpression` + // arm. Both internal branches must be exercised in a single, dedicated + // test so coverage tooling can attribute hits to this exact line: + // + // 207: `if let Some(name) = name { dynamic_style(...) }` (Some path) + // 211: `else { ExtractResult::default() }` (None path, + // reached only via `_xxx={...}` selector recursion) + // + // Each variant (binary / static-member / call) is asserted on its own so + // a regression in any single pattern fails loudly instead of being hidden + // behind a multi-snapshot test. #[test] #[serial] - fn test_stylex_props_logical_unresolvable_right() { + fn extract_dynamic_style_props_binary_member_call_arm() { + // ── Some(name) branch — line 207 ──────────────────────────────── + // BinaryExpression on a real prop name reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: { color: 'red' } }); -const el =
;", + r#"import { Box } from "@devup-ui/core"; +; +"#, ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - /// Line 332: include with empty `class_name_str` — include a namespace that has no properties - #[test] - #[serial] - fn test_stylex_include_empty_namespace() { + // StaticMemberExpression on a real prop name reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const base = stylex.create({ empty: {} }); -const composed = stylex.create({ test: { ...stylex.include(base.empty), color: 'red' } });", + r#"import { Box } from "@devup-ui/core"; +; +"#, ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); - } - /// Dynamic namespace with bare expression body (not object): (x) => x - /// Covers: `extract_style_from_stylex.rs` `ObjectExpression` else branch - #[test] - #[serial] - fn test_stylex_dynamic_bare_expression_body() { + // CallExpression on a real prop name reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: (x) => x });", + r#"import { Box } from "@devup-ui/core"; +; +"#, ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: HashMap::new() - }, + } ) .unwrap() )); + + // A selector takes styles, so a value it cannot read is an error + for src in [ + // BinaryExpression + r"import {Box} from '@devup-ui/react' +declare const a: any; declare const b: any; +export const A = () => ; +", + // StaticMemberExpression + r"import {Box} from '@devup-ui/react' +declare const t: { hover: object }; +export const A = () => ; +", + // CallExpression + r"import {Box} from '@devup-ui/react' +declare const fn: () => object; +export const A = () => ; +", + ] { + reset_class_map(); + reset_file_map(); + let error = extract("test.tsx", src, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!( + error.contains("`` cannot use") && error.contains(utils::STYLE_OBJECT), + "{src}: {error}" + ); + } + } + + fn extract_tsx(code: &str) -> ExtractOutput { + reset_class_map(); + reset_file_map(); + extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .expect("extract should not fail") + } + + /// `Argument::to_expression` panics on `...spread`. Every call site that can receive + /// user-written arguments must reject the spread instead of unwrapping it. + #[test] + #[serial] + fn test_spread_arguments_never_panic() { + let stylex = "import stylex from '@stylexjs/stylex';\n"; + for source in [format!("{stylex}const e =
;"), format!("{stylex}const s = stylex.create(...a);"), format!("{stylex}const k = stylex.keyframes(...a);"), format!("{stylex}const s = stylex.create({{ bar: (h) => ({{ height: h }}) }});\nconst e =
;"), format!("{stylex}const s = stylex.create({{ b: {{ position: stylex.firstThatWorks(...a) }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ width: stylex.types.length(...a) }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ ...stylex.include(...a) }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ color: {{ default: stylex.firstThatWorks(...a) }} }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ width: {{ default: stylex.types.length(...a) }} }} }});"), "import { jsx } from 'react/jsx-runtime';\nimport { Box } from '@devup-ui/react';\nconst e = jsx(...a);".to_string(), "import { jsx } from 'react/jsx-runtime';\nimport { Box } from '@devup-ui/react';\nconst e = jsx(Box, ...a);".to_string(), "import { globalCss } from '@devup-ui/react';\nglobalCss({ imports: [...list] });".to_string()] { + reset_class_map(); + reset_file_map(); + let _ = extract("test.tsx", &source, ExtractOption::default()); + } + } + + #[test] + #[serial] + fn test_stylex_props_style_x_array() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const s = stylex.create({ a: { color: 'red' }, b: { marginTop: '1px' } }); +const flat =
; +const nested =
; +const conditional =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_props_ts_wrapper_and_optional_chain() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const s = stylex.create({ a: { color: 'red' }, b: { marginTop: '1px' } }); +const cast =
; +const satisfied =
; +const nonNull =
; +const parens =
; +const chained =
; +const computedChain =
; +const chainedCall =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_attrs_emits_class_attribute() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const s = stylex.create({ a: { color: 'red' } }); +const e =
;" + ))); } - /// Dynamic namespace with parenthesized non-object body: (x) => (x) - /// Covers: `extract_style_from_stylex.rs` `ParenthesizedExpression` unwrap + `ObjectExpression` else #[test] #[serial] - fn test_stylex_dynamic_paren_non_object_body() { + fn test_stylex_define_vars_and_create_theme() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const colors = stylex.defineVars({ primary: 'blue', secondary: 'grey' }); +const dark = stylex.createTheme(colors, { primary: 'navy' }); +const styles = stylex.create({ box: { color: colors.primary, backgroundColor: colors.secondary } }); +const el =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_theme_apis_with_named_imports() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { defineVars, createTheme, create, props } from '@stylexjs/stylex'; +const colors = defineVars({ primary: 'blue' }); +const dark = createTheme(colors, { primary: 'navy' }); +const styles = create({ box: { color: colors.primary } }); +const el =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_theme_contract_and_constants() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +import { defineConsts } from '@stylexjs/stylex'; +const vars = stylex.createThemeContract({ primary: null }); +const consts = defineConsts({ gap: '8px' }); +const dark = stylex.createTheme(vars, { primary: 'navy' }); +const styles = stylex.create({ box: { color: vars.primary, marginTop: consts.gap } }); +const el =
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_position_try_and_view_transition_class() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import stylex from '@stylexjs/stylex'; +const fallback = stylex.positionTry({ top: '0', insetBlockEnd: 'auto' }); +const transition = stylex.viewTransitionClass({ animationDuration: '300ms' });" + ))); + } + + #[test] + #[serial] + fn test_styled_object_form_theme_interpolation() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { styled } from '@devup-ui/react'; +const Themed = styled('div')({ color: (p) => p.theme.brand });" + ))); reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_eq!( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const styles = stylex.create({ base: (x) => (x) });", - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - }, + "import { styled } from '@devup-ui/react';\nconst Plain = styled('span')({ color: (p) => p.color });", + ExtractOption::default(), ) - .unwrap() - )); + .unwrap_err() + .to_string(), + "test.tsx:2:39: `styled()` cannot use `(p) => p.color` at build time: its styles must be an object literal or a constant object, or be computed from constants" + ); } - /// Include-only namespace with no own styles — `class_name_str` starts empty - /// Covers: visit.rs line 332 (`class_name_str` = `included_class` when empty) #[test] #[serial] - fn test_stylex_include_only_no_own_styles() { + fn test_emotion_global_component_and_import_surface() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import stylex from '@stylexjs/stylex'; -const base = stylex.create({ root: { color: 'red' } }); -const composed = stylex.create({ combined: { ...stylex.include(base.root) } });", + r"import styled from '@emotion/styled'; +import { css, keyframes, Global, ThemeProvider, useTheme, ClassNames } from '@emotion/react'; +const S = styled.div`color: ${p => p.theme.brand};`; +export const App = () => <>;", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, - import_aliases: HashMap::new() + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()) + ) + ]) }, ) .unwrap() @@ -16963,720 +17814,2246 @@ const composed = stylex.create({ combined: { ...stylex.include(base.root) } });" #[test] #[serial] - fn test_length_token_extraction() { - // Test $token on gap prop + fn test_emotion_and_styled_components_object_styles() { + let aliases = HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ( + "styled-components".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]); + for code in [ + r"import styled from '@emotion/styled'; +import { css, keyframes, Global } from '@emotion/react'; +export const a = css({ padding: 8, lineHeight: 1.5, m: 2 }, [{ margin: 1 }, null, undefined, false]); +export const B = styled('div', { shouldForwardProp: (p) => p !== 'x' })({ left: 3 }); +export const C = styled('span', { label: 'c' })`color: red;`; +export const D = styled.div([{ top: 1, color: 'red', '&:hover': { margin: 5 } }, { top: 3, '&:hover': { padding: 6 } }]); +export const E = styled.div({ bottom: 2 }, cond && { right: 4 }); +export const F = styled('p', { color: 'blue' }); +export const spin = keyframes({ to: { width: 10 } }); +export const App = () => <>;", + r"import * as Styled from 'styled-components'; +import { css } from 'styled-components'; +export const G = Styled.div.attrs({ type: 'button' }).withConfig({ displayName: 'g' })({ right: 7 }); +export const H = Styled(Base).withConfig({ displayName: 'h' })({ left: 9 }); +export const I = Styled.div({ width: 1 }); +export const j = css({ height: 2 });", + ] { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: aliases.clone(), + }, + ) + .unwrap() + )); + } + } + + #[test] + #[serial] + fn test_styled_components_attrs_and_every_styled_import() { + let aliases = HashMap::from([ + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ( + "styled-components".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]); reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, + r"import emotion from '@emotion/styled'; +import sc from 'styled-components'; +export const A = emotion.div({ top: 1 }); +export const B = sc.span({ left: 2 }); +export const C = sc.button.attrs({ type: 'button' }).withConfig({ displayName: 'c' })({ right: 3 }); +export const D = sc(Base).withConfig({ shouldForwardProp: () => true })`color: red;`; +export const E = (sc.input.attrs((props) => ({ size: props.small ? 5 : 10 })) as any).attrs(extra)({ bottom: 4 }); +export const F = sc.div.attrs({ role: 'note' }); +export const G = other.div.attrs({ role: 'note' })({ margin: 1 }); +export const H = sc.div.attrs(...rest)({ margin: 2 }); +export const I = factory.attrs({ id: 'i' })({ margin: 3 }); +export const J = other(Base).attrs({ id: 'j' })({ margin: 4 });", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, - import_aliases: HashMap::new() - } + import_aliases: aliases, + }, ) .unwrap() )); + } - // Test $token on padding/margin shortcuts + #[test] + #[serial] + fn test_composed_css_keeps_composed_classes() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, + r"import { style } from '@vanilla-extract/css'; +import { css } from '@emotion/react'; +const base = style({ color: 'red' }); +export const a = style([base, { margin: 2 }]); +export const b = css(base, 'extra', { padding: 1 }); +export const c = css([base]); +export const d = style([cond && base]);", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, - import_aliases: HashMap::new() - } + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed + ) + ]) + }, ) .unwrap() )); + } - // Test $token on multiple spacing props - reset_class_map(); - reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( + fn memory_resolver( + files: &'static [(&'static str, &'static str)], + ) -> impl Fn(&str, &str) -> Option { + move |specifier, importer| { + let directory = importer + .rsplit_once('/') + .map_or("", |(directory, _)| directory); + let path = format!("{directory}/{}", specifier.trim_start_matches("./")); + files + .iter() + .find(|(file, _)| { + [".ts", ".css.ts", ".js", ""] + .iter() + .any(|extension| format!("{path}{extension}") == *file) + }) + .map(|(file, code)| ResolvedModule { + path: (*file).to_string(), + code: (*code).to_string(), + }) + } + } + + const STYLESHEET_MODULES: &[(&str, &str)] = &[ + ( + "/src/theme.css.ts", + r"import { createTheme, style } from '@vanilla-extract/css'; +export const [themeClass, vars] = createTheme({ color: { brand: 'red' }, space: '4px' }); +export const base = style({ padding: vars.space });", + ), + ( + "/src/tokens.ts", + r"import { extra } from './more'; +export const brand: string = 'blue'; +export default 8; +const hidden = 1; +export { hidden as shown }; +export * from './more'; +export * as more from './more'; +export { extra as renamed } from './more'; +export function double(n: number) { return n * 2 + extra - extra; } +export class Scale {}", + ), + ("/src/more.ts", "export const extra = 3;"), + ( + "/src/named.ts", + "export default function named() { return 1 }\nexport const other = 1", + ), + ("/src/anonymous.ts", "export default class { }"), + ( + "/src/legacy.js", + "const { extra } = require('./more');\nconst compiled = require('./compiled');\nmodule.exports = { legacy: extra + compiled.default + compiled.named, default: 'ignored' };", + ), + ( + "/src/compiled.js", + "'use strict';\nObject.defineProperty(exports, '__esModule', { value: true });\nexports.named = 2;\nexports.default = 10;", + ), + ("/src/value.js", "module.exports = 5;"), + ]; + + const STYLEX_MODULES: &[(&str, &str)] = &[ + ( + "/src/vars.stylex.ts", + r"import * as stylex from '@stylexjs/stylex'; +import { defineConsts, create } from '@stylexjs/stylex'; +import sx from '@stylexjs/stylex'; +import other from './brand'; +import { BRAND } from './brand'; +const DARK = '@media (prefers-color-scheme: dark)'; +const GAP = 'gap'; +export const colors = stylex.defineVars({ primary: 'blue', brand: BRAND, mode: { default: 'light', [DARK]: 'dark' }, typed: stylex.types.color({ default: 'red', '@supports (color: red)': null }) }); +export const consts = defineConsts({ [GAP]: '8px', size: 4 }); +export const contract = sx.createThemeContract({ accent: null }); +export const dark = stylex.createTheme(colors, { primary: 'navy' }); +export const notStylex = other.defineVars({ a: 'b' }); +export const unknownCallee = missing.defineVars({ a: 'b' }); +export const called = stylex({ a: 'b' }); +export const nested = stylex.types.defineVars({ a: 'b' }); +export const memberOfNamed = create.defineVars({ a: 'b' }); +export const indirect = (0, stylex.defineVars)({ a: 'b' }); +export const styles = stylex.create({ base: { color: 'red' } });", + ), + ( + "/src/brand.ts", + "export const BRAND = 'green';\nexport default {};", + ), + ( + "/src/loose.stylex.ts", + r"import * as stylex from '@stylexjs/stylex'; +import { consts } from './vars.stylex'; +export const loose = stylex.defineVars({ fine: 'ok', call: getColor(), pseudo: { default: 'a', ':hover': 'b' }, spread: { ...base }, unknownKey: { [unknown]: 'x' }, [computedKey]: 'x' }); +export const unknownTheme = stylex.createTheme(unknown, { primary: 'navy' }); +export const notContract = stylex.createTheme(consts, { primary: 'navy' }); +export const looseConsts = stylex.defineConsts({ fine: '1px', dynamic: getSize() });", + ), + ( + "/src/themes.ts", + r"import * as stylex from '@stylexjs/stylex'; +import { colors } from './vars.stylex'; +export const light = stylex.createTheme(colors, { primary: 'white' });", + ), + ]; + + #[test] + #[serial] + fn test_stylex_code_that_cannot_compile() { + for (code, errors) in [ + ( + "let key = 'base';\nconst styles = stylex.create({ [key]: { color: 'red' } });", + &["`stylex.create()` cannot use `[key]` at build time: its keys must be known"][..], + ), + ( + "const styles = stylex.create(\"not-object\");", + &[ + "`stylex.create()` cannot use `\"not-object\"` at build time: it takes one object literal", + ], + ), + ( + "const styles = stylex.create(...args);", + &[ + "`stylex.create()` cannot use `...args` at build time: it takes one object literal", + ], + ), + ( + "const styles = stylex.create({ base: { opacity: { default: [1, 2] } } });", + &["`stylex.create()` cannot use `[1, 2]` at build time"], + ), + ( + "let prop = 'color';\nconst styles = stylex.create({ base: { [prop]: 'red' } });", + &["`stylex.create()` cannot use `[prop]` at build time"], + ), + ( + "let key = ':hover';\nconst styles = stylex.create({ base: { color: { [key]: 'blue', default: 'red' } } });", + &["`stylex.create()` cannot use `[key]` at build time"], + ), + ( + "const styles = stylex.create({ base: { color: { default: 'red', hover: 'blue' } } });", + &[ + "`stylex.create()` cannot use `hover` at build time: a condition is `default`, a pseudo-class", + ], + ), + ( + "const styles = stylex.create({ a: (x) => x, b: (x) => { return { color: x }; }, c: ({ x }) => ({ color: x }) });", + &[ + "`stylex.create()` cannot use `(x) => x` at build time: a dynamic style is", + "`stylex.create()` cannot use `(x) => { return { color: x }; }` at build time", + "`stylex.create()` cannot use `({ x }) => ({ color: x })` at build time", + ], + ), + ( + "const styles = stylex.create({ base: (x) => ({ ...other, [Symbol()]: x, fontSize: someVar }) });", + &[ + "`stylex.create()` cannot use `...other` at build time: write every entry out", + "`stylex.create()` cannot use `[Symbol()]` at build time", + "`stylex.create()` cannot use `someVar` at build time: a dynamic style's value", + ], + ), + ( + "const { base } = stylex.create({ base: { color: 'red' } });", + &["test.tsx:2:7: `stylex.create()` cannot be destructured at build time"], + ), + ( + "const styles = stylex.create({ ...other, base: { ...shared, ...notInclude(), color: { ...spreadObj, default: 'red' } } });", + &[ + "`stylex.create()` cannot use `...other`", + "`stylex.create()` cannot use `...shared`", + "`stylex.create()` cannot use `...notInclude()`", + "`stylex.create()` cannot use `...spreadObj`", + ], + ), + ( + "const styles = stylex.create({ base: 'not-an-object', hover: { ':hover': 'invalid' }, inner: { ':hover': { ...other, [Symbol()]: 'val' } } });", + &[ + "`stylex.create()` cannot use `\"not-an-object\"` at build time: a namespace is", + "`stylex.create()` cannot use `\"invalid\"` at build time: a pseudo-class or pseudo-element key takes an object of styles", + "`stylex.create()` cannot use `...other`", + ], + ), + ( + "const styles = stylex.create({ base: { fontSize: stylex.types.length(someVar), width: { default: stylex.types.length() }, position: stylex.firstThatWorks('sticky', someVar, ...rest) } });", + &[ + "`stylex.create()` cannot use `stylex.types.length(someVar)` at build time", + "`stylex.create()` cannot use `stylex.types.length()` at build time", + "`stylex.firstThatWorks()` cannot use `someVar` at build time", + "`stylex.firstThatWorks()` cannot use `...rest` at build time", + ], + ), + ( + "const styles = stylex.create({ base: { ...stylex.include(getStyles()) }, other: { ...stylex.include(elsewhere.base) } });", + &[ + "`stylex.include()` cannot use `stylex.include(getStyles())` at build time: it takes a namespace such as `styles.base`", + "`stylex.include()` cannot use `elsewhere.base` at build time: it takes a namespace `stylex.create()` defines earlier in this file", + ], + ), + ( + "const consts = stylex.defineConsts({ ...other, [computed]: '1px', dynamic: someVar });", + &[ + "`stylex.defineConsts()` cannot use `...other`", + "`stylex.defineConsts()` cannot use `[computed]`", + "`stylex.defineConsts()` cannot use `someVar`", + ], + ), + ( + "const vars = stylex.defineVars({ ...other, [computed]: 'red' });\nconst contract = stylex.createThemeContract({ ...other });", + &[ + "`stylex.defineVars()` cannot use `...other`", + "`stylex.defineVars()` cannot use `[computed]`", + "`stylex.createThemeContract()` cannot use `...other`", + ], + ), + ( + "const c = stylex.createThemeContract(x);\nconst d = stylex.defineConsts(x);\nconst v = stylex.viewTransitionClass(x);", + &[ + "`stylex.createThemeContract()` cannot use `x`", + "`stylex.defineConsts()` cannot use `x`", + "`stylex.viewTransitionClass()` cannot use `x`", + ], + ), + ( + "const vars = stylex.defineVars(someVariable);\nconst theme = stylex.createTheme(notAContract, { primary: 'navy' });", + &[ + "`stylex.defineVars()` cannot use `someVariable` at build time: it takes one object literal", + "`stylex.createTheme()` cannot use `notAContract, { primary: \"navy\" }` at build time: it takes a `defineVars()` group", + ], + ), + ( + "const vars = stylex.defineVars({ primary: 'blue' });\nconst theme = stylex.createTheme(vars, { ...other, [computed]: 'red', missing: 'navy' });", + &[ + "`stylex.createTheme()` cannot use `...other`", + "`stylex.createTheme()` cannot use `[computed]`", + "`stylex.createTheme()` cannot use `missing` at build time: `vars` has no such variable", + ], + ), + ( + "const fallback = stylex.positionTry(notAnObject);\nconst entries = stylex.positionTry({ ...spread, [computed]: '0', top: someVar });\nconst transition = stylex.viewTransitionClass({ animationDuration: later });\nconst fade = stylex.keyframes(frames);", + &[ + "`stylex.positionTry()` cannot use `notAnObject` at build time: it takes one object literal", + "`stylex.positionTry()` cannot use `...spread`", + "`stylex.positionTry()` cannot use `[computed]`", + "`stylex.positionTry()` cannot use `someVar`", + "`stylex.viewTransitionClass()` cannot use `later`", + "`stylex.keyframes()` cannot use `frames` at build time: it takes one object literal", + ], + ), + ] { + reset_class_map(); + reset_file_map(); + let message = extract( "test.tsx", - r#"import {Flex} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } + &format!("import stylex from '@stylexjs/stylex';\n{code}"), + ExtractOption::default(), ) - .unwrap() - )); + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + for error in errors { + assert!(message.contains(error), "{error}\nmissing from\n{message}"); + } + } } #[test] #[serial] - fn test_shadow_token_extraction() { - // Test $token on boxShadow prop - reset_class_map(); - reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + fn test_stylex_props_join_styles_compiled_elsewhere() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import * as stylex from '@stylexjs/stylex'; +const fade = stylex.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }); +const colors = stylex.defineVars({ text: 'black' }); +const styles = stylex.create({ + base: { animationName: { default: fade, ':hover': 'none' }, color: { default: colors.text, ':hover': 'red' } }, + bar: (h) => ({ height: h }), + none: null, +}); +export const A = ({ style, rest }) =>
;" + ))); + } - // Test shadow token with other props - reset_class_map(); - reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( + #[test] + #[serial] + fn test_stylex_conditional_variables() { + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import * as stylex from '@stylexjs/stylex'; +const DARK = '@media (prefers-color-scheme: dark)'; +const colors = stylex.defineVars({ + layered: { '@supports (display: grid)': { [DARK]: 'navy' }, [DARK]: 'teal', default: 'white' }, + text: { default: 'black', [DARK]: 'white' }, + accent: stylex.types.color({ + default: 'blue', + [DARK]: 'lightblue', + '@supports (color: oklch(0 0 0))': { default: 'oklch(0.6 0.2 250)', [DARK]: 'oklch(0.8 0.1 250)' }, + }), + onlyDark: { default: null, [DARK]: 'gray' }, + size: '4px', +}); +const dracula = stylex.createTheme(colors, { text: { [DARK]: 'pink', default: 'purple' }, accent: 'red' }); +const styles = stylex.create({ box: { color: colors.text, borderColor: colors.accent } }); +export const A = () =>
;" + ))); + } + + #[test] + #[serial] + fn test_stylex_variable_values_known_only_at_runtime() { + for (code, error) in [ + ( + "const colors = stylex.defineVars({ text: someValue });", + "`stylex.defineVars()` cannot use `someValue`", + ), + ( + "const colors = stylex.defineVars({ text: { default: 'a', ':hover': 'b' } });", + "`stylex.defineVars()` cannot use `{", + ), + ( + "const colors = stylex.defineVars({ text: { default: 'a', ...rest } });", + "`stylex.defineVars()` cannot use `{", + ), + ( + "const colors = stylex.defineVars({ text: 'a' });\nconst theme = stylex.createTheme(colors, { text: getText() });", + "`stylex.createTheme()` cannot use `getText()`", + ), + ] { + reset_class_map(); + reset_file_map(); + let message = extract( "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } + &format!("import * as stylex from '@stylexjs/stylex';\n{code}"), + ExtractOption::default(), ) - .unwrap() - )); + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(message.contains(error), "{message}"); + } } #[test] #[serial] - fn test_responsive_length_token_literal_vs_array() { - use css::theme_tokens::set_theme_token_levels; + fn test_stylex_values_imported_from_other_modules() { + for single_css in [true, false] { + reset_class_map(); + reset_file_map(); + let option = ExtractOption { + single_css, + ..ExtractOption::default() + }; + let resolver = memory_resolver(STYLEX_MODULES); + let extract_module = |path: &str| { + let code = STYLEX_MODULES + .iter() + .find(|(file, _)| *file == path) + .map(|(_, code)| *code) + .unwrap(); + extract_with_modules(path, code, option.clone(), false, &resolver).unwrap() + }; + let vars = extract_module("/src/vars.stylex.ts"); + let themes = extract_module("/src/themes.ts"); + let app = extract_with_modules( + "/src/App.tsx", + r"import * as stylex from '@stylexjs/stylex'; +import { colors, consts, contract, dark } from './vars.stylex'; +import { loose } from './loose.stylex'; +import { light } from './themes'; +const s = stylex.create({ base: { color: colors.primary, backgroundColor: colors.brand, marginTop: consts.gap, zIndex: consts.size, borderColor: contract.accent, outlineColor: colors.mode, caretColor: colors.typed, accentColor: loose.fine } }); +const custom = stylex.createTheme(colors, { primary: 'purple' }); +export const A = () =>
;", + option.clone(), + false, + &resolver, + ) + .unwrap(); + let styles = format!("{:?}", app.styles); + // Each name the defining modules generate is the one the importer reads + let text_after = |code: &str, marker: &str, end: char| { + let (_, rest) = code.split_once(marker).unwrap(); + rest.split(end).next().unwrap().to_string() + }; + for key in ["primary", "brand", "mode", "typed"] { + let reference = format!( + "var({})", + text_after(&vars.code, &format!("\"{key}\": \"var("), ')') + ); + assert!( + styles.contains(&reference), + "{reference} missing from {styles}" + ); + } + for (module, name) in [(&vars.code, "dark"), (&themes.code, "light")] { + let class = text_after(module, &format!("export const {name} = \""), '"'); + assert!( + app.code.contains(&class), + "{class} missing from {}", + app.code + ); + } + assert!(styles.contains("value: \"8px\""), "{styles}"); + assert!(styles.contains("--"), "{styles}"); + assert!(!app.code.contains("createTheme"), "{}", app.code); + let vars_css = format!("{:?}", vars.styles); + assert!( + vars_css.contains("@media(prefers-color-scheme:dark){:root{--"), + "{vars_css}" + ); + assert_eq!( + app.dependencies, + vec![ + "/src/brand.ts", + "/src/loose.stylex.ts", + "/src/themes.ts", + "/src/vars.stylex.ts" + ] + ); + } + } - let mut length = BTreeMap::new(); - length.insert("containerX".to_string(), vec![0, 2]); - set_theme_token_levels(length, BTreeMap::new()); + const CONSTANT_MODULES: &[(&str, &str)] = &[ + ( + "/src/tokens.ts", + r"import { base } from './base'; +import * as spaced from './base'; +import baseDefault from './base'; +export const FROM_DEFAULT = baseDefault; +console.log(FROM_DEFAULT); +export const PRIMARY = 'red'; +export const SIZE = 4; +export const NEG = -2; +export const TEMPLATE = `10px`; +export const colors = { [computed]: 'x', ...rest, primary: 'blue', nested: { deep: 'green' }, fromBase: base, skip: fn() } as const; +export const ALIAS = (PRIMARY satisfies string); +export let MUTABLE = 'no'; +export const DYNAMIC = fn(), [destructured] = [1]; +export const NEG_STRING = -'a'; +export const TEMPLATE_EXPRESSION = `${PRIMARY}`; +export const SPACED = spaced.value; +export const NOT_OBJECT = PRIMARY.length; +const LOCAL = 'purple'; +export { LOCAL as renamed, MUTABLE as alsoMutable }; +export { base as reBase, missing as notThere } from './base'; +export { gone } from './missing'; +export * from './base'; +export * from './missing'; +export * as ns from './base'; +export default 'orange'; +export function helper() {}", + ), + ( + "/src/base.ts", + r"import { x } from './loop'; +export const base = 'teal'; +export const value = 'navy'; +export default 'hidden-by-star'; +export const looped = x;", + ), + ( + "/src/loop.ts", + "import { looped } from './base';\nexport const x = looped;", + ), + ( + "/src/named.ts", + "const DEFAULT = 'olive';\nexport default DEFAULT;", + ), + ("/src/handler.ts", "export const handler = () => {};"), + ( + "/src/cjs-tokens.js", + "'use strict';\nObject.defineProperty(exports, '__esModule', { value: true });\nexports.COMPILED = exports.OTHER = void 0;\nconst base_1 = require('./base');\nconst { value: navy } = require('./base');\nlet loose = require('./base');\nconst notRequire = load('./base');\nconst [first] = require('./base');\nconst fromName = require(name);\nother.exports.y = 1;\nlist[0].x = 1;\nexports.COMPILED = 'maroon';\nexports.FROM_REQUIRE = base_1.base;\nexports.DESTRUCTURED = navy;\nexports.MUTATED = 'a';\nfunction mutate() { exports.MUTATED = 'b'; }\nexports['computed'] = 'x';\nexports.DYNAMIC = compute();\nother.thing = 1;\nexports.default = 'indigo';", + ), + ( + "/src/cjs-object.js", + "module.exports = { OBJ: 'plum', OVERRIDDEN: 'x' };\nmodule.exports.OVERRIDDEN = 'y';\nmodule.exports.LATER = 'lime';\nmodule.id = 1;", + ), + ("/src/cjs-value.js", "module.exports = 12;"), + ( + "/src/cjs-twice.js", + "module.exports = { A: 'a' };\nmodule.exports = { A: 'b' };", + ), + ( + "/src/cjs-marker.js", + "exports.__esModule = true;\nexports.named = 'teal';", + ), + ]; + + const CHANGED_MODULES: &[(&str, &str)] = &[ + ( + "/src/tokens.ts", + r"import { css } from '@devup-ui/react'; +export const base = { p: 4 }; +export const fixed = { p: 2 }; +export const colors = { primary: 'red' }; +export const grid = [1, 2]; +const local = { m: 1 }; +export { local as renamed }; +export default base; +export const styles = css(fixed); +export function dark() { base.p = 8; } +export const hover = darken(colors.primary); +register(grid.length);", + ), + ( + "/src/reexport.ts", + "export { base, fixed } from './tokens';", + ), + ]; + + #[test] + #[serial] + fn test_changed_constants() { + let cases = [ + "const base = { p: 4 };\nbase.p = 8;\nexport const a = css(base);", + "const base = { p: 4 };\nObject.assign(base, { p: 8 });\nexport const a = ;\nexport const b = css({ p: base.p });", + "const base = { p: 4 };\nregister(base);\nexport const a = ;\nexport const b = styled.div(base);\nexport const c = css(x ? base : null);\nexport const d = css(x ? null : [y && base]);\nexport const e = css(...[base]);", + "const base = { p: 4 };\nbase.p = 8;\nconst baseline = x;\nexport const a = ;\nexport const b = css({ p: baseline });", + "const colors = { primary: 'red' };\nconst hover = darken(colors.primary);\nexport const a = ;", + "const colors = { primary: 'red' };\nexport const theme = { colors };\nexport const a = ;", + "const colors = { primary: 'red' };\nexport const theme = { colors };\ntheme.colors.primary = 'blue';\nexport const a = ;", + "const loop1 = { x: loop2 };\nconst loop2 = { y: loop1 };\nexport const a = ;", + "const list = [{ p: 1 }];\nwatch(...list);\nexport const a = ;", + "const flat = [1, 2];\nwatch(...flat);\nexport const a = ;", + "const partial = { ...unknown, p: 1 };\nwatch(partial.q);\nexport const a = ;", + "const record = { p: 1 };\nwatch(record.q);\nexport const a = ;", + "const spread = { ...unknown, p: 1 };\nwatch({ ...spread });\nexport const a = ;", + "export enum E { A = 1 }\nenum F { B = 2 }\nexport function g() {}\nvar v = 1;\nconst make = () => ({ p: 1 });\nconst made = make();\nwatch(made);\nconst f = () => made.p;\nexport const a = css({ p: f() });", + "const make = () => ({ p: 1 });\nconst made = make();\nmade.p = 2;\nconst f = () => made.p;\nexport const a = css({ p: f() });", + "const pick = () => (typeof window === 'undefined' ? 'white' : 'black');\nexport const a = css({ color: pick() });", + "const brand = () => globalThis.BRAND ?? 'red';\nexport const a = css({ color: brand() });", + "const node = () => process.env.NODE_ENV;\nexport const a = css({ content: node() });", + "import { base } from './tokens';\nconst f = () => base.p;\nexport const a = css({ p: f() });", + "import base, { fixed, colors, grid, renamed } from './tokens';\nimport * as tokens from './tokens';\nexport const a = ;", + "import { base as again } from './reexport';\nexport const a = css(again);", + ]; + let outputs: Vec = cases + .iter() + .map(|case| { + reset_class_map(); + reset_file_map(); + match extract_with_modules( + "/src/App.tsx", + &format!("import {{ Box, css, styled }} from '@devup-ui/react';\n{case}"), + ExtractOption::default(), + false, + &memory_resolver(CHANGED_MODULES), + ) { + Ok(output) => output.code, + Err(error) => format!("Error: {error}"), + } + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + const RUNTIME_MODULES: &[(&str, &str)] = &[ + ( + "/src/tokens.ts", + "export const colors = { primary: 'red' };\nexport default { colors };", + ), + ( + "/src/frozen.ts", + "export const heading = { fontSize: 24 };\nexport const typography = Object.freeze({ heading });", + ), + ( + "/src/changed.ts", + "export const base = { p: 4 };\nexport const colors = { primary: 'red' };\nexport const helper = () => 1;\nexport function dark() { base.p = 8; }", + ), + ( + "/src/cjs.js", + "const colors = { primary: 'red' };\ncolors.primary = 'blue';\nmodule.exports = { colors };", + ), + ( + "/src/cjs-plain.js", + "const colors = { primary: 'red' };\nconst spacing = { m: 2 };\nmodule.exports = { colors };\nexports.spacing = spacing;", + ), + ( + "/src/env.ts", + "import { base } from './changed';\nconsole.log(base);\nexport const isBrowser = typeof window !== 'undefined';\nexport const mode = isBrowser ? 'dark' : 'light';\nexport const label = (1).toLocaleString();\nexport const format = (n) => n.toLocaleString();\nexport const double = (n) => n * 2;\nexport function triple(n) { return n * 3; }\nexport class Scale { static of(n) { return n * 4; } }", + ), + ]; + + #[test] + #[serial] + fn test_values_known_only_at_runtime() { + let cases = [ + "import { colors } from './tokens';\nexport const a = ;", + "import { heading } from './frozen';\nexport const a = ;", + "const theme = { text: { color: 'red' } };\nexport const App = () => ;", + "const handle = { current: null, p: 1 };\nexport const App = () =>
;", + "import * as tokens from './changed';\nexport const a = css(tokens.base);\nexport const b = ;\nexport const c = ;\nexport const d = css(tokens[key]);\nexport const e = css(make() || tokens.base);\ntokens.helper();\ntokens.colors = null;", + "import * as tokens from './changed';\ntokens.colors.primary = 'blue';\nexport const a = ;", + "import * as tokens from './changed';\nregister(tokens);\nexport const a = ;", + "import { base } from './changed';\nimport * as tokens from './changed';\nconst theme = { base };\nexport const a = css(theme.base, tokens.base, base);\nexport const b = ;\nexport const c = css(theme.missing);", + "import { double, triple, Scale, mode } from './env';\nexport const a = css({ w: double(2), h: triple(2), m: Scale.of(1) });\nexport const b = css({ color: mode });", + "import { label, format } from './env';\nexport const a = css({ content: label });\nexport const b = css({ content: format(1) });", + "const label = () => (1234.5).toLocaleString();\nconst LABEL = 'i'.toLocaleUpperCase();\nconst pick = () => LABEL;\nexport const a = css({ content: label() });\nexport const b = css({ content: pick() });\nexport const c = css({ w: window.innerWidth });\nexport const d = css({ content: new Intl.NumberFormat().format(1) });", + "import { colors } from './cjs';\nimport cjs, { colors as plain, spacing } from './cjs-plain';\nexport const a = ;", + "const list = [{ p: 1 }];\nfor (const item of list) item.p = 2;\nexport const a = ;", + "const list = [{ p: 1 }];\nlist.forEach((item) => { item.p = 2; });\nexport const a = ;", + "const store = { items: [1], add(x) { this.items.push(x); } };\nstore.add(2);\nexport const a = ;", + "const theme = { spacing: (n) => n * 4, colors: { primary: 'red' }, label: 'x' };\nconst gap = theme.spacing(2);\ntheme.label.custom();\nexport const a = ;", + "const sizes = [1, 2];\nconst copy = Array.from(sizes);\nexport const a = ;", + "const isBrowser = typeof window !== 'undefined';\nconst pick = () => (!isBrowser ? 'white' : 'black');\nexport const a = css({ color: pick() });", + "const parsed = JSON.parse('{bad');\nconst pick = () => (parsed ? 'a' : 'b');\nexport const a = css({ content: pick() });", + "import { isMobile } from 'unknown-package';\nconst pick = () => (isMobile ? 'a' : 'b');\nexport const a = css({ content: pick() });", + "export const a = css({ content: '1234.5', fontSizeAdjust: 0.5, mathDepth: 2 });", + ]; + let outputs: Vec = cases + .iter() + .map(|case| { + reset_class_map(); + reset_file_map(); + match extract_with_modules( + "/src/App.tsx", + &format!("import {{ Box, css, styled }} from '@devup-ui/react';\n{case}"), + ExtractOption::default(), + false, + &memory_resolver(RUNTIME_MODULES), + ) { + Ok(output) => format!("{:?}", ToBTreeSet::from(output)), + Err(error) => format!("Error: {error}"), + } + }) + .collect(); + assert_debug_snapshot!(outputs); + + reset_class_map(); + reset_file_map(); + let stylesheet = extract( + "/src/log.css.ts", + "import { style } from '@devup-ui/react';\nconsole.warn('loaded');\nexport const box = style({ color: 'red' });", + ExtractOption::default(), + ) + .unwrap(); + assert_eq!(stylesheet.styles.len(), 1); + } + + #[test] + #[serial] + fn test_inline_imported_constants() { + reset_class_map(); + reset_file_map(); + let output = extract_with_modules( + "/src/App.tsx", + r"import { Box, css, styled } from '@devup-ui/react'; +import * as Devup from '@devup-ui/react'; +import { PRIMARY, SIZE, NEG, TEMPLATE, colors, ALIAS, MUTABLE, DYNAMIC, renamed, alsoMutable, reBase, notThere, gone, value, ns, NEG_STRING, TEMPLATE_EXPRESSION, SPACED, NOT_OBJECT, helper, looped } from './tokens'; +import orange from './tokens'; +import * as tokens from './tokens'; +import named from './named'; +import { handler } from './handler'; +import cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from './cjs-tokens'; +import cjsObject, { OBJ, OVERRIDDEN, LATER } from './cjs-object'; +import twelve from './cjs-value'; +import twice from './cjs-twice'; +import marker, { named as markerNamed } from './cjs-marker'; +import { unused } from './unused'; +import missing from './missing'; +export const a = ; +export const b = css({ color: PRIMARY }); +export const c = ; +export const d = styled.div`color: ${PRIMARY};`; +export const e = (PRIMARY) => ; +export const f = { PRIMARY, unused, SIZE: tokens.SIZE }; +export const g =
; +export const h = ; +export const i = Devup['css']({ color: SIZE }); +export const l = ; +export const j = ; +export const k = styled('div')({ color: SIZE });", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + false, + &memory_resolver(CONSTANT_MODULES), + ) + .unwrap(); + assert_eq!( + output.dependencies, + [ + "/src/base.ts", + "/src/cjs-marker.js", + "/src/cjs-object.js", + "/src/cjs-tokens.js", + "/src/cjs-twice.js", + "/src/cjs-value.js", + "/src/handler.ts", + "/src/loop.ts", + "/src/named.ts", + "/src/tokens.ts" + ] + ); + assert_debug_snapshot!(ToBTreeSet::from(output)); + let without_imports = extract_with_modules( + "/src/Plain.tsx", + "import { Box } from '@devup-ui/react';\nexport const a = ;", + ExtractOption::default(), + false, + &memory_resolver(CONSTANT_MODULES), + ) + .unwrap(); + assert!(without_imports.dependencies.is_empty()); + let without_constants = extract_with_modules( + "/src/Handler.tsx", + "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", + ExtractOption::default(), + false, + &memory_resolver(CONSTANT_MODULES), + ) + .unwrap(); + assert_eq!(without_constants.dependencies, ["/src/handler.ts"]); + } + + #[test] + #[serial] + fn test_constant_objects_arrays_and_enums() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[( + "/src/tokens.ts", + "export const space = [0, 4, 8];\nexport enum Size { S = 4, M = 8 }\nenum Local { A = 'a' }\nexport { Local };\nexport const focus = { color: 'navy', ...{ outlineColor: 'teal' } };", + )]; + let output = extract_with_modules( + "/src/App.tsx", + r"import { Box, css, styled, globalCss, keyframes } from '@devup-ui/react'; +import * as Devup from '@devup-ui/react'; +import * as stylex from '@stylexjs/stylex'; +import { space, Size, Local, focus } from './tokens'; +const base = { p: 4, color: 'red', color: 'blue' }; +const hover = { opacity: 0.5 }; +const merged = { ...base, m: 1 }; +const partial = { ...unknown, m: 1 }; +const proto = { __proto__: null, a: 1 }; +const getter = { get a() { return 1; } }; +export const withGetter = ; +const NONE = null; +const MEDIA = '@media (min-width: 1px)'; +const keyed = { [MEDIA]: { color: 'green' } }; +const widths = [10, null, 20]; +const more = [...widths, 30]; +const bad = [...unknown]; +enum Level { Low = 1, High, Name = 'n', Computed = Math.max(1, 2), Last = 9 } +declare enum Ambient { X = 1 } +const sx = { color: 'red' }; +export const a = css(base); +export const b = css({ ...merged, _hover: hover, ...keyed, color: NONE }); +export const c = css(base, { m: 3 }); +export const d = styled.div(base); +export const e = styled.div({ ...base, _hover: hover }); +export const f = (Comp, handler) => ; +export const g = ; +export const h = Devup.css(base); +export const i = styled(Box)(base); +export const j = styled.div.attrs({ role: 'button' })(base); +export const k = stylex.create({ x: sx }); +globalCss({ body: base }); +export const l = keyframes({ from: hover }); +export const m = css({ right: proto.a, zIndex: partial.m, order: Level.Computed, flexGrow: Level.Last });", + ExtractOption::default(), + false, + &memory_resolver(modules), + ); + assert_debug_snapshot!( + output + .map(ToBTreeSet::from) + .map_err(|error| error.to_string()) + ); + } - // String literal: w="$containerX" → expands to multiple breakpoint classes + #[test] + #[serial] + fn test_styles_the_build_cannot_read() { + for (code, error) in [ + ( + "export const a = (x) => css({ ...x });", + "`css()` cannot use `x`", + ), + ( + "const shared = { m: 1, ...unknown };\nexport const a = css({ m: shared.m });", + "`css()` cannot use `shared.m`", + ), + ( + "const scale = [0, 4];\nexport const a = css({ top: scale[9], left: scale['01'] });", + "`css()` cannot use `scale[", + ), + ( + "export const a = (x) => css({ _hover: x });", + "`css()` cannot use `x`", + ), + ( + "export const a = (k) => css({ [k]: 1 });", + "`css()` cannot use `[k]`", + ), + ( + "export const a = (x) => css({ _hover: { ...x } });", + "`css()` cannot use `x`", + ), + ( + "export const a = (k) => css({ _hover: { [k]: 'red' } });", + "`css()` cannot use `[k]`", + ), + ( + "export const a = (x) => css({ _hover: new x() });", + "`css()` cannot use `new x()`", + ), + ( + "export const a = (x) => css({ _hover: () => x });", + "`css()` cannot use `() => x`", + ), + ( + "export const a = (make) => css(make());", + "Cannot compose `make()`", + ), + ( + "export const a = (x) => globalCss(x);", + "`globalCss()` cannot use `x`", + ), + ( + "export const a = (k) => globalCss({ [k]: {} });", + "`globalCss()` cannot use `[k]`", + ), + ( + "export const a = (x) => globalCss({ body: { ...x } });", + "`globalCss()` cannot use `x`", + ), + ( + "export const a = (x) => keyframes(x);", + "`keyframes()` cannot use `x`", + ), + ( + "export const a = (k) => keyframes({ [k]: {} });", + "`keyframes()` cannot use `[k]`", + ), + ( + "export const a = (x) => keyframes({ ...x });", + "`keyframes()` cannot use `...x`", + ), + ( + "export const a = (x) => styled.div({ ...x });", + "`styled()` cannot use `x`", + ), + ( + "export const a = (x) => ;", + "`` cannot use `x`", + ), + ( + "export const a = (x) => ;", + "`` cannot use `x`", + ), + ( + "export const a = (k) => ;", + "`` cannot use `[k]`", + ), + ( + "export const a = (on, rest) => ;", + "`` cannot use `rest`", + ), + ( + "export const a = (list) => ;", + "`` cannot use `[...list][0]`", + ), + ( + "export const a = (list, i) => ;", + "`` cannot use `[...list, {}][i]`", + ), + ( + "export const a = (extra) => ;", + "`` cannot use `({ ...extra", + ), + ( + "export const a = (styles, k) => ;", + "`` cannot use `styles.all[k]`", + ), + ( + "export const a = (c, x) => ;", + "`` cannot use `x`", + ), + ( + "export const a = (rest) => ;", + "`` cannot use `rest`", + ), + ( + "declare const X: number;\nexport const a = css({ w: String(X) });", + "`css()` cannot use `String(X)`", + ), + ] { + reset_class_map(); + reset_file_map(); + let message = extract( + "test.tsx", + &format!("import {{ Box, css, styled, globalCss, keyframes }} from '@devup-ui/react';\n{code}"), + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(message.contains(error), "{code}\n{message}"); + } reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } + r"import { Box, css, styled, keyframes } from '@devup-ui/react'; +export const a = (base) => css(base); +export const b = (on) => css(on && { color: 'blue' }); +export const c = css('color: green; margin: 0'); +export const d = (on, base) => css(on ? base : { color: 'red' }); +export const e = css({ ...null, ...false, ...undefined, ...(void 0), p: 1 }); +export const f = (rest, handler, k) => ; +export const g = (theme, key) => ; +export const h = (Base) => styled.div(Base); +export const i = keyframes({ from: { opacity: 0 } } as const); +export const j = css(...{ bg: 'red' });", + ExtractOption::default(), ) .unwrap() )); - - // Expression: w={"$containerX"} → also expands (same as string literal) reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_eq!( extract( "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, + "import { Global } from '@emotion/react';\nexport const a = (x) => ;", ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, ) - .unwrap() - )); + .unwrap_err() + .to_string(), + "test.tsx:2:41: `` cannot use `x` at build time: its values must be literals, theme tokens or constants" + ); + } - // Array with single element: w={["$containerX"]} → single class, base value only + #[test] + #[serial] + fn test_computed_values_through_functions_and_style_packages() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[( + "/src/sx.ts", + "import * as stylex from '@stylexjs/stylex';\nexport const S = stylex.create({ x: { color: 'red' } });\nexport const W = String(3) + 'px';", + )]; + let output = extract_with_modules( + "/src/App.tsx", + r"import { Box, css } from '@devup-ui/react'; +import { W } from './sx'; +const space = [0, 4, 8]; +const NONE = null; +const IDX = space[1]; +const LABEL = `x${NONE}`; +const bad = [...'ab']; +export function triple(n: number) { return n * 3; } +export const a = css({ h: triple(2), m: IDX, content: LABEL, p: bad }); +export const w = ; +export function b() { return ; }", + ExtractOption::default(), + false, + &memory_resolver(modules), + ) + .unwrap(); + assert_eq!(output.dependencies, ["/src/sx.ts"]); + let styles = format!("{:?}", ToBTreeSet::from(output).styles); + for value in ["24px", "16px", "xnull", "identifier: \"W\""] { + assert!(styles.contains(value), "{value}\n{styles}"); + } + assert!(!styles.contains("3px"), "{styles}"); + } + + #[test] + #[serial] + fn test_compose_rules_the_module_computes() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[ + ( + "/src/styles.ts", + "import { css, styled } from '@devup-ui/react';\nexport const card = css({ p: 1 });\nexport const Card = styled.div({ p: 2 });\nexport const Linked = styled('a')({ p: 3 });\nexport const tagged = css`color: red;`;\nexport const make = (n: number) => ({ m: n });\nexport const computed = make(3);\nexport const rules = { m: 7 };\nexport const nested = { x: make(9), known: 'k' };", + ), + ( + "/src/emotion.ts", + "import { css } from '@emotion/react';\nexport const emotionClass = css({ p: 1 });", + ), + ]; + let output = extract_with_modules( + "/src/App.tsx", + r"import { css, styled } from '@devup-ui/react'; +import * as Devup from '@devup-ui/react'; +import * as tokens from './styles'; +import { card, rules } from './styles'; +import { emotionClass } from './emotion'; +const make = (n: number) => ({ m: n }); +const local = make(2); +const computed = make(3); +const name = String('named'); +const shared = { rules: make(4), fixed: { p: 5 } }; +export const a = css(local); +export const b = css(card, computed); +export const c = css(name, null, true, 'x', `y`); +export const d = styled.div(local); +export const e = styled('span')(computed); +export const f = styled.p.attrs({ role: 'note' })(shared.rules); +export const g = (on) => css(on ? local : card, on || computed, on && local, [local, shared.fixed]); +export const h = css(rules, tokens.rules); +export const i = Devup.css(local); +export const k = css(tokens.nested.known, tokens.card); +export const l = css(emotionClass, { m: 1 }); +export const n = ;", + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + false, + &memory_resolver(modules), + ) + .unwrap(); + assert_eq!(output.dependencies, ["/src/emotion.ts", "/src/styles.ts"]); + assert_debug_snapshot!(ToBTreeSet::from(output)); + + for (code, part) in [ + ("css(tokens.computed)", "tokens.computed"), + ("css(tokens.nested.x, { m: 1 })", "tokens.nested.x"), + ("css(tokens.make(12))", "tokens.make(12)"), + ("styled.div(computed)", "computed"), + ("(key) => css(shared[key])", "shared[key]"), + ] { + reset_class_map(); + reset_file_map(); + let message = extract_with_modules( + "/src/App.tsx", + &format!("import {{ css, styled }} from '@devup-ui/react';\nimport * as tokens from './styles';\nimport {{ computed }} from './styles';\nconst shared = {{ rules: tokens.make(4) }};\nexport const a = {code};"), + ExtractOption::default(), + false, + &memory_resolver(modules), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!( + message.contains(&format!("cannot use `{part}`")), + "{code}\n{message}" + ); + } + } + + #[test] + #[serial] + fn test_source_map_points_at_the_code_as_written() { + reset_class_map(); + reset_file_map(); + let code = "import { css } from '@emotion/react';\nconst PRIMARY = 'red';\nconst darken = (amount, color) => color;\nexport const a = css({\n color: darken(\n 0.1,\n PRIMARY,\n ),\n});\nexport const after = 1;\n"; + let output = extract( + "src/App.tsx", + code, + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + ) + .unwrap(); + let map = + oxc_sourcemap::SourceMap::from_json_string(output.map.as_deref().unwrap()).unwrap(); + assert_eq!(map.get_source_content(0), Some(code)); + let (line, generated) = output + .code + .lines() + .enumerate() + .find(|(_, line)| line.contains("after")) + .unwrap(); + let column = generated.find("after").unwrap(); + let token = map + .get_tokens() + .find(|token| { + token.get_dst_line() as usize == line && token.get_dst_col() as usize == column + }) + .unwrap(); + let written = code.lines().nth(token.get_src_line() as usize).unwrap(); + assert!( + written[token.get_src_col() as usize..].starts_with("after"), + "{written}" + ); + } + + #[test] + #[serial] + fn test_styles_with_no_class_to_switch() { + for (code, error) in [ + ( + "export const a = (on) => keyframes({ from: { opacity: on ? 0 : 1 } });", + "`keyframes()` cannot use `on`", + ), + ( + "export const a = (on) => globalCss({ body: { color: on ? 'red' : 'blue' } });", + "`globalCss()` cannot use `on`", + ), + ( + "export const a = (k) => globalCss({ body: { color: { a: 'red', b: 'blue' }[k] } });", + "`globalCss()` cannot use `k`", + ), + ( + "export const a = (on) => globalCss({ body: { color: [on ? 'red' : 'blue'] } });", + "`globalCss()` cannot use `on`", + ), + ( + "export const a = (on) => ;", + "`` cannot use `on`", + ), + ( + "export const a = (side) => globalCss({ body: { positioning: side } });", + "`globalCss()` cannot use `side`", + ), + ( + "export const a = (on) => globalCss({ body: { color: 'red', bg: on ? 'a' : 'b' } });", + "`globalCss()` cannot use `on`", + ), + ] { + reset_class_map(); + reset_file_map(); + let message = extract( + "test.tsx", + &format!( + "import {{ Global, globalCss, keyframes }} from '@devup-ui/react';\n{code}" + ), + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(message.contains(error), "{code}\n{message}"); + } reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } + r"import { css, globalCss, keyframes } from '@devup-ui/react'; +const DARK = true; +export const a = css({ color: DARK ? 'red' : x, bg: false || 'blue', m: null ?? 2, p: 0 && x, w: 'a' && 3, h: undefined ?? 4, opacity: 0 ? 1 : 0.5 }); +export const g = globalCss({ body: { color: DARK ? 'red' : 'blue' } }); +export const h = () => globalCss({ body: { m: 0 } }); +export const i = [globalCss({ html: { p: 0 } })]; +export const j = (on) => on && globalCss`body { color: red; }`; +globalCss({ div: { m: 1 } }); +export const k = keyframes({ from: { opacity: DARK ? 0 : 1 } });", + ExtractOption::default(), ) .unwrap() )); + } - // Mixed array: w={["1px", null, "$containerX"]} → token inside array stays single per slot + #[test] + #[serial] + fn test_every_runtime_value_an_element_holds() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } + "import { Box } from '@devup-ui/react';\nclass A { #w = 1; render(f) { return ; } }\nexport const a = async (f, X, tag) => ;", + ExtractOption::default(), ) .unwrap() )); + for value in ["function () {}", "class {}"] { + reset_class_map(); + reset_file_map(); + let message = extract( + "test.tsx", + &format!("import {{ Box }} from '@devup-ui/react';\nexport const a = ;"), + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(message.contains("`` cannot use"), "{value}\n{message}"); + } } #[test] #[serial] - fn test_responsive_shadow_token_literal_vs_array() { - use css::theme_tokens::set_theme_token_levels; + fn test_compute_what_elements_and_styled_objects_take() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[( + "/src/tokens.ts", + "export const make = (n: number) => ({ m: n });\nexport const KEY = ['co', 'lor'].join('');\nexport const PRIMARY = '#336699';\nexport function darken(amount: number, color: string) { return color === PRIMARY ? '#112233' : color; }", + )]; + let output = extract_with_modules( + "/src/App.tsx", + r"import { Box, css, styled } from '@devup-ui/react'; +import * as Devup from '@devup-ui/react'; +import { PRIMARY } from './tokens'; +const make = (n: number) => ({ m: n }); +const KEY = ['co', 'lor'].join(''); +function darken(amount: number, color: string) { return color === PRIMARY ? '#112233' : color; } +const card = make(2); +const hover = make(3); +const parts = [make(4), { p: 1 }]; +export const a = 1} />; +export const b = ; +export const c = ; +export const d = ; +export const e = styled.div({ ...make(6), _hover: hover, [KEY]: 'red' }); +export const f = css(...parts); +export const g = css(`color: ${darken(0.1, PRIMARY)};`); +export const h =
; +function local() { return { p: 7 }; } +export const i = ; +export const j = (register) => ;", + ExtractOption::default(), + false, + &memory_resolver(modules), + ) + .unwrap(); + assert_debug_snapshot!(ToBTreeSet::from(output)); - let mut shadow = BTreeMap::new(); - shadow.insert("card".to_string(), vec![0, 3]); - set_theme_token_levels(BTreeMap::new(), shadow); + reset_class_map(); + reset_file_map(); + let imported = extract_with_modules( + "/src/App.tsx", + r"import { Box } from '@devup-ui/react'; +import { make } from './tokens'; +export const a = ;", + ExtractOption::default(), + false, + &memory_resolver(modules), + ) + .unwrap(); + assert!(imported.code.contains(")(make(5))"), "{}", imported.code); + } - // String literal: boxShadow="$card" → expands to multiple breakpoint classes + #[test] + #[serial] + fn test_spreads_are_read_once() { reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + let output = extract( + "test.tsx", + r"import { Box } from '@devup-ui/react'; +import { jsx as _jsx } from 'react/jsx-runtime'; +export const a = (register, rest, C) => ( + + + +); +export const b = async (load) => ; +export const c = (props) => ; +export const d = (f) => _jsx(Box, { ...f(), bg: 'red' }); +export const e = async (f, g) => _jsx(Box, { ...f(), title: await g() }); +export const g = async (f, g, h) => ; +export const h = async (f, g) => {await g()}; +export const i = async (f, g) => _jsx(Box, { className: await g(), ...f() });", + ExtractOption::default(), + ) + .unwrap(); + assert_debug_snapshot!(ToBTreeSet::from(output)); + } - // Expression: boxShadow={"$card"} → also expands (same as string literal) + #[test] + #[serial] + fn test_read_once_in_every_place() { reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + let output = extract( + "test.tsx", + r"import { Box } from '@devup-ui/react'; +import { jsx as _jsx } from 'react/jsx-runtime'; +export const a = async (f, g, list, C) => ( + await g()} title=> + text + <>{g} + {...list()} + {g} + {await g()} + after + +); +export const b = function* (f, g) { yield ; }; +export const c = (props) => _jsx(Box, props); +export const d = async (f, g) => _jsx(Box, { ...f(), [g()]: await g() }); +export const e = async (rest, g) => _jsx(Box, { id: await g(), ...rest }); +export const h = (f, g, k) => _jsx(Box, { className: 'z', onClick: g(), [k]: 2, ...f(), [k]: 1, title: 'x' }); +export const i = (rest) => _jsx(Box, { ...rest, className: 'a' }); +export const j = (rest) => ;", + ExtractOption::default(), + ) + .unwrap(); + assert_debug_snapshot!(ToBTreeSet::from(output)); + } - // Array with single element: boxShadow={["$card"]} → single class, base value only + #[test] + #[serial] + fn test_dynamic_as_where_the_element_stands() { reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + let output = extract( + "test.tsx", + r"import { Box } from '@devup-ui/react'; +export const a = (on, C, items) => ( +
}> + + {items.map((item) => )} + {on} +
+); +export const after = 42;", + ExtractOption::default(), + ) + .unwrap(); + assert_debug_snapshot!(ToBTreeSet::from(output)); + } - // Mixed array: boxShadow={["none", null, null, "$card"]} → token inside array stays single per slot + #[test] + #[serial] + fn test_dynamic_values_keep_the_code_as_written() { reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {Box} from '@devup-ui/react' - - "#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); + let output = extract( + "test.tsx", + "import { Box } from '@devup-ui/react';\nexport const a = (f, i) => ;", + ExtractOption::default(), + ) + .unwrap(); + for written in [ + "#336699", + "10px 0px", + "rgba(0, 0, 0, 0.5)", + "--i", + "#ffffff", + ] { + assert!(output.code.contains(written), "{written}\n{}", output.code); + } + assert!(!output.code.contains("var(--i)"), "{}", output.code); + assert!(!output.code.contains(";`"), "{}", output.code); } #[test] #[serial] - fn extract_pseudo_selector_with_non_literal_value_graceful() { - // Minimal regression tests for the panic that ERROR.tsx surfaced. - // - // When a pseudo-selector prop (`_hover`, `_active`, `_focus`, ...) - // receives anything other than an inline object literal, the - // extractor used to `unwrap()` a None prop-name inside the - // `_xxx` selector recursion path and panic. The fix is to return - // an empty ExtractResult from the non-literal branches of - // `extract_style_from_expression` and - // `extract_style_from_member_expression` when `name` is `None`. - // - // Each case below must: - // 1. NOT panic (used to panic before the fix) - // 2. Return Ok from `extract()` - // 3. Drop the un-extractable pseudo-selector attribute from the - // generated code (no class, no runtime style — devup-ui is - // fully static, so there is no runtime fallback to fall back - // to) - let cases: &[(&str, &str)] = &[ + fn test_imported_stylesheet_runs_once_per_script() { + reset_class_map(); + reset_file_map(); + let option = ExtractOption { + import_aliases: HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + let red = memory_resolver(&[( + "/src/theme.css.ts", + "import { style } from '@vanilla-extract/css';\nexport const brand = ['re', 'd'].join('');\nexport const base = style({ color: brand });", + )]); + let blue = memory_resolver(&[( + "/src/theme.css.ts", + "import { style } from '@vanilla-extract/css';\nexport const brand = ['blu', 'e'].join('');\nexport const base = style({ color: brand });", + )]); + let colors = |name: &str, resolver: &ModuleResolver| { + let output = extract_with_modules( + &format!("/src/{name}.css.ts"), + &format!("import {{ style }} from '@vanilla-extract/css';\nimport {{ brand }} from './theme.css';\nexport const {name} = style({{ color: brand }});"), + option.clone(), + false, + resolver, + ) + .unwrap(); + format!("{:?}", ToBTreeSet::from(output).styles) + }; + for (name, resolver, color) in [ + ("first", &red as &ModuleResolver, "\"red\""), + ("again", &red, "\"red\""), + ("changed", &blue, "\"blue\""), + ] { + let styles = colors(name, resolver); + assert!(styles.contains(color), "{name}: {styles}"); + } + } + + #[test] + #[serial] + fn test_evaluate_build_time_values() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[ ( - "identifier", - r"import {Box} from '@devup-ui/react' -const hoverStyle = { opacity: 1 }; -export const A = () => ; -", + "/src/color.ts", + "export const PRIMARY = '#336699';\nexport const SPACE = [4, 8];\nexport function darken(amount: number, color: string): string { return color === PRIMARY ? `darker(${amount})` : color; }\nexport const DARK = darken(0.5, PRIMARY);\nexport const hover = { color: darken(0.6, PRIMARY) };", ), ( - "call expression", - r"import {Box} from '@devup-ui/react' -declare const getHover: () => object; -export const A = () => ; -", + "/src/throws.ts", + "export const boom = () => { throw new Error('x'); };", ), ( - "member expression", - r"import {Box} from '@devup-ui/react' -declare const styles: { hover: object }; -export const A = () => ; -", + "/src/styles.ts", + "import { css } from '@devup-ui/react';\nimport * as stylex from '@stylexjs/stylex';\nimport styled from 'styled-components';\nimport logo from './logo.svg';\nexport const card = css({ p: 1 });\nexport const TOKEN = String(2) + 'px';\nexport const LOGO = `url(${logo})`;", ), ( - "binary expression", - r"import {Box} from '@devup-ui/react' -declare const a: any; declare const b: any; -export const A = () => ; -", + "/src/fails.ts", + "export const BEFORE = 'teal';\nthrow new Error('x');\nexport const AFTER = String('navy');", ), + ]; + let resolver = memory_resolver(modules); + let option = ExtractOption { + import_aliases: HashMap::from([( + "styled-components".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + )]), + ..ExtractOption::default() + }; + let output = extract_with_modules( + "/src/App.tsx", + r"import { Box, css, keyframes, globalCss } from '@devup-ui/react'; +import * as stylex from '@stylexjs/stylex'; +import { PRIMARY, SPACE } from './color'; +import { card } from './styles'; +import { BEFORE } from './fails'; +export const SIZE = 4; +export default function twice(n: number) { return n * 2; } +enum Level { Low = 1, High = twice(2) } +function darken(amount: number, color: string): string { return color === PRIMARY ? `darker(${amount})` : color; } +const LIGHT = darken(0.2, PRIMARY); +const color = darken(0.3, PRIMARY); +const make = (size: number) => ({ p: size, _hover: { color: LIGHT } }); +const label = (text: string) => text.toUpperCase().padStart(4, '-'); +export const a = css({ color: darken(0.1, PRIMARY), width: twice(SIZE), height: [1, 2].map(twice)[1], top: -twice(1), margin: SPACE[1] * 2, content: JSON.stringify(label('a')) }); +export const b = keyframes({ from: { opacity: twice(0.25) } }); +globalCss({ body: { color: LIGHT } }); +const styles = stylex.create({ base: { color: LIGHT } }); +export const c = ; +export const d = css({ color, ...make(2), [`@media (min-width: ${twice(300)}px)`]: { m: 1 }, zIndex: Level.High, outlineColor: BEFORE }); +export const e = (dark: boolean) => css({ color: dark ? LIGHT : PRIMARY, bg: dark && LIGHT, fill: [LIGHT, ...[PRIMARY]][1] }); +export const f = css(card, { p: String(SIZE) + 'px' });", + option, + false, + &resolver, + ) + .unwrap(); + assert_eq!( + output.dependencies, + ["/src/color.ts", "/src/fails.ts", "/src/styles.ts"] + ); + assert_debug_snapshot!(ToBTreeSet::from(output)); + + for (code, error) in [ ( - "template literal", - r"import {Box} from '@devup-ui/react' -declare const x: string; -export const A = () => ; -", + "import { boom } from './throws';\nexport const a = css({ color: boom() });", + "/src/App.tsx:3:18: `css()` cannot use `boom()`", ), ( - "unary expression", - r"import {Box} from '@devup-ui/react' -declare const v: any; -export const A = () => ; -", + "export const a = css({ color: String(Date.now()) });", + "/src/App.tsx:2:18: `css()` cannot use `String(Date.now())`", ), ( - "computed member expression (array index)", - r"import {Box} from '@devup-ui/react' -declare const arr: any[]; -export const A = () => ; -", + "export const a = css({ width: Math.random() + 'px' });", + "`css()` cannot use `Math.random()", ), - ]; - - for (label, src) in cases { + ( + "let changing = 1;\nexport const a = css({ width: String(changing) });", + "`css()` cannot use `String(changing)`", + ), + ( + "export const a = (n) => css({ width: String(n) });", + "`css()` cannot use `String(n)`", + ), + ( + "export const a = css({ width: String(unknownGlobal), color: ({}).toString() });", + "`css()` cannot use `String(unknownGlobal)`", + ), + ( + "import { missing } from './nowhere';\nexport const a = css({ width: missing() });", + "`css()` cannot use `missing()`", + ), + ( + "import { AFTER } from './fails';\nexport const a = css({ color: AFTER });", + "`css()` cannot use `AFTER`", + ), + ( + "export const a = css({ ...(() => ({ get p() { return 1; } }))() });", + "`css()` cannot use `(() =>", + ), + ( + "export const a = css({ ...(() => ({ p: undefined }))(), m: Object.create({ x: 1 }) });", + "`css()` cannot use `Object.create(", + ), + ( + "import { darken } from 'polished';\nexport const a = css({ color: darken(0.1, 'red') });", + "`css()` cannot use `darken(.1,`red`)`", + ), + ( + "import { darken } from './color';\nexport const a = css({ color: darken(0.1, 'red') });", + "`css()` cannot use `darken(.1,`red`)`", + ), + ( + "import { DARK, hover } from './color';\nexport const a = css({ color: DARK });\nexport const b = css({ _hover: hover });", + "`css()` cannot use `DARK`", + ), + ( + "class S { px() { return '1px'; } }\nexport const a = css({ width: new S().px() });", + "`css()` cannot use `new S().px()`", + ), + ( + "const plain = (text) => text.replace(/-/g, '');\nexport const a = css({ content: plain('a-b') });", + "`css()` cannot use `plain(`a-b`)`", + ), + ( + "const LABEL = 'i'.toLocaleUpperCase();\nconst pick = () => LABEL;\nexport const a = css({ content: pick() });", + "`css()` cannot use `pick()`", + ), + ( + "const fold = (text) => text.normalize('NFD');\nexport const a = css({ content: fold('a') });", + "`css()` cannot use `fold(`a`)`", + ), + ( + "const W = typeof IntersectionObserver === 'undefined' ? 10 : 20;\nexport const a = css({ width: W });", + "`css()` cannot use `W`", + ), + ( + "const safe = () => { try { return 1; } catch { return 2; } };\nexport const a = css({ width: safe() });", + "`css()` cannot use `safe()`", + ), + ( + "const root = () => 2 ** 0.5;\nexport const a = css({ width: root() });", + "`css()` cannot use `root()`", + ), + ( + "const wave = () => Math.sin(1);\nexport const a = css({ opacity: wave() });", + "`css()` cannot use `wave()`", + ), + ( + "const hex = () => (255).toString(16);\nexport const a = css({ color: '#' + hex() });", + "`css()` cannot use `", + ), + ( + "const node = () =>
;\nexport const a = css({ content: node() });", + "`css()` cannot use `node()`", + ), + ( + "const later = async () => 1;\nexport const a = css({ width: later() });", + "`css()` cannot use `later()`", + ), + ( + "const box = { n: 1 };\nconst bump = () => { box.n = 2; return box.n; };\nexport const a = css({ width: bump() });", + "`css()` cannot use `bump()`", + ), + ( + "const pick = { a: () => 1 };\nconst key = 'a';\nexport const a = css({ width: pick[key]() });", + "`css()` cannot use `pick[`a`]()`", + ), + ( + "const read = { get a() { return 1; } };\nexport const a = css({ width: read.a });", + "`css()` cannot use `read.a`", + ), + ( + "import { make } from './shapes';\nconst local = make(2);\nexport const a = css(local);\nexport const b = styled.div(local);", + "`css()` cannot use `local`", + ), + ] { reset_class_map(); reset_file_map(); - let result = std::panic::catch_unwind(|| { - extract( - "test.tsx", - src, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new(), - }, - ) - }); - match result { - Ok(Ok(output)) => { - println!( - "[OK] {label}: extract succeeded, {} styles", - output.styles.len() - ); - } - Ok(Err(e)) => { - panic!("[FAIL] {label}: extract returned Err: {e}"); - } - Err(panic_payload) => { - let msg = panic_payload - .downcast_ref::<&'static str>() - .map(|s| (*s).to_string()) - .or_else(|| panic_payload.downcast_ref::().cloned()) - .unwrap_or_else(|| "".to_string()); - panic!("[FAIL] {label}: extract panicked: {msg}"); - } - } + let message = extract_with_modules( + "/src/App.tsx", + &format!("import {{ css, styled }} from '@devup-ui/react';\n{code}"), + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(message.contains(error), "{code}\n{message}"); + } + reset_class_map(); + reset_file_map(); + let switched = extract_with_modules( + "/src/App.tsx", + "import { css } from '@devup-ui/react';\nconst hasIO = typeof IntersectionObserver !== 'undefined';\nexport const a = css({ content: hasIO ? 'a' : 'b' });", + ExtractOption::default(), + false, + &resolver, + ) + .unwrap(); + assert!(switched.code.contains("hasIO ?"), "{}", switched.code); + let option = ExtractOption::default(); + for (code, computes) in [ + ( + "import { css } from '@devup-ui/react';\nimport { darken } from 'polished';\ncss({ color: darken(0.1, 'red') });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nimport { DARK } from './color';\ncss({ color: DARK });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nimport { PRIMARY, SPACE } from './color';\nconst f = (n) => `${PRIMARY}${n}`;\ncss({ color: f(SPACE[0]) });", + true, + ), + ( + "import { css } from '@devup-ui/react';\nimport { PRIMARY } from './color';\nconst X = `${PRIMARY}`;\ncss({ color: X, borderColor: PRIMARY });", + false, + ), + ( + "import { css } from '@devup-ui/react';\ncss({ width: Math.max(1, 2), color: 'red' });", + false, + ), + ( + "import { Box } from '@devup-ui/react';\nconst f = () => 1;\n;", + false, + ), + ( + "import { css } from 'other';\nconst f = () => 1;\ncss({ w: f() });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nconst x = css({ w: this.f(), h: String(Date.now()) });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nconst f = () => 1;\ncss({ w: [...[f()], 2] });", + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst make = (n) => ({ m: n });\nconst local = make(2);\ncss(local);", + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst card = css({ p: 1 });\ncss(card, { m: 1 });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nimport * as styles from './styles';\ncss(styles.card, { m: 1 });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nimport * as styles from './styles';\ncss(styles.TOKEN);", + false, + ), + ( + "import { Box } from '@devup-ui/react';\nconst make = (n) => ({ m: n });\nconst card = make(2);\n;", + true, + ), + ( + "import { css } from '@devup-ui/react';\nimport { darken } from './color';\ncss(`color: ${darken(0.1, 'red')};`);", + false, + ), + ( + "import { css } from '@devup-ui/react';\nconst darken = (amount, color) => color;\ncss(`color: ${darken(0.1, 'red')};`);", + true, + ), + ("import { css } from '@devup-ui/react';\ncss({", false), + ] { + assert_eq!( + has_build_time_values("/src/a.tsx", code, &option, Some(&resolver)), + computes, + "{code}" + ); } + assert!(has_build_time_values( + "a.tsx", + "import { css } from '@emotion/react';\nconst f = () => 1;\nexport const a = css`width: ${f()}px;`;", + &ExtractOption { + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + None, + )); } #[test] #[serial] - fn extract_pseudo_selector_with_identifier_snapshot() { - // Snapshot-locks the current behavior for the minimal ERROR.tsx - // reduction so future refactors can't silently regress either - // direction (re-introducing the panic, or over-eagerly turning the - // `_hover={ident}` attribute into something surprising). + fn test_strict_build_time_reads() { + let option = ExtractOption::default(); + for (code, computes) in [ + ( + "import * as Devup from '@devup-ui/react';\nconst make = (n) => ({ m: n });\nconst local = make(2);\nDevup.css(local);", + true, + ), + ( + "import { styled } from '@devup-ui/react';\nconst make = (n) => ({ m: n });\nstyled.div({ p: 1, _hover: make(2) });", + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst fresh = () => ({ n: 1 });\nconst f = () => { const o = { a: { n: 1 } }; o.a.n++; o['a'].n = 2; fresh().n = 3; return o.a.n; };\ncss({ w: f() });", + true, + ), + ( + "import { css } from '@devup-ui/react';\nconst box = { n: 1 };\nconst bump = () => { box.n++; return 1; };\ncss({ w: bump() });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nconst { normalize } = 'a';\nconst f = () => normalize;\ncss({ w: f() });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nconst o = { m() { return super.toString(); } };\nconst f = () => o.m();\ncss({ w: f() });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nconst f = () => class {};\ncss({ w: f() });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nfunction* g() { yield 1; }\nconst f = () => g();\ncss({ w: f() });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nconst f = () => import('./x');\ncss({ w: f() });", + false, + ), + ( + "import { css } from '@devup-ui/react';\nconst f = () => <>;\ncss({ w: f() });", + false, + ), + ] { + assert_eq!( + has_build_time_values("/src/a.tsx", code, &option, None), + computes, + "{code}" + ); + } + + let modules: &[(&str, &str)] = &[ + ( + "/src/t.ts", + "export const T = { a: null, b: true, c: undefined, d: { e: 3 } };", + ), + ("/src/cjs.js", "exports.SIZE = 5;"), + ]; + let resolver = memory_resolver(modules); + reset_class_map(); + reset_file_map(); + let output = extract_with_modules( + "/src/App.tsx", + r"import { Box, css, styled } from '@devup-ui/react'; +import { T } from './t'; +import { SIZE } from './cjs'; +const ON = true; +const U = undefined; +const N = null; +const OBJ = { a: 1 }; +const p = 4; +const f = () => (T.a === null && T.b && T.c === undefined ? T.d.e : 0); +const g = () => SIZE; +export const a = css({ w: f(), h: g(), m: g() }); +export const b = css({ content: `${ON}-${U}`, top: U ? 1 : 2, left: N ? 1 : 2, right: OBJ ? 1 : 2, opacity: ON, p }); +export const c = styled('a')({ p: 1 }); +export const d = ;", + ExtractOption::default(), + false, + &resolver, + ) + .unwrap(); + let styles = format!("{:?}", output.styles); + for value in [ + "\"12px\"", + "\"20px\"", + "\"true-undefined\"", + "\"8px\"", + "\"4px\"", + "\"300ms\"", + "\".5ms\"", + ] { + assert!(styles.contains(value), "{value}\n{styles}"); + } + assert!( + styles.contains("property: \"counter-reset\", value: \"2\""), + "{styles}" + ); + reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( - "test.tsx", - r#"import {Box} from '@devup-ui/react' -const hoverStyle = { opacity: 1 }; -export const A = () => ; -"#, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - }, - ) - .unwrap() - )); + let literal = extract( + "test.tsx", + "import { Box } from '@devup-ui/react';\nexport const a = ;", + ExtractOption::default(), + ) + .unwrap(); + let styles = format!("{:?}", literal.styles); + for value in ["\"blue\"", "\"8px\"", "\"12px\"", "\"20px\""] { + assert!(styles.contains(value), "{value}\n{styles}"); + } + + reset_class_map(); + reset_file_map(); + let partial = extract_with_modules( + "/src/App.tsx", + "import { css } from '@devup-ui/react';\nimport * as tokens from './tokens';\nexport const a = css(tokens.nested.known.length);", + ExtractOption::default(), + false, + &memory_resolver(&[( + "/src/tokens.ts", + "export const make = (n) => ({ m: n });\nexport const nested = { x: make(1), known: 'k' };", + )]), + ); + assert!( + partial + .as_ref() + .is_ok_and(|output| output.styles.is_empty()), + "{partial:?}" + ); + + reset_class_map(); + reset_file_map(); + let message = extract( + "test.tsx", + "import { css } from '@devup-ui/react';\nconst pick = () => 1;\nexport const a = css(pick());", + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(message.contains("cannot use `pick()`"), "{message}"); + } + + #[test] + #[serial] + fn test_build_time_value_candidates() { + let option = ExtractOption::default(); + assert!(has_build_time_values( + "a.tsx", + r"import type { T } from './types'; +import { type U, helper } from './helper'; +import Default from './default'; +import * as ns from './ns'; +import * as Devup from '@devup-ui/react'; +import { css as devupCss, Box } from '@devup-ui/react'; +import * as sx from '@stylexjs/stylex'; +import { create, props } from '@stylexjs/stylex'; +export const A = 1; +export function f(n: T): U { return n; } +export class K { static v() { return 1; } } +export interface I { a: number } +export enum Kind { A = 'a' } +declare enum Ambient { B } +export default function g() { return 2; } +let mutable = 1; +function local() { return new.target ? 1 : 2; } +class Local extends K { m() { return super.toString(); } } +console.log(A); +const cyclic1 = () => cyclic2(); +const cyclic2 = () => cyclic1(); +function wrap() { const inner = 1; return devupCss({ w: String(inner) }); } +devupCss({ a: f(A), b: [helper(), , null, true, () => 1, 'x'], ...rest, c: Default(), d: ns.x(), e: mutable.toString(), f: this.x(), g: import.meta.url, h: Local.name.at(0), i: K.v(), j: g(), k: cyclic1(), l: new Local(), m: tag`x`, n: ((x) => x)(1), o: Math.max(1, 2), p: local(), q: (1 as number).toFixed(), r: (() => { const z = 1; return String(z); })(), s: Kind.A.repeat(2), t: Ambient.B }); +Devup.css({ a: f(1) }); +sx.create({ a: { color: f(1) } }); +sx.props(f(1)); +create({ a: { color: f(1) } }); +props(f(1)); +Devup['css']({ a: f(1) }); +devupCss`width: ${f(1)}px;`; +devupCss(...[f(1)]); +export default class {}", + &option, + None, + )); + assert!(!has_build_time_values( + "a.tsx", + "import { css } from '@devup-ui/react';\nexport default class Theme { static size() { return 1; } }\ncss({ w: Theme.size() });", + &option, + None, + )); + assert!(!has_build_time_values( + "a.tsx", + "import { css } from '@devup-ui/react';\nexport default 1;\ncss({ w: 1 });", + &option, + None, + )); + assert!(!has_build_time_values( + "a.unknown", + "css({ w: f() })", + &option, + None, + )); + + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[( + "/src/throws.ts", + "export const boom = () => { throw new Error('x'); };", + )]; + let resolver = memory_resolver(modules); + for (code, error) in [ + ( + "const X = (() => { throw new Error('x'); })();\nexport const a = css({ width: String(X) });", + "`css()` cannot use `String(X)`", + ), + ( + "import { boom } from './throws';\nexport const a = css({ width: String(1), color: boom() });", + "/src/App.tsx:3:18: `css()` cannot use `boom()`", + ), + ] { + let message = extract_with_modules( + "/src/App.tsx", + &format!("import {{ css }} from '@devup-ui/react';\n{code}"), + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(message.contains(error), "{code}\n{message}"); + } } - // Coverage for extract_style_from_expression.rs:206 — the - // `Expression::BinaryExpression | StaticMemberExpression | CallExpression` - // arm. Both internal branches must be exercised in a single, dedicated - // test so coverage tooling can attribute hits to this exact line: - // - // 207: `if let Some(name) = name { dynamic_style(...) }` (Some path) - // 211: `else { ExtractResult::default() }` (None path, - // reached only via `_xxx={...}` selector recursion) - // - // Each variant (binary / static-member / call) is asserted on its own so - // a regression in any single pattern fails loudly instead of being hidden - // behind a multi-snapshot test. #[test] #[serial] - fn extract_dynamic_style_props_binary_member_call_arm() { - // ── Some(name) branch — line 207 ──────────────────────────────── - // BinaryExpression on a real prop name + fn test_fold_math_at_build_time() { reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( - extract( + assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( + r"import { Box, css } from '@devup-ui/react'; +const W = 10; +const GAP = Math.round(2.5) * 4; +const HALF = Math.round(-2.5); +export const a = css({ width: Math.max(W, 20), height: Math.min(W, -W, 3), gap: GAP, top: HALF, left: Math.floor(W / 3), right: Math.ceil(1.2), bottom: Math.trunc(-1.7), opacity: Math.abs(-0.5), zIndex: Math.sign(-3), order: Math.sign(0), flexGrow: Math.pow(2, 3), lineHeight: Math.sqrt(4), rotate: `${Math.PI}rad`, scale: Math.round(1.4), flexShrink: Math.sign(3) }); +export const b = 2 ? 1 : 2} />;" + ))); + for code in [ + "css({ width: Math.random() })", + "css({ width: Math.max() })", + "css({ width: Math.sqrt(-1) })", + "css({ width: Math.NOPE })", + "css({ width: Math.max(...list) })", + "css({ width: Math.pow(2, 0.5) })", + "css({ width: Math.pow(2, 99) })", + "css({ width: Math.pow(10, 17) })", + ] { + reset_class_map(); + reset_file_map(); + let message = extract( "test.tsx", - r#"import { Box } from "@devup-ui/core"; -; -"#, - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } + &format!("import {{ css }} from '@devup-ui/react';\nexport const a = {code};"), + ExtractOption::default(), ) - .unwrap() - )); - - // StaticMemberExpression on a real prop name + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(message.contains("cannot use"), "{code}: {message}"); + } + // An exact function over what folding cannot read runs at build time 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(), - css_dir: "@devup-ui/core".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } - ) - .unwrap() - )); - - // CallExpression on a real prop name + let computed = extract( + "test.tsx", + "import { css } from '@devup-ui/react';\nexport const a = css({ top: Math.max('1', 2) });", + ExtractOption::default(), + ) + .unwrap(); + let styles = format!("{:?}", computed.styles); + assert!(styles.contains("8px"), "{styles}"); reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert!( extract( "test.tsx", - r#"import { Box } from "@devup-ui/core"; -; -"#, - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new() - } + "import { css } from '@devup-ui/react';\nexport const a = css({ width: -`${Math.PI}` });", + ExtractOption::default(), ) - .unwrap() - )); - - // ── None branch (line 211) — pseudo-selector recursion drops `name` ─ - // Each of these enters extract_style_from_expression with - // `name = Some("_hover")`, hits the `strip_prefix("_")` recursion that - // calls back with `name = None`, and lands in the else arm of line - // 206 → 211. Without the fix this used to panic; the assertion is - // simply that extract() returns Ok with no styles for the dropped - // pseudo-selector attribute. - for src in [ - // BinaryExpression - r"import {Box} from '@devup-ui/react' -declare const a: any; declare const b: any; -export const A = () => ; -", - // StaticMemberExpression - r"import {Box} from '@devup-ui/react' -declare const t: { hover: object }; -export const A = () => ; -", - // CallExpression - r"import {Box} from '@devup-ui/react' -declare const fn: () => object; -export const A = () => ; -", + .is_ok() + ); + for code in [ + "const Math = { max: () => 1 };\nexport const a = css({ width: Math.max(1, 2) });", + "const Math = { PI: 3 };\nexport const a = css({ width: Math.PI, height: Math.max(1, 2) });", + "import Math from './math';\nexport const a = css({ width: Math.max(1, 2) });", + "export const a = (Math) => css({ width: Math.max(1, 2) });", ] { reset_class_map(); reset_file_map(); - let out = extract( + let message = extract( "test.tsx", - src, - ExtractOption { - package: "@devup-ui/react".to_string(), - css_dir: "@devup-ui/react".to_string(), - single_css: true, - import_main_css: false, - import_aliases: HashMap::new(), - }, + &format!("import {{ css }} from '@devup-ui/react';\n{code}"), + ExtractOption::default(), ) - .expect("graceful extract for pseudo-selector with non-literal value"); - assert!( - out.styles.is_empty(), - "expected no extracted styles for pseudo-selector dropped attr, got: {:#?}", - out.styles - ); + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(message.contains("Math."), "{code}: {message}"); } } - fn extract_tsx(code: &str) -> ExtractOutput { + #[test] + #[serial] + fn test_stylesheet_imports_modules() { reset_class_map(); reset_file_map(); - extract( - "test.tsx", - code, + let output = extract_with_modules( + "/src/button.css.ts", + r"import { style } from '@vanilla-extract/css'; +import { vars, base } from './theme.css'; +import size, { brand, shown, extra, more, renamed, double, Scale } from './tokens'; +import * as tokens from './tokens'; +import named from './named'; +import Anonymous from './anonymous'; +import './global.css'; +import legacyModule, { legacy } from './legacy'; +import value from './value'; +import compiled from './compiled'; +export const button = style([base, { color: vars.color.brand, background: brand, margin: double(size), padding: shown + extra + more.extra + renamed + tokens.default + named(), zIndex: typeof Scale === typeof Anonymous ? 1 : 0, borderWidth: legacy + value + compiled + legacyModule.legacy }]); +export const hover = style({ selectors: { [`${base}:hover &`]: { color: 'red' } } });", ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), single_css: true, import_main_css: false, - import_aliases: HashMap::new(), + import_aliases: HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), }, + false, + &memory_resolver(STYLESHEET_MODULES), ) - .expect("extract should not fail") - } - - /// `Argument::to_expression` panics on `...spread`. Every call site that can receive - /// user-written arguments must reject the spread instead of unwrapping it. - #[test] - #[serial] - fn test_spread_arguments_never_panic() { - let stylex = "import stylex from '@stylexjs/stylex';\n"; - for source in [format!("{stylex}const e =
;"), format!("{stylex}const s = stylex.create(...a);"), format!("{stylex}const k = stylex.keyframes(...a);"), format!("{stylex}const s = stylex.create({{ bar: (h) => ({{ height: h }}) }});\nconst e =
;"), format!("{stylex}const s = stylex.create({{ b: {{ position: stylex.firstThatWorks(...a) }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ width: stylex.types.length(...a) }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ ...stylex.include(...a) }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ color: {{ default: stylex.firstThatWorks(...a) }} }} }});"), format!("{stylex}const s = stylex.create({{ b: {{ width: {{ default: stylex.types.length(...a) }} }} }});"), "import { jsx } from 'react/jsx-runtime';\nimport { Box } from '@devup-ui/react';\nconst e = jsx(...a);".to_string(), "import { jsx } from 'react/jsx-runtime';\nimport { Box } from '@devup-ui/react';\nconst e = jsx(Box, ...a);".to_string(), "import { globalCss } from '@devup-ui/react';\nglobalCss({ imports: [...list] });".to_string()] { - extract_tsx(&source); - } - } - - #[test] - #[serial] - fn test_stylex_props_style_x_array() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import stylex from '@stylexjs/stylex'; -const s = stylex.create({ a: { color: 'red' }, b: { marginTop: '1px' } }); -const flat =
; -const nested =
; -const conditional =
;" - ))); - } - - #[test] - #[serial] - fn test_stylex_props_ts_wrapper_and_optional_chain() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import stylex from '@stylexjs/stylex'; -const s = stylex.create({ a: { color: 'red' }, b: { marginTop: '1px' } }); -const cast =
; -const satisfied =
; -const nonNull =
; -const parens =
; -const chained =
; -const computedChain =
; -const chainedCall =
;" - ))); - } - - #[test] - #[serial] - fn test_stylex_attrs_emits_class_attribute() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import stylex from '@stylexjs/stylex'; -const s = stylex.create({ a: { color: 'red' } }); -const e =
;" - ))); - } - - #[test] - #[serial] - fn test_stylex_define_vars_and_create_theme() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import stylex from '@stylexjs/stylex'; -const colors = stylex.defineVars({ primary: 'blue', secondary: 'grey' }); -const dark = stylex.createTheme(colors, { primary: 'navy' }); -const styles = stylex.create({ box: { color: colors.primary, backgroundColor: colors.secondary } }); -const el =
;" - ))); - } - - #[test] - #[serial] - fn test_stylex_theme_apis_with_named_imports() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import { defineVars, createTheme, create, props } from '@stylexjs/stylex'; -const colors = defineVars({ primary: 'blue' }); -const dark = createTheme(colors, { primary: 'navy' }); -const styles = create({ box: { color: colors.primary } }); -const el =
;" - ))); - } - - #[test] - #[serial] - fn test_stylex_theme_apis_ignore_unresolvable_input() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import stylex from '@stylexjs/stylex'; -const empty = stylex.defineVars({ dynamic: someValue }); -const unknownContract = stylex.createTheme(notAContract, { primary: 'navy' }); -const noOverlap = stylex.createTheme(empty, { missing: 'navy' }); -const notAnObject = stylex.defineVars(someVariable); -const deepRef = stylex.create({ box: { color: theme.colors.primary } }); -const spreadVars = stylex.defineVars({ ...other, kept: 'red' }); -const spreadTheme = stylex.createTheme(empty, { ...other });" - ))); - } - - #[test] - #[serial] - fn test_stylex_theme_contract_and_constants() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import stylex from '@stylexjs/stylex'; -import { defineConsts } from '@stylexjs/stylex'; -const vars = stylex.createThemeContract({ primary: null }); -const consts = defineConsts({ gap: '8px' }); -const dark = stylex.createTheme(vars, { primary: 'navy' }); -const styles = stylex.create({ box: { color: vars.primary, marginTop: consts.gap } }); -const el =
;" - ))); - } - - #[test] - #[serial] - fn test_stylex_position_try_and_view_transition_class() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import stylex from '@stylexjs/stylex'; -const fallback = stylex.positionTry({ top: '0', insetBlockEnd: 'auto' }); -const transition = stylex.viewTransitionClass({ animationDuration: '300ms' }); -const empty = stylex.positionTry(notAnObject);" - ))); - } - - #[test] - #[serial] - fn test_styled_object_form_theme_interpolation() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import { styled } from '@devup-ui/react'; -const Themed = styled('div')({ color: (p) => p.theme.brand }); -const Plain = styled('span')({ color: (p) => p.color });" - ))); + .unwrap(); + assert_eq!( + output.dependencies, + [ + "/src/anonymous.ts", + "/src/compiled.js", + "/src/legacy.js", + "/src/more.ts", + "/src/named.ts", + "/src/theme.css.ts", + "/src/tokens.ts", + "/src/value.js" + ] + ); + assert_debug_snapshot!(ToBTreeSet::from(output)); + let cycle: &'static [(&str, &str)] = &[ + ("/src/b.css.ts", ""), + ( + "/src/a.css.ts", + "import { style } from '@devup-ui/react';\nimport { b } from './b.css';\nexport const a = style([b, { color: 'red' }]);", + ), + ]; + let error = extract_with_modules( + "/src/b.css.ts", + "import { style } from '@devup-ui/react';\nimport { a } from './a.css';\nexport const b = style({ margin: 4 });\nexport const useA = () => a;", + ExtractOption::default(), + false, + &memory_resolver(cycle), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!( + error.contains("Cannot access 'b' of '/src/b.css.ts' before its initialization: it is part of an import cycle"), + "{error}" + ); } #[test] #[serial] - fn test_emotion_global_component_and_import_surface() { + fn test_conditional_composition() { reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", - r"import styled from '@emotion/styled'; -import { css, keyframes, Global, ThemeProvider, useTheme, ClassNames } from '@emotion/react'; -const S = styled.div`color: ${p => p.theme.brand};`; -export const App = () => <>;", + r"import { css } from '@devup-ui/react'; +import styled from '@emotion/styled'; +export const a = css({ color: 'red', p: 1 }, cond && { color: 'blue', _hover: { color: 'green' } }); +export const b = css([{ m: 1 }, flag ? { m: 2 } : { m: 3, bg: 'red' }]); +export const c = css({ color: 'red' }, cond && other, flag ? 'x' : null, [undefined, false]); +export const d = css({ _hover: { color: 'red' } }, cond && { _hover: { bg: 'blue' } }); +export const e = css({ color: 'red' }, flag ? other : { color: 'blue' }); +export const h = css({ color: 'red' }, cond || { color: 'blue' }); +export const i = css({ color: 'red' }, cond ? null : undefined); +export const j = css({ color: 'red' }, value ?? { color: 'blue' }); +export const K = styled.div(base, cond && { color: 'blue' }, { margin: 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::from([ - ("@emotion/react".to_string(), ImportAlias::NamedToNamed), - ( - "@emotion/styled".to_string(), - ImportAlias::DefaultToNamed("styled".to_string()) - ) - ]) + import_aliases: HashMap::from([( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + )]), }, ) .unwrap() )); + for (code, error) in [ + ( + "import { css } from '@devup-ui/react';\ncss({ _hover: 'x' }, cond && { _hover: { color: 'blue' } });", + "Cannot compose `{ _hover: \"x\" }, cond && { _hover: { color: \"blue\" } }` at build time: each style must be a rule object, a class, or a condition choosing between them", + ), + ( + "import { css } from '@devup-ui/react';\ncss([{ color: 'red' }, cond && { [key]: 'blue' }]);", + "Cannot compose `[{ color: \"red\" }, cond && { [key]: \"blue\" }]` at build time: each style must be a rule object, a class, or a condition choosing between them", + ), + ( + "import { css } from '@devup-ui/react';\ncss({ color: 'red' }, ...rest);", + "Cannot compose `{ color: \"red\" }, ...rest` at build time: each style must be a rule object, a class, or a condition choosing between them", + ), + ( + "import { styled } from '@devup-ui/react';\nstyled.div({ color: 'red' }, getStyles());", + "Cannot compose `{ color: \"red\" }, getStyles()` at build time: each style must be a rule object, a class, or a condition choosing between them", + ), + ] { + assert_eq!( + extract("test.tsx", code, ExtractOption::default()) + .err() + .map(|error| error.to_string()), + Some(format!("test.tsx:2:1: {error}")), + ); + } } #[test] @@ -17928,18 +20305,6 @@ const S = styled['div']`color: red;`;" ))); } - #[test] - #[serial] - fn test_stylex_variable_declarations_skip_unreadable_entries() { - assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( - r"import stylex from '@stylexjs/stylex'; -const consts = stylex.defineConsts({ ...other, gap: '8px', dynamic: someVar }); -const vars = stylex.defineVars({ [computed]: 'red', primary: 'blue' }); -const tryBlock = stylex.positionTry({ ...spread, top: '0' }); -const styles = stylex.create({ box: { marginTop: consts.gap, color: vars.primary } });" - ))); - } - #[test] #[serial] fn test_type_instantiation_expression_as_style_value() { diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs new file mode 100644 index 000000000..4082ff395 --- /dev/null +++ b/libs/extractor/src/module_loader.rs @@ -0,0 +1,468 @@ +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::fmt::Write; + +use oxc_allocator::Allocator; +use oxc_ast::AstKind; +use oxc_ast::ast::{ + Argument, Declaration, ExportDefaultDeclarationKind, ImportDeclarationSpecifier, + ModuleExportName, Statement, +}; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::{GetSpan, SourceType}; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::{ExtractOption, ModuleResolver, utils::is_vanilla_extract_file}; + +/// The object the package's API is bound to while a stylesheet runs +pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__"; + +/// A console whose calls do nothing, as what a module logs while it runs +/// changes no value +pub(crate) const CONSOLE: &str = "if (typeof console === \"undefined\") globalThis.console = new Proxy({}, { get: () => () => undefined });\n"; + +/// Loop iterations an evaluation may run before it fails instead of hanging +/// the build +pub(crate) const LOOP_ITERATION_LIMIT: u64 = 10_000_000; + +/// The end of the error a module read before its evaluation throws +pub(crate) const IMPORT_CYCLE: &str = "before its initialization: it is part of an import cycle"; + +const MODULE_HELPER: &str = "function __module__(path) { let started = false; const module = new Proxy({}, { get(target, key, receiver) { if (!started && typeof key === \"string\") throw new ReferenceError(`Cannot access '${key}' of '${path}' before its initialization: it is part of an import cycle`); return Reflect.get(target, key, receiver); } }); return { module, start() { started = true; } }; }\n"; + +thread_local! { + /// Stylesheets being evaluated, outermost first: a stylesheet importing one + /// that is still being evaluated is a cycle no evaluation order can satisfy + static EVALUATING: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Marks `filename` as being evaluated until dropped +pub(crate) struct Evaluating; + +impl Evaluating { + pub(crate) fn enter(filename: &str) -> Self { + EVALUATING.with_borrow_mut(|stack| stack.push(filename.to_string())); + Self + } +} + +impl Drop for Evaluating { + fn drop(&mut self) { + EVALUATING.with_borrow_mut(Vec::pop); + } +} + +/// Whether a stylesheet is being evaluated, so the one extracted now is loaded +/// by it and must not fall back to plain extraction +pub(crate) fn loading_for_stylesheet() -> bool { + EVALUATING.with_borrow(|stack| !stack.is_empty()) +} + +/// Whether the stylesheet evaluated now is one another evaluation imports +pub(crate) fn evaluating_import() -> bool { + EVALUATING.with_borrow(|stack| stack.len() > 1) +} + +fn is_evaluating(filename: &str) -> bool { + EVALUATING.with_borrow(|stack| stack.iter().any(|entry| entry == filename)) +} + +/// Loads what a stylesheet imports, each module once, as JavaScript that +/// defines the module's exports object ahead of the stylesheet +pub(crate) struct ModuleLoader<'r> { + resolver: Option<&'r ModuleResolver>, + option: &'r ExtractOption, + /// Definitions of the loaded modules, each after the ones it uses + definitions: Vec, + loaded: FxHashMap, + /// `(path, name)` of the modules being defined, outermost first + loading: Vec<(String, String)>, + /// Names of modules imported before they finished evaluating, whose + /// bindings are read when used, as ES modules read an import cycle + pending: FxHashSet, + next_module: usize, + /// Every file read, including those the loaded stylesheets read + pub dependencies: BTreeSet, + /// What the stylesheet imports for its side effects, and the stylesheets it + /// imports, which emit their own styles: its output keeps importing them + pub kept_imports: Vec, +} + +impl<'r> ModuleLoader<'r> { + pub(crate) fn new(resolver: Option<&'r ModuleResolver>, option: &'r ExtractOption) -> Self { + Self { + resolver, + option, + definitions: Vec::new(), + loaded: FxHashMap::default(), + loading: Vec::new(), + pending: FxHashSet::default(), + next_module: 0, + dependencies: BTreeSet::new(), + kept_imports: Vec::new(), + } + } + + fn keep_import(&mut self, specifier: &str) { + if !self.kept_imports.iter().any(|kept| kept == specifier) { + self.kept_imports.push(specifier.to_string()); + } + } + + /// The loaded modules' definitions, to run before the stylesheet + pub(crate) fn prelude(&self) -> String { + self.definitions.concat() + } + + /// The name of the exports object of `specifier` imported by `importer` + fn load(&mut self, specifier: &str, importer: &str, direct: bool) -> Result { + let resolver = self + .resolver + .ok_or_else(|| format!("Cannot load '{specifier}' without a module resolver"))?; + let module = resolver(specifier, importer) + .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))?; + let stylesheet = is_vanilla_extract_file(&module.path); + if direct && stylesheet { + self.keep_import(specifier); + } + if let Some(name) = self.loaded.get(&module.path) { + return Ok(name.clone()); + } + if let Some((_, name)) = self.loading.iter().find(|(path, _)| *path == module.path) { + let name = name.clone(); + self.pending.insert(name.clone()); + return Ok(name); + } + let name = format!("__module_{}__", self.next_module); + self.next_module += 1; + // Created before the modules it imports, so a cycle among them can + // reach it; reading it before it starts evaluating is an error + if self.definitions.is_empty() { + self.definitions.push(MODULE_HELPER.to_string()); + } + self.definitions.push(format!( + "const {name}$ = __module__({:?});\nconst {name} = {name}$.module;\n", + module.path + )); + if is_evaluating(&module.path) { + // The stylesheet importing it is evaluated on its own, so it never + // starts here + self.pending.insert(name.clone()); + self.loaded.insert(module.path, name.clone()); + return Ok(name); + } + self.dependencies.insert(module.path.clone()); + self.loading.push((module.path.clone(), name.clone())); + let defined = self.define(&name, module, stylesheet, resolver); + let (path, _) = self.loading.pop().unwrap_or_default(); + defined?; + self.loaded.insert(path, name.clone()); + Ok(name) + } + + fn define( + &mut self, + name: &str, + module: crate::ResolvedModule, + stylesheet: bool, + resolver: &ModuleResolver, + ) -> Result<(), String> { + let code = if stylesheet { + // Extracted the way the bundler extracts it, so the names it + // exports are the ones its own CSS uses + let output = crate::extract_with_source_map( + &module.path, + &module.code, + self.option.clone(), + false, + Some(resolver), + ) + .map_err(|error| error.to_string())?; + self.dependencies.extend(output.dependencies); + output.code + } else { + module.code + }; + let script = crate::vanilla_extract::strip_typescript(&code, &module.path); + let module_script = module_script(&script, &module.path, self, false)?; + // Live bindings: a read before the binding is initialized fails as it + // does in an ES module + let getters: Vec = module_script + .exports + .iter() + .map(|(exported, local)| { + format!("{exported:?}: {{ get() {{ return {local}; }}, enumerable: true }}") + }) + .collect(); + if module_script.commonjs { + // Its exports are what `module.exports` holds once it ran; the + // default follows bundler interop (`__esModule` marks a compiled + // ES module) + self.definitions.push(format!( + "(function () {{\n{name}$.start();\nconst module = {{ exports: {{}} }};\nconst exports = module.exports;\n{}\nconst e = module.exports;\nObject.defineProperty({name}, \"__exports__\", {{ value: e }});\nif (e !== null && (typeof e === \"object\" || typeof e === \"function\")) for (const key of Object.keys(e)) if (key !== \"default\") Object.defineProperty({name}, key, {{ get: () => e[key], enumerable: true }});\nObject.defineProperty({name}, \"default\", {{ value: e !== null && typeof e === \"object\" && e.__esModule ? e.default : e, enumerable: true }});\n}})();\n", + module_script.body, + )); + return Ok(()); + } + let mut spreads = String::new(); + for spread in &module_script.spreads { + let _ = writeln!( + spreads, + "for (const key of Object.keys({spread})) if (key !== \"default\" && !(key in {name})) Object.defineProperty({name}, key, {{ get: () => {spread}[key], enumerable: true }});" + ); + } + self.definitions.push(format!( + "(function () {{\n{name}$.start();\nObject.defineProperties({name}, {{ {} }});\n{spreads}{}\n}})();\n", + getters.join(", "), + module_script.body, + )); + Ok(()) + } +} + +/// A module turned into a script: imports bound to the loaded modules, export +/// keywords dropped +pub(crate) struct ModuleScript { + pub body: String, + /// `(exported name, expression)` + exports: Vec<(String, String)>, + /// Modules re-exported whole + spreads: Vec, + /// Written with `module.exports` rather than `export` + commonjs: bool, +} + +pub(crate) fn module_script( + script: &str, + filename: &str, + loader: &mut ModuleLoader<'_>, + entry: bool, +) -> Result { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, script, SourceType::mjs()) + .parse() + .program; + let semantic = SemanticBuilder::new() + .with_build_nodes(true) + .build(&program) + .semantic; + let package = loader.option.package.clone(); + + // Imports of a module still evaluating are read where they are used, as ES + // modules read an import cycle + let mut modules: FxHashMap = FxHashMap::default(); + let mut lazy: FxHashMap = FxHashMap::default(); + let mut lazy_names: FxHashMap = FxHashMap::default(); + for statement in &program.body { + let Statement::ImportDeclaration(import) = statement else { + continue; + }; + let source = import.source.value.as_str(); + let specifiers = import.specifiers.as_deref().map_or(&[][..], |s| s); + if specifiers.is_empty() { + if entry && !source.starts_with(package.as_str()) { + loader.keep_import(source); + } + continue; + } + let module = if source == package { + PACKAGE_BINDING.to_string() + } else { + loader.load(source, filename, entry)? + }; + if loader.pending.contains(&module) { + let lazy_module = &module; + for specifier in specifiers { + let binding = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + format!("{lazy_module}[{:?}]", specifier.imported.name()) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => { + format!("{lazy_module}[\"default\"]") + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => module.clone(), + }; + let local = specifier.local(); + if let Some(symbol) = local.symbol_id.get() { + lazy.insert(symbol, binding.clone()); + } + lazy_names.insert(local.name.to_string(), binding); + } + } + modules.insert(import.span.start, module); + } + let mut replacements: Vec<(u32, u32, String)> = Vec::new(); + for (symbol, binding) in &lazy { + for reference in semantic.scoping().get_resolved_reference_ids(*symbol) { + let node = semantic.scoping().get_reference(*reference).node_id(); + let span = semantic.nodes().kind(node).span(); + let replacement = match semantic.nodes().parent_kind(node) { + AstKind::ObjectProperty(property) if property.shorthand => { + format!( + "{}: {binding}", + &script[span.start as usize..span.end as usize] + ) + } + AstKind::ExportSpecifier(_) => continue, + _ => binding.clone(), + }; + replacements.push((span.start, span.end, replacement)); + } + } + // CommonJS: `require` of a literal path loads the module like an import + let commonjs = !program.body.iter().any(Statement::is_module_declaration) + && semantic + .scoping() + .root_unresolved_references() + .keys() + .any(|name| matches!(name.as_str(), "module" | "exports" | "require")); + if commonjs + && let Some(references) = semantic + .scoping() + .root_unresolved_references() + .get("require") + { + for reference in references { + let node = semantic.scoping().get_reference(*reference).node_id(); + if let AstKind::CallExpression(call) = semantic.nodes().parent_kind(node) + && let [Argument::StringLiteral(specifier)] = call.arguments.as_slice() + { + let module = loader.load(specifier.value.as_str(), filename, entry)?; + replacements.push(( + call.span.start, + call.span.end, + format!("(\"__exports__\" in {module} ? {module}.__exports__ : {module})"), + )); + } + } + } + replacements.sort_by_key(|(start, ..)| *start); + let text = |span: oxc_span::Span| { + let mut code = String::new(); + let mut copied = span.start; + for (start, end, replacement) in &replacements { + if *start >= span.start && *end <= span.end { + code.push_str(&script[copied as usize..*start as usize]); + code.push_str(replacement); + copied = *end; + } + } + code.push_str(&script[copied as usize..span.end as usize]); + code + }; + + let mut body = String::with_capacity(script.len()); + let mut exports = Vec::new(); + let mut spreads = Vec::new(); + for statement in &program.body { + match statement { + Statement::ImportDeclaration(import) => { + let Some(module) = modules.get(&import.span.start) else { + continue; + }; + if loader.pending.contains(module) { + continue; + } + let mut named = Vec::new(); + for specifier in import.specifiers.iter().flatten() { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => named.push( + format!("{:?}: {}", specifier.imported.name(), specifier.local.name), + ), + ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { + named.push(format!("\"default\": {}", specifier.local.name)); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { + let _ = writeln!(body, "const {} = {module};", specifier.local.name); + } + } + } + if !named.is_empty() { + let _ = writeln!(body, "const {{ {} }} = {module};", named.join(", ")); + } + } + Statement::ExportDeclaration(export) => { + body.push_str(&text(export.declaration.span())); + body.push('\n'); + for name in declared_names(&export.declaration) { + exports.push((name.clone(), name)); + } + } + Statement::ExportNamedDeclaration(export) => { + for specifier in &export.specifiers { + let local = export_name(&specifier.local); + exports.push(( + export_name(&specifier.exported), + lazy_names.get(&local).cloned().unwrap_or(local), + )); + } + } + Statement::ExportFromDeclaration(export) => { + let module = loader.load(export.source.value.as_str(), filename, entry)?; + for specifier in &export.specifiers { + exports.push(( + export_name(&specifier.exported), + format!("{module}[{:?}]", export_name(&specifier.local)), + )); + } + } + Statement::ExportDefaultDeclaration(export) => { + let id = match &export.declaration { + ExportDefaultDeclarationKind::FunctionDeclaration(function) => { + function.id.as_ref() + } + ExportDefaultDeclarationKind::ClassDeclaration(class) => class.id.as_ref(), + _ => None, + }; + let declaration = text(export.declaration.span()); + let local = if let Some(id) = id { + body.push_str(&declaration); + id.name.to_string() + } else { + let _ = write!(body, "const __default__ = ({declaration});"); + "__default__".to_string() + }; + body.push('\n'); + exports.push(("default".to_string(), local)); + } + Statement::ExportAllDeclaration(export) => { + let module = loader.load(export.source.value.as_str(), filename, entry)?; + match &export.exported { + Some(exported) => exports.push((export_name(exported), module)), + None => spreads.push(module), + } + } + statement => { + body.push_str(&text(statement.span())); + body.push('\n'); + } + } + } + Ok(ModuleScript { + body, + exports, + spreads, + commonjs, + }) +} +fn export_name(name: &ModuleExportName<'_>) -> String { + name.name().to_string() +} + +fn declared_names(declaration: &Declaration<'_>) -> Vec { + match declaration { + Declaration::VariableDeclaration(declaration) => declaration + .declarations + .iter() + .flat_map(|declarator| declarator.id.get_binding_identifiers()) + .map(|identifier| identifier.name.to_string()) + .collect(), + declaration => declaration + .id() + .iter() + .map(|id| id.name.to_string()) + .collect(), + } +} diff --git a/libs/extractor/src/mutations.rs b/libs/extractor/src/mutations.rs new file mode 100644 index 000000000..0c49cae63 --- /dev/null +++ b/libs/extractor/src/mutations.rs @@ -0,0 +1,671 @@ +//! Where a module changes the objects and arrays its top-level bindings hold, +//! or hands them to code that may change them, so the build does not read +//! them as the constants they are declared as. + +use oxc_ast::AstKind; +use oxc_ast::ast::{ + Expression, JSXAttributeName, JSXElementName, ObjectPropertyKind, Program, + VariableDeclarationKind, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::{AstNodes, SemanticBuilder}; +use oxc_span::GetSpan; +use oxc_syntax::node::NodeId; +use oxc_syntax::operator::UnaryOperator; +use oxc_syntax::scope::ScopeFlags; +use rustc_hash::FxHashMap; + +/// How code uses a top-level binding +#[derive(Debug)] +pub(crate) enum Use { + /// Changes what it holds `depth` members deep, at this offset + Changes { at: u32, depth: usize }, + /// Hands the value at `path` (`None` for any key) to code that may change + /// it, or puts it in the top-level `const` named `into`; an empty `into` + /// is what the module exports + Escapes { + at: u32, + path: Vec>, + into: Option, + }, + /// Calls a method on the value at `path` that may change it through `this` + Calls { at: u32, path: Vec> }, +} + +const MUTATING_METHODS: [&str; 13] = [ + "push", + "pop", + "shift", + "unshift", + "splice", + "sort", + "reverse", + "fill", + "copyWithin", + "set", + "delete", + "clear", + "add", +]; + +/// Methods handing the elements of what they are called on to a callback or +/// to the array they return +const ELEMENT_METHODS: [&str; 23] = [ + "forEach", + "map", + "filter", + "find", + "findIndex", + "findLast", + "findLastIndex", + "some", + "every", + "reduce", + "reduceRight", + "flatMap", + "flat", + "concat", + "slice", + "toReversed", + "toSorted", + "toSpliced", + "with", + "values", + "entries", + "at", + "get", +]; + +/// Methods that only read what they are called on +const READING_METHODS: [&str; 10] = [ + "join", + "includes", + "indexOf", + "lastIndexOf", + "keys", + "has", + "toString", + "valueOf", + "hasOwnProperty", + "propertyIsEnumerable", +]; + +/// Global functions that only read their arguments +const READING_FUNCTIONS: [&str; 8] = [ + "String", + "Number", + "Boolean", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "structuredClone", +]; + +/// The uses of each top-level binding of `program` that may change what it +/// holds; `style` tells the names of the style APIs, whose arguments the +/// build reads and which never run +pub(crate) fn uses( + program: &Program<'_>, + style: &dyn Fn(&str) -> bool, +) -> FxHashMap> { + let semantic = SemanticBuilder::new() + .with_build_nodes(true) + .build(program) + .semantic; + let scoping = semantic.scoping(); + let nodes = semantic.nodes(); + let is_global = |name: &str| scoping.get_root_binding(name.into()).is_none(); + let mut uses: FxHashMap> = FxHashMap::default(); + for (name, symbol) in scoping.get_bindings(scoping.root_scope_id()) { + let init = match nodes.kind(scoping.symbol_declaration(*symbol)) { + AstKind::VariableDeclarator(declarator) => declarator.init.as_ref(), + _ => None, + }; + let context = Context { + nodes, + style, + is_global: &is_global, + init, + }; + let found: Vec = scoping + .get_resolved_reference_ids(*symbol) + .iter() + .map(|reference| scoping.get_reference(*reference)) + .filter(|reference| reference.is_value()) + .filter_map(|reference| context.classify(reference.node_id())) + .collect(); + if !found.is_empty() { + uses.insert(name.to_string(), found); + } + } + uses +} + +struct Context<'s, 'a> { + nodes: &'s AstNodes<'a>, + style: &'s dyn Fn(&str) -> bool, + is_global: &'s dyn Fn(&str) -> bool, + /// What the binding is declared as, when a `const` or `let` gives it + init: Option<&'s Expression<'a>>, +} + +impl Context<'_, '_> { + fn classify(&self, node: NodeId) -> Option { + let at = self.nodes.kind(node).span().start; + let mut path = Vec::new(); + let mut current = node; + loop { + let span = self.nodes.kind(current).span(); + let parent = self.nodes.parent_id(current); + match self.nodes.kind(parent) { + AstKind::StaticMemberExpression(member) if member.object.span() == span => { + path.push(Some(member.property.name.to_string())); + } + AstKind::ComputedMemberExpression(member) if member.object.span() == span => { + path.push( + crate::utils::get_string_by_literal_expression(&member.expression) + .map(std::borrow::Cow::into_owned), + ); + } + AstKind::ConditionalExpression(conditional) if conditional.test.span() == span => { + return None; + } + AstKind::SequenceExpression(sequence) + if sequence.expressions.last().map(GetSpan::span) != Some(span) => + { + return None; + } + AstKind::ParenthesizedExpression(_) + | AstKind::TSAsExpression(_) + | AstKind::TSSatisfiesExpression(_) + | AstKind::TSNonNullExpression(_) + | AstKind::TSTypeAssertion(_) + | AstKind::ChainExpression(_) + | AstKind::ConditionalExpression(_) + | AstKind::LogicalExpression(_) + | AstKind::SequenceExpression(_) + | AstKind::AwaitExpression(_) => {} + kind => return self.use_in(kind, parent, span, at, path), + } + current = parent; + } + } + + fn use_in( + &self, + kind: AstKind<'_>, + parent: NodeId, + span: oxc_span::Span, + at: u32, + mut path: Vec>, + ) -> Option { + // Writing the binding itself is an error for a `const` or an import + let changes = |path: &[Option]| { + (!path.is_empty()).then_some(Use::Changes { + at, + depth: path.len(), + }) + }; + match kind { + AstKind::AssignmentExpression(assignment) if assignment.left.span() == span => { + changes(&path) + } + AstKind::AssignmentTargetWithDefault(target) if target.binding.span() == span => { + changes(&path) + } + AstKind::ForInStatement(statement) if statement.left.span() == span => changes(&path), + AstKind::ForOfStatement(statement) if statement.left.span() == span => changes(&path), + // Each element is handed to the loop's binding, or to what is spread + AstKind::ForOfStatement(_) | AstKind::SpreadElement(_) => { + path.push(None); + self.escapes(parent, at, path) + } + AstKind::UpdateExpression(_) + | AstKind::ArrayAssignmentTarget(_) + | AstKind::ObjectAssignmentTarget(_) + | AstKind::AssignmentTargetRest(_) + | AstKind::AssignmentTargetPropertyProperty(_) => changes(&path), + AstKind::UnaryExpression(unary) if unary.operator == UnaryOperator::Delete => { + changes(&path) + } + AstKind::CallExpression(call) if call.callee.span() == span => { + self.method_call(parent, at, path) + } + AstKind::CallExpression(call) => { + let function = self.global_function(&call.callee); + if matches!( + function, + Some(( + "Object", + "assign" | "defineProperty" | "defineProperties" | "setPrototypeOf" + )) + ) && call.arguments.first().map(GetSpan::span) == Some(span) + { + return Some(Use::Changes { + at, + depth: path.len() + 1, + }); + } + match function { + Some( + ("Object", "assign" | "values" | "entries") | ("Array", "from" | "of"), + ) => { + path.push(None); + self.escapes(parent, at, path) + } + Some( + ( + "Object", + "keys" | "freeze" | "seal" | "preventExtensions" | "isFrozen" + | "isSealed" | "getOwnPropertyNames" | "hasOwn", + ) + | ("JSON" | "Math" | "console" | "", _) + | ("Array", "isArray"), + ) => None, + _ => self.escapes(parent, at, path), + } + } + AstKind::ObjectProperty(property) if property.value.span() != span => None, + // React components must not change their props, so an element + // only reads them, except the `ref` React assigns + AstKind::JSXExpressionContainer(_) => { + matches!(self.nodes.parent_kind(parent), AstKind::JSXAttribute(attribute) + if matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name == "ref")) + .then(|| self.escapes(parent, at, path)) + .flatten() + } + AstKind::ObjectProperty(_) + | AstKind::ArrayExpression(_) + | AstKind::VariableDeclarator(_) + | AstKind::AssignmentExpression(_) + | AstKind::AssignmentTargetWithDefault(_) + | AstKind::AssignmentPattern(_) + | AstKind::FormalParameter(_) + | AstKind::NewExpression(_) + | AstKind::ReturnStatement(_) + | AstKind::YieldExpression(_) + | AstKind::ExportDefaultDeclaration(_) + | AstKind::TaggedTemplateExpression(_) + // The body of `() => value` gives its value + | AstKind::ArrowFunctionExpression(_) => self.escapes(parent, at, path), + AstKind::TemplateLiteral(_) + if matches!( + self.nodes.parent_kind(parent), + AstKind::TaggedTemplateExpression(_) + ) => + { + self.escapes(parent, at, path) + } + _ => None, + } + } + + /// `path` read as a method called on what comes before its last key + fn method_call(&self, call: NodeId, at: u32, mut path: Vec>) -> Option { + // Calling the binding itself hands it nothing + let method = path.pop()?; + if let Some(method) = method.as_deref() { + if MUTATING_METHODS.contains(&method) { + return Some(Use::Changes { + at, + depth: path.len() + 1, + }); + } + if ELEMENT_METHODS.contains(&method) { + path.push(None); + return self.escapes(call, at, path); + } + if READING_METHODS.contains(&method) || !self.uses_this(&path, method) { + return None; + } + } + (!self.in_style(call)).then_some(Use::Calls { at, path }) + } + + /// Whether the method `method` of what `path` leads to in the binding's + /// declaration may read `this`: an arrow function cannot, and a function + /// written there can only when its body does + fn uses_this(&self, path: &[Option], method: &str) -> bool { + let mut value = self.init; + for key in path.iter().map(Option::as_deref).chain([Some(method)]) { + let (Some(key), Some(Expression::ObjectExpression(object))) = + (key, value.map(crate::utils::unwrap_syntax_only)) + else { + return true; + }; + let mut found = None; + for property in &object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if property.key.static_name().as_deref() == Some(key) { + found = Some(&property.value); + } else if property.computed { + found = None; + } + } + ObjectPropertyKind::SpreadProperty(_) => found = None, + } + } + value = found; + } + match value.map(crate::utils::unwrap_syntax_only) { + Some(Expression::ArrowFunctionExpression(_)) => false, + Some(Expression::FunctionExpression(function)) => { + let mut this = ReadsThis::default(); + if let Some(body) = &function.body { + this.visit_function_body(body); + } + this.found + } + _ => true, + } + } + + /// `(object, member)` when `callee` is a global function, `("", name)` for + /// one that only reads its arguments + fn global_function<'e>(&self, callee: &'e Expression<'_>) -> Option<(&'e str, &'e str)> { + match callee { + Expression::Identifier(identifier) + if READING_FUNCTIONS.contains(&identifier.name.as_str()) + && (self.is_global)(&identifier.name) => + { + Some(("", identifier.name.as_str())) + } + Expression::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(object) if (self.is_global)(&object.name) => { + Some((object.name.as_str(), member.property.name.as_str())) + } + _ => None, + }, + _ => None, + } + } + + /// Whether code at `node` is read by a style API, which never runs it + fn in_style(&self, node: NodeId) -> bool { + std::iter::once(node) + .chain(self.nodes.ancestor_ids(node)) + .any(|id| match self.nodes.kind(id) { + AstKind::CallExpression(call) => self.is_style(&call.callee), + AstKind::TaggedTemplateExpression(tagged) => self.is_style(&tagged.tag), + AstKind::JSXOpeningElement(element) => self.is_style_element(&element.name), + _ => false, + }) + } + + /// A value handed on at `node`: read where the style APIs read it, kept + /// in a top-level `const` or in what the module exports, or out of the + /// build's sight + fn escapes(&self, node: NodeId, at: u32, path: Vec>) -> Option { + if self.in_style(node) { + return None; + } + // Through the literals it is written in, and the calls giving back + // what they are given, up to what holds it + let holder = std::iter::once(node) + .chain(self.nodes.ancestor_ids(node)) + .find(|id| match self.nodes.kind(*id) { + AstKind::ObjectProperty(_) + | AstKind::ObjectExpression(_) + | AstKind::ArrayExpression(_) + | AstKind::SpreadElement(_) + | AstKind::ParenthesizedExpression(_) + | AstKind::TSAsExpression(_) + | AstKind::TSSatisfiesExpression(_) => false, + AstKind::CallExpression(call) => !matches!( + self.global_function(&call.callee), + Some(("Object", "freeze" | "seal" | "preventExtensions")) + ), + _ => true, + }); + let into = holder.and_then(|id| self.holder(id)); + Some(Use::Escapes { at, path, into }) + } + + /// The top-level `const` `id` declares, or an empty name for what the + /// module exports at `id` + fn holder(&self, id: NodeId) -> Option { + let top_level = |id: NodeId| { + matches!( + self.nodes.ancestor_kinds(id).nth(1), + Some(AstKind::Program(_) | AstKind::ExportDeclaration(_)) + ) + }; + match self.nodes.kind(id) { + AstKind::VariableDeclarator(declarator) + if matches!(self.nodes.parent_kind(id), + AstKind::VariableDeclaration(declaration) + if declaration.kind == VariableDeclarationKind::Const) + && top_level(id) => + { + declarator + .id + .get_identifier_name() + .map(|name| name.to_string()) + } + AstKind::ExportDefaultDeclaration(_) => Some(String::new()), + AstKind::AssignmentExpression(assignment) + if top_level(id) + && assignment + .left + .as_simple_assignment_target() + .and_then(|target| target.as_member_expression()) + .is_some_and(|member| self.is_commonjs_export(member)) => + { + Some(String::new()) + } + _ => None, + } + } + + /// `module.exports`, `module.exports.x` or `exports.x` + fn is_commonjs_export(&self, member: &oxc_ast::ast::MemberExpression<'_>) -> bool { + let is_global = |expression: &Expression<'_>, name: &str| { + matches!(expression, Expression::Identifier(identifier) + if identifier.name == name && (self.is_global)(name)) + }; + match member.object() { + Expression::StaticMemberExpression(inner) => { + inner.property.name == "exports" && is_global(&inner.object, "module") + } + object => { + is_global(object, "exports") + || (is_global(object, "module") + && member.static_property_name() == Some("exports")) + } + } + } + + fn is_style(&self, callee: &Expression<'_>) -> bool { + let mut expression = callee; + loop { + match crate::utils::unwrap_syntax_only(expression) { + Expression::Identifier(identifier) => return (self.style)(&identifier.name), + Expression::StaticMemberExpression(member) => expression = &member.object, + Expression::CallExpression(call) => expression = &call.callee, + _ => return false, + } + } + } + + fn is_style_element(&self, name: &JSXElementName<'_>) -> bool { + crate::imported_constants::jsx_root(name).is_some_and(|root| (self.style)(root)) + } +} + +/// Whether a function body reads `this`, outside the functions it declares +#[derive(Default)] +struct ReadsThis { + found: bool, + depth: usize, +} + +impl<'a> Visit<'a> for ReadsThis { + fn visit_this_expression(&mut self, _: &oxc_ast::ast::ThisExpression) { + self.found |= self.depth == 0; + } + + fn visit_function(&mut self, function: &oxc_ast::ast::Function<'a>, flags: ScopeFlags) { + self.depth += 1; + walk::walk_function(self, function, flags); + self.depth -= 1; + } +} + +#[cfg(test)] +mod tests { + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + + use super::{Use, uses}; + + fn describe(code: &str) -> Vec { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let line = |at: u32| { + code[at as usize..] + .lines() + .next() + .unwrap_or_default() + .to_string() + }; + let mut found: Vec = + uses(&program, &|name| matches!(name, "css" | "Box" | "Devup")) + .into_iter() + .flat_map(|(name, uses)| { + uses.into_iter().map(move |found| match found { + Use::Changes { at, depth } => { + format!("{name} changes {depth}: {}", line(at)) + } + Use::Escapes { at, path, into } => { + format!("{name} escapes {path:?} into {into:?}: {}", line(at)) + } + Use::Calls { at, path } => format!("{name} calls {path:?}: {}", line(at)), + }) + }) + .collect(); + found.sort(); + found + } + + #[test] + fn changes() { + insta::assert_debug_snapshot!(describe( + "import { a } from './a'; +a.x = 1; +a['y'] += 1; +a[k]++; +delete a.x; +[a.x] = [1]; +({ x: a.x } = {}); +({ ...a.rest } = {}); +[a.x = 1] = []; +for (a.x in {}); +for (a.x of []); +a.list.push(1); +a.list.map(String); +Object.assign(a, {}); +Object.defineProperty(a, 'x', {}); +(a as any).x = 1; +(a!).x = 1; +a?.x.sort(); +a = 1; +a();" + )); + } + + #[test] + fn escapes() { + insta::assert_debug_snapshot!(describe( + "const a = { x: {} }; +f(a.x); +new F(a); +Object.defineProperty(o, 'k', a); +Object.assign({}, a); +Object.values(a); Object.entries(a); Array.from(a); Array.of(a); +g(...a.list); +const copy = { ...a }; +export const whole = { a, list: [a.x] }; +const frozen = Object.freeze({ a }); +const nested = f({ a }); +let later = a; +later = a; +[later = a] = []; +function get() { return a; } +function* all() { yield a; } +function p(q = a) {} +const arrow = () => a.x; +const block = () => { a; }; +const tagged = tag`${a}`; +for (const item of a) {} +a.forEach(f); a.map(f); +export default { a }; +module.exports = { a }; +exports.b = a; +module.exports.c = a; +other.exports = a; +
; +x ? a : a; +x && a; +(x, a); +await a;" + )); + } + + #[test] + fn reads() { + insta::assert_debug_snapshot!(describe( + "const a = { x: 1, arrow: () => 1, plain() { return 1; }, nested() { return function () { return this; }; }, self() { return this; } }; +String(a.x); parseInt(a.x); structuredClone(a); Object.keys(a); Object.freeze(a); Object.hasOwn(a, 'x'); Object.getOwnPropertyNames(a); JSON.stringify(a); Math.max(a.x); console.log(a); Array.isArray(a); +css(a); css({ ...a, color: f(a) }); Devup.css(a); css.x`${a}`; css(1)(a); css({ w: a.self() }); +; {a}; ; ; {a}; +const text = `${a.x}`; const sum = a.x + 1; const key = { [a.x]: 1 }; const b = { a: 1 }.a; +if (x ? 1 : 2) {} (a ? 1 : 2); (a, 1); a; typeof a; void a; +a.x.toString(); a.join(','); [1][a.x]; o[a]; for (const k in a) {} +a.arrow(); a.plain(); a.nested(); a.self(); a[k](); a.missing(); +let unset; const { y = a } = {}; +export { a as b };" + )); + } + + #[test] + fn this_through_the_declaration() { + insta::assert_debug_snapshot!(describe( + "const spread = { ...base, m() {} }; +spread.m(); +const computed = { [k]: 1, m() {} }; +computed.m(); +const path = { inner: { m() { return this; } } }; +path.inner.m(); +const indirect = { m: helper }; +indirect.m(); +const later = { m() {}, [k]: () => 1 }; +later.m(); +const deep = { x: 1 }; +deep.x.y.m(); +const plain = f(); +plain.m();" + )); + } + + #[test] + fn globals_shadowed() { + insta::assert_debug_snapshot!(describe( + "const a = {}; +const Object = { assign() {} }; +const String = (x) => x; +const exports = {}; +Object.assign(a, {}); +String(a); +(f.g)(a); +(0, h)(a); +(0, css)(a); +exports.x = a; +function inner() { const kept = { a }; }" + )); + } +} diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index bf26a8bb6..7076a2e68 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}; @@ -339,18 +341,23 @@ pub fn get_class_name_expression<'a>( class_expressions.push(class_name); } if class_name_prop.is_none() { - class_expressions.extend(spread_props.iter().map(|ex| { - convert_class_name( - ast_builder, - &Expression::StaticMemberExpression(StaticMemberExpression::boxed( - SPAN, - ex.clone_in(ast_builder.allocator()), - IdentifierName::new(SPAN, "className", ast_builder), - true, - ast_builder, - )), - ) - })); + class_expressions.extend( + spread_props + .iter() + .filter(|ex| may_hold(ex, "className")) + .map(|ex| { + convert_class_name( + ast_builder, + &Expression::StaticMemberExpression(StaticMemberExpression::boxed( + SPAN, + ex.clone_in(ast_builder.allocator()), + IdentifierName::new(SPAN, "className", ast_builder), + true, + ast_builder, + )), + ) + }), + ); } let expression = merge_string_expressions(ast_builder, &class_expressions); @@ -432,8 +439,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); } @@ -642,6 +651,20 @@ fn extract_classes_from_expression(expr: &Expression, classes: &mut String) { } } +/// Whether the object `spread` gives may hold `key`: an object literal holds +/// only the keys it writes +fn may_hold(spread: &Expression<'_>, key: &str) -> bool { + let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(spread) else { + return true; + }; + object.properties.iter().any(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => { + property.computed || get_str_by_property_key(&property.key).as_deref() == Some(key) + } + ObjectPropertyKind::SpreadProperty(_) => true, + }) +} + pub fn get_style_expression<'a>( ast_builder: &AstBuilder<'a>, style_prop: &Option>, @@ -664,15 +687,17 @@ pub fn get_style_expression<'a>( style_expressions.push(style_prop); } if style_prop.is_none() { - style_expressions.extend(spread_props.iter().map(|ex| { - Expression::StaticMemberExpression(StaticMemberExpression::boxed( - SPAN, - ex.clone_in(ast_builder.allocator()), - IdentifierName::new(SPAN, "style", ast_builder), - true, - ast_builder, - )) - })); + style_expressions.extend(spread_props.iter().filter(|ex| may_hold(ex, "style")).map( + |ex| { + Expression::StaticMemberExpression(StaticMemberExpression::boxed( + SPAN, + ex.clone_in(ast_builder.allocator()), + IdentifierName::new(SPAN, "style", ast_builder), + true, + ast_builder, + )) + }, + )); } merge_object_expressions(ast_builder, &style_expressions) diff --git a/libs/extractor/src/snapshots/extractor__mutations__tests__changes.snap b/libs/extractor/src/snapshots/extractor__mutations__tests__changes.snap new file mode 100644 index 000000000..f5d361621 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__mutations__tests__changes.snap @@ -0,0 +1,23 @@ +--- +source: libs/extractor/src/mutations.rs +expression: "describe(\"import { a } from './a';\na.x = 1;\na['y'] += 1;\na[k]++;\ndelete a.x;\n[a.x] = [1];\n({ x: a.x } = {});\n({ ...a.rest } = {});\n[a.x = 1] = [];\nfor (a.x in {});\nfor (a.x of []);\na.list.push(1);\na.list.map(String);\nObject.assign(a, {});\nObject.defineProperty(a, 'x', {});\n(a as any).x = 1;\n(a!).x = 1;\na?.x.sort();\na = 1;\na();\")" +--- +[ + "a changes 1: a as any).x = 1;", + "a changes 1: a!).x = 1;", + "a changes 1: a, 'x', {});", + "a changes 1: a, {});", + "a changes 1: a.rest } = {});", + "a changes 1: a.x = 1;", + "a changes 1: a.x = 1] = [];", + "a changes 1: a.x in {});", + "a changes 1: a.x of []);", + "a changes 1: a.x } = {});", + "a changes 1: a.x;", + "a changes 1: a.x] = [1];", + "a changes 1: a['y'] += 1;", + "a changes 1: a[k]++;", + "a changes 2: a.list.push(1);", + "a changes 2: a?.x.sort();", + "a escapes [Some(\"list\"), None] into None: a.list.map(String);", +] diff --git a/libs/extractor/src/snapshots/extractor__mutations__tests__escapes.snap b/libs/extractor/src/snapshots/extractor__mutations__tests__escapes.snap new file mode 100644 index 000000000..ffd833ea8 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__mutations__tests__escapes.snap @@ -0,0 +1,37 @@ +--- +source: libs/extractor/src/mutations.rs +expression: "describe(\"const a = { x: {} };\nf(a.x);\nnew F(a);\nObject.defineProperty(o, 'k', a);\nObject.assign({}, a);\nObject.values(a); Object.entries(a); Array.from(a); Array.of(a);\ng(...a.list);\nconst copy = { ...a };\nexport const whole = { a, list: [a.x] };\nconst frozen = Object.freeze({ a });\nconst nested = f({ a });\nlet later = a;\nlater = a;\n[later = a] = [];\nfunction get() { return a; }\nfunction* all() { yield a; }\nfunction p(q = a) {}\nconst arrow = () => a.x;\nconst block = () => { a; };\nconst tagged = tag`${a}`;\nfor (const item of a) {}\na.forEach(f); a.map(f);\nexport default { a };\nmodule.exports = { a };\nexports.b = a;\nmodule.exports.c = a;\nother.exports = a;\n
;\nx ? a : a;\nx && a;\n(x, a);\nawait a;\")" +--- +[ + "a escapes [None] into None: a) {}", + "a escapes [None] into None: a);", + "a escapes [None] into None: a);", + "a escapes [None] into None: a); Array.from(a); Array.of(a);", + "a escapes [None] into None: a); Array.of(a);", + "a escapes [None] into None: a); Object.entries(a); Array.from(a); Array.of(a);", + "a escapes [None] into None: a.forEach(f); a.map(f);", + "a escapes [None] into None: a.map(f);", + "a escapes [None] into Some(\"copy\"): a };", + "a escapes [Some(\"list\"), None] into None: a.list);", + "a escapes [Some(\"x\")] into None: a.x);", + "a escapes [Some(\"x\")] into None: a.x;", + "a escapes [Some(\"x\")] into Some(\"whole\"): a.x] };", + "a escapes [] into None: a });", + "a escapes [] into None: a) {}", + "a escapes [] into None: a);", + "a escapes [] into None: a);", + "a escapes [] into None: a;", + "a escapes [] into None: a;", + "a escapes [] into None: a;", + "a escapes [] into None: a; }", + "a escapes [] into None: a; }", + "a escapes [] into None: a] = [];", + "a escapes [] into None: a} />;", + "a escapes [] into None: a}`;", + "a escapes [] into Some(\"\"): a };", + "a escapes [] into Some(\"\"): a };", + "a escapes [] into Some(\"\"): a;", + "a escapes [] into Some(\"\"): a;", + "a escapes [] into Some(\"frozen\"): a });", + "a escapes [] into Some(\"whole\"): a, list: [a.x] };", +] diff --git a/libs/extractor/src/snapshots/extractor__mutations__tests__globals_shadowed.snap b/libs/extractor/src/snapshots/extractor__mutations__tests__globals_shadowed.snap new file mode 100644 index 000000000..0b6447df7 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__mutations__tests__globals_shadowed.snap @@ -0,0 +1,14 @@ +--- +source: libs/extractor/src/mutations.rs +expression: "describe(\"const a = {};\nconst Object = { assign() {} };\nconst String = (x) => x;\nconst exports = {};\nObject.assign(a, {});\nString(a);\n(f.g)(a);\n(0, h)(a);\n(0, css)(a);\nexports.x = a;\nfunction inner() { const kept = { a }; }\")" +--- +[ + "a escapes [] into None: a }; }", + "a escapes [] into None: a);", + "a escapes [] into None: a);", + "a escapes [] into None: a);", + "a escapes [] into None: a);", + "a escapes [] into None: a, {});", + "a escapes [] into None: a;", + "exports changes 1: exports.x = a;", +] diff --git a/libs/extractor/src/snapshots/extractor__mutations__tests__reads.snap b/libs/extractor/src/snapshots/extractor__mutations__tests__reads.snap new file mode 100644 index 000000000..003904ab2 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__mutations__tests__reads.snap @@ -0,0 +1,10 @@ +--- +source: libs/extractor/src/mutations.rs +expression: "describe(\"const a = { x: 1, arrow: () => 1, plain() { return 1; }, nested() { return function () { return this; }; }, self() { return this; } };\nString(a.x); parseInt(a.x); structuredClone(a); Object.keys(a); Object.freeze(a); Object.hasOwn(a, 'x'); Object.getOwnPropertyNames(a); JSON.stringify(a); Math.max(a.x); console.log(a); Array.isArray(a);\ncss(a); css({ ...a, color: f(a) }); Devup.css(a); css.x`${a}`; css(1)(a); css({ w: a.self() });\n; {a}; ; ; {a};\nconst text = `${a.x}`; const sum = a.x + 1; const key = { [a.x]: 1 }; const b = { a: 1 }.a;\nif (x ? 1 : 2) {} (a ? 1 : 2); (a, 1); a; typeof a; void a;\na.x.toString(); a.join(','); [1][a.x]; o[a]; for (const k in a) {}\na.arrow(); a.plain(); a.nested(); a.self(); a[k](); a.missing();\nlet unset; const { y = a } = {};\nexport { a as b };\")" +--- +[ + "a calls []: a.missing();", + "a calls []: a.self(); a[k](); a.missing();", + "a calls []: a[k](); a.missing();", + "a escapes [] into None: a } = {};", +] diff --git a/libs/extractor/src/snapshots/extractor__mutations__tests__this_through_the_declaration.snap b/libs/extractor/src/snapshots/extractor__mutations__tests__this_through_the_declaration.snap new file mode 100644 index 000000000..2d284ca3a --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__mutations__tests__this_through_the_declaration.snap @@ -0,0 +1,11 @@ +--- +source: libs/extractor/src/mutations.rs +expression: "describe(\"const spread = { ...base, m() {} };\nspread.m();\nconst computed = { [k]: 1, m() {} };\ncomputed.m();\nconst path = { inner: { m() { return this; } } };\npath.inner.m();\nconst indirect = { m: helper };\nindirect.m();\nconst later = { m() {}, [k]: () => 1 };\nlater.m();\nconst deep = { x: 1 };\ndeep.x.y.m();\nconst plain = f();\nplain.m();\")" +--- +[ + "deep calls [Some(\"x\"), Some(\"y\")]: deep.x.y.m();", + "indirect calls []: indirect.m();", + "later calls []: later.m();", + "path calls [Some(\"inner\")]: path.inner.m();", + "plain calls []: plain.m();", +] 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__changed_constants.snap b/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap new file mode 100644 index 000000000..99505d10d --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap @@ -0,0 +1,27 @@ +--- +source: libs/extractor/src/lib.rs +expression: outputs +--- +[ + "Error: /src/App.tsx:4:22: `css()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:1: `base` is changed here, so the build cannot read it as a constant", + "Error: /src/App.tsx:5:18: `css()` cannot use `base.p` at build time: its values must be literals, theme tokens or constants, or be computed from them\n/src/App.tsx:3:15: `base` is changed here, so the build cannot read it as a constant", + "Error: /src/App.tsx:4:23: `` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:5:29: `styled()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:6:22: `css()` cannot use `x ? base : null` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:7:18: Cannot compose `x ? null : [y && base]` at build time: each style must be a rule object, a class, or a condition choosing between them\n/src/App.tsx:7:22: `css()` cannot use `x ? null : [y && base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:8:22: `css()` cannot use `...[base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:10: `base` is handed here to code that may change it, so the build cannot read it as a constant", + "Error: /src/App.tsx:6:18: `css()` cannot use `baseline` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nconst hover = darken(colors.primary);\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nexport const theme = { colors };\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nexport const theme = { colors };\ntheme.colors.primary = \"blue\";\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst loop1 = { x: loop2 };\nconst loop2 = { y: loop1 };\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst list = [{ p: 1 }];\nwatch(...list);\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst flat = [1, 2];\nwatch(...flat);\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst partial = {\n\t...unknown,\n\tp: 1\n};\nwatch(partial.q);\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst record = { p: 1 };\nwatch(record.q);\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst spread = {\n\t...unknown,\n\tp: 1\n};\nwatch({ ...spread });\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nexport enum E {\n\tA = 1\n}\nenum F {\n\tB = 2\n}\nexport function g() {}\nvar v = 1;\nconst make = () => ({ p: 1 });\nconst made = make();\nwatch(made);\nconst f = () => made.p;\nexport const a = \"a-b\";\n", + "Error: /src/App.tsx:6:18: `css()` cannot use `f()` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "Error: /src/App.tsx:3:18: `css()` cannot use `pick()` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "Error: /src/App.tsx:3:18: `css()` cannot use `brand()` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "Error: /src/App.tsx:3:18: `css()` cannot use `node()` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "Error: /src/App.tsx:4:18: `css()` cannot use `f()` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "import \"@devup-ui/react/devup-ui-0.css\";\nimport base, { fixed, colors, grid, renamed } from \"./tokens\";\nimport * as tokens from \"./tokens\";\nexport const a =
;\n", + "Error: /src/App.tsx:3:22: `css()` cannot use `again` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/tokens.ts:10:26: `base` is changed here, so the build cannot read it as a constant", +] diff --git a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap new file mode 100644 index 000000000..5325a6ce2 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap @@ -0,0 +1,69 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(output)" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "margin", + value: "12px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "16px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "28px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "20px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-d\";\nexport const b = `a-e ${card}`;\nexport const c = `named x ${`y`}`;\nexport const d = ({ style, className, ...rest }) =>
;\nexport const e = ({ style, className, ...rest }) => ;\nexport const f = (__devupProps) => (({ style, className, ...rest }) =>

)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n});\nexport const g = (on) => `a-d a-a ${on ? \"\" : card} ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-b\";\nexport const i = \"a-d\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-c ${emotionClass}`;\nexport const n = ;\n", +} 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..ac7e55b24 --- /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 = cond ? base : \"\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap new file mode 100644 index 000000000..0288b22d1 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap @@ -0,0 +1,103 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(output)" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "#123", + 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: "margin", + value: "12px", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "16px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "20px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "24px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "28px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY } from \"./tokens\";\nconst make = (n: number) => ({ m: n });\nconst KEY = [\"co\", \"lor\"].join(\"\");\nfunction darken(amount: number, color: string) {\n\treturn color === PRIMARY ? \"#112233\" : color;\n}\nconst card = make(2);\nconst hover = make(3);\nconst parts = [make(4), { p: 1 }];\nexport const a =

1} className=\"a-c\" />;\nexport const b =
;\nexport const c =
;\nexport const d =
;\nexport const e = ({ style, className, ...rest }) =>
;\nexport const f = \"a-h a-a\";\nexport const g = \"a-i\";\nexport const h =
;\nfunction local() {\n\treturn { p: 7 };\n}\nexport const i =
;\nexport const j = (register) => ((__devupSpread0) =>
)(register(\"email\"));\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap new file mode 100644 index 000000000..d60be1a7c --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap @@ -0,0 +1,121 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { css } from '@devup-ui/react';\nimport styled from '@emotion/styled';\nexport const a = css({ color: 'red', p: 1 }, cond && { color: 'blue', _hover: { color: 'green' } });\nexport const b = css([{ m: 1 }, flag ? { m: 2 } : { m: 3, bg: 'red' }]);\nexport const c = css({ color: 'red' }, cond && other, flag ? 'x' : null, [undefined, false]);\nexport const d = css({ _hover: { color: 'red' } }, cond && { _hover: { bg: 'blue' } });\nexport const e = css({ color: 'red' }, flag ? other : { color: 'blue' });\nexport const h = css({ color: 'red' }, cond || { color: 'blue' });\nexport const i = css({ color: 'red' }, cond ? null : undefined);\nexport const j = css({ color: 'red' }, value ?? { color: 'blue' });\nexport const K = styled.div(base, cond && { color: 'blue' }, { margin: 1 });\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@emotion/styled\".to_string(),\n ImportAlias::DefaultToNamed(\"styled\".to_string()),)]),\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "blue", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "green", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "12px", + 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: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const a = `a ${cond ? \"b\" : \"c\"} ${cond ? \"d\" : \"\"}`;\nexport const b = `${flag ? \"e\" : \"f\"} ${flag ? \"\" : \"g\"}`;\nexport const c = `c ${cond ? other : \"\"} ${flag ? \"x\" : \"\"}`;\nexport const d = `i ${cond ? \"h\" : \"\"}`;\nexport const e = `${flag ? other : \"\"} ${flag ? \"c\" : \"b\"}`;\nexport const h = `${cond ? typeof cond === \"string\" ? cond : \"\" : \"\"} ${cond ? \"c\" : \"b\"}`;\nexport const i = \"c\";\nexport const j = `${value != null ? typeof value === \"string\" ? value : \"\" : \"\"} ${value != null ? \"c\" : \"b\"}`;\nexport const K = ({ style, className, ...rest }) =>
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__conditional_with_both_branches.snap b/libs/extractor/src/snapshots/extractor__tests__conditional_with_both_branches.snap index c14bcabc5..116b25965 100644 --- a/libs/extractor/src/snapshots/extractor__tests__conditional_with_both_branches.snap +++ b/libs/extractor/src/snapshots/extractor__tests__conditional_with_both_branches.snap @@ -1,19 +1,9 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst isActive = true;\n\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())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst isActive = true;\n\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 import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { - Static( - ExtractStaticStyle { - property: "background", - value: "blue", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), Static( ExtractStaticStyle { property: "background", @@ -34,16 +24,6 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { - property: "padding", - value: "8px", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst isActive = true;\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst isActive = true;\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap b/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap new file mode 100644 index 000000000..5ca82d42b --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap @@ -0,0 +1,310 @@ +--- +source: libs/extractor/src/lib.rs +expression: "output.map(ToBTreeSet::from).map_err(|error| error.to_string())" +--- +Ok( + ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: Some( + Global( + "body", + "/src/App.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "green", + level: 0, + selector: Some( + At { + kind: Media, + query: "(min-width:1px)", + selector: None, + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "navy", + level: 0, + selector: Some( + Selector( + "&:focus", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "content", + value: "n", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "flex-grow", + value: "9", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "font-family", + value: "a", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "height", + value: "120px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "12px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "32px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "opacity", + value: ".5", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "order", + value: "1", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "order", + value: "2", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "outline-color", + value: "teal", + level: 0, + selector: Some( + Selector( + "&:focus", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "16px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "16px", + level: 0, + selector: Some( + Global( + "body", + "/src/App.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "32px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "width", + value: "40px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "width", + value: "80px", + level: 2, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "z-index", + value: "1", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "z-index", + value: "2", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "bottom", + level: 0, + identifier: "getter.a", + selector: None, + style_order: None, + }, + ), + Keyframes( + ExtractKeyframes { + keyframes: { + "from": [ + ExtractStaticStyle { + property: "opacity", + value: ".5", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ], + }, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { space, Size, Local, focus } from \"./tokens\";\nconst base = {\n\tp: 4,\n\tcolor: \"red\",\n\tcolor: \"blue\"\n};\nconst hover = { opacity: .5 };\nconst merged = {\n\t...base,\n\tm: 1\n};\nconst partial = {\n\t...unknown,\n\tm: 1\n};\nconst proto = {\n\t__proto__: null,\n\ta: 1\n};\nconst getter = { get a() {\n\treturn 1;\n} };\nexport const withGetter =
;\nconst NONE = null;\nconst MEDIA = \"@media (min-width: 1px)\";\nconst keyed = { [MEDIA]: { color: \"green\" } };\nconst widths = [\n\t10,\n\tnull,\n\t20\n];\nconst more = [...widths, 30];\nconst bad = [...unknown];\nenum Level {\n\tLow = 1,\n\tHigh,\n\tName = \"n\",\n\tComputed = Math.max(1, 2),\n\tLast = 9\n}\ndeclare enum Ambient {\n\tX = 1\n}\nconst sx = { color: \"red\" };\nexport const a = \"a-b a-c\";\nexport const b = \"a-b a-d a-e a-f\";\nexport const c = \"a-b a-c a-g\";\nexport const d = ({ style, className, ...rest }) =>
;\nexport const e = ({ style, className, ...rest }) =>
;\nexport const f = (Comp, handler) => ;\nexport const g =
;\nexport const h = \"a-b a-c\";\nexport const i = ({ style, className, ...rest }) =>
;\nexport const j = (__devupProps) => (({ style, className, ...rest }) =>
)({\n\t...__devupProps,\n\t...{ role: \"button\" }\n});\nexport const k = { \"x\": \"a-s\" };\n;\nexport const l = \"a-t\";\nexport const m = \"a-u a-v a-y a-z\";\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__convert_tag-10.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-10.snap index 089ac3365..00cdecdcb 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-10.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-10.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: "<>{b ?
: undefined};\n", + code: "b ?
: undefined;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-11.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-11.snap index 870125a2d..cc47cc6ae 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-11.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-11.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: "<>{b ?
: null};\n", + code: "b ?
: null;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-12.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-12.snap index eb065ebd2..bfa76802e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-12.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-12.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Box} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: "<>{b ? null : undefined};\n", + code: "b ? null : undefined;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-13.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-13.snap index 7d756d570..9c6e90d5e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-13.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-13.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-14.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-14.snap index 9b9687904..6df1a7684 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-14.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-14.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{b ? :
};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\nb ? :
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-15.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-15.snap index 225d78704..fdf63697d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-15.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-15.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{{\n\tA:
,\n\tB:
,\n\tC: ,\n\tD: ,\n\t[key]:
,\n\t...rest\n}[key]};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n({\n\tA:
,\n\tB:
,\n\tC: ,\n\tD: ,\n\t[key]:
,\n\t...rest\n})[key];\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-16.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-16.snap index 9a9efcd8c..44f571e58 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-16.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-16.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{{\n\tA:
,\n\tB:
,\n\tC: ,\n\tD: ,\n\t[\"key\"]:
,\n\t...rest\n}[\"key\"]};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n({\n\tA:
,\n\tB:
,\n\tC: ,\n\tD: ,\n\t[\"key\"]:
,\n\t...rest\n})[\"key\"];\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-17.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-17.snap index f9f24e00b..5bfd16b5d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-17.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-17.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{b === 1 ?
:
};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\nb === 1 ?
:
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-18.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-18.snap index acf553236..be15f9ca1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-18.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-18.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{[
,
][b]};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n[
,
][b];\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-19.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-19.snap index acf553236..be15f9ca1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-19.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-19.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{[
,
][b]};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n[
,
][b];\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-20.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-20.snap index ff7b172a1..c3fca5294 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-20.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-20.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-21.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-21.snap index 9edff711f..858a122d9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-21.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-21.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-6.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-6.snap index 971c4fdc1..c3db9019c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-6.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-6.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: "<>{b ?
:
};\n", + code: "b ?
:
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-7.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-7.snap index 3b0e216df..98faaabc1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-7.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-7.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n<>{b ?
:
};\n", + code: "import \"@devup-ui/core/devup-ui.css\";\nb ?
:
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-8.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-8.snap index e5329bf7f..9887abb4b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-8.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-8.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: "<>{b ? undefined :
};\n", + code: "b ? undefined :
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__convert_tag-9.snap b/libs/extractor/src/snapshots/extractor__tests__convert_tag-9.snap index 32fb70e7b..8d4f2acdd 100644 --- a/libs/extractor/src/snapshots/extractor__tests__convert_tag-9.snap +++ b/libs/extractor/src/snapshots/extractor__tests__convert_tag-9.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: "<>{b ? null :
};\n", + code: "b ? null :
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__css_and_global_css_typescript_wrappers.snap b/libs/extractor/src/snapshots/extractor__tests__css_and_global_css_typescript_wrappers.snap index 8449ab222..0823b74f5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__css_and_global_css_typescript_wrappers.snap +++ b/libs/extractor/src/snapshots/extractor__tests__css_and_global_css_typescript_wrappers.snap @@ -43,7 +43,18 @@ ToBTreeSet { ), Keyframes( ExtractKeyframes { - keyframes: {}, + keyframes: { + "from": [ + ExtractStaticStyle { + property: "opacity", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: 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__css_with_template_literal.snap b/libs/extractor/src/snapshots/extractor__tests__css_with_template_literal.snap index 5018f1d99..7c337ef1b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__css_with_template_literal.snap +++ b/libs/extractor/src/snapshots/extractor__tests__css_with_template_literal.snap @@ -1,16 +1,17 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {css} from '@devup-ui/core'\nconst size = 16;\nconst className = css({ fontSize: `${size}px` })\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())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {css} from '@devup-ui/core'\nconst size = 16;\nconst className = css({ fontSize: `${size}px` })\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 import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { - Dynamic( - ExtractDynamicStyle { + Static( + ExtractStaticStyle { property: "font-size", + value: "16px", level: 0, - identifier: "`${size}px`", selector: None, style_order: None, + layer: None, }, ), }, diff --git a/libs/extractor/src/snapshots/extractor__tests__dynamic_as_where_the_element_stands.snap b/libs/extractor/src/snapshots/extractor__tests__dynamic_as_where_the_element_stands.snap new file mode 100644 index 000000000..9f0a7d29c --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__dynamic_as_where_the_element_stands.snap @@ -0,0 +1,39 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(output)" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "margin", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nexport const a = (on, C, items) =>
}>\n {on ? : }\n {items.map((item) => )}\n {on}\n
;\nexport const after = 42;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__dynamic_value_with_important.snap b/libs/extractor/src/snapshots/extractor__tests__dynamic_value_with_important.snap index a1cf67ae0..55ef30419 100644 --- a/libs/extractor/src/snapshots/extractor__tests__dynamic_value_with_important.snap +++ b/libs/extractor/src/snapshots/extractor__tests__dynamic_value_with_important.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst color = \"red\";\n\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 import_aliases: HashMap::new()\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nlet color = \"red\";\n\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 import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst color = \"red\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet color = \"red\";\n
;\n", } 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..c5b7c96d9 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap @@ -0,0 +1,49 @@ +--- +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: "left", + value: "9px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "7px", + 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 = (__devupProps) => (({ style, className, ...rest }) =>
)({\n\t...__devupProps,\n\t...{ type: \"button\" }\n});\nexport const H = ({ style, className, ...rest }) => ;\nexport const I = ({ style, className, ...rest }) =>
;\nexport const j = \"d\";\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..fbdff4405 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap @@ -0,0 +1,170 @@ +--- +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: "bottom", + value: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "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: "right", + value: "4px", + 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 = ({ style, className, ...rest }) =>
;\nexport const F = ({ style, className, ...rest }) =>

;\nexport const spin = \"m\";\nexport const App = () => <>;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__enum_style_prop.snap b/libs/extractor/src/snapshots/extractor__tests__enum_style_prop.snap index 68da1aef3..80df0a778 100644 --- a/libs/extractor/src/snapshots/extractor__tests__enum_style_prop.snap +++ b/libs/extractor/src/snapshots/extractor__tests__enum_style_prop.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst variant = \"primary\";\nconst colors = { primary: \"blue\", secondary: \"red\" };\n\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())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nlet variant = \"primary\";\nlet colors = { primary: \"blue\", secondary: \"red\" };\n\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 import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -14,5 +14,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst variant = \"primary\";\nconst colors = {\n\tprimary: \"blue\",\n\tsecondary: \"red\"\n};\n

;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet variant = \"primary\";\nlet colors = {\n\tprimary: \"blue\",\n\tsecondary: \"red\"\n};\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap b/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap new file mode 100644 index 000000000..290d709ff --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap @@ -0,0 +1,233 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(output)" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "darker(.2)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "#369", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "darker(.1)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "darker(.2)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "darker(.2)", + level: 0, + selector: Some( + Global( + "body", + "/src/App.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "darker(.2)", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "darker(.3)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "content", + value: "\"---A\"", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "fill", + value: "#369", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "height", + value: "16px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "4px", + level: 0, + selector: Some( + At { + kind: Media, + query: "(min-width:600px)", + selector: None, + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "64px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "outline-color", + value: "teal", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + 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: "-8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "width", + value: "32px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "z-index", + value: "4", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "darken(.4,`#336699`)", + selector: None, + style_order: None, + }, + ), + Keyframes( + ExtractKeyframes { + keyframes: { + "from": [ + ExtractStaticStyle { + property: "opacity", + value: ".5", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ], + }, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { PRIMARY, SPACE } from \"./color\";\nimport { card } from \"./styles\";\nimport { BEFORE } from \"./fails\";\nexport const SIZE = 4;\nexport default function twice(n: number) {\n\treturn n * 2;\n}\nenum Level {\n\tLow = 1,\n\tHigh = twice(2)\n}\nfunction darken(amount: number, color: string): string {\n\treturn color === PRIMARY ? `darker(${amount})` : color;\n}\nconst LIGHT = darken(.2, PRIMARY);\nconst color = darken(.3, PRIMARY);\nconst make = (size: number) => ({\n\tp: size,\n\t_hover: { color: LIGHT }\n});\nconst label = (text: string) => text.toUpperCase().padStart(4, \"-\");\nexport const a = \"a-n a-o a-p a-q a-e a-r\";\nexport const b = \"a-s\";\n;\nconst styles = { \"base\": \"a-t\" };\nexport const c =
;\nexport const d = \"a-u a-v a-w a-x a-y a-i\";\nexport const e = (dark: boolean) => `a-_ ${dark ? \"a-t\" : \"a-j\"} ${dark ? \"a-z\" : \"\"}`;\nexport const f = `a-aa ${card}`;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap b/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap new file mode 100644 index 000000000..0be1eadf6 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap @@ -0,0 +1,91 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\n\"import { Box } from '@devup-ui/react';\\nclass A { #w = 1; render(f) { return ; } }\\nexport const a = async (f, X, tag) => ;\",\nExtractOption::default(),).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "flex", + value: "false", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "background", + level: 0, + identifier: "new X", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "tag`x`", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "height", + level: 0, + identifier: "(f(),2)", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "margin", + level: 0, + identifier: "f.n++", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "padding", + level: 0, + identifier: "f.v=3", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "width", + level: 0, + identifier: "await f()", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "width", + level: 0, + identifier: "this.#w", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "z-index", + level: 0, + identifier: "10n", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nclass A {\n\t#w = 1;\n\trender(f) {\n\t\treturn ((__devupSpread0) =>
)(f());\n\t}\n}\nexport const a = async (f, X, tag) =>
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-3.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-3.snap index e5dfbe84b..0f7fc9903 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-3.snap @@ -1,19 +1,19 @@ --- source: libs/extractor/src/lib.rs -assertion_line: 982 expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { - Dynamic( - ExtractDynamicStyle { + Static( + ExtractStaticStyle { property: "padding", + value: "20px", level: 0, - identifier: "Math.abs(5)", selector: None, style_order: None, + layer: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_pseudo_selector_with_identifier_snapshot.snap b/libs/extractor/src/snapshots/extractor__tests__extract_pseudo_selector_with_identifier_snapshot.snap index d50730dce..d85444f22 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_pseudo_selector_with_identifier_snapshot.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_pseudo_selector_with_identifier_snapshot.snap @@ -1,7 +1,6 @@ --- source: libs/extractor/src/lib.rs -assertion_line: 16905 -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/react'\nconst hoverStyle = { opacity: 1 };\nexport const A = () => ;\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())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/react'\nconst hoverStyle = { opacity: 1 };\nexport const A = () => ;\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: { @@ -15,6 +14,20 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "opacity", + value: "1", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst hoverStyle = { opacity: 1 };\nexport const A = () =>
;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst hoverStyle = { opacity: 1 };\nexport const A = () =>
;\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__extract_style_props_with_class_name-8.snap b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-8.snap index 754c6f48a..5e6c02dae 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-8.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-8.snap @@ -1,23 +1,9 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box as C} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Box as C} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { - Static( - ExtractStaticStyle { - property: "border-color", - value: "", - level: 0, - selector: Some( - Selector( - "&:hover", - ), - ), - style_order: None, - layer: None, - }, - ), Static( ExtractStaticStyle { property: "border-color", @@ -43,5 +29,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_wrong_global_css-3.snap b/libs/extractor/src/snapshots/extractor__tests__extract_wrong_global_css-3.snap deleted file mode 100644 index 64723b2ec..000000000 --- a/libs/extractor/src/snapshots/extractor__tests__extract_wrong_global_css-3.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { globalCss } from \"@devup-ui/core\";\nglobalCss(1)\n\"#, ExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" ---- -ToBTreeSet { - styles: {}, - code: ";\n", -} diff --git a/libs/extractor/src/snapshots/extractor__tests__fold_math_at_build_time.snap b/libs/extractor/src/snapshots/extractor__tests__fold_math_at_build_time.snap new file mode 100644 index 000000000..10feaa7a2 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__fold_math_at_build_time.snap @@ -0,0 +1,189 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box, css } from '@devup-ui/react';\nconst W = 10;\nconst GAP = Math.round(2.5) * 4;\nconst HALF = Math.round(-2.5);\nexport const a = css({ width: Math.max(W, 20), height: Math.min(W, -W, 3), gap: GAP, top: HALF, left: Math.floor(W / 3), right: Math.ceil(1.2), bottom: Math.trunc(-1.7), opacity: Math.abs(-0.5), zIndex: Math.sign(-3), order: Math.sign(0), flexGrow: Math.pow(2, 3), lineHeight: Math.sqrt(4), rotate: `${Math.PI}rad`, scale: Math.round(1.4), flexShrink: Math.sign(3) });\nexport const b = 2 ? 1 : 2} />;\"))" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "bottom", + value: "-4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "flex-grow", + value: "8", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "flex-shrink", + value: "1", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "gap", + value: "48px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "height", + value: "-40px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "left", + value: "12px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "2", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "opacity", + value: ".5", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "order", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "40px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "rotate", + value: "3.141592653589793rad", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "scale", + value: "1", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "top", + value: "-8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "width", + value: "80px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "z-index", + value: "-1", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst W = 10;\nconst GAP = Math.round(2.5) * 4;\nconst HALF = Math.round(-2.5);\nexport const a = \"a b c d e f g h i j k l m n o\";\nexport const b =
2 ? \"q\" : \"r\"}`} />;\n", +} 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__inline_imported_constants.snap b/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap new file mode 100644 index 000000000..c3fa67766 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap @@ -0,0 +1,479 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(output)" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "accent-color", + value: "lime", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "background", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "background", + value: "teal", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "border-color", + value: "green", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "border-color", + value: "navy", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "bottom", + value: "navy", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "caret-color", + value: "y", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "16px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "maroon", + 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: "column-rule-color", + value: "purple", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "content", + value: "olive", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "fill", + value: "indigo", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "fill", + value: "teal", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "flood-color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "lighting-color", + value: "orange", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "-8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "outline-color", + value: "teal", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "16px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "stop-color", + value: "navy", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "stop-color", + value: "plum", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "stroke", + value: "navy", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "stroke", + value: "plum", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "text-decoration-color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "text-decoration-color", + value: "teal", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "width", + value: "10px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "z-index", + value: "12", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "accent-color", + level: 0, + identifier: "DYNAMIC", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "caret-color", + level: 0, + identifier: "MUTABLE", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "PRIMARY", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "column-gap", + level: 0, + identifier: "colors[key]", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "column-rule-color", + level: 0, + identifier: "marker", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "flex", + level: 0, + identifier: "notThere", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "flex-basis", + level: 0, + identifier: "gone", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "flex-grow", + level: 0, + identifier: "helper", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "flex-shrink", + level: 0, + identifier: "looped", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "flood-color", + level: 0, + identifier: "twice.A", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "gap", + level: 0, + identifier: "colors.skip", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "left", + level: 0, + identifier: "NEG_STRING", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "lighting-color", + level: 0, + identifier: "DYNAMIC", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "order", + level: 0, + identifier: "`red`[`length`]", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "order", + level: 0, + identifier: "alsoMutable", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "outline-color", + level: 0, + identifier: "MUTATED", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "row-gap", + level: 0, + identifier: "colors.nope", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "top", + level: 0, + identifier: "missing", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "z-index", + level: 0, + identifier: "NOT_OBJECT", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "z-index", + level: 0, + identifier: "`red`.length", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY, SIZE, NEG, TEMPLATE, colors, ALIAS, MUTABLE, DYNAMIC, renamed, alsoMutable, reBase, notThere, gone, value, ns, NEG_STRING, TEMPLATE_EXPRESSION, SPACED, NOT_OBJECT, helper, looped } from \"./tokens\";\nimport orange from \"./tokens\";\nimport * as tokens from \"./tokens\";\nimport named from \"./named\";\nimport { handler } from \"./handler\";\nimport cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from \"./cjs-tokens\";\nimport cjsObject, { OBJ, OVERRIDDEN, LATER } from \"./cjs-object\";\nimport twelve from \"./cjs-value\";\nimport twice from \"./cjs-twice\";\nimport marker, { named as markerNamed } from \"./cjs-marker\";\nimport { unused } from \"./unused\";\nimport missing from \"./missing\";\nexport const a =
;\nexport const b = \"a\";\nexport const c =
;\nexport const d = ({ style, className, ...rest }) =>
;\nexport const e = (PRIMARY) =>
;\nexport const f = {\n\tPRIMARY,\n\tunused,\n\tSIZE: tokens.SIZE\n};\nexport const g =
;\nexport const h = ;\nexport const i = Devup[\"css\"]({ color: SIZE });\nexport const l =
;\nexport const j =
;\nexport const k = ({ style, className, ...rest }) =>
;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__inline_local_constants.snap b/libs/extractor/src/snapshots/extractor__tests__inline_local_constants.snap new file mode 100644 index 000000000..4691e1188 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__inline_local_constants.snap @@ -0,0 +1,207 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { Box, css, keyframes, globalCss } from '@devup-ui/react';\nconst SIZE = 4;\nconst UNIT = `${SIZE}px`;\nconst DOUBLE = SIZE * 2;\nconst HALF = SIZE / 2 - 1;\nconst LABEL = 'a' + SIZE;\nconst TINY = 0.0000001;\nconst HUGE = 1e21;\nconst ZERO = -0;\nconst PX = 'px';\nconst SELF = SELF;\nlet mutable = 1;\nexport const a = ;\nexport const b = css({ padding: `${SIZE * 2}px`, margin: SIZE - 1, width: SIZE * SIZE, height: (SIZE) });\nexport const k = keyframes({ from: { opacity: SIZE / 8 } });\nglobalCss({ body: { padding: UNIT } });\nexport const c = ;\",\nExtractOption::default(),).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "content", + value: "a4", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "height", + value: "16px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "height", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "left", + value: "1e+21px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "12px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "32px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: Some( + Global( + "body", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "top", + value: "1e-7px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "width", + value: "20px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "width", + value: "64px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "bottom", + level: 0, + identifier: "mutable", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "margin", + level: 0, + identifier: "`px`-1", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "padding", + level: 0, + identifier: "4/0", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "width", + level: 0, + identifier: "`${`px`}${other}`", + selector: None, + style_order: None, + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "z-index", + level: 0, + identifier: "SELF", + selector: None, + style_order: None, + }, + ), + Keyframes( + ExtractKeyframes { + keyframes: { + "from": [ + ExtractStaticStyle { + property: "opacity", + value: ".5", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ], + }, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nconst SIZE = 4;\nconst UNIT = `${SIZE}px`;\nconst DOUBLE = SIZE * 2;\nconst HALF = SIZE / 2 - 1;\nconst LABEL = \"a\" + SIZE;\nconst TINY = 1e-7;\nconst HUGE = 1e21;\nconst ZERO = -0;\nconst PX = \"px\";\nconst SELF = SELF;\nlet mutable = 1;\nexport const a =
;\nexport const b = \"a-k a-l a-m a-n\";\nexport const k = \"a-o\";\n;\nexport const c =
;\n", +} 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..aa462f70d --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap @@ -0,0 +1,151 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { styled } from '@devup-ui/react'\nconst A = styled.div({ '@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, + layer: "base", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst A = ({ style, className, ...rest }) =>
;\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__member_expression_style.snap b/libs/extractor/src/snapshots/extractor__tests__member_expression_style.snap index 5112e9341..454692fb7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__member_expression_style.snap +++ b/libs/extractor/src/snapshots/extractor__tests__member_expression_style.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst colors = { primary: \"blue\", secondary: \"red\" };\nconst key = \"primary\";\n\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())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nlet colors = { primary: \"blue\", secondary: \"red\" };\nlet key = \"primary\";\n\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 import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -14,5 +14,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst colors = {\n\tprimary: \"blue\",\n\tsecondary: \"red\"\n};\nconst key = \"primary\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet colors = {\n\tprimary: \"blue\",\n\tsecondary: \"red\"\n};\nlet key = \"primary\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__multiple_dynamic_values.snap b/libs/extractor/src/snapshots/extractor__tests__multiple_dynamic_values.snap index 8fcd7624e..00afa166b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__multiple_dynamic_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__multiple_dynamic_values.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst color = \"red\";\nconst padding = 10;\nconst margin = 5;\n\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())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nlet color = \"red\";\nlet padding = 10;\nlet margin = 5;\n\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 import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -32,5 +32,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst color = \"red\";\nconst padding = 10;\nconst margin = 5;\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet color = \"red\";\nlet padding = 10;\nlet margin = 5;\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__nested_conditional.snap b/libs/extractor/src/snapshots/extractor__tests__nested_conditional.snap index 4c9684aa5..7def97b85 100644 --- a/libs/extractor/src/snapshots/extractor__tests__nested_conditional.snap +++ b/libs/extractor/src/snapshots/extractor__tests__nested_conditional.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst a = true;\nconst b = false;\n\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())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst a = true;\nconst b = false;\n\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 import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -14,26 +14,6 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { - property: "background", - value: "green", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), - 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 a = true;\nconst b = false;\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst a = true;\nconst b = false;\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_direct_array_select-6.snap b/libs/extractor/src/snapshots/extractor__tests__props_direct_array_select-6.snap index a23d80adf..b5e9a9cbd 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_direct_array_select-6.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_direct_array_select-6.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr\"import {Flex} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -20,11 +20,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "opacity", level: 0, - identifier: "some[100]", + identifier: "[1,.5,...some][100]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_direct_array_select-7.snap b/libs/extractor/src/snapshots/extractor__tests__props_direct_array_select-7.snap index e07651135..44bb8e86a 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_direct_array_select-7.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_direct_array_select-7.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr\"import {Flex} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -40,11 +40,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "opacity", level: 0, - identifier: "some[a]", + identifier: "[1,.5,...some][a]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-2.snap b/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-2.snap index 0e48f9b7e..87731e0ea 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -16,16 +16,15 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { + Dynamic( + ExtractDynamicStyle { property: "opacity", - value: ".5", level: 0, + identifier: "({a:1,b:.5,...any})[`b`]", selector: None, style_order: None, - layer: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-3.snap b/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-3.snap index f66d8d2e1..817a3487b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-3.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -16,35 +16,15 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { - property: "opacity", - value: ".5", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), - Static( - ExtractStaticStyle { - property: "opacity", - value: "1", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), Dynamic( ExtractDynamicStyle { property: "opacity", level: 0, - identifier: "any[`some`]", + identifier: "({a:1,b:.5,...any})[`some`]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_direct_wrong.snap b/libs/extractor/src/snapshots/extractor__tests__props_direct_wrong.snap index 9c40cefbd..13a9b639b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_direct_wrong.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_direct_wrong.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n;\n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr\"import {Flex} from '@devup-ui/core'\n;\n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -16,6 +16,15 @@ ToBTreeSet { layer: None, }, ), + Dynamic( + ExtractDynamicStyle { + property: "gap", + level: 0, + identifier: "true[1]", + selector: None, + style_order: None, + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-6.snap b/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-6.snap index 413ea6380..757e1dcda 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-6.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-6.snap @@ -16,16 +16,15 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { + Dynamic( + ExtractDynamicStyle { property: "opacity", - value: "1", level: 0, + identifier: "[...arr,1][1]", selector: None, style_order: None, - layer: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-7.snap b/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-7.snap index 0f878e5dd..fa25a3761 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-7.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-7.snap @@ -20,11 +20,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "opacity", level: 0, - identifier: "arr[5]", + identifier: "[...arr,1][5]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-8.snap b/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-8.snap index ad1358a1e..0cf3b8707 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-8.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_array_select-8.snap @@ -30,11 +30,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "opacity", level: 0, - identifier: "arr[idx]", + identifier: "[...arr,1][idx]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_object_select-5.snap b/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_object_select-5.snap index 6c54f4a3f..f125e807d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_object_select-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_wrong_direct_object_select-5.snap @@ -16,35 +16,15 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { - property: "opacity", - value: ".5", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), - Static( - ExtractStaticStyle { - property: "opacity", - value: "1", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), Dynamic( ExtractDynamicStyle { property: "opacity", level: 0, - identifier: "rest[`nonexistent`]", + identifier: "({...rest,a:1,b:.5})[`nonexistent`]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__read_once_in_every_place.snap b/libs/extractor/src/snapshots/extractor__tests__read_once_in_every_place.snap new file mode 100644 index 000000000..f61f7f1c6 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__read_once_in_every_place.snap @@ -0,0 +1,8 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(output)" +--- +ToBTreeSet { + styles: {}, + code: "import { jsx as _jsx } from \"react/jsx-runtime\";\nexport const a = async (f, g, list, C) => ((__devupSpread0, __devupSpread1, __devupSpread2, __devupSpread3, __devupSpread4, __devupSpread5) =>
await g()} title={__devupSpread1} className={__devupSpread0?.className || \"\"} style={__devupSpread0?.style}>\n text\n {__devupSpread2}\n {...__devupSpread3}\n {__devupSpread4}\n {__devupSpread5}\n after\n
)(f(), , <>{g}, list(), {g}, await g());\nexport const b = function* (f, g) {\n\tyield ((__devupSpread6, __devupSpread7) =>
)(f(), yield g);\n};\nexport const c = (props) => _jsx(\"div\", props);\nexport const d = async (f, g) => _jsx(\"div\", {\n\t...f(),\n\t[g()]: await g(),\n\tclassName: f()?.className || \"\",\n\tstyle: f()?.style\n});\nexport const e = async (rest, g) => _jsx(\"div\", {\n\tid: await g(),\n\t...rest,\n\tclassName: rest?.className || \"\",\n\tstyle: rest?.style\n});\nexport const h = (f, g, k) => ((__devupSpread8, __devupSpread9) => _jsx(\"div\", {\n\tonClick: __devupSpread8,\n\t[k]: 2,\n\t...__devupSpread9,\n\t[k]: 1,\n\ttitle: \"x\",\n\tclassName: \"z\",\n\tstyle: __devupSpread9?.style\n}))(g(), f());\nexport const i = (rest) => _jsx(\"div\", {\n\t...rest,\n\tclassName: \"a\",\n\tstyle: rest?.style\n});\nexport const j = (rest) =>
;\n", +} 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__spreads_are_read_once.snap b/libs/extractor/src/snapshots/extractor__tests__spreads_are_read_once.snap new file mode 100644 index 000000000..195bb7e80 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__spreads_are_read_once.snap @@ -0,0 +1,49 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(output)" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "12px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { jsx as _jsx } from \"react/jsx-runtime\";\nexport const a = (register, rest, C) => ((__devupSpread1) =>
\n {((__devupSpread0) => )(useFocusRing())}\n
)(register(\"email\"));\nexport const b = async (load) => ((__devupSpread2) =>
)(await load());\nexport const c = (props) =>
;\nexport const d = (f) => ((__devupSpread3) => _jsx(\"div\", {\n\t...__devupSpread3,\n\tclassName: `a-d ${__devupSpread3?.className || \"\"}`,\n\tstyle: __devupSpread3?.style\n}))(f());\nexport const e = async (f, g) => ((__devupSpread4, __devupSpread5) => _jsx(\"div\", {\n\t...__devupSpread4,\n\ttitle: __devupSpread5,\n\tclassName: __devupSpread4?.className || \"\",\n\tstyle: __devupSpread4?.style\n}))(f(), await g());\nexport const g = async (f, g, h) => ((__devupSpread6, __devupSpread7, __devupSpread8) =>
)(h(), f(), await g());\nexport const h = async (f, g) => ((__devupSpread9, __devupSpread10) =>
{__devupSpread10}
)(f(), await g());\nexport const i = async (f, g) => _jsx(\"div\", {\n\t...f(),\n\tclassName: await g() || \"\",\n\tstyle: f()?.style\n});\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__style_prop_merge.snap b/libs/extractor/src/snapshots/extractor__tests__style_prop_merge.snap index 4e088b052..23890c2c1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__style_prop_merge.snap +++ b/libs/extractor/src/snapshots/extractor__tests__style_prop_merge.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst color = \"red\";\n\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())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nlet color = \"red\";\n\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 import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -14,5 +14,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst color = \"red\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet color = \"red\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__style_vars_prop.snap b/libs/extractor/src/snapshots/extractor__tests__style_vars_prop.snap index 9360e5503..3b3e63495 100644 --- a/libs/extractor/src/snapshots/extractor__tests__style_vars_prop.snap +++ b/libs/extractor/src/snapshots/extractor__tests__style_vars_prop.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst dynamicColor = \"blue\";\n\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())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nlet dynamicColor = \"blue\";\n\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 import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst dynamicColor = \"blue\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet dynamicColor = \"blue\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap b/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap new file mode 100644 index 000000000..75e735921 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap @@ -0,0 +1,59 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import emotion from '@emotion/styled';\nimport sc from 'styled-components';\nexport const A = emotion.div({ top: 1 });\nexport const B = sc.span({ left: 2 });\nexport const C = sc.button.attrs({ type: 'button' }).withConfig({ displayName: 'c' })({ right: 3 });\nexport const D = sc(Base).withConfig({ shouldForwardProp: () => true })`color: red;`;\nexport const E = (sc.input.attrs((props) => ({ size: props.small ? 5 : 10 })) as any).attrs(extra)({ bottom: 4 });\nexport const F = sc.div.attrs({ role: 'note' });\nexport const G = other.div.attrs({ role: 'note' })({ margin: 1 });\nexport const H = sc.div.attrs(...rest)({ margin: 2 });\nexport const I = factory.attrs({ id: 'i' })({ margin: 3 });\nexport const J = other(Base).attrs({ id: 'j' })({ margin: 4 });\",\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: aliases,\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "bottom", + value: "16px", + 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: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "3px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "top", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const A = ({ style, className, ...rest }) =>
;\nexport const B = ({ style, className, ...rest }) => ;\nexport const C = (__devupProps) => (({ style, className, ...rest }) =>