diff --git a/.eslintrc.js b/.eslintrc.js index 0ba078fed..49b10c5f1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -46,6 +46,9 @@ module.exports = { '**/*.d.ts', '**/pages.gen.ts', '**/devtools-extension/extension/**', + // codemod fixtures are deliberately non-conforming (broken-on-purpose + // inputs that the correctness gates must fail on) + '**/codemods/__fixtures__/**', ], overrides: [ { diff --git a/.flowconfig b/.flowconfig index e74e90e47..4271b77a6 100644 --- a/.flowconfig +++ b/.flowconfig @@ -24,6 +24,7 @@ module.name_mapper='^@stylexjs/babel-plugin$' -> '/packages/@style module.name_mapper='^@stylexjs/stylex$' -> '/packages/@stylexjs/stylex/src/stylex.js' module.name_mapper='^@stylexjs/shared$' -> '/packages/@stylexjs/shared/src/index.js' module.name_mapper='^style-value-parser$' -> '/packages/style-value-parser/src/index.js' +module.name_mapper='^@stylexjs/eslint-plugin$' -> '/packages/@stylexjs/eslint-plugin/src/index.js' ; type-stubs module.system.node.resolve_dirname=flow_modules module.system.node.resolve_dirname=node_modules diff --git a/packages/@stylexjs/codemods/.babelrc.js b/packages/@stylexjs/codemods/.babelrc.js new file mode 100644 index 000000000..5863e3f85 --- /dev/null +++ b/packages/@stylexjs/codemods/.babelrc.js @@ -0,0 +1,17 @@ +module.exports = { + assumptions: { + iterableIsArray: true, + }, + presets: [ + [ + '@babel/preset-env', + { + exclude: ['@babel/plugin-transform-typeof-symbol'], + targets: 'defaults', + }, + ], + '@babel/preset-flow', + '@babel/preset-react', + ], + plugins: [['babel-plugin-syntax-hermes-parser', { flow: 'detect' }]], +}; diff --git a/packages/@stylexjs/codemods/ARCHITECTURE.md b/packages/@stylexjs/codemods/ARCHITECTURE.md new file mode 100644 index 000000000..227309481 --- /dev/null +++ b/packages/@stylexjs/codemods/ARCHITECTURE.md @@ -0,0 +1,100 @@ +# Architecture + +The codemod is a source-to-source compiler: styling code in, equivalent +StyleX out. It is built so that supporting a new source library never means +rebuilding the compiler — only writing a new front-end for it. + +> **One engine, swappable readers.** A small library-specific **adapter** +> turns one styling library into a neutral style model (the IR). A single +> library-agnostic **core** turns that model into correct, lint-clean +> StyleX. To add Emotion, MUI, or CSS Modules you write an adapter — the +> engine never changes. + +## The seam + +| | Adapter (per source) | Core engine (shared) | +| --- | --- | --- | +| Owns | detect, read, rewrite call sites, import cleanup | IR build, conflict referee, value normalization, emit, registry, postprocess, gates | +| Knows about | one styling library's syntax | only the IR and StyleX | +| Changes when | a new source is added | StyleX itself gains a feature | + +**Load-bearing invariant:** `src/core/` imports *nothing* from +`src/adapters/` and never touches a parser node — it only sees +declarations, the IR, and emitted StyleX fragments. Enforced by +`__tests__/seam-test.js` from commit one. The seam is crossed exactly +twice: + +1. **Adapter → core:** a flat list of `declarations` + (`{ context, property, value }`) — "here is what styles exist". +2. **Core → adapter:** the **binding map** — "style site #1 becomes + `styles.button`" — which the adapter places back into that library's + idiom. + +A second invariant keeps the AST toolkit swappable: only +`src/core/rewriter.js` may import jscodeshift/recast (also enforced by the +seam test). + +## The IR (`src/core/ir.js`) + +The IR is shaped like **StyleX's output** (property-grouped, conditions +nested inside a property), not like any source's input. A style either maps +into the IR (convertible) or it does not (flagged — never guessed); the +edge of the IR is the edge of what is automatable. + +Its only two open axes are the ones StyleX itself grows along, both modeled +as variant types so growth stays additive: + +- `Condition` — `pseudo-class` / `pseudo-element` / `at-rule` (later: + ancestor selectors, `@supports`, `@container`). +- `Value` — `static` and `first-that-works` today; a `dynamic` + (function-form) variant lands in v1.1. + +Completeness is **measured, not asserted**: the IR-completeness harness +(`__tests__/ir-completeness-test.js`) round-trips valid StyleX style +objects through a test-only reader and reports a coverage percentage. An +IR gap is safe — in the pipeline the construct is flagged, never +emitted incorrectly. + +## The gates (`src/core/gates/`) + +Independent correctness checks, used as test assertions and as a runtime +safety net. Each is proven to FAIL on a deliberately-broken input +(`__fixtures__/broken/`) — a gate that never fails is worthless. + +- **Compile** (`compile.js`) — output must compile through the real + `@stylexjs/babel-plugin`; also yields the plugin's rule metadata. +- **Lint** (`lint.js`) — output must pass every `@stylexjs/eslint-plugin` + rule at *error* with zero messages (zero autofixes needed). +- **Semantic diff** (`semanticDiff.js`) — net CSS before (from + `@emotion/serialize`, the source library's own ground truth) and after + (from babel-plugin metadata) must match across every condition + combination, minus an explicit allowlist of sanctioned diffs + (hover-guard, physical→logical). Its parsers throw on anything they + cannot provably represent, so an unparsable input can never diff clean + by accident. +- **Render gate** *(future, v2.0)* — Playwright computed-style diff in a + real browser, keeping the semantic-diff gate honest. + +## Testing layers + +1. **Gate self-tests** — each gate green on the trivial pair AND red on a + broken pair (`__tests__/gates-test.js`). +2. **Fixture tests** — the executable spec. Every + `__fixtures__/emotion//{input,expected}.js` pair must: match the + transform byte-exactly (after Prettier), compile, lint clean, and pass + the semantic diff (`__tests__/emotion-fixtures-test.js`). +3. **Engine unit tests** — hand-written IR fed to the engine (from M1), + proving the engine is source-neutral. +4. **Seam tests** — the architectural invariants, as assertions. +5. **IR-completeness** — the measured coverage number. + +## Layer map + +| Layer | Module | +| --- | --- | +| Parse/print wrapper | `src/core/rewriter.js` (jscodeshift, isolated) | +| IR types | `src/core/ir.js` | +| Gates | `src/core/gates/{compile,lint,semanticDiff}.js` | +| Emotion adapter | `src/adapters/emotion/` (M1+) | +| Engine (build IR, referee, normalize, emit) | `src/core/` (M1–M4) | +| CLI & dry-run report | `src/cli/` (M6) | diff --git a/packages/@stylexjs/codemods/README.md b/packages/@stylexjs/codemods/README.md new file mode 100644 index 000000000..cc7a9765d --- /dev/null +++ b/packages/@stylexjs/codemods/README.md @@ -0,0 +1,92 @@ +# @stylexjs/codemods + +Codemods for migrating styling libraries to [StyleX](https://stylexjs.com) — +Emotion first, built as **one library-agnostic engine with swappable +per-library adapters**. + +> **Status: v1.0 (MVP) feature-complete, pre-publish.** The Emotion adapter +> converts object-syntax styles, self-targeting conditions, physical→logical +> properties, multi-value shorthands, and object-form keyframes; merges into +> partially-migrated files; flags what it can't safely convert with `// TODO` +> markers; and ships as a CLI with a dry-run report. Correctness is enforced by +> three gates (compile, lint, semantic-diff) plus a robustness corpus. Nothing +> here is published yet, and the package name/location is pending maintainer +> confirmation. + +See the [**Migrating from Emotion to StyleX** guide](./docs/migrating-from-emotion.md) +for usage, what converts/flags/refuses, and known limitations. + +## Principles + +- **Bail loudly.** Only provably-safe styles are converted. Anything else is + flagged with a `// TODO(stylex-migration): …` comment, and a file is + refused outright when a partial conversion could change rendering. + Wrong-but-plausible output is the one unacceptable result. +- **Gated output.** Every conversion must compile through + `@stylexjs/babel-plugin`, pass `@stylexjs/eslint-plugin` at *error* with + zero autofixes needed, and have semantically identical net CSS (checked + against Emotion's own serializer, minus an explicit allowlist: + hover-guard, physical→logical properties). + +## Converts / flags / refuses (planned v1.0 scope) + +| Bucket | Patterns | +| --- | --- | +| Converts | static `css={{…}}` / `css({})`; self-targeting pseudo-classes/elements; media queries and nesting; physical→logical properties; multi-value margin/padding shorthands; object-form `keyframes`; fallback arrays | +| Flags *(planned)* | template literals; dynamic styles; `styled(Component)`; out-of-element selectors; ``; `shouldForwardProp`; `!important` | +| Refuses | any file where partial conversion could change rendering; conflicting cascades (referee disagreement); ≥2 sibling media queries on one property; theme tokens (until M6 — see [ADR-0001](./docs/decisions/0001-tokens-are-a-trusted-transformation-deferred-to-config.md)) | + +Theme tokens → `defineVars` is deferred to the configuration milestone (M6): +it is a *trusted* transformation (a token's value is external to the file, so +it cannot be verified by the semantic-diff gate) and requires the +user-authored `resolveValue` config. + +## Compatibility + +| | Version | +| --- | --- | +| Emits StyleX | 0.19.x | +| Reads Emotion | 10–11 (object syntax) | + +See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the design. + +## Usage + +```sh +# DRY RUN by default — previews a convert / refuse / TODO report, writes nothing: +stylex-codemod emotion "src/**/*.jsx" + +# Apply the changes: +stylex-codemod emotion "src/**/*.jsx" --write + +# List unchanged files and every TODO reason: +stylex-codemod emotion "src/**/*.jsx" --verbose +``` + +Each file is reported as **convert** (fully, or partially with `+N TODO`s left +in place), **refuse** (a whole-file structural issue, with the reason), or +**skip** (no Emotion / nothing to do). Convertible-but-unsafe styles are left +in place with a `// TODO(stylex-migration): …` marker rather than dropped. + +### Config + +An optional `stylex-codemod.config.js` (or `--config `) tunes behavior: + +```js +module.exports = { + hoverGuard: true, // wrap :hover in @media (hover: hover) (default true) + logicalProperties: true, // map marginLeft -> marginInlineStart, etc. (default true) +}; +``` + +## Development + +```sh +cd packages/@stylexjs/codemods +yarn jest # run from the package cwd, not the repo root +yarn build +``` + +Fixture pairs live in `__fixtures__/emotion//{input,expected}.js`; set +`UPDATE_STYLEX_CODEMOD_FIXTURES=1` to regenerate expected files when a +change is intentional. diff --git a/packages/@stylexjs/codemods/__fixtures__/broken/compile-invalid.js b/packages/@stylexjs/codemods/__fixtures__/broken/compile-invalid.js new file mode 100644 index 000000000..b9ab9f903 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/broken/compile-invalid.js @@ -0,0 +1,12 @@ +// DELIBERATELY BROKEN: `stylex.create` must be called with a statically +// analyzable object literal — a function call argument makes the real +// @stylexjs/babel-plugin throw. The compile gate must FAIL on this file. +import * as stylex from '@stylexjs/stylex'; + +function getStyles() { + return { badge: { color: 'red' } }; +} + +const styles = stylex.create(getStyles()); + +export default styles; diff --git a/packages/@stylexjs/codemods/__fixtures__/broken/lint-invalid.js b/packages/@stylexjs/codemods/__fixtures__/broken/lint-invalid.js new file mode 100644 index 000000000..a7f9654c2 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/broken/lint-invalid.js @@ -0,0 +1,12 @@ +// DELIBERATELY BROKEN: compiles, but violates @stylexjs/eslint-plugin rules +// ('colr' is not a valid property; styles are never used). The lint gate +// must FAIL on this file. +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + badge: { colr: 'red' }, +}); + +export default function Badge() { + return styles != null; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/expected.js new file mode 100644 index 000000000..245fe7065 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/expected.js @@ -0,0 +1,13 @@ +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + alert: { + color: 'maroon', + fontWeight: 700, + }, +}); + +export default function Alert() { + return

Alert

; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/input.js new file mode 100644 index 000000000..83ec208ce --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/input.js @@ -0,0 +1,7 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { css } from '@emotion/react'; + +export default function Alert() { + return

Alert

; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js new file mode 100644 index 000000000..ef7a9bd48 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js @@ -0,0 +1,10 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { Button } from './Button'; + +export default function Toolbar() { + return ( + /* TODO(stylex-migration): css on a component element */ + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/input.js new file mode 100644 index 000000000..856de0164 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/input.js @@ -0,0 +1,7 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { Button } from './Button'; + +export default function Toolbar() { + return ; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/expected.js new file mode 100644 index 000000000..a5031977f --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/expected.js @@ -0,0 +1,13 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// A descendant selector reaches outside the element — not self-targeting, +// so it cannot enter the IR and the file is refused. +export default function List() { + return ( + /* TODO(stylex-migration): selector '& > li' is not self-targeting */ +
    li': { color: 'gray' } }}> +
  • Item
  • +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/input.js new file mode 100644 index 000000000..79bcb3666 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/input.js @@ -0,0 +1,12 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// A descendant selector reaches outside the element — not self-targeting, +// so it cannot enter the IR and the file is refused. +export default function List() { + return ( +
    li': { color: 'gray' } }}> +
  • Item
  • +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/expected.js new file mode 100644 index 000000000..7544f4f0b --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/expected.js @@ -0,0 +1,39 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + panel: { + color: 'gray', + fontSize: 14, + }, + + panel2: { + backgroundColor: { + default: 'white', + + '@media (hover: hover)': { + default: null, + ':hover': 'navy', + }, + }, + }, +}); + +export default function Panel() { + return ( +
+ convert me + {/* TODO(stylex-migration): 'color': Emotion source-order and StyleX priority disagree on which conditional value wins when several are active (:focus vs :hover) */} + + + convert me too + +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/input.js new file mode 100644 index 000000000..7a9c57611 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/input.js @@ -0,0 +1,24 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Panel() { + return ( +
+ convert me + + + convert me too + +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/expected.js new file mode 100644 index 000000000..f41797cd5 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/expected.js @@ -0,0 +1,19 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// :focus before :hover — Emotion's source order makes :hover win when both +// are active, but StyleX's priority makes :focus win. The referees disagree, +// so the whole file must be refused (not silently converted incorrectly). +export default function Toggle() { + return ( + /* TODO(stylex-migration): 'color': Emotion source-order and StyleX priority disagree on which conditional value wins when several are active (:focus vs :hover) */ + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/input.js new file mode 100644 index 000000000..c38b05faa --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/input.js @@ -0,0 +1,18 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// :focus before :hover — Emotion's source order makes :hover win when both +// are active, but StyleX's priority makes :focus win. The referees disagree, +// so the whole file must be refused (not silently converted incorrectly). +export default function Toggle() { + return ( + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/expected.js new file mode 100644 index 000000000..d436386a5 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/expected.js @@ -0,0 +1,9 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Box() { + return ( + /* TODO(stylex-migration): shorthand/longhand overlap ('margin' + 'marginTop') */ +
Box
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/input.js new file mode 100644 index 000000000..fc139e9f0 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/input.js @@ -0,0 +1,6 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Box() { + return
Box
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/expected.js new file mode 100644 index 000000000..bebf49d4e --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/expected.js @@ -0,0 +1,20 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// Two sibling media queries on one property: their source order is semantic +// to the StyleX compiler, but sort-keys would reorder them. Refused until the +// upstream inconsistency is resolved. +export default function Panel() { + return ( + /* TODO(stylex-migration): 'width': ≥2 sibling at-rule (e.g. @media) conditions whose order is semantic to the StyleX compiler but which sort-keys would reorder (@media (min-width: 700px), @media (min-width: 500px)) */ +
+ Panel +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/input.js new file mode 100644 index 000000000..b53202b41 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/input.js @@ -0,0 +1,19 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// Two sibling media queries on one property: their source order is semantic +// to the StyleX compiler, but sort-keys would reorder them. Refused until the +// upstream inconsistency is resolved. +export default function Panel() { + return ( +
+ Panel +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/expected.js new file mode 100644 index 000000000..3c492394c --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/expected.js @@ -0,0 +1,14 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { css } from '@emotion/react'; + +const fancy = css` + color: red; +`; + +export default function Fancy() { + return ( + /* TODO(stylex-migration): dynamic value (props-driven) */ +
Fancy
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/input.js new file mode 100644 index 000000000..3ed42dc92 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/input.js @@ -0,0 +1,11 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { css } from '@emotion/react'; + +const fancy = css` + color: red; +`; + +export default function Fancy() { + return
Fancy
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/expected.js new file mode 100644 index 000000000..b075743a5 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/expected.js @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + field: { + color: { + default: 'black', + ':focus': 'blue', + }, + + fontSize: { + default: null, + '@media (min-width: 600px)': 18, + }, + }, +}); + +export default function Field() { + return ; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/input.js new file mode 100644 index 000000000..9fb728f51 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/input.js @@ -0,0 +1,14 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Field() { + return ( + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/expected.js new file mode 100644 index 000000000..60ac4ec14 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/expected.js @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + link: { + color: { + default: 'blue', + + '@media (hover: hover)': { + default: null, + ':hover': 'navy', + }, + }, + }, +}); + +export default function Link() { + return ( + + Link + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/input.js new file mode 100644 index 000000000..c63e63fa1 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/input.js @@ -0,0 +1,10 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Link() { + return ( + + Link + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/expected.js new file mode 100644 index 000000000..23e4d0ed6 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/expected.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +const spin = stylex.keyframes({ + from: { + transform: 'rotate(0deg)', + }, + + to: { + transform: 'rotate(360deg)', + }, +}); + +const styles = stylex.create({ + spinner: { + animationDuration: '1s', + animationName: spin, + }, +}); + +export default function Spinner() { + return
Loading
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/input.js new file mode 100644 index 000000000..96adfe295 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/input.js @@ -0,0 +1,14 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { keyframes } from '@emotion/react'; + +const spin = keyframes({ + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(360deg)' }, +}); + +export default function Spinner() { + return ( +
Loading
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/expected.js new file mode 100644 index 000000000..365be0b0b --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/expected.js @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + sidebar: { + insetInlineStart: 0, + marginInlineStart: 16, + paddingInlineEnd: 8, + marginTop: 4, + }, +}); + +export default function Sidebar() { + return ; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/input.js new file mode 100644 index 000000000..c9d3ddcc4 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/input.js @@ -0,0 +1,10 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Sidebar() { + return ( + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/expected.js new file mode 100644 index 000000000..196ae8402 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/expected.js @@ -0,0 +1,20 @@ +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + card: { + padding: '8px', + }, + + mixed: { + color: 'gray', + }, +}); + +export default function Mixed() { + return ( +
+ Mixed +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/input.js new file mode 100644 index 000000000..56dc390c8 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/input.js @@ -0,0 +1,17 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + card: { + padding: '8px', + }, +}); + +export default function Mixed() { + return ( +
+ Mixed +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/expected.js new file mode 100644 index 000000000..21bfe485a --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/expected.js @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + tag: { + color: { + default: 'black', + '::before': 'gray', + }, + }, +}); + +export default function Tag() { + return Tag; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/input.js new file mode 100644 index 000000000..c91ff2813 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/input.js @@ -0,0 +1,15 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Tag() { + return ( + + Tag + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/expected.js new file mode 100644 index 000000000..59e035764 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/expected.js @@ -0,0 +1,13 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { create, props } from '@stylexjs/stylex'; + +const styles = create({ card: { padding: '8px' } }); + +export default function Mixed() { + return ( +
+ Mixed +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/input.js new file mode 100644 index 000000000..59e035764 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/input.js @@ -0,0 +1,13 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { create, props } from '@stylexjs/stylex'; + +const styles = create({ card: { padding: '8px' } }); + +export default function Mixed() { + return ( +
+ Mixed +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/expected.js new file mode 100644 index 000000000..1f71471fe --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/expected.js @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + box: { + marginBlock: '8px', + marginInline: '16px', + color: 'navy', + }, +}); + +export default function Box() { + return
Box
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/input.js new file mode 100644 index 000000000..dd6c80c2d --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/input.js @@ -0,0 +1,6 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Box() { + return
Box
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/expected.js new file mode 100644 index 000000000..d633d7b63 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/expected.js @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + card: { + paddingBottom: '3px', + paddingLeft: '4px', + paddingRight: '2px', + paddingTop: '1px', + }, +}); + +export default function Card() { + return
Card
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/input.js new file mode 100644 index 000000000..2a2e2165a --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/input.js @@ -0,0 +1,6 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Card() { + return
Card
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js new file mode 100644 index 000000000..29ea198b7 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + badge: { + color: 'red', + }, +}); + +export default function Badge() { + return
Badge
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/input.js new file mode 100644 index 000000000..a57db9ccc --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/input.js @@ -0,0 +1,6 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Badge() { + return
Badge
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/expected.js new file mode 100644 index 000000000..36010c2f4 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/expected.js @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + fancyCard: { + color: 'rgb(10, 20, 30)', + fontSize: 16, + lineHeight: 1.5, + marginTop: -4, + }, +}); + +export default function Card() { + return
Card
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/input.js new file mode 100644 index 000000000..f0f4c1463 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/input.js @@ -0,0 +1,18 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Card() { + return ( +
+ Card +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/expected.js new file mode 100644 index 000000000..92d453733 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/expected.js @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + badge: { + color: 'darkgreen', + }, + + card: { + padding: '16px', + }, +}); + +export function Badge() { + return Badge; +} + +export function Card() { + return
Card
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/input.js new file mode 100644 index 000000000..868975bd5 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/input.js @@ -0,0 +1,10 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export function Badge() { + return Badge; +} + +export function Card() { + return
Card
; +} diff --git a/packages/@stylexjs/codemods/__tests__/cli-test.js b/packages/@stylexjs/codemods/__tests__/cli-test.js new file mode 100644 index 000000000..4ff4b2d38 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/cli-test.js @@ -0,0 +1,162 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + loadConfig, + validateConfig, + DEFAULT_CONFIG, + ConfigError, +} from '../src/config/loadConfig'; +import { runCodemod } from '../src/cli/run'; +import { formatReport } from '../src/cli/report'; + +const CONVERTIBLE = + '/** @jsxImportSource @emotion/react */\n' + + "import * as React from 'react';\n" + + 'export default function A() {\n' + + " return
A
;\n" + + '}\n'; + +const FLAGGED = + '/** @jsxImportSource @emotion/react */\n' + + "import * as React from 'react';\n" + + 'export default function B() {\n' + + ' return B;\n' + + '}\n'; + +const REFUSED = + '/** @jsxImportSource @emotion/react */\n' + + "import * as React from 'react';\n" + + "import { create } from '@stylexjs/stylex';\n" + + "const s = create({ a: { color: 'red' } });\n" + + 'export default function C() {\n' + + " return {s ? 'x' : 'y'};\n" + + '}\n'; + +const PLAIN = 'export default function D() {\n return null;\n}\n'; + +function makeProject(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stylex-codemod-')); + fs.writeFileSync(path.join(dir, 'convert.jsx'), CONVERTIBLE); + fs.writeFileSync(path.join(dir, 'flag.jsx'), FLAGGED); + fs.writeFileSync(path.join(dir, 'refuse.jsx'), REFUSED); + fs.writeFileSync(path.join(dir, 'plain.jsx'), PLAIN); + return dir; +} + +describe('loadConfig', () => { + test('missing config falls back to defaults', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stylex-cfg-')); + expect(loadConfig({ cwd: dir })).toEqual(DEFAULT_CONFIG); + }); + + test('an explicit missing config path throws', () => { + expect(() => loadConfig({ configPath: '/nope/x.js' })).toThrow(ConfigError); + }); + + test('validates and merges over defaults', () => { + expect(validateConfig({ hoverGuard: false }, 'x')).toEqual({ + hoverGuard: false, + logicalProperties: true, + }); + }); + + test('unknown option throws', () => { + expect(() => validateConfig({ nope: true }, 'x')).toThrow(/unknown option/); + }); + + test('non-boolean option throws', () => { + expect(() => validateConfig({ hoverGuard: 'yes' }, 'x')).toThrow( + /must be a boolean/, + ); + }); +}); + +describe('runCodemod (dry run is the default)', () => { + test('reports convert / flag / refuse / unchanged without writing', () => { + const dir = makeProject(); + const before = fs.readFileSync(path.join(dir, 'convert.jsx'), 'utf8'); + const report = runCodemod({ + patterns: ['*.jsx'], + cwd: dir, + config: DEFAULT_CONFIG, + write: false, + }); + expect(report.dryRun).toBe(true); + expect(report.summary).toMatchObject({ + files: 4, + converted: 1, // convert.jsx (no flags) + partiallyConverted: 1, // flag.jsx (a TODO) + skipped: 1, // refuse.jsx + unchanged: 1, // plain.jsx + }); + expect(report.summary.totalFlags).toBe(1); + // Dry run wrote nothing. + expect(fs.readFileSync(path.join(dir, 'convert.jsx'), 'utf8')).toEqual( + before, + ); + expect(report.results.every((r) => r.wrote === false)).toBe(true); + }); + + test('--write applies the conversion to disk', () => { + const dir = makeProject(); + runCodemod({ + patterns: ['convert.jsx'], + cwd: dir, + config: DEFAULT_CONFIG, + write: true, + }); + const after = fs.readFileSync(path.join(dir, 'convert.jsx'), 'utf8'); + expect(after).toContain('stylex.props(styles.a)'); + expect(after).not.toContain('css={{'); + }); + + test('logicalProperties: false is threaded through', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stylex-opt-')); + fs.writeFileSync( + path.join(dir, 'a.jsx'), + '/** @jsxImportSource @emotion/react */\n' + + "import * as React from 'react';\n" + + 'export default function A() {\n' + + ' return
A
;\n' + + '}\n', + ); + runCodemod({ + patterns: ['a.jsx'], + cwd: dir, + config: { hoverGuard: true, logicalProperties: false }, + write: true, + }); + const after = fs.readFileSync(path.join(dir, 'a.jsx'), 'utf8'); + expect(after).toContain('marginLeft'); // NOT converted to marginInlineStart + }); +}); + +describe('formatReport', () => { + test('renders a preview with a summary and an apply hint', () => { + const dir = makeProject(); + const report = runCodemod({ + patterns: ['*.jsx'], + cwd: dir, + config: DEFAULT_CONFIG, + write: false, + }); + const text = formatReport(report, { cwd: dir }); + expect(text).toContain('Dry run'); + expect(text).toMatch(/convert\.jsx/); + expect(text).toMatch(/\+1 TODO/); // flag.jsx marker count + expect(text).toMatch(/refuse\.jsx/); + expect(text).toContain('Re-run with --write to apply.'); + // unchanged file hidden unless verbose + expect(text).not.toMatch(/plain\.jsx/); + }); +}); diff --git a/packages/@stylexjs/codemods/__tests__/emit-test.js b/packages/@stylexjs/codemods/__tests__/emit-test.js new file mode 100644 index 000000000..30e270a8f --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/emit-test.js @@ -0,0 +1,301 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Engine unit tests: hand-written IR in, emitted create data out. + * Deliberately NO Emotion anywhere in this file — this is what proves the + * engine is source-neutral (any adapter producing this IR gets the same + * output). + */ + +import type { Atom, FileIR } from '../src/core/ir'; +import { buildFileIR } from '../src/core/buildIR'; +import { emitFileIR, sanitizeKey, EmitError } from '../src/core/emit'; + +function irOf( + entries: Array<[string, Array<[string, string | number]>]>, +): FileIR { + return { + rules: entries.map(([name, decls]) => ({ + name, + atoms: decls.map(([property, value]) => ({ + property, + conditions: [], + value: { kind: 'static', value }, + })), + })), + keyframes: [], + }; +} + +describe('emitFileIR', () => { + test('one rule becomes one create entry plus its binding', () => { + const result = emitFileIR(irOf([['badge', [['color', 'red']]]])); + expect(result.rules).toEqual([{ key: 'badge', style: { color: 'red' } }]); + expect(result.bindings).toEqual(['badge']); + }); + + test('sorts properties alphabetically (stylex/sort-keys, zero autofixes) and keeps value types', () => { + // Safe for flat longhands: order-dependent cases (duplicates, + // shorthand/longhand overlap) are refused before emit. + const result = emitFileIR( + irOf([ + [ + 'card', + [ + ['fontSize', 16], + ['lineHeight', 1.5], + ['color', 'rgb(0, 0, 0)'], + ], + ], + ]), + ); + expect(Object.entries(result.rules[0].style)).toEqual([ + ['color', 'rgb(0, 0, 0)'], + ['fontSize', 16], + ['lineHeight', 1.5], + ]); + }); + + test('key collisions get numeric suffixes in rule order', () => { + const result = emitFileIR( + irOf([ + ['badge', [['color', 'red']]], + ['badge', [['color', 'blue']]], + ['badge', [['color', 'green']]], + ]), + ); + expect(result.bindings).toEqual(['badge', 'badge2', 'badge3']); + }); + + test('sanitizes hostile name hints instead of emitting bad keys', () => { + expect(sanitizeKey('MyButton')).toBe('myButton'); + expect(sanitizeKey('nav-bar item')).toBe('navBarItem'); + expect(sanitizeKey('123')).toBe('styles'); + expect(sanitizeKey('')).toBe('styles'); + expect(sanitizeKey('default')).toBe('styles'); + }); + + test('REFUSES a duplicate unconditional base declaration', () => { + const ir = irOf([ + [ + 'badge', + [ + ['color', 'red'], + ['color', 'blue'], + ], + ], + ]); + expect(() => emitFileIR(ir)).toThrow(EmitError); + }); +}); + +describe('emitFileIR — conditions (the flip)', () => { + const cond = ( + name: string, + kind: 'pseudo-class' | 'pseudo-element', + value: string, + ): Atom => ({ + property: 'color', + conditions: [ + kind === 'pseudo-class' + ? { kind: 'pseudo-class', name } + : { kind: 'pseudo-element', name }, + ], + value: { kind: 'static', value }, + }); + const baseAtom = (value: string): Atom => ({ + property: 'color', + conditions: [], + value: { kind: 'static', value }, + }); + const ruleOf = (atoms: Array): FileIR => ({ + rules: [{ name: 'badge', atoms }], + keyframes: [], + }); + + test('base + :hover nests property-grouped, hover-guarded by default', () => { + const { rules } = emitFileIR( + ruleOf([baseAtom('red'), cond(':hover', 'pseudo-class', 'blue')]), + ); + expect(rules[0].style).toEqual({ + color: { + default: 'red', + '@media (hover: hover)': { default: null, ':hover': 'blue' }, + }, + }); + }); + + test('hover-guard can be disabled', () => { + const { rules } = emitFileIR( + ruleOf([baseAtom('red'), cond(':hover', 'pseudo-class', 'blue')]), + { hoverGuard: false }, + ); + expect(rules[0].style).toEqual({ + color: { default: 'red', ':hover': 'blue' }, + }); + }); + + test(':focus is not hover-guarded', () => { + const { rules } = emitFileIR( + ruleOf([baseAtom('red'), cond(':focus', 'pseudo-class', 'green')]), + ); + expect(rules[0].style).toEqual({ + color: { default: 'red', ':focus': 'green' }, + }); + }); + + test('a condition with no base gets default: null', () => { + const { rules } = emitFileIR( + ruleOf([cond(':focus', 'pseudo-class', 'green')]), + ); + expect(rules[0].style).toEqual({ + color: { default: null, ':focus': 'green' }, + }); + }); + + test('media condition nests as an at-rule object', () => { + const { rules } = emitFileIR( + ruleOf([ + baseAtom('red'), + { + property: 'color', + conditions: [{ kind: 'at-rule', rule: '@media (min-width: 600px)' }], + value: { kind: 'static', value: 'green' }, + }, + ]), + ); + expect(rules[0].style).toEqual({ + color: { default: 'red', '@media (min-width: 600px)': 'green' }, + }); + }); + + test('pseudo-element nests (different box, not hover-guarded)', () => { + const { rules } = emitFileIR( + ruleOf([baseAtom('red'), cond('::before', 'pseudo-element', 'gray')]), + ); + expect(rules[0].style).toEqual({ + color: { default: 'red', '::before': 'gray' }, + }); + }); +}); + +describe('buildFileIR', () => { + test('flat declarations become zero-condition static atoms', () => { + const ir = buildFileIR([ + { + nameHint: 'badge', + declarations: [ + { property: 'color', value: { kind: 'static', value: 'red' } }, + { property: 'fontSize', value: { kind: 'static', value: 16 } }, + ], + }, + ]); + expect(ir).toEqual({ + rules: [ + { + name: 'badge', + atoms: [ + { + property: 'color', + conditions: [], + value: { kind: 'static', value: 'red' }, + }, + { + property: 'fontSize', + conditions: [], + value: { kind: 'static', value: 16 }, + }, + ], + }, + ], + keyframes: [], + }); + }); + + test('buildFileIR -> emitFileIR round-trips a group into create data', () => { + const { rules, bindings } = emitFileIR( + buildFileIR([ + { + nameHint: 'Badge', + declarations: [ + { property: 'color', value: { kind: 'static', value: 'red' } }, + ], + }, + ]), + ); + expect(rules).toEqual([{ key: 'badge', style: { color: 'red' } }]); + expect(bindings).toEqual(['badge']); + }); +}); + +describe('emitFileIR — references and keyframes', () => { + test('a reference value emits a $$ref sentinel (rendered as a bare identifier)', () => { + const ir: FileIR = { + rules: [ + { + name: 'spinner', + atoms: [ + { + property: 'animationName', + conditions: [], + value: { kind: 'reference', name: 'spin' }, + }, + ], + }, + ], + keyframes: [], + }; + expect(emitFileIR(ir).rules[0].style).toEqual({ + animationName: { $$ref: 'spin' }, + }); + }); + + test('keyframes emit as named frame objects', () => { + const ir: FileIR = { + rules: [], + keyframes: [ + { + name: 'spin', + frames: [ + { + selector: 'from', + atoms: [ + { + property: 'opacity', + conditions: [], + value: { kind: 'static', value: 0 }, + }, + ], + }, + { + selector: 'to', + atoms: [ + { + property: 'opacity', + conditions: [], + value: { kind: 'static', value: 1 }, + }, + ], + }, + ], + }, + ], + }; + expect(emitFileIR(ir).keyframes).toEqual([ + { + name: 'spin', + frames: [ + { selector: 'from', style: { opacity: 0 } }, + { selector: 'to', style: { opacity: 1 } }, + ], + }, + ]); + }); +}); diff --git a/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js new file mode 100644 index 000000000..224396648 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js @@ -0,0 +1,177 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * The fixture harness — the executable spec. Every input/expected pair + * under `__fixtures__/emotion/` must satisfy four checks: + * + * 1. transform(input) matches expected byte-exactly (after Prettier); + * 2. expected compiles through @stylexjs/babel-plugin; + * 3. expected passes @stylexjs/eslint-plugin at error, zero messages; + * 4. the net CSS of input and expected is semantically identical + * (Emotion's own serializer vs the babel-plugin metadata, minus the + * sanctioned allowlist). Skip-fixtures (expected === input) assert + * that the transform really did refuse. + * + * Set UPDATE_STYLEX_CODEMOD_FIXTURES=1 to regenerate expected files when a + * change is intentional. + */ + +import * as fs from 'fs'; +import { serializeStyles } from '@emotion/serialize'; +import { compileGate } from '../src/core/gates/compile'; +import { lintGate } from '../src/core/gates/lint'; +import { + semanticDiffGate, + netCssFromSerializedCss, + netCssFromStylexMetadata, + keyframesFromStylexMetadata, + parseFrames, +} from '../src/core/gates/semanticDiff'; +import { transformEmotionFile } from '../src/adapters/emotion/transform'; +import { loadFixtures, formatWithPrettier } from './utils/harness'; + +const UPDATE = process.env.UPDATE_STYLEX_CODEMOD_FIXTURES === '1'; + +/** Deterministic JSON with recursively-sorted keys, for set comparison. */ +function canonicalJson(value: mixed): string { + if (value == null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'null'; + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]`; + } + const obj: { +[string]: mixed } = value; + return `{${Object.keys(obj) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`) + .join(',')}}`; +} + +const fixtures = loadFixtures('emotion'); + +test('there is at least one fixture pair', () => { + expect(fixtures.length).toBeGreaterThan(0); +}); + +test('prettier normalization is available and idempotent', () => { + const [fixture] = fixtures; + const once = formatWithPrettier(fixture.expected, fixture.expectedPath); + expect(formatWithPrettier(once, fixture.expectedPath)).toEqual(once); +}); + +describe.each(fixtures.map((f) => [f.name, f]))( + 'fixture: %s', + (_name, fixture) => { + const result = transformEmotionFile(fixture.input, fixture.inputPath); + const output = result.status === 'converted' ? result.code : fixture.input; + + // Check 1 — byte-exact against expected, formatting-insensitive. + test('transform(input) matches expected byte-exactly', () => { + const actual = formatWithPrettier(output, fixture.expectedPath); + if (UPDATE) { + fs.writeFileSync(fixture.expectedPath, actual); + } + expect(actual).toEqual( + formatWithPrettier(fixture.expected, fixture.expectedPath), + ); + }); + + // Check 2. + test('expected compiles through @stylexjs/babel-plugin', () => { + const compiled = compileGate(fixture.expected, { + filename: fixture.expectedPath, + }); + if (!compiled.ok) { + throw new Error(compiled.errors.join('\n')); + } + expect(compiled.ok).toBe(true); + }); + + // Check 3. + test('expected passes @stylexjs/eslint-plugin at error with zero messages', () => { + const linted = lintGate(fixture.expected, { + filename: fixture.expectedPath, + }); + if (!linted.ok) { + throw new Error(JSON.stringify(linted.messages, null, 2)); + } + expect(linted.ok).toBe(true); + }); + + // Check 4. + test('net CSS of input and expected is semantically identical', () => { + if (result.status !== 'converted') { + // A skip-fixture: the transform must have refused (loudly, with + // reasons) and left the file byte-identical. + expect(fixture.expected).toEqual(fixture.input); + if (result.status === 'skipped') { + expect(result.reasons.length).toBeGreaterThan(0); + } + return; + } + // Before: any pre-existing StyleX in the INPUT (unchanged by a merge, + // so it appears on both sides) + Emotion's serializer over each + // converted object. + // (Fixture-design constraint: sites must not restate the same + // property+conditions with different values, or the union is lossy.) + const before: { [string]: $FlowFixMe } = {}; + const inputCompiled = compileGate(fixture.input, { + filename: fixture.inputPath, + }); + if (inputCompiled.ok) { + const preExisting = netCssFromStylexMetadata(inputCompiled.metadata); + for (const coordinate of Object.keys(preExisting)) { + before[coordinate] = preExisting[coordinate]; + } + } + for (const site of result.sites) { + const net = netCssFromSerializedCss( + serializeStyles([site.cssObject]).styles, + ); + for (const coordinate of Object.keys(net)) { + if ( + before[coordinate] != null && + before[coordinate].value !== net[coordinate].value + ) { + throw new Error( + `fixture restates '${coordinate}' with different values across sites`, + ); + } + before[coordinate] = net[coordinate]; + } + } + // After: the real babel-plugin metadata for the converted output. + const compiled = compileGate(output, { filename: fixture.expectedPath }); + if (!compiled.ok) { + throw new Error(compiled.errors.join('\n')); + } + const diff = semanticDiffGate( + before, + netCssFromStylexMetadata(compiled.metadata), + ); + if (!diff.ok) { + throw new Error(JSON.stringify(diff.diffs, null, 2)); + } + expect(diff.ok).toBe(true); + + // Keyframes: the generated animation-name differs, so the frame + // CONTENTS are compared directly (Emotion serializer vs StyleX + // @keyframes metadata), as an order-independent multiset. + const emotionFrames = result.keyframes + .map((kf) => parseFrames(serializeStyles([kf.framesObject]).styles)) + .map(canonicalJson) + .sort(); + const stylexFrames = keyframesFromStylexMetadata(compiled.metadata) + .map(canonicalJson) + .sort(); + expect(stylexFrames).toEqual(emotionFrames); + }); + }, +); diff --git a/packages/@stylexjs/codemods/__tests__/flagging-test.js b/packages/@stylexjs/codemods/__tests__/flagging-test.js new file mode 100644 index 000000000..0d3ab0f0d --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/flagging-test.js @@ -0,0 +1,81 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * M5 per-site flagging: convert what is safe, leave a `// TODO` marker on the + * rest, and never silently drop or re-flag. + */ + +import { transformEmotionFile } from '../src/adapters/emotion/transform'; + +const HEADER = + '/** @jsxImportSource @emotion/react */\n' + + "import * as React from 'react';\n"; + +test('a flag marker is not duplicated on a second run (re-run guard)', () => { + const input = + HEADER + + 'export default function C() {\n' + + ' return (\n' + + '
\n' + + ' \n' + + '
\n' + + ' );\n' + + '}\n'; + const first = transformEmotionFile(input, 'in.js'); + expect(first.status).toBe('converted'); + if (first.status !== 'converted') { + return; + } + expect(first.flags.length).toBe(1); + const markerCount = (s: string) => + (s.match(/TODO\(stylex-migration\)/g) ?? []).length; + expect(markerCount(first.code)).toBe(1); + + // Running again on the already-flagged output must not add a second marker. + const second = transformEmotionFile(first.code, 'in.js'); + if (second.status === 'converted') { + expect(markerCount(second.code)).toBe(1); + } else { + // 'unchanged' is also acceptable (nothing left to do). + expect(second.status).toBe('unchanged'); + } +}); + +test('a whole-file structural issue still refuses (does not flag)', () => { + // Non-namespace stylex import can't be merged into — whole-file refusal. + const input = [ + '/** @jsxImportSource @emotion/react */', + "import * as React from 'react';", + "import { create } from '@stylexjs/stylex';", + "const s = create({ a: { color: 'red' } });", + 'export default function C() {', + " return {s ? 'x' : 'y'};", + '}', + '', + ].join('\n'); + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('skipped'); + if (result.status === 'skipped') { + expect(result.reasons.join('\n')).toMatch(/namespace/); + } +}); + +test('a fully-convertible file reports no flags', () => { + const input = + HEADER + + 'export default function C() {\n' + + " return x;\n" + + '}\n'; + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('converted'); + if (result.status === 'converted') { + expect(result.flags).toEqual([]); + } +}); diff --git a/packages/@stylexjs/codemods/__tests__/gates-test.js b/packages/@stylexjs/codemods/__tests__/gates-test.js new file mode 100644 index 000000000..0bf01c385 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/gates-test.js @@ -0,0 +1,193 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Gate self-tests. A gate that never fails is worthless, so every gate is + * proven BOTH ways: green on the trivial hand-written pair, and red on a + * deliberately-broken input. + */ + +import { serializeStyles } from '@emotion/serialize'; +import { compileGate } from '../src/core/gates/compile'; +import { lintGate } from '../src/core/gates/lint'; +import { + semanticDiffGate, + netCssFromSerializedCss, + netCssFromStylexMetadata, + UnsupportedCssError, +} from '../src/core/gates/semanticDiff'; +import { loadFixtures, readBrokenFixture } from './utils/harness'; + +const [trivialPair] = loadFixtures('emotion').filter( + (f) => f.name === 'static-flat-color', +); + +// The style object literally present in the trivial pair's input.js. +const trivialEmotionObject = { color: 'red' }; + +function emotionNetCss(styleObject: mixed) { + return netCssFromSerializedCss(serializeStyles([styleObject]).styles); +} + +describe('compile gate', () => { + test('passes on the trivial expected output', () => { + const result = compileGate(trivialPair.expected, { + filename: trivialPair.expectedPath, + }); + expect(result.ok).toBe(true); + }); + + test('FAILS on the deliberately-broken pair (non-static create arg)', () => { + const result = compileGate(readBrokenFixture('compile-invalid.js')); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.join('\n')).toMatch(/create\(\)/); + } + }); +}); + +describe('lint gate', () => { + test('passes on the trivial expected output', () => { + const result = lintGate(trivialPair.expected, { + filename: trivialPair.expectedPath, + }); + expect(result).toEqual({ ok: true }); + }); + + test('FAILS on the deliberately-broken pair (invalid property, unused styles)', () => { + const result = lintGate(readBrokenFixture('lint-invalid.js')); + expect(result.ok).toBe(false); + if (!result.ok) { + const ruleIds = result.messages.map((m) => m.ruleId); + expect(ruleIds).toContain('@stylexjs/valid-styles'); + expect(ruleIds).toContain('@stylexjs/no-unused'); + } + }); +}); + +describe('semantic-diff gate', () => { + test('trivial pair end-to-end: Emotion input and compiled StyleX output have equal net CSS', () => { + const compiled = compileGate(trivialPair.expected, { + filename: trivialPair.expectedPath, + }); + expect(compiled.ok).toBe(true); + if (!compiled.ok) { + return; + } + const before = emotionNetCss(trivialEmotionObject); + const after = netCssFromStylexMetadata(compiled.metadata); + const result = semanticDiffGate(before, after); + expect(result.ok).toBe(true); + }); + + test('FAILS end-to-end when the output renders a different value', () => { + const wrongExpected = trivialPair.expected.replace("'red'", "'blue'"); + const compiled = compileGate(wrongExpected); + expect(compiled.ok).toBe(true); + if (!compiled.ok) { + return; + } + const result = semanticDiffGate( + emotionNetCss(trivialEmotionObject), + netCssFromStylexMetadata(compiled.metadata), + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diffs).toEqual([ + expect.objectContaining({ + property: 'color', + beforeValue: 'red', + afterValue: 'blue', + }), + ]); + } + }); + + test('FAILS when a declaration is dropped entirely', () => { + const result = semanticDiffGate( + emotionNetCss({ color: 'red', fontSize: 16 }), + emotionNetCss({ color: 'red' }), + ); + expect(result.ok).toBe(false); + }); + + test('parses nested pseudo-class and media conditions', () => { + const net = emotionNetCss({ + color: 'red', + ':hover': { color: 'blue' }, + '@media (min-width: 600px)': { color: 'green' }, + }); + expect(Object.keys(net).sort()).toEqual([ + 'color', + 'color @ :hover', + 'color @ @media(min-width:600px)', + ]); + }); + + test('allowlist: the hover-guard is a sanctioned diff', () => { + const before = emotionNetCss({ ':hover': { color: 'blue' } }); + const after = emotionNetCss({ + '@media (hover: hover)': { ':hover': { color: 'blue' } }, + }); + const result = semanticDiffGate(before, after); + expect(result.ok).toBe(true); + expect(result.allowed.length).toBeGreaterThan(0); + }); + + test('allowlist: physical -> logical is a sanctioned diff (inset, not canonicalized)', () => { + // margin/padding are canonicalized to physical longhands and match + // directly; left/right (inset) still flow through the allowlist. + const result = semanticDiffGate( + emotionNetCss({ left: '0px' }), + emotionNetCss({ insetInlineStart: '0px' }), + ); + expect(result.ok).toBe(true); + expect(result.allowed.length).toBeGreaterThan(0); + }); + + test('box shorthand and its expanded longhands compare equal', () => { + // Emotion `margin: 8px 16px` vs the codemod's logical expansion. + const result = semanticDiffGate( + emotionNetCss({ margin: '8px 16px' }), + emotionNetCss({ marginBlock: '8px', marginInline: '16px' }), + ); + expect(result.ok).toBe(true); + }); + + test('shorthand canonicalization still catches a real per-side difference', () => { + const result = semanticDiffGate( + emotionNetCss({ margin: '8px 16px' }), + emotionNetCss({ marginBlock: '8px', marginInline: '99px' }), + ); + expect(result.ok).toBe(false); + }); + + test('allowlist does NOT excuse a value change under the same disguise', () => { + const result = semanticDiffGate( + emotionNetCss({ marginLeft: '8px' }), + emotionNetCss({ marginInlineStart: '9px' }), + ); + expect(result.ok).toBe(false); + }); + + test('BAILS LOUDLY on selectors that reach outside the element', () => { + expect(() => emotionNetCss({ '& > span': { color: 'red' } })).toThrow( + UnsupportedCssError, + ); + expect(() => emotionNetCss({ 'div span': { color: 'red' } })).toThrow( + UnsupportedCssError, + ); + }); + + test('BAILS LOUDLY on unrecognized StyleX metadata', () => { + expect(() => netCssFromStylexMetadata({ nope: true })).toThrow( + UnsupportedCssError, + ); + }); +}); diff --git a/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js b/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js new file mode 100644 index 000000000..65c51de62 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js @@ -0,0 +1,148 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * IR-completeness harness. + * + * Completeness is MEASURED, not asserted: every valid StyleX style object + * in the corpus is read into the IR, re-emitted, compiled through the real + * babel-plugin, and required to be semantically identical to the original + * (empty allowlist — same object, so zero sanctioned drift). Whatever the + * reader cannot represent is a concrete, SAFE coverage gap (in the real + * pipeline it would be flagged, never emitted incorrectly). + * + * M1+: grow the corpus toward extraction from StyleX's own valid fixtures + * (`@stylexjs/babel-plugin` / `@stylexjs/eslint-plugin` tests). + */ + +import { + stylexObjectToIR, + IRCoverageGapError, +} from '../src/testing/stylexToIR'; +import { emitFileIR } from '../src/core/emit'; +import type { EmittedStyle, EmittedValue } from '../src/core/emit'; +import { compileGate } from '../src/core/gates/compile'; +import { + semanticDiffGate, + netCssFromStylexMetadata, +} from '../src/core/gates/semanticDiff'; + +const SEED_CORPUS: $ReadOnlyArray<[string, mixed]> = [ + ['flat static styles', { color: 'red', fontSize: 16 }], + [ + 'pseudo-class conditions in the value object', + { color: { default: 'black', ':hover': 'blue' } }, + ], + // Valid modern StyleX — pseudo-elements may appear inside value objects. + [ + 'pseudo-element condition in the value object', + { color: { default: 'black', '::placeholder': 'gray' } }, + ], + [ + 'at-rule conditions', + { width: { default: '100%', '@media (min-width: 600px)': '50%' } }, + ], + ['fallback array (firstThatWorks)', { position: ['sticky', 'fixed'] }], +]; + +function valueToSource(value: EmittedValue | mixed): string { + if (typeof value === 'string') { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + } + if (typeof value === 'number') { + return String(value); + } + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return `[${value.map(valueToSource).join(', ')}]`; + } + if (typeof value === 'object') { + // A nested condition object: { default, ':hover', '@media …' }. + return objectToSource(value); + } + throw new Error(`unstringifiable value: ${String(value)}`); +} + +const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +function objectToSource(object: { +[string]: mixed }): string { + const entries = Object.keys(object).map((key) => { + const renderedKey = BARE_KEY.test(key) ? key : `'${key}'`; + return `${renderedKey}: ${valueToSource(object[key])}`; + }); + return `{ ${entries.join(', ')} }`; +} + +function styleToSource(style: EmittedStyle | mixed): string { + if (style == null || typeof style !== 'object') { + throw new Error('unstringifiable style object'); + } + return objectToSource(style); +} + +function netCssOfStyleObject(style: EmittedStyle | mixed) { + const source = [ + "import * as stylex from '@stylexjs/stylex';", + `export const styles = stylex.create({ k: ${styleToSource(style)} });`, + '', + ].join('\n'); + const compiled = compileGate(source); + if (!compiled.ok) { + throw new Error( + `corpus entry does not compile:\n${compiled.errors.join('\n')}`, + ); + } + return netCssFromStylexMetadata(compiled.metadata); +} + +test('every corpus entry round-trips through the IR or is a typed coverage gap', () => { + const covered: Array = []; + const gaps: Array = []; + for (const [name, styleObject] of SEED_CORPUS) { + let rule; + try { + rule = stylexObjectToIR(name, styleObject); + } catch (error) { + if (!(error instanceof IRCoverageGapError)) { + throw error; // a real bug, not a coverage gap + } + gaps.push(name); + continue; + } + // Round-trip: IR -> emit -> compile, and compare against the original + // object compiled directly. hoverGuard is off so the round-trip is a + // true identity (the guard is a sanctioned change, not an equivalence); + // empty allowlist means any drift is a failure. + const { rules } = emitFileIR( + { rules: [rule], keyframes: [] }, + { hoverGuard: false }, + ); + const diff = semanticDiffGate( + netCssOfStyleObject(styleObject), + netCssOfStyleObject(rules[0].style), + { allowlist: [] }, + ); + if (!diff.ok) { + throw new Error( + `round-trip drift for '${name}': ${JSON.stringify(diff.diffs)}`, + ); + } + covered.push(name); + } + expect(covered.length + gaps.length).toBe(SEED_CORPUS.length); + // M2 covers conditions, so the whole seed corpus now round-trips. + expect(gaps).toEqual([]); + // eslint-disable-next-line no-console + console.info( + `[ir-completeness] ${covered.length}/${SEED_CORPUS.length} corpus entries covered` + + (gaps.length > 0 ? ` — gaps: ${gaps.join(', ')}` : ''), + ); +}); diff --git a/packages/@stylexjs/codemods/__tests__/ir-test.js b/packages/@stylexjs/codemods/__tests__/ir-test.js new file mode 100644 index 000000000..5c9f3a439 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/ir-test.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type { Condition } from '../src/core/ir'; +import { conditionKey, atomCoordinate } from '../src/core/ir'; + +test('conditionKey covers every condition kind', () => { + expect(conditionKey({ kind: 'pseudo-class', name: ':hover' })).toEqual( + ':hover', + ); + expect(conditionKey({ kind: 'pseudo-element', name: '::before' })).toEqual( + '::before', + ); + expect( + conditionKey({ kind: 'at-rule', rule: '@media (min-width: 600px)' }), + ).toEqual('@media (min-width: 600px)'); +}); + +test('atomCoordinate is stable under condition ordering', () => { + const hover: Condition = { kind: 'pseudo-class', name: ':hover' }; + const media: Condition = { + kind: 'at-rule', + rule: '@media (min-width: 600px)', + }; + expect(atomCoordinate('color', [hover, media])).toEqual( + atomCoordinate('color', [media, hover]), + ); + expect(atomCoordinate('color', [])).toEqual('color'); +}); diff --git a/packages/@stylexjs/codemods/__tests__/normalize-test.js b/packages/@stylexjs/codemods/__tests__/normalize-test.js new file mode 100644 index 000000000..7337d19c9 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/normalize-test.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Normalize unit tests — hand-written IR, no Emotion. + */ + +import type { FileIR } from '../src/core/ir'; +import { normalizeFileIR } from '../src/core/normalize'; + +function irOf(props: Array<[string, string | number]>): FileIR { + return { + rules: [ + { + name: 'x', + atoms: props.map(([property, value]) => ({ + property, + conditions: [], + value: { kind: 'static', value }, + })), + }, + ], + keyframes: [], + }; +} + +function properties(ir: FileIR): Array { + return ir.rules[0].atoms.map((a) => a.property); +} + +describe('normalizeFileIR — physical to logical', () => { + test('maps inline-axis physical properties to logical', () => { + const out = normalizeFileIR( + irOf([ + ['marginLeft', 8], + ['marginRight', 8], + ['paddingLeft', 4], + ['paddingRight', 4], + ['left', 0], + ['right', 0], + ]), + ); + expect(properties(out)).toEqual([ + 'marginInlineStart', + 'marginInlineEnd', + 'paddingInlineStart', + 'paddingInlineEnd', + 'insetInlineStart', + 'insetInlineEnd', + ]); + }); + + test('leaves block-axis and non-directional properties untouched', () => { + const out = normalizeFileIR( + irOf([ + ['marginTop', 8], + ['bottom', 0], + ['color', 'red'], + ]), + ); + expect(properties(out)).toEqual(['marginTop', 'bottom', 'color']); + }); + + test('preserves conditions and values while renaming', () => { + const ir: FileIR = { + rules: [ + { + name: 'x', + atoms: [ + { + property: 'marginLeft', + conditions: [{ kind: 'pseudo-class', name: ':hover' }], + value: { kind: 'static', value: 12 }, + }, + ], + }, + ], + keyframes: [], + }; + const out = normalizeFileIR(ir); + expect(out.rules[0].atoms[0]).toEqual({ + property: 'marginInlineStart', + conditions: [{ kind: 'pseudo-class', name: ':hover' }], + value: { kind: 'static', value: 12 }, + }); + }); + + test('can be disabled', () => { + const out = normalizeFileIR(irOf([['marginLeft', 8]]), { + logicalProperties: false, + }); + expect(properties(out)).toEqual(['marginLeft']); + }); +}); diff --git a/packages/@stylexjs/codemods/__tests__/referee-test.js b/packages/@stylexjs/codemods/__tests__/referee-test.js new file mode 100644 index 000000000..3e21518aa --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/referee-test.js @@ -0,0 +1,170 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Referee unit tests — hand-written IR, no Emotion. The referee is the + * silent-wrong-cascade guard, so it gets the heaviest scrutiny: every + * shorthand of the agreement/disagreement matrix. + */ + +import type { Atom, Condition, StyleRule } from '../src/core/ir'; +import { checkRule } from '../src/core/referee'; + +const base = (value: string): Atom => ({ + property: 'color', + conditions: [], + value: { kind: 'static', value }, +}); +const pc = (name: string, value: string): Atom => ({ + property: 'color', + conditions: [{ kind: 'pseudo-class', name }], + value: { kind: 'static', value }, +}); +const pe = (name: string, value: string): Atom => ({ + property: 'color', + conditions: [{ kind: 'pseudo-element', name }], + value: { kind: 'static', value }, +}); +const media = (rule: string, value: string): Atom => ({ + property: 'color', + conditions: [{ kind: 'at-rule', rule }], + value: { kind: 'static', value }, +}); + +const rule = (atoms: Array): StyleRule => ({ name: 'x', atoms }); +const MEDIA = '@media (min-width: 600px)'; + +describe('checkRule — agreement (converts)', () => { + test('base then :hover — cascade and priority agree', () => { + expect(checkRule(rule([base('red'), pc(':hover', 'blue')])).ok).toBe(true); + }); + + test(':hover then :focus (source order matches ascending priority)', () => { + expect( + checkRule(rule([pc(':hover', 'blue'), pc(':focus', 'green')])).ok, + ).toBe(true); + }); + + test(':hover then base — base wins by cascade despite later source (lower specificity)', () => { + // Reversed source order, but :hover has higher specificity AND higher + // priority, so both systems agree :hover wins. + expect(checkRule(rule([pc(':hover', 'blue'), base('red')])).ok).toBe(true); + }); + + test('pseudo-element and base never compete (different boxes)', () => { + expect(checkRule(rule([base('red'), pe('::before', 'gray')])).ok).toBe( + true, + ); + }); + + test(':hover and ::before mix does not false-refuse (partitioned by target)', () => { + // Cascade orders ::before before :hover (b dominates), priority orders + // :hover before ::before — but they are different boxes, so partitioning + // must prevent a spurious conflict. + expect( + checkRule(rule([pc(':hover', 'blue'), pe('::before', 'gray')])).ok, + ).toBe(true); + }); + + test('base then media (source order matches priority)', () => { + expect(checkRule(rule([base('red'), media(MEDIA, 'green')])).ok).toBe(true); + }); +}); + +describe('checkRule — disagreement (refuses)', () => { + test(':focus then :hover — priority picks focus, source order picks hover', () => { + const result = checkRule( + rule([pc(':focus', 'green'), pc(':hover', 'blue')]), + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.conflicts[0]).toMatch(/disagree/); + } + }); + + test('media then base — priority picks media, cascade picks base (later, equal specificity)', () => { + const result = checkRule(rule([media(MEDIA, 'green'), base('red')])); + expect(result.ok).toBe(false); + }); + + test('≥2 sibling media queries on one property (sort-keys reorder hazard)', () => { + const result = checkRule( + rule([ + base('black'), + media('@media (min-width: 500px)', 'blue'), + media('@media (min-width: 700px)', 'green'), + ]), + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.conflicts[0]).toMatch(/sibling at-rule/); + } + }); + + test('a single media query alongside base is fine', () => { + expect(checkRule(rule([base('black'), media(MEDIA, 'blue')])).ok).toBe( + true, + ); + }); +}); + +describe('checkRule — dedup', () => { + test('same-coordinate duplicates collapse to the last in source order', () => { + const result = checkRule( + rule([pc(':hover', 'blue'), pc(':hover', 'navy')]), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.rule.atoms).toHaveLength(1); + expect(result.rule.atoms[0].value).toEqual({ + kind: 'static', + value: 'navy', + }); + } + }); + + test('distinct properties are refereed independently', () => { + const hover: Condition = { kind: 'pseudo-class', name: ':hover' }; + const focus: Condition = { kind: 'pseudo-class', name: ':focus' }; + // color agrees (hover→focus), but backgroundColor disagrees (focus→hover). + const mixed: StyleRule = { + name: 'x', + atoms: [ + { + property: 'color', + conditions: [hover], + value: { kind: 'static', value: 'blue' }, + }, + { + property: 'color', + conditions: [focus], + value: { kind: 'static', value: 'green' }, + }, + { + property: 'backgroundColor', + conditions: [focus], + value: { kind: 'static', value: 'white' }, + }, + { + property: 'backgroundColor', + conditions: [hover], + value: { kind: 'static', value: 'gray' }, + }, + ], + }; + const result = checkRule(mixed); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.conflicts.some((c) => c.includes('backgroundColor'))).toBe( + true, + ); + expect(result.conflicts.some((c) => c.includes("'color'"))).toBe(false); + } + }); +}); diff --git a/packages/@stylexjs/codemods/__tests__/rewriter-test.js b/packages/@stylexjs/codemods/__tests__/rewriter-test.js new file mode 100644 index 000000000..9ce8ea0fb --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/rewriter-test.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import { parseSource, printSource, parserForFile } from '../src/core/rewriter'; + +test('parse -> print with no mutation preserves the source byte-exactly', () => { + const source = [ + '/** @jsxImportSource @emotion/react */', + "import * as React from 'react';", + '', + 'export default function Badge() {', + " return
Badge
;", + '}', + '', + ].join('\n'); + expect(printSource(parseSource(source))).toEqual(source); +}); + +test('parses Flow-typed user code', () => { + const source = 'const x: number = 1;\n'; + expect(printSource(parseSource(source))).toEqual(source); +}); + +test('picks the TS parser for TypeScript files', () => { + expect(parserForFile('Component.tsx')).toEqual('tsx'); + expect(parserForFile('Component.ts')).toEqual('tsx'); + expect(parserForFile('Component.js')).toEqual('flow'); + expect(parserForFile('Component.jsx')).toEqual('flow'); +}); diff --git a/packages/@stylexjs/codemods/__tests__/robustness-test.js b/packages/@stylexjs/codemods/__tests__/robustness-test.js new file mode 100644 index 000000000..44aa7be14 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/robustness-test.js @@ -0,0 +1,161 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * M7 hardening: run a corpus of realistic and awkward inputs through the + * transform and assert the invariants that must hold on ANY input — + * (1) it never throws; + * (2) a `converted` result compiles through the babel-plugin and lints clean; + * (3) a `skipped`/`unchanged` result leaves the source byte-identical. + * This is the net that catches real-world surprises before v1.0. + */ + +import { transformEmotionFile } from '../src/adapters/emotion/transform'; +import { compileGate } from '../src/core/gates/compile'; +import { lintGate } from '../src/core/gates/lint'; + +const H = '/** @jsxImportSource @emotion/react */\n'; + +const CORPUS: Array<{ name: string, filename: string, source: string }> = [ + { + name: 'TypeScript (.tsx) input', + filename: 'a.tsx', + source: + H + + "import * as React from 'react';\n" + + 'type P = { title: string };\n' + + 'export function C({ title }: P): React.ReactNode {\n' + + " return
{title}
;\n" + + '}\n', + }, + { + name: 'flagged site inside a Fragment', + filename: 'b.jsx', + source: + H + + 'export function C() {\n' + + ' return (\n <>\n' + + " ok\n" + + ' \n' + + ' \n );\n}\n', + }, + { + name: 'arrow-function component + nested elements each with css', + filename: 'c.jsx', + source: + H + + 'const C = () => (\n
    \n' + + "
  • one
  • \n
\n);\n" + + 'export default C;\n', + }, + { + name: 'vendor-prefixed + kebab-case-ish properties', + filename: 'd.jsx', + source: + H + + 'export const C = () =>
x
;\n', + }, + { + name: 'source comments are preserved around a conversion', + filename: 'e.jsx', + source: + H + + '// leading comment\n' + + "export function C() {\n // inside\n return
x
;\n}\n", + }, + { + name: 'ternary css value is flagged, not crashed', + filename: 'f.jsx', + source: + H + + 'export function C({ on }) {\n' + + " return
x
;\n}\n", + }, + { + name: 'empty css object', + filename: 'g.jsx', + source: H + 'export const C = () =>
x
;\n', + }, + { + name: 'css with only an emotion label', + filename: 'h.jsx', + source: H + "export const C = () =>
x
;\n", + }, + { + name: 'two keyframes + a component using one', + filename: 'i.jsx', + source: + H + + "import { keyframes } from '@emotion/react';\n" + + 'const spin = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } });\n' + + "const pulse = keyframes({ '0%': { opacity: 1 }, '100%': { opacity: 0 } });\n" + + 'export const C = () => (\n' + + "
x
\n" + + ');\n', + }, + { + name: 'deeply nested condition (media > hover)', + filename: 'j.jsx', + source: + H + + 'export const C = () => (\n' + + " x\n" + + ');\n', + }, + { + name: 'no emotion at all (plain component)', + filename: 'k.jsx', + source: "export const C = () =>
x
;\n", + }, + { + name: 'css prop whose value references a module-scope object', + filename: 'l.jsx', + source: + H + + "const shared = { color: 'red' };\n" + + 'export const C = () =>
x
;\n', + }, +]; + +describe.each(CORPUS.map((c) => [c.name, c]))( + 'robustness: %s', + (_name, entry) => { + let result; + test('does not throw', () => { + expect(() => { + result = transformEmotionFile(entry.source, entry.filename); + }).not.toThrow(); + }); + + test('output is compilable and lint-clean, or source is untouched', () => { + const r = result ?? transformEmotionFile(entry.source, entry.filename); + if (r.status === 'converted') { + const compiled = compileGate(r.code, { filename: entry.filename }); + if (!compiled.ok) { + throw new Error(`does not compile:\n${compiled.errors.join('\n')}`); + } + const linted = lintGate(r.code, { filename: entry.filename }); + if (!linted.ok) { + throw new Error( + `not lint-clean:\n${JSON.stringify(linted.messages, null, 2)}`, + ); + } + // A conversion with NO leftover flagged sites must fully de-Emotion the + // file: no `@emotion/react` import, no `@jsxImportSource` pragma. + if (r.flags.length === 0) { + expect(r.code).not.toMatch(/@emotion\/react/); + expect(r.code).not.toMatch(/jsxImportSource/); + } + } else { + // skipped/unchanged must not mutate the source. + expect(r.status === 'skipped' || r.status === 'unchanged').toBe(true); + } + }); + }, +); diff --git a/packages/@stylexjs/codemods/__tests__/scoped-postprocess-test.js b/packages/@stylexjs/codemods/__tests__/scoped-postprocess-test.js new file mode 100644 index 000000000..602285a39 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/scoped-postprocess-test.js @@ -0,0 +1,82 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Proves the M4 scoping guarantee: the postprocess autofix runs only on the + * codemod's OWN emitted stylex (a standalone snippet), never on the user's + * pre-existing `stylex.create`. So a user's existing registry is either + * preserved exactly (when clean) or the file is refused (when the user's own + * code is lint-dirty) — never silently reordered. + */ + +import { transformEmotionFile } from '../src/adapters/emotion/transform'; + +const HEADER = + '/** @jsxImportSource @emotion/react */\n' + + "import * as React from 'react';\n" + + "import * as stylex from '@stylexjs/stylex';\n"; + +test('merge appends to a pre-existing registry and leaves the user entry untouched', () => { + const input = + HEADER + + 'const styles = stylex.create({\n' + + " card: { padding: '8px' },\n" + + '});\n' + + 'export default function C() {\n' + + " return
x
;\n" + + '}\n'; + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('converted'); + if (result.status === 'converted') { + // The user's `card` entry survives verbatim; ours is appended (named `c` + // after the enclosing component). + expect(result.code).toMatch(/card:\s*{\s*padding: '8px'\s*}/); + expect(result.code).toMatch(/stylex\.props\(styles\.c\)/); + // No second registry / duplicate import. + expect(result.code.match(/stylex\.create/g)).toHaveLength(1); + expect(result.code.match(/@stylexjs\/stylex/g)).toHaveLength(1); + } +}); + +test('a user registry with lint-dirty ordering is REFUSED, never silently reordered', () => { + // The user's `card` object has unsorted keys (zIndex before color) — a + // file-wide autofix would reorder them. Our scoped fix does not touch it, + // so the final verify flags it and we refuse rather than rewrite user code. + const input = + HEADER + + 'const styles = stylex.create({\n' + + " card: { zIndex: 1, color: 'red' },\n" + + '});\n' + + 'export default function C() {\n' + + " return
x
;\n" + + '}\n'; + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('skipped'); + if (result.status === 'skipped') { + expect(result.reasons.join('\n')).toMatch(/sort-keys/); + } +}); + +test('keys collide-safely with the pre-existing registry (numeric suffix)', () => { + // Our converted
would want the name `mixed`; the user already has it. + const input = + HEADER + + 'const styles = stylex.create({\n' + + " mixed: { padding: '8px' },\n" + + '});\n' + + 'export default function Mixed() {\n' + + " return
x
;\n" + + '}\n'; + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('converted'); + if (result.status === 'converted') { + expect(result.code).toMatch(/mixed2:/); // suffixed to avoid collision + expect(result.code).toMatch(/stylex\.props\(styles\.mixed2\)/); + } +}); diff --git a/packages/@stylexjs/codemods/__tests__/seam-test.js b/packages/@stylexjs/codemods/__tests__/seam-test.js new file mode 100644 index 000000000..7b766f914 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/seam-test.js @@ -0,0 +1,78 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Enforces the load-bearing architectural invariants from commit one: + * + * 1. `src/core/` never imports from `src/adapters/` — the neutral-IR seam. + * The day core needs an adapter, the seam has leaked; that is a + * regression, not a refactor. + * 2. Only `src/core/rewriter.js` may import the AST toolkit (jscodeshift), + * so the toolkit stays swappable behind one wrapper. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const SRC = path.join(__dirname, '..', 'src'); + +function jsFilesUnder(dir: string): Array { + const out: Array = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + // String(): Flow's Dirent libdef types `name` as string | Buffer. + const name = String(entry.name); + const full = path.join(dir, name); + if (entry.isDirectory()) { + out.push(...jsFilesUnder(full)); + } else if (name.endsWith('.js')) { + out.push(full); + } + } + return out; +} + +function importSpecifiers(source: string): Array { + const specifiers = []; + const pattern = /(?:from\s+|require\(\s*|import\(\s*)['"]([^'"]+)['"]/g; + for (;;) { + const match = pattern.exec(source); + if (match == null) { + break; + } + specifiers.push(match[1]); + } + return specifiers; +} + +test('src/core/ never imports from src/adapters/', () => { + for (const file of jsFilesUnder(path.join(SRC, 'core'))) { + for (const spec of importSpecifiers(fs.readFileSync(file, 'utf8'))) { + expect({ file: path.relative(SRC, file), imports: spec }).not.toEqual( + expect.objectContaining({ + imports: expect.stringMatching(/adapters/), + }), + ); + } + } +}); + +test('only core/rewriter.js imports the AST toolkit', () => { + for (const file of jsFilesUnder(SRC)) { + if (path.relative(SRC, file) === path.join('core', 'rewriter.js')) { + continue; + } + for (const spec of importSpecifiers(fs.readFileSync(file, 'utf8'))) { + expect({ file: path.relative(SRC, file), imports: spec }).not.toEqual( + expect.objectContaining({ + imports: expect.stringMatching(/^(jscodeshift|recast)/), + }), + ); + } + } +}); diff --git a/packages/@stylexjs/codemods/__tests__/utils/harness.js b/packages/@stylexjs/codemods/__tests__/utils/harness.js new file mode 100644 index 000000000..7d5ee3503 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/utils/harness.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +export const FIXTURES_DIR: string = path.join( + __dirname, + '..', + '..', + '__fixtures__', +); + +/** + * Normalizes code through the Prettier CLI so fixture comparison is + * byte-exact but formatting-insensitive. + * + * Deliberately the CLI via spawnSync, NOT the Node API: Prettier 3's Node + * API is async-only and breaks under Jest's module sandbox. + */ +export function formatWithPrettier(code: string, filepath: string): string { + // $FlowFixMe[cannot-resolve-module] - untyped bin file + const bin = require.resolve('prettier/bin/prettier.cjs'); + const result = spawnSync( + process.execPath, + [bin, '--stdin-filepath', filepath], + { input: code, encoding: 'utf8' }, + ); + if (result.status !== 0) { + throw new Error( + `prettier failed for ${filepath}:\n${String(result.stderr)}`, + ); + } + // String(): Flow types spawnSync stdout as string | Buffer. + return String(result.stdout); +} + +export type Fixture = { + name: string, + inputPath: string, + expectedPath: string, + input: string, + expected: string, +}; + +/** Discovers all input/expected fixture pairs for an adapter. */ +export function loadFixtures(adapter: string): Array { + const dir = path.join(FIXTURES_DIR, adapter); + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + // String(): Flow's Dirent libdef types `name` as string | Buffer. + const name = String(entry.name); + const inputPath = path.join(dir, name, 'input.js'); + const expectedPath = path.join(dir, name, 'expected.js'); + return { + name, + inputPath, + expectedPath, + input: fs.readFileSync(inputPath, 'utf8'), + expected: fs.readFileSync(expectedPath, 'utf8'), + }; + }); +} + +export function readBrokenFixture(name: string): string { + return fs.readFileSync(path.join(FIXTURES_DIR, 'broken', name), 'utf8'); +} diff --git a/packages/@stylexjs/codemods/docs/migrating-from-emotion.md b/packages/@stylexjs/codemods/docs/migrating-from-emotion.md new file mode 100644 index 000000000..208db5d71 --- /dev/null +++ b/packages/@stylexjs/codemods/docs/migrating-from-emotion.md @@ -0,0 +1,118 @@ +# Migrating from Emotion to StyleX + +This codemod mechanically migrates the common, provably-safe parts of an +Emotion (object-syntax) codebase to [StyleX](https://stylexjs.com), and leaves +a precise `// TODO(stylex-migration): …` marker on everything it can't convert +safely — so you can do the bulk automatically and work down a clear list of the +rest. + +Its guiding rule: **only convert what it can prove renders identically.** +Anything else is flagged in place or, for a genuinely file-level problem, +refused with a stated reason. It never produces confident-but-wrong output. + +## Quick start + +```sh +# 1. DRY RUN (the default) — preview a convert / refuse / TODO report; writes nothing: +stylex-codemod emotion "src/**/*.{jsx,tsx}" + +# 2. Read the report. Then apply: +stylex-codemod emotion "src/**/*.{jsx,tsx}" --write + +# 3. Search for the markers it left and finish those by hand: +grep -rn "TODO(stylex-migration)" src +``` + +The report classifies every file: + +- **convert** — rewritten to StyleX (a `+N TODO` suffix means N sites in that + file were left flagged for you). +- **refuse** — a whole-file structural issue (with the reason); left untouched. +- **skip** — no Emotion, or nothing to do. + +## What it converts + +- The `css` prop as an object: `
` and + `
`, on host (lowercase) elements. +- Self-targeting conditions: pseudo-classes (`:hover`, `:focus`), + pseudo-elements (`::before`), media queries, and their nesting. +- Multi-value `margin`/`padding` shorthands (`'8px 16px'`), expanded to + StyleX's canonical longhands. +- Object-form `keyframes({ … })` → `stylex.keyframes({ … })`, referenced via + `animationName`. +- Fallback arrays (`position: ['sticky', 'fixed']`). +- Merges into a file's pre-existing `stylex.create` without touching your + existing entries. + +Two conversions are **sanctioned, intentional changes** (they render +identically in left-to-right, and are the correct StyleX idiom): + +- **Physical → logical properties**: `marginLeft` → `marginInlineStart`, etc. + This makes the result right-to-left correct. Opt out with + `logicalProperties: false`. +- **Hover-guard**: `:hover` is wrapped in `@media (hover: hover)` so hover + styles don't stick on touch devices. Opt out with `hoverGuard: false`. + +## What it flags (leaves in place with a TODO) + +These are left exactly as they were, with a marker, because converting them +safely needs a human: + +- template-literal styles (`` css`…` ``) +- dynamic / props-driven values (`css={{ color: props.color }}`) +- selectors that reach outside the element (`& > li`, descendant/child) +- `!important` +- cascades where Emotion's source order and StyleX's priority disagree +- theme tokens (`theme.colors.primary`) — see *Design tokens* below + +## What it refuses (whole file) + +- a file importing StyleX in a non-namespace form (`import { create } …`) — + it can only merge into `import * as stylex` +- a file with two or more `stylex.create` registries +- an unconvertible `keyframes` (e.g. a tagged template) + +## Config + +Optional `stylex-codemod.config.js` (or `--config `): + +```js +module.exports = { + hoverGuard: true, // wrap :hover in @media (hover: hover) — default true + logicalProperties: true, // map marginLeft -> marginInlineStart, etc. — default true +}; +``` + +## Design tokens + +Theme tokens (`theme.colors.primary`) are **flagged, not converted**, in this +version. A token's value lives in your theme, outside the file the codemod is +reading, so the codemod can't know it — and, unlike everything else, a token +conversion can't be proven correct by comparing before/after CSS (the value is +external). Automatic token → `stylex.defineVars` conversion is planned as a +later, config-driven feature paired with a rendered-output check. For now: +define your StyleX variables first, then wire up the flagged references. (This +mirrors how the styled-components → StyleX migration at Linear was done.) + +## Known limitations + +- **TypeScript is best-effort.** `.ts`/`.tsx` files convert for the common + cases, but files using TypeScript-specific syntax the Flow-based lint gate + can't parse (enums, `satisfies`, non-null `!`, etc.) are safely skipped rather + than converted. JavaScript, JSX, and Flow are the fully-verified path. +- **`styled(Component)`**, `@emotion/styled`, ``, `injectGlobal`, + `cx`/composition, and `shouldForwardProp` are out of scope for this version + (files using them are flagged or refused, never converted incorrectly). +- **Cross-file** styles (a `css`/`keyframes` value imported from another file) + are flagged, not followed. +- **Per-site keyframe flagging**: an unconvertible `keyframes` currently refuses + the whole file rather than flagging just that declaration. + +## How to think about a run + +1. Dry-run and skim the report — the ratio of convert/flag/refuse tells you how + mechanical your migration is. +2. `--write`, commit the mechanical diff on its own. +3. Work down the `TODO(stylex-migration)` markers; each is a specific, + self-contained thing to convert by hand. +4. Remove the Emotion dependency once the markers are gone. diff --git a/packages/@stylexjs/codemods/flow_modules/@emotion/serialize/index.js.flow b/packages/@stylexjs/codemods/flow_modules/@emotion/serialize/index.js.flow new file mode 100644 index 000000000..9fb1ac0bd --- /dev/null +++ b/packages/@stylexjs/codemods/flow_modules/@emotion/serialize/index.js.flow @@ -0,0 +1,17 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +// Minimal type stub for `@emotion/serialize` (test-only devDependency; +// the semantic-diff gate's "before" ground truth). + +declare export function serializeStyles( + args: $ReadOnlyArray, + registered?: mixed, + props?: mixed, +): { +name: string, +styles: string, ... }; diff --git a/packages/@stylexjs/codemods/flow_modules/eslint/index.js.flow b/packages/@stylexjs/codemods/flow_modules/eslint/index.js.flow new file mode 100644 index 000000000..5df2bfdb0 --- /dev/null +++ b/packages/@stylexjs/codemods/flow_modules/eslint/index.js.flow @@ -0,0 +1,37 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +// Minimal type stub for the parts of `eslint` the codemod uses. The real +// package's entry point lives under `lib/`, which this repo's .flowconfig +// ignores, so the module is unresolvable without a stub. + +export type LinterMessage = { + +ruleId: string | null, + +message: string, + +severity: number, + +line?: number, + +column?: number, + +fix?: mixed, + ... +}; + +declare export class Linter { + defineParser(name: string, parser: mixed): void; + defineRule(name: string, rule: mixed): void; + verify( + code: string, + config: mixed, + options?: mixed, + ): Array; + verifyAndFix( + code: string, + config: mixed, + options?: mixed, + ): { +fixed: boolean, +output: string, +messages: Array }; +} diff --git a/packages/@stylexjs/codemods/flow_modules/hermes-eslint/index.js.flow b/packages/@stylexjs/codemods/flow_modules/hermes-eslint/index.js.flow new file mode 100644 index 000000000..a0488e0b0 --- /dev/null +++ b/packages/@stylexjs/codemods/flow_modules/hermes-eslint/index.js.flow @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +// Minimal type stub for `hermes-eslint` (its compiled entry lives under +// `dist/`, which this repo's .flowconfig ignores). The module is +// ESM-compiled with NO default export — always use a namespace import; +// the namespace itself is the ESLint parser object. + +declare export function parseForESLint( + code: string, + options?: mixed, +): { +ast: mixed, +scopeManager: mixed, ... }; + +declare export function parse(code: string, options?: mixed): mixed; diff --git a/packages/@stylexjs/codemods/package.json b/packages/@stylexjs/codemods/package.json new file mode 100644 index 000000000..2eb64ffcb --- /dev/null +++ b/packages/@stylexjs/codemods/package.json @@ -0,0 +1,44 @@ +{ + "name": "@stylexjs/codemods", + "version": "0.19.0", + "description": "Codemods for migrating styling libraries (Emotion first) to StyleX", + "main": "./lib/index.js", + "repository": "https://www.github.com/facebook/stylex", + "license": "MIT", + "private": true, + "scripts": { + "prebuild": "gen-types -i src/ -o lib/", + "build": "cross-env BABEL_ENV=cjs babel src/ --out-dir lib/ --copy-files", + "test": "jest" + }, + "bin": { + "stylex-codemod": "./lib/cli/index.js" + }, + "dependencies": { + "@babel/core": "^7.28.5", + "@stylexjs/babel-plugin": "0.19.0", + "@stylexjs/eslint-plugin": "0.19.0", + "@stylexjs/shared": "0.19.0", + "babel-plugin-syntax-hermes-parser": "0.36.1", + "eslint": "8.57.1", + "fast-glob": "^3.3.3", + "hermes-eslint": "0.36.1", + "jscodeshift": "^17.3.0", + "style-value-parser": "0.19.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@babel/preset-typescript": "^7.28.5", + "@emotion/serialize": "^1.3.3", + "scripts": "0.19.0" + }, + "jest": { + "testPathIgnorePatterns": [ + "/__fixtures__/", + "/__tests__/utils/" + ] + }, + "files": [ + "lib/*" + ] +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/README.md b/packages/@stylexjs/codemods/src/adapters/emotion/README.md new file mode 100644 index 000000000..c6a726039 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/README.md @@ -0,0 +1,9 @@ +# Emotion adapter (reader) + +Lands from **M1**. This directory will hold the library-specific side of the +seam: `detect.js` (find Emotion style sites), `read.js` (style sites -> +neutral `declarations`), `rewriteSites.js` (binding map -> `stylex.props` +call sites), and `imports.js` (Emotion import/pragma cleanup). + +Invariant: `src/core/` never imports from this directory. The seam is +enforced by `__tests__/seam-test.js`. diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/detect.js b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js new file mode 100644 index 000000000..ed851827a --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js @@ -0,0 +1,298 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L2 — Detect. Finds every Emotion style site in the file and everything + * that makes the file unconvertible. M1 policy (user-ratified): a file is + * converted only if EVERY style site is convertible; a single blocker + * skips the whole file untouched. + * + * Convertible site forms: + *
— object literal + *
— inline `css()` call around an object + * on host (lowercase) elements with no `className`/`style`/spread props. + */ + +import { REASONS } from '../../core/todos'; + +export type ConvertibleSite = { + +kind: 'convertible', + +attrPath: $FlowFixMe, // JSXAttribute path + +objectNode: $FlowFixMe, // the style ObjectExpression + +tagName: string, +}; +export type FlaggedSite = { + +kind: 'flagged', + +attrPath: $FlowFixMe, + +tagName: string, + +reason: string, +}; +export type StyleSite = ConvertibleSite | FlaggedSite; + +export type Detection = { + +sites: Array, + +blockers: Array, +}; + +/** + * Classifies every `css` prop as convertible (a static object) or flagged + * (with a reason). M5: a flagged site no longer skips the file — it gets a + * `// TODO(stylex-migration): …` marker while the rest of the file converts. + */ +export function detectSites( + j: $FlowFixMe, + root: $FlowFixMe, + cssLocalName: string | null, +): Detection { + const sites: Array = []; + const blockers: Array = []; + + root + .find(j.JSXAttribute, { name: { name: 'css' } }) + .forEach((path: $FlowFixMe) => { + const opening = path.parent.node; + const nameNode = opening.name; + const tagName = String(nameNode.name ?? '?'); + const flag = (reason: string) => + sites.push({ kind: 'flagged', attrPath: path, tagName, reason }); + + if (nameNode.type !== 'JSXIdentifier' || !/^[a-z]/.test(tagName)) { + flag(REASONS.componentElement); + return; + } + const conflicting = (opening.attributes ?? []).find( + (attr: $FlowFixMe) => + attr.type === 'JSXSpreadAttribute' || + attr.name?.name === 'className' || + attr.name?.name === 'style', + ); + if (conflicting != null) { + flag(REASONS.propConflict); + return; + } + const container = path.node.value; + if (container?.type !== 'JSXExpressionContainer') { + flag('css prop is not an expression'); + return; + } + const expression = container.expression; + if (expression.type === 'ObjectExpression') { + sites.push({ + kind: 'convertible', + attrPath: path, + objectNode: expression, + tagName, + }); + return; + } + if ( + expression.type === 'CallExpression' && + expression.callee.type === 'Identifier' && + cssLocalName != null && + expression.callee.name === cssLocalName && + expression.arguments.length === 1 && + expression.arguments[0].type === 'ObjectExpression' + ) { + sites.push({ + kind: 'convertible', + attrPath: path, + objectNode: expression.arguments[0], + tagName, + }); + return; + } + flag( + expression.type === 'TaggedTemplateExpression' + ? REASONS.templateLiteral + : REASONS.dynamicValue, + ); + }); + + return { sites, blockers }; +} + +export type ExistingRegistry = { + +objectNode: $FlowFixMe, // the ObjectExpression passed to stylex.create + +varName: string, // the `const = stylex.create(...)` binding + +keys: $ReadOnlySet, // existing style-name keys +}; + +export type RegistryDetection = { + +registry: ExistingRegistry | null, + +stylexImported: boolean, + +blockers: Array, +}; + +/** + * Finds a pre-existing `import * as stylex` + `const X = stylex.create({...})` + * so new styles can be MERGED into it (M4) rather than the file being refused. + * Anything non-standard (stylex imported by a name other than a namespace, + * ≥2 creates, a non-object/non-const create) is a blocker. + */ +export function detectExistingRegistry( + j: $FlowFixMe, + root: $FlowFixMe, +): RegistryDetection { + const blockers: Array = []; + + const stylexImport = root + .find(j.ImportDeclaration) + .filter( + (path: $FlowFixMe) => + String(path.node.source.value) === '@stylexjs/stylex', + ); + if (stylexImport.size() === 0) { + return { registry: null, stylexImported: false, blockers }; + } + + const specifiers = stylexImport.paths()[0].node.specifiers ?? []; + const namespace = specifiers.find( + (s: $FlowFixMe) => s.type === 'ImportNamespaceSpecifier', + ); + if (specifiers.length !== 1 || namespace == null) { + blockers.push( + 'file imports @stylexjs/stylex in a non-namespace form (merge needs `import * as stylex`)', + ); + return { registry: null, stylexImported: true, blockers }; + } + const stylexLocal = namespace.local.name; + + const registries: Array = []; + root + .find(j.CallExpression) + .filter( + (path: $FlowFixMe) => + path.node.callee.type === 'MemberExpression' && + path.node.callee.object.type === 'Identifier' && + path.node.callee.object.name === stylexLocal && + path.node.callee.property.name === 'create', + ) + .forEach((path: $FlowFixMe) => { + const declarator = path.parent.node; + const arg = path.node.arguments[0]; + if ( + declarator.type !== 'VariableDeclarator' || + declarator.id.type !== 'Identifier' || + arg?.type !== 'ObjectExpression' + ) { + blockers.push( + 'pre-existing stylex.create is not a simple const object', + ); + return; + } + const keys = new Set(); + for (const prop of arg.properties) { + if (prop.key?.type === 'Identifier') { + keys.add(prop.key.name); + } else if ( + prop.key?.type === 'Literal' || + prop.key?.type === 'StringLiteral' + ) { + keys.add(String(prop.key.value)); + } + } + registries.push({ objectNode: arg, varName: declarator.id.name, keys }); + }); + + if (registries.length > 1) { + blockers.push('file has ≥2 stylex.create registries (merge targets one)'); + return { registry: null, stylexImported: true, blockers }; + } + + return { + registry: registries[0] ?? null, + stylexImported: true, + blockers, + }; +} + +export type KeyframesSite = { + +callPath: $FlowFixMe, // CallExpression path (keyframes({...})) + +objectNode: $FlowFixMe, // the frames ObjectExpression + +varName: string, // the `const = keyframes(...)` binding +}; + +export type KeyframesDetection = { + +sites: Array, + +names: Set, + +blockers: Array, +}; + +/** + * Finds `const NAME = keyframes({ ... })` declarations. Only the object form + * bound to a simple `const` is convertible; anything else (tagged template, + * inline, reassignment) is a blocker. + */ +export function detectKeyframes( + j: $FlowFixMe, + root: $FlowFixMe, + keyframesLocalName: string | null, +): KeyframesDetection { + const sites: Array = []; + const names: Set = new Set(); + const blockers: Array = []; + if (keyframesLocalName == null) { + return { sites, names, blockers }; + } + + const siteCallees = new Set<$FlowFixMe>(); + root + .find(j.CallExpression) + .filter( + (path: $FlowFixMe) => + path.node.callee.type === 'Identifier' && + path.node.callee.name === keyframesLocalName, + ) + .forEach((path: $FlowFixMe) => { + const declarator = path.parent.node; + const isSimpleConstBinding = + declarator.type === 'VariableDeclarator' && + declarator.init === path.node && + declarator.id.type === 'Identifier'; + const arg = path.node.arguments[0]; + if ( + !isSimpleConstBinding || + path.node.arguments.length !== 1 || + arg.type !== 'ObjectExpression' + ) { + blockers.push( + 'keyframes() must be an object bound to a const to convert ' + + '(tagged-template or inline keyframes land later)', + ); + return; + } + siteCallees.add(path.node.callee); + names.add(declarator.id.name); + sites.push({ + callPath: path, + objectNode: arg, + varName: declarator.id.name, + }); + }); + + // Any other use of the keyframes identifier (not a convertible call, not + // the import) means we cannot fully remove it — refuse. + root + .find(j.Identifier, { name: keyframesLocalName }) + .forEach((path: $FlowFixMe) => { + const parentNode = path.parent.node; + if ( + parentNode.type === 'ImportSpecifier' || + siteCallees.has(path.node) || + (parentNode.type === 'Property' && parentNode.key === path.node) + ) { + return; + } + blockers.push( + `'${keyframesLocalName}' is used in an unsupported position`, + ); + }); + + return { sites, names, blockers }; +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/imports.js b/packages/@stylexjs/codemods/src/adapters/emotion/imports.js new file mode 100644 index 000000000..ab19203aa --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/imports.js @@ -0,0 +1,199 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Emotion wiring: how a file opts into the css prop. M1 recognizes the + * modern `@emotion/react` forms only — the `@jsxImportSource` pragma and a + * named `css` import. Any other `@emotion/*` surface (styled, class-based + * `@emotion/css`, Global, keyframes, classic `jsx` pragma) is a blocker: + * we skip the whole file rather than half-migrate it. + */ + +const PRAGMA_PATTERN = /@jsxImportSource\s+@emotion\/react/; + +export type EmotionWiring = { + +hasPragma: boolean, + +cssLocalName: string | null, + +keyframesLocalName: string | null, + +blockers: Array, +}; + +// Named @emotion/react imports the adapter knows how to convert. +const CONVERTIBLE_IMPORTS = new Set(['css', 'keyframes']); + +export function analyzeEmotionWiring( + j: $FlowFixMe, + root: $FlowFixMe, +): EmotionWiring { + let cssLocalName: string | null = null; + let keyframesLocalName: string | null = null; + const blockers: Array = []; + + root.find(j.ImportDeclaration).forEach((path: $FlowFixMe) => { + const source = String(path.node.source.value); + if (!source.startsWith('@emotion/')) { + return; + } + if (source !== '@emotion/react') { + blockers.push(`import from '${source}' is not convertible yet`); + return; + } + for (const specifier of path.node.specifiers ?? []) { + const imported = + specifier.type === 'ImportSpecifier' ? specifier.imported.name : null; + if (imported === 'css') { + cssLocalName = specifier.local.name; + } else if (imported === 'keyframes') { + keyframesLocalName = specifier.local.name; + } else { + blockers.push( + `'@emotion/react' import of '${ + imported ?? specifier.local?.name ?? '?' + }' is not convertible yet`, + ); + } + } + }); + + return { + hasPragma: PRAGMA_PATTERN.test(findPragmaText(j, root) ?? ''), + cssLocalName, + keyframesLocalName, + blockers, + }; +} + +// The pragma can attach to the program or to whichever top-level statement +// leads the file — and that statement's index shifts once we insert the stylex +// import. Scanning every top-level statement finds it regardless of position. +function allCommentSlots(j: $FlowFixMe, root: $FlowFixMe): Array<$FlowFixMe> { + const program = root.get().node.program; + return [program, ...program.body]; +} + +function findPragmaText(j: $FlowFixMe, root: $FlowFixMe): string | null { + for (const slot of allCommentSlots(j, root)) { + for (const comment of slot.comments ?? []) { + if (PRAGMA_PATTERN.test(comment.value)) { + return comment.value; + } + } + } + return null; +} + +/** + * Removes the `@jsxImportSource @emotion/react` pragma — but only when no + * `css` prop remains (a flagged, still-Emotion site needs the pragma). + */ +export function removePragma(j: $FlowFixMe, root: $FlowFixMe): void { + if (root.find(j.JSXAttribute, { name: { name: 'css' } }).size() > 0) { + return; + } + for (const slot of allCommentSlots(j, root)) { + if (slot.comments != null) { + slot.comments = slot.comments.filter( + (comment: $FlowFixMe) => !PRAGMA_PATTERN.test(comment.value), + ); + } + } +} + +/** + * Whether an identifier name is still referenced AS A VALUE — not as its own + * import specifier, a member-access property (`stylex.keyframes`), or an + * object key. Without those exclusions the newly-emitted `stylex.keyframes` + * property would be mistaken for a use of the Emotion `keyframes` import. + */ +function isStillReferenced( + j: $FlowFixMe, + root: $FlowFixMe, + name: string, +): boolean { + return ( + root + .find(j.Identifier, { name }) + .filter((path: $FlowFixMe) => { + const parent = path.parent.node; + if (parent.type === 'ImportSpecifier') { + return false; + } + if ( + parent.type === 'MemberExpression' && + parent.property === path.node && + !parent.computed + ) { + return false; + } + if ( + (parent.type === 'Property' || parent.type === 'ObjectProperty') && + parent.key === path.node && + !parent.computed + ) { + return false; + } + return true; + }) + .size() > 0 + ); +} + +/** + * Removes the converted specifiers (`css`, `keyframes`) from the + * `@emotion/react` import — but only those whose local name is no longer + * referenced (a flagged tagged-template still using `css` keeps it). Drops the + * whole declaration when nothing remains, transplanting any non-pragma + * comments onto the next statement so file headers survive. + */ +export function removeCssImport(j: $FlowFixMe, root: $FlowFixMe): void { + root.find(j.ImportDeclaration).forEach((path: $FlowFixMe) => { + if (String(path.node.source.value) !== '@emotion/react') { + return; + } + const remaining = (path.node.specifiers ?? []).filter( + (specifier: $FlowFixMe) => + !( + specifier.type === 'ImportSpecifier' && + CONVERTIBLE_IMPORTS.has(specifier.imported.name) && + !isStillReferenced(j, root, specifier.local.name) + ), + ); + if (remaining.length > 0) { + path.node.specifiers = remaining; + return; + } + const comments = (path.node.comments ?? []).filter( + (comment: $FlowFixMe) => + comment.leading === true && !PRAGMA_PATTERN.test(comment.value), + ); + const next = path.parent.node.body[path.name + 1]; + if (comments.length > 0 && next != null) { + next.comments = [...comments, ...(next.comments ?? [])]; + } + j(path).remove(); + }); +} + +/** + * Inserts `import * as stylex from '@stylexjs/stylex';` after the last + * import. Detection of a pre-existing StyleX import is a blocker upstream + * (registry merge lands in M4), so this never duplicates. + */ +export function insertStylexImport(j: $FlowFixMe, root: $FlowFixMe): void { + const declaration = j.importDeclaration( + [j.importNamespaceSpecifier(j.identifier('stylex'))], + j.literal('@stylexjs/stylex'), + ); + const imports = root.find(j.ImportDeclaration); + if (imports.size() > 0) { + j(imports.paths()[imports.size() - 1]).insertAfter(declaration); + } else { + root.get().node.program.body.unshift(declaration); + } +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/read.js b/packages/@stylexjs/codemods/src/adapters/emotion/read.js new file mode 100644 index 000000000..2f819cc93 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/read.js @@ -0,0 +1,392 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L3 — Read. Walks a detected style site's ObjectExpression into neutral + * declarations (the first seam hand-off). No StyleX knowledge. + * + * M2 scope: flat static values AND self-targeting conditions — pseudo-classes + * (`:hover`), pseudo-elements (`::before`), media queries (`@media`), and + * their nesting. Refuses (whole file, per M1 policy) anything not provably + * self-targeting: descendant/combinator/class selectors, functional pseudos + * (`:not(...)`), conditions nested inside a pseudo-element, and — until the + * M3 normalizer expands shorthands — a shorthand/longhand overlap the M2 + * referee cannot yet arbitrate. + */ + +import type { Atom, Condition, KeyframesRule, Value } from '../../core/ir'; +import type { Declaration } from '../../core/buildIR'; +import { atomCoordinate } from '../../core/ir'; +import type { StyleSite } from './detect'; + +export type PlainStyleObject = { +[string]: mixed }; + +export type ReadSite = + | { + +ok: true, + +declarations: Array, + +cssObject: PlainStyleObject, + +nameHint: string, + } + | { +ok: false, +blocker: string }; + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const SIMPLE_PSEUDO_CLASS = /^:[a-zA-Z][a-zA-Z-]*$/; +const SIMPLE_PSEUDO_ELEMENT = /^::[a-zA-Z][a-zA-Z-]*$/; +const AT_RULE = /^@(media|supports|container)\b/; + +type Classified = + | { +role: 'condition', +condition: Condition, +normalizedKey: string } + | { +role: 'refuse', +reason: string }; + +/** Classifies an object-valued key as a self-targeting condition or a + * refusal. Strips a leading `&` (Emotion self-reference). */ +function classifyConditionKey(rawKey: string): Classified { + let key = rawKey.trim(); + if (key.startsWith('&')) { + key = key.slice(1).trim(); + } + if (key === '') { + return { role: 'refuse', reason: 'bare `&` self-reference' }; + } + if (AT_RULE.test(key)) { + return { + role: 'condition', + condition: { kind: 'at-rule', rule: key }, + normalizedKey: key, + }; + } + if (key.startsWith('@')) { + return { role: 'refuse', reason: `unsupported at-rule '${rawKey}'` }; + } + if (SIMPLE_PSEUDO_ELEMENT.test(key)) { + return { + role: 'condition', + condition: { kind: 'pseudo-element', name: key }, + normalizedKey: key, + }; + } + if (SIMPLE_PSEUDO_CLASS.test(key)) { + return { + role: 'condition', + condition: { kind: 'pseudo-class', name: key }, + normalizedKey: key, + }; + } + if (key.startsWith(':')) { + return { + role: 'refuse', + reason: `functional or complex pseudo-selector '${rawKey}'`, + }; + } + return { + role: 'refuse', + reason: `selector '${rawKey}' is not self-targeting`, + }; +} + +function literalValue(node: $FlowFixMe): string | number | null { + if ( + (node.type === 'Literal' || + node.type === 'StringLiteral' || + node.type === 'NumericLiteral') && + (typeof node.value === 'string' || typeof node.value === 'number') + ) { + return node.value; + } + if (node.type === 'UnaryExpression' && node.operator === '-') { + const inner = literalValue(node.argument); + if (typeof inner === 'number') { + return -inner; + } + } + return null; +} + +/** A property's value: a static literal, or a fallback array of them. */ +function valueOf(node: $FlowFixMe): Value | null { + const literal = literalValue(node); + if (literal != null) { + return { kind: 'static', value: literal }; + } + if (node.type === 'ArrayExpression' && node.elements.length > 0) { + const values: Array = []; + for (const element of node.elements) { + const v = literalValue(element); + if (v == null) { + return null; + } + values.push(v); + } + return { kind: 'first-that-works', values }; + } + return null; +} + +function propertyKey(node: $FlowFixMe): string | null { + if (node.type === 'Identifier') { + return node.name; + } + if ( + (node.type === 'Literal' || node.type === 'StringLiteral') && + typeof node.value === 'string' + ) { + return node.value; + } + return null; +} + +function plainOf(value: Value): mixed { + return value.kind === 'first-that-works' ? [...value.values] : value.value; +} + +/** Conservative shorthand/longhand overlap within one condition group: + * `marginTop` extends `margin`. False positives only skip a file (safe); + * the M2 referee + M3 normalizer replace this with real priority data. */ +function findShorthandOverlap(properties: Array): string | null { + for (const a of properties) { + for (const b of properties) { + if (b.length > a.length && b.startsWith(a) && /[A-Z]/.test(b[a.length])) { + return `'${a}' + '${b}'`; + } + } + } + return null; +} + +export function readSite( + site: StyleSite, + keyframesNames?: $ReadOnlySet, +): ReadSite { + const knownKeyframes = keyframesNames ?? new Set(); + const declarations: Array = []; + const cssObject: { [string]: mixed } = {}; + let label: string | null = null; + + const walk = ( + objectNode: $FlowFixMe, + conditions: $ReadOnlyArray, + insidePseudoElement: boolean, + mirror: { [string]: mixed }, + ): string | null => { + for (const property of objectNode.properties) { + if (property.type !== 'Property' && property.type !== 'ObjectProperty') { + return 'spread in style object'; + } + if (property.computed) { + return 'computed key in style object'; + } + const key = propertyKey(property.key); + if (key == null) { + return 'un-analyzable style key'; + } + const valueNode = property.value; + + if (valueNode.type === 'ObjectExpression') { + const classified = classifyConditionKey(key); + if (classified.role === 'refuse') { + return classified.reason; + } + if (insidePseudoElement) { + return `condition '${key}' nested inside a pseudo-element`; + } + const nested: { [string]: mixed } = {}; + mirror[classified.normalizedKey] = nested; + // insidePseudoElement is always false here (we returned above if it + // was true), so the new flag is just whether THIS key is one. + const blocker = walk( + valueNode, + [...conditions, classified.condition], + classified.condition.kind === 'pseudo-element', + nested, + ); + if (blocker != null) { + return blocker; + } + continue; + } + + if (key === 'label' && conditions.length === 0) { + const literal = literalValue(valueNode); + if (typeof literal === 'string') { + label = literal; + continue; // debugging metadata, not a CSS declaration + } + } + + if (!IDENTIFIER.test(key) && !/^[a-zA-Z-]+$/.test(key)) { + return `un-convertible style key '${key}'`; + } + + // `animationName: ` — a reference, not a static value. + // Omitted from the mirror; the keyframes content is diffed separately + // and the generated animation-name is allowlisted. + if ( + key === 'animationName' && + valueNode.type === 'Identifier' && + knownKeyframes.has(valueNode.name) + ) { + declarations.push({ + property: key, + value: { kind: 'reference', name: valueNode.name }, + conditions, + }); + continue; + } + + const value = valueOf(valueNode); + if (value == null) { + return `dynamic value (props-driven) on '${key}'`; + } + declarations.push({ property: key, value, conditions }); + mirror[key] = plainOf(value); + } + return null; + }; + + const blocker = walk(site.objectNode, [], false, cssObject); + if (blocker != null) { + return { ok: false, blocker }; + } + + if (declarations.length === 0) { + return { ok: false, blocker: 'empty style object' }; + } + + // Duplicate-coordinate and shorthand-overlap checks, per condition group. + const byCoordinate: Map> = new Map(); + for (const declaration of declarations) { + const coord = atomCoordinate( + declaration.property, + declaration.conditions ?? [], + ); + const group = byCoordinate.get(coord) ?? []; + group.push(declaration.property); + byCoordinate.set(coord, group); + } + const seenProps: Map> = new Map(); + for (const declaration of declarations) { + const conditionKey = (declaration.conditions ?? []) + .map((c) => atomCoordinate('', [c])) + .join('&&'); + const set = seenProps.get(conditionKey) ?? new Set(); + if (set.has(declaration.property)) { + return { + ok: false, + blocker: `duplicate style key '${declaration.property}'`, + }; + } + set.add(declaration.property); + seenProps.set(conditionKey, set); + } + for (const [, group] of seenProps) { + const overlap = findShorthandOverlap([...group]); + if (overlap != null) { + return { + ok: false, + blocker: `shorthand/longhand overlap (${overlap})`, + }; + } + } + + return { + ok: true, + declarations, + cssObject, + nameHint: label ?? enclosingComponentName(site) ?? site.tagName, + }; +} + +export type ReadKeyframes = + | { + +ok: true, + +rule: KeyframesRule, + +framesObject: { +[selector: string]: { +[string]: mixed } }, + } + | { +ok: false, +blocker: string }; + +const FRAME_SELECTOR = /^(from|to|-?\d+(\.\d+)?%)$/; + +/** Reads a `keyframes({ from: {...}, to: {...} })` object into a + * KeyframesRule plus a plain mirror (for the semantic-diff "before"). Frames + * are flat style objects — nested conditions inside a frame are refused. */ +export function readKeyframes( + varName: string, + objectNode: $FlowFixMe, +): ReadKeyframes { + const frames: Array<{ selector: string, atoms: Array }> = []; + const framesObject: { [selector: string]: { [string]: mixed } } = {}; + + for (const property of objectNode.properties) { + if (property.type !== 'Property' && property.type !== 'ObjectProperty') { + return { ok: false, blocker: 'spread in keyframes object' }; + } + const selector = propertyKey(property.key); + if (selector == null || !FRAME_SELECTOR.test(selector)) { + return { + ok: false, + blocker: `keyframes selector '${String(selector)}' is not from/to/%`, + }; + } + if (property.value.type !== 'ObjectExpression') { + return { + ok: false, + blocker: `keyframes frame '${selector}' is not an object`, + }; + } + const atoms: Array = []; + const mirror: { [string]: mixed } = {}; + for (const decl of property.value.properties) { + if (decl.type !== 'Property' && decl.type !== 'ObjectProperty') { + return { ok: false, blocker: 'spread in keyframes frame' }; + } + const key = propertyKey(decl.key); + if (key == null || decl.computed) { + return { + ok: false, + blocker: `un-analyzable key in frame '${selector}'`, + }; + } + const value = valueOf(decl.value); + if (value == null) { + return { + ok: false, + blocker: `value of '${key}' in frame '${selector}' is not static`, + }; + } + atoms.push({ property: key, conditions: [], value }); + mirror[key] = plainOf(value); + } + frames.push({ selector, atoms }); + framesObject[selector] = mirror; + } + + if (frames.length === 0) { + return { ok: false, blocker: 'empty keyframes object' }; + } + return { ok: true, rule: { name: varName, frames }, framesObject }; +} + +function enclosingComponentName(site: StyleSite): string | null { + for (let path = site.attrPath; path != null; path = path.parent) { + const node = path.node; + if (node.type === 'FunctionDeclaration' && node.id?.name != null) { + return node.id.name; + } + if ( + node.type === 'VariableDeclarator' && + node.id?.type === 'Identifier' && + (node.init?.type === 'ArrowFunctionExpression' || + node.init?.type === 'FunctionExpression') + ) { + return node.id.name; + } + } + return null; +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/rewriteSites.js b/packages/@stylexjs/codemods/src/adapters/emotion/rewriteSites.js new file mode 100644 index 000000000..3c47762e4 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/rewriteSites.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L8 — Rewrite. Consumes the binding map (the second seam hand-off) and + * swaps each Emotion css prop for `{...stylex.props(styles.key)}` in that + * site's place. + */ + +import type { StyleSite } from './detect'; + +export function rewriteSite( + j: $FlowFixMe, + site: StyleSite, + stylesLocalName: string, + key: string, +): void { + const spread = j.jsxSpreadAttribute( + j.callExpression( + j.memberExpression(j.identifier('stylex'), j.identifier('props')), + [j.memberExpression(j.identifier(stylesLocalName), j.identifier(key))], + ), + ); + j(site.attrPath).replaceWith(spread); +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js new file mode 100644 index 000000000..d874ea583 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js @@ -0,0 +1,488 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * The Emotion -> StyleX pipeline for one file: detect -> read (adapter), + * buildIR -> emit (core), rewrite -> registry -> imports (adapter). + * + * M5 policy (bail loudly, per site): every convertible css site is rewritten + * and each unconvertible one is left in place with a + * `// TODO(stylex-migration): …` marker — the file is no longer skipped + * wholesale. Genuinely file-level structural issues (a non-namespace stylex + * import, ≥2 registries, an unconvertible keyframes) still refuse the whole + * file with stated reasons. Nothing is ever silently dropped or guessed. + */ + +import { buildFileIR } from '../../core/buildIR'; +import { emitFileIR } from '../../core/emit'; +import type { + EmittedRule, + EmittedStyle, + EmittedKeyframes, +} from '../../core/emit'; +import { normalizeFileIR } from '../../core/normalize'; +import { postprocess } from '../../core/postprocess'; +import { lintGate } from '../../core/gates/lint'; +import { checkRule } from '../../core/referee'; +import { formatTodo, isTodoMarker } from '../../core/todos'; +import { + parseSource, + printSource, + parserForFile, + styleToObjectAst, + jsxComment, +} from '../../core/rewriter'; +import { + analyzeEmotionWiring, + removePragma, + removeCssImport, + insertStylexImport, +} from './imports'; +import { detectSites, detectKeyframes, detectExistingRegistry } from './detect'; +import { readSite, readKeyframes } from './read'; +import type { PlainStyleObject } from './read'; +import { rewriteSite } from './rewriteSites'; + +export type TransformResult = + | { + +status: 'converted', + +code: string, + +sites: $ReadOnlyArray<{ +key: string, +cssObject: PlainStyleObject }>, + +keyframes: $ReadOnlyArray<{ + +framesObject: { +[selector: string]: { +[string]: mixed } }, + }>, + /** Reasons for each site left in place with a TODO marker (M5). */ + +flags: $ReadOnlyArray, + } + | { +status: 'skipped', +reasons: $ReadOnlyArray } + | { +status: 'unchanged' }; + +export type TransformOptions = { + /** Wrap `:hover` in `@media (hover: hover)` (default true). */ + +hoverGuard?: boolean, + /** Map inline-axis physical properties to logical (default true). */ + +logicalProperties?: boolean, +}; + +export function transformEmotionFile( + source: string, + filename: string = 'file.js', + options?: TransformOptions, +): TransformResult { + // Cheap bail before parsing anything. + if (!source.includes('@emotion/react')) { + return { status: 'unchanged' }; + } + + const { j, root } = parseSource(source, { + parser: parserForFile(filename), + }); + + const wiring = analyzeEmotionWiring(j, root); + if ( + !wiring.hasPragma && + wiring.cssLocalName == null && + wiring.keyframesLocalName == null + ) { + return { status: 'unchanged' }; + } + + // Keyframes first, so css sites can reference them by name. + const kfDetection = detectKeyframes(j, root, wiring.keyframesLocalName); + const kfReads = kfDetection.sites.map((s) => + readKeyframes(s.varName, s.objectNode), + ); + + const detection = detectSites(j, root, wiring.cssLocalName); + const registryDetection = detectExistingRegistry(j, root); + + // Whole-file refusals: genuinely file-level structural issues (not a single + // site). Keyframes stay whole-file for now — per-site keyframe flagging is a + // later slice. + const wholeFileBlockers = [ + ...wiring.blockers, + ...detection.blockers, + ...kfDetection.blockers, + ...registryDetection.blockers, + ...kfReads.map((r) => (r.ok ? null : r.blocker)).filter(Boolean), + ]; + if (wholeFileBlockers.length > 0) { + return { status: 'skipped', reasons: wholeFileBlockers }; + } + + // --- Per-site classification: each css site either converts or is flagged + // with a `// TODO(stylex-migration): …` marker (bail loudly, in place). --- + const flags: Array<{ +attrPath: $FlowFixMe, +reason: string }> = []; + const candidates: Array<{ +site: $FlowFixMe, +read: $FlowFixMe }> = []; + for (const site of detection.sites) { + if (site.kind === 'flagged') { + flags.push({ attrPath: site.attrPath, reason: site.reason }); + continue; + } + if (siteAlreadyFlagged(j, site.attrPath)) { + continue; // re-run guard: leave a previously-flagged site alone + } + const read = readSite(site, kfDetection.names); + if (read.ok) { + candidates.push({ site, read }); + } else { + flags.push({ attrPath: site.attrPath, reason: read.blocker }); + } + } + + // Adapter -> core for the convertible candidates. + const fileIR = normalizeFileIR( + { + rules: buildFileIR( + candidates.map((c) => ({ + nameHint: c.read.nameHint, + declarations: c.read.declarations, + })), + ).rules, + keyframes: kfReads.map((r) => { + if (!r.ok) { + throw new Error('unreachable: keyframes blockers checked above'); + } + return r.rule; + }), + }, + { logicalProperties: options?.logicalProperties ?? true }, + ); + + // L5 Referee, per rule: a conflicting site is flagged, not fatal to the file. + const convertRules: Array<{ +rule: $FlowFixMe, +candidateIndex: number }> = + []; + fileIR.rules.forEach((rule, i) => { + const checked = checkRule(rule); + if (checked.ok) { + convertRules.push({ rule: checked.rule, candidateIndex: i }); + } else { + flags.push({ + attrPath: candidates[i].site.attrPath, + reason: checked.conflicts[0], + }); + } + }); + + if (convertRules.length === 0 && kfDetection.sites.length === 0) { + if (flags.length === 0) { + return { status: 'unchanged' }; + } + // Nothing convertible, but sites to flag: inject TODOs and keep Emotion. + for (const flag of flags) { + injectTodo(j, flag.attrPath, flag.reason); + } + return { + status: 'converted', + code: printSource({ j, root }), + sites: [], + keyframes: [], + flags: flags.map((f) => f.reason), + }; + } + + const existing = registryDetection.registry; + const { rules, keyframes, bindings } = emitFileIR( + { + rules: convertRules.map((c) => c.rule), + keyframes: fileIR.keyframes, + }, + { hoverGuard: options?.hoverGuard ?? true, reservedKeys: existing?.keys }, + ); + + // L10 Scoped postprocess (see scopedFix): fixes ONLY our emitted stylex. + const stylesLocalName = + existing != null ? existing.varName : pickStylesName(j, root); + const fixed = scopedFix(j, rules, keyframes, stylesLocalName, filename); + if (fixed.residualErrors.length > 0) { + return { status: 'skipped', reasons: fixed.residualErrors }; + } + + // Rewrite converted sites; flag the rest in place. + convertRules.forEach((c, k) => { + rewriteSite( + j, + candidates[c.candidateIndex].site, + stylesLocalName, + bindings[k], + ); + }); + rewriteKeyframes(j, kfDetection.sites, fixed.keyframesByName); + for (const flag of flags) { + injectTodo(j, flag.attrPath, flag.reason); + } + + if (rules.length > 0 && fixed.createObject != null) { + if (existing != null) { + existing.objectNode.properties.push(...fixed.createObject.properties); + } else { + insertRegistry( + j, + root, + candidates[convertRules[0].candidateIndex].site, + stylesLocalName, + fixed.createObject, + ); + } + } + if (existing == null && (rules.length > 0 || keyframes.length > 0)) { + insertStylexImport(j, root); + } + // Remove Emotion wiring only where it is no longer referenced: flagged css + // props keep the pragma; a still-used `css`/`keyframes` keeps its import. + removeCssImport(j, root); + removePragma(j, root); + + const code = printSource({ j, root }); + // Final safety VERIFY (not fix) over the whole file — catches a merge into a + // user registry whose own code is lint-dirty; we refuse rather than rewrite. + const finalLint = lintGate(code, { filename }); + if (!finalLint.ok) { + return { + status: 'skipped', + reasons: finalLint.messages.map( + (m) => `${m.ruleId ?? 'error'}: ${m.message} (line ${m.line})`, + ), + }; + } + + return { + status: 'converted', + code, + sites: convertRules.map((c, k) => ({ + key: bindings[k], + cssObject: candidates[c.candidateIndex].read.cssObject, + })), + keyframes: kfReads.map((kf) => { + if (!kf.ok) { + throw new Error('unreachable: keyframes blockers checked above'); + } + return { framesObject: kf.framesObject }; + }), + flags: flags.map((f) => f.reason), + }; +} + +/** Whether a css site was already flagged on a previous run (re-run guard): + * a TODO comment sibling immediately before its element, or leading on it. */ +function siteAlreadyFlagged(j: $FlowFixMe, attrPath: $FlowFixMe): boolean { + const elementPath = attrPath.parent.parent; + const parent = elementPath.parent.node; + const children = Array.isArray(parent.children) ? parent.children : null; + const leading = (elementPath.node.comments ?? []).some((c: $FlowFixMe) => + isTodoMarker(c.value), + ); + if (leading) { + return true; + } + if (children == null) { + return false; + } + const idx = children.indexOf(elementPath.node); + for (let i = idx - 1; i >= 0; i--) { + const sibling = children[i]; + if (sibling.type === 'JSXText' && String(sibling.value).trim() === '') { + continue; + } + return ( + sibling.type === 'JSXExpressionContainer' && + sibling.expression?.type === 'JSXEmptyExpression' && + (sibling.expression.comments ?? []).some((c: $FlowFixMe) => + isTodoMarker(c.value), + ) + ); + } + return false; +} + +/** Injects a `TODO(stylex-migration): reason` marker at a flagged css site: a + * braced JSX comment sibling when the element is a JSX child, else a leading + * block comment on the element (both valid, unlike a bare comment as a child). */ +function injectTodo(j: $FlowFixMe, attrPath: $FlowFixMe, reason: string): void { + const elementPath = attrPath.parent.parent; + const element = elementPath.node; + const parent = elementPath.parent.node; + const text = formatTodo(reason); + if ( + (parent.type === 'JSXElement' || parent.type === 'JSXFragment') && + Array.isArray(parent.children) + ) { + const idx = parent.children.indexOf(element); + parent.children.splice(idx, 0, jsxComment(j, text), j.jsxText('\n')); + } else { + element.comments = [ + ...(element.comments ?? []), + { type: 'CommentBlock', value: text, leading: true, trailing: false }, + ]; + } +} + +const STYLEX_IMPORT = "import * as stylex from '@stylexjs/stylex';"; + +/** + * Scoped postprocess: build a standalone module of just the emitted stylex + * (keyframes + create + usage stubs), run StyleX's eslint autofixes on it, + * and extract the FIXED object expressions. The user's file is never linted, + * so their pre-existing stylex code cannot be reordered. + */ +function scopedFix( + j: $FlowFixMe, + rules: $ReadOnlyArray, + keyframes: $ReadOnlyArray, + stylesLocalName: string, + filename: string, +): { + createObject: $FlowFixMe | null, + keyframesByName: Map, + residualErrors: $ReadOnlyArray, +} { + const lines: Array = [STYLEX_IMPORT]; + for (const kf of keyframes) { + const framesObject: { [string]: EmittedStyle } = {}; + for (const frame of kf.frames) { + framesObject[frame.selector] = frame.style; + } + lines.push( + `const ${kf.name} = stylex.keyframes(` + + `${printExpr(j, styleToObjectAst(j, framesObject))});`, + ); + } + if (rules.length > 0) { + const createStyle: { [string]: EmittedStyle } = {}; + for (const rule of rules) { + createStyle[rule.key] = rule.style; + } + lines.push( + `const ${stylesLocalName} = stylex.create(` + + `${printExpr(j, styleToObjectAst(j, createStyle))});`, + ); + // Usage stubs so no-unused stays quiet (real usage is in the JSX). + for (const rule of rules) { + lines.push(`stylex.props(${stylesLocalName}.${rule.key});`); + } + } + + const { code, residualErrors } = postprocess(lines.join('\n'), filename, { + excludeRules: ['no-unused'], + }); + + const parsed = j(code); + let createObject: $FlowFixMe | null = null; + const keyframesByName: Map = new Map(); + parsed.find(j.CallExpression).forEach((path: $FlowFixMe) => { + const callee = path.node.callee; + if ( + callee.type !== 'MemberExpression' || + callee.object.type !== 'Identifier' || + callee.object.name !== 'stylex' + ) { + return; + } + if (callee.property.name === 'create') { + createObject = unparenthesize(path.node.arguments[0]); + } else if (callee.property.name === 'keyframes') { + const declarator = path.parent.node; + if ( + declarator.type === 'VariableDeclarator' && + declarator.id.type === 'Identifier' + ) { + keyframesByName.set( + declarator.id.name, + unparenthesize(path.node.arguments[0]), + ); + } + } + }); + + return { createObject, keyframesByName, residualErrors }; +} + +/** Prints a single expression node to source (via a throwaway wrapper). An + * object literal at statement position is parenthesized to avoid block + * ambiguity; that grouping is stripped again on the way back out. */ +function printExpr(j: $FlowFixMe, node: $FlowFixMe): string { + return j(j.expressionStatement(node)) + .toSource({ quote: 'single' }) + .replace(/;\s*$/, ''); +} + +/** Clears any parenthesized-grouping metadata from a node so recast reprints + * it without redundant parens (the TS parser records it, Flow does not). */ +function unparenthesize(node: $FlowFixMe): $FlowFixMe { + if (node != null && node.extra != null) { + node.extra.parenthesized = false; + node.extra.parens = undefined; + } + return node; +} + +/** + * Rewrites each `keyframes({...})` call in place to `stylex.keyframes()`, matched to detected sites by the bound variable name. + */ +function rewriteKeyframes( + j: $FlowFixMe, + sites: $ReadOnlyArray<{ +callPath: $FlowFixMe, +varName: string, ... }>, + fixedByName: Map, +): void { + for (const site of sites) { + const framesObject = fixedByName.get(site.varName); + if (framesObject == null) { + continue; + } + j(site.callPath).replaceWith( + j.callExpression( + j.memberExpression(j.identifier('stylex'), j.identifier('keyframes')), + [framesObject], + ), + ); + } +} + +/** `styles`, or a numbered variant if the file already uses that name. */ +function pickStylesName(j: $FlowFixMe, root: $FlowFixMe): string { + const taken = (name: string) => root.find(j.Identifier, { name }).size() > 0; + let name = 'styles'; + for (let n = 2; taken(name); n++) { + name = `styles${n}`; + } + return name; +} + +/** + * Inserts `const = stylex.create({...})` directly above the + * top-level statement containing the first converted site (per the + * best-practices doc's registry placement). + */ +function insertRegistry( + j: $FlowFixMe, + root: $FlowFixMe, + firstSite: $FlowFixMe, + stylesLocalName: string, + createObject: $FlowFixMe, +): void { + const declaration = j.variableDeclaration('const', [ + j.variableDeclarator( + j.identifier(stylesLocalName), + j.callExpression( + j.memberExpression(j.identifier('stylex'), j.identifier('create')), + [createObject], + ), + ), + ]); + + let statementPath = firstSite.attrPath; + while ( + statementPath.parent != null && + statementPath.parent.node.type !== 'Program' + ) { + statementPath = statementPath.parent; + } + j(statementPath).insertBefore(declaration); +} diff --git a/packages/@stylexjs/codemods/src/cli/index.js b/packages/@stylexjs/codemods/src/cli/index.js new file mode 100644 index 000000000..8067f56f3 --- /dev/null +++ b/packages/@stylexjs/codemods/src/cli/index.js @@ -0,0 +1,71 @@ +#! /usr/bin/env node +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L0 CLI — `stylex-codemod emotion "" [--write] [--config ]`. + * + * DRY RUN IS THE DEFAULT: with no `--write`, nothing is written; you get a + * convert / refuse / TODO report to preview. `--write` applies the changes. + */ + +import yargs from 'yargs'; +// $FlowFixMe[cannot-resolve-module] - yargs/helpers has no flow libdef here +import { hideBin } from 'yargs/helpers'; +import { loadConfig } from '../config/loadConfig'; +import { runCodemod } from './run'; +import { formatReport } from './report'; + +export function main(argv: $ReadOnlyArray): number { + const args = yargs([...argv]) + .scriptName('stylex-codemod') + .usage('$0 [options]') + .command('emotion ', 'Migrate Emotion styles to StyleX') + .positional('glob', { + describe: 'File glob(s) to transform', + type: 'string', + }) + .option('write', { + type: 'boolean', + default: false, + describe: 'Apply changes (default: dry run — preview only)', + }) + .option('config', { + type: 'string', + describe: 'Path to a stylex-codemod.config.js', + }) + .option('verbose', { + type: 'boolean', + default: false, + describe: 'List unchanged files and every TODO reason', + }) + .demandCommand(1) + .strict() + .help() + .parseSync(); + + const adapter = String(args._[0]); + if (adapter !== 'emotion') { + process.stderr.write(`Unknown adapter '${adapter}' (only 'emotion').\n`); + return 2; + } + + const patterns = (args.glob ?? []).map(String); + const config = loadConfig({ configPath: args.config ?? null }); + const report = runCodemod({ patterns, config, write: Boolean(args.write) }); + process.stdout.write( + `${formatReport(report, { verbose: Boolean(args.verbose) })}\n`, + ); + return report.summary.errors > 0 ? 1 : 0; +} + +// $FlowFixMe[cannot-resolve-module] - require.main is a runtime guard +if (require.main === module) { + process.exit(main(hideBin(process.argv))); +} diff --git a/packages/@stylexjs/codemods/src/cli/report.js b/packages/@stylexjs/codemods/src/cli/report.js new file mode 100644 index 000000000..fb45d702c --- /dev/null +++ b/packages/@stylexjs/codemods/src/cli/report.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L13 report — renders a RunReport as human-readable text. Kept separate from + * `run` so the report can be asserted on the structured data in tests and the + * text formatting stays presentation-only. + */ + +import * as path from 'path'; +import type { RunReport, FileOutcome } from './run'; + +function relativize(file: string, cwd: string): string { + const rel = path.relative(cwd, file); + return rel === '' || rel.startsWith('..') ? file : rel; +} + +function outcomeLine(outcome: FileOutcome, cwd: string): string { + const file = relativize(outcome.file, cwd); + if (outcome.status === 'converted') { + const suffix = + outcome.flags.length > 0 + ? ` (+${outcome.flags.length} TODO${outcome.flags.length === 1 ? '' : 's'})` + : ''; + return ` convert ${file}${suffix}`; + } + if (outcome.status === 'unchanged') { + return ` skip ${file}`; + } + const detail = outcome.reasons[0] != null ? ` — ${outcome.reasons[0]}` : ''; + const label = outcome.status === 'error' ? 'ERROR ' : 'refuse '; + return ` ${label} ${file}${detail}`; +} + +export function formatReport( + report: RunReport, + options?: { +cwd?: string, +verbose?: boolean }, +): string { + const cwd = options?.cwd ?? process.cwd(); + const verbose = options?.verbose ?? false; + const lines: Array = []; + + lines.push(report.dryRun ? 'Dry run (no files written):' : 'Applied:'); + for (const outcome of report.results) { + if (outcome.status === 'unchanged' && !verbose) { + continue; // unchanged files are noise unless asked for + } + lines.push(outcomeLine(outcome, cwd)); + if (verbose) { + for (const flag of outcome.flags) { + lines.push(` TODO: ${flag}`); + } + } + } + + const s = report.summary; + lines.push(''); + lines.push( + `${s.files} file(s): ${s.converted} converted, ` + + `${s.partiallyConverted} partial (+TODOs), ${s.skipped} refused, ` + + `${s.unchanged} unchanged` + + (s.errors > 0 ? `, ${s.errors} error(s)` : ''), + ); + if (s.totalFlags > 0) { + lines.push(`${s.totalFlags} TODO marker(s) left for manual follow-up.`); + } + if (report.dryRun && (s.converted > 0 || s.partiallyConverted > 0)) { + lines.push('Re-run with --write to apply.'); + } + return lines.join('\n'); +} diff --git a/packages/@stylexjs/codemods/src/cli/run.js b/packages/@stylexjs/codemods/src/cli/run.js new file mode 100644 index 000000000..193ef3025 --- /dev/null +++ b/packages/@stylexjs/codemods/src/cli/run.js @@ -0,0 +1,152 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L0 driver core — the testable engine behind the CLI. Expands the glob(s), + * runs the Emotion transform on each file, and returns a structured report. + * Writes files only when `write` is true; the CLI defaults to a dry run. + */ + +import * as fs from 'fs'; +// $FlowFixMe[cannot-resolve-module] - fast-glob has no flow libdef here +import fastGlob from 'fast-glob'; +import { transformEmotionFile } from '../adapters/emotion/transform'; +import type { CodemodConfig } from '../config/loadConfig'; + +export type FileOutcome = { + +file: string, + +status: 'converted' | 'skipped' | 'unchanged' | 'error', + +flags: $ReadOnlyArray, // per-site TODO reasons (converted) + +reasons: $ReadOnlyArray, // whole-file refusal reasons (skipped) + +wrote: boolean, +}; + +export type RunSummary = { + +files: number, + +converted: number, // fully converted (no flags) + +partiallyConverted: number, // converted with ≥1 flag + +skipped: number, + +unchanged: number, + +errors: number, + +totalFlags: number, +}; + +export type RunReport = { + +dryRun: boolean, + +results: $ReadOnlyArray, + +summary: RunSummary, +}; + +export type RunOptions = { + +patterns: $ReadOnlyArray, + +config: CodemodConfig, + +cwd?: string, + +write?: boolean, +}; + +export function runCodemod(options: RunOptions): RunReport { + const cwd = options.cwd ?? process.cwd(); + const write = options.write ?? false; + const files: Array = fastGlob.sync(options.patterns.slice(), { + cwd, + absolute: true, + ignore: ['**/node_modules/**'], + }); + + const results: Array = files.map((file) => { + let source: string; + try { + source = fs.readFileSync(file, 'utf8'); + } catch (error) { + return errorOutcome(file, error); + } + let result; + try { + result = transformEmotionFile(source, file, options.config); + } catch (error) { + return errorOutcome(file, error); + } + if (result.status === 'converted') { + let wrote = false; + if (write) { + try { + fs.writeFileSync(file, result.code); + wrote = true; + } catch (error) { + return errorOutcome(file, error); + } + } + return { + file, + status: 'converted', + flags: result.flags, + reasons: [], + wrote, + }; + } + if (result.status === 'skipped') { + return { + file, + status: 'skipped', + flags: [], + reasons: result.reasons, + wrote: false, + }; + } + return { file, status: 'unchanged', flags: [], reasons: [], wrote: false }; + }); + + return { dryRun: !write, results, summary: summarize(results) }; +} + +function errorOutcome(file: string, error: mixed): FileOutcome { + return { + file, + status: 'error', + flags: [], + reasons: [error instanceof Error ? error.message : String(error)], + wrote: false, + }; +} + +function summarize(results: $ReadOnlyArray): RunSummary { + const summary = { + files: results.length, + converted: 0, + partiallyConverted: 0, + skipped: 0, + unchanged: 0, + errors: 0, + totalFlags: 0, + }; + for (const r of results) { + summary.totalFlags += r.flags.length; + switch (r.status) { + case 'converted': + if (r.flags.length > 0) { + summary.partiallyConverted += 1; + } else { + summary.converted += 1; + } + break; + case 'skipped': + summary.skipped += 1; + break; + case 'unchanged': + summary.unchanged += 1; + break; + case 'error': + summary.errors += 1; + break; + default: + break; + } + } + return summary; +} diff --git a/packages/@stylexjs/codemods/src/config/loadConfig.js b/packages/@stylexjs/codemods/src/config/loadConfig.js new file mode 100644 index 000000000..32ddc8f27 --- /dev/null +++ b/packages/@stylexjs/codemods/src/config/loadConfig.js @@ -0,0 +1,102 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L0 config — loads a `stylex-codemod.config.js` (or an explicit path) into + * the options the transform accepts. Missing file → defaults. Unknown keys + * or wrong-typed values throw, so a typo fails loudly instead of silently + * doing the wrong thing. + * + * M6 wires the two behavioral toggles that already have implementations: + * `hoverGuard` (M2) and `logicalProperties` (M3a). `resolveValue` + * (theme-token mapping) is reserved for the token slice and + * is intentionally not part of this schema yet. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +export type CodemodConfig = { + +hoverGuard: boolean, + +logicalProperties: boolean, +}; + +export const DEFAULT_CONFIG: CodemodConfig = { + hoverGuard: true, + logicalProperties: true, +}; + +const DEFAULT_CONFIG_FILENAME = 'stylex-codemod.config.js'; + +const BOOLEAN_KEYS: $ReadOnlyArray = [ + 'hoverGuard', + 'logicalProperties', +]; + +export class ConfigError extends Error { + constructor(message: string) { + super(`[stylex-codemod config] ${message}`); + this.name = 'ConfigError'; + } +} + +/** + * Resolves the config: an explicit `configPath`, else + * `stylex-codemod.config.js` in `cwd`, else defaults. + */ +export function loadConfig(options?: { + +cwd?: string, + +configPath?: string | null, +}): CodemodConfig { + const cwd = options?.cwd ?? process.cwd(); + const explicit = options?.configPath ?? null; + const resolved = + explicit != null + ? path.resolve(cwd, explicit) + : path.join(cwd, DEFAULT_CONFIG_FILENAME); + + if (!fs.existsSync(resolved)) { + if (explicit != null) { + throw new ConfigError(`config file not found: ${resolved}`); + } + return DEFAULT_CONFIG; + } + + // $FlowFixMe[unsupported-syntax] - dynamic require of the user's config + const loaded: mixed = require(resolved); + return validateConfig(loaded, resolved); +} + +/** Validates a plain object into a CodemodConfig, merging over defaults. */ +export function validateConfig(raw: mixed, source: string): CodemodConfig { + const object = + raw != null && typeof raw === 'object' && raw.default != null + ? raw.default + : raw; + if (object == null || typeof object !== 'object') { + throw new ConfigError(`${source}: config must export an object`); + } + const merged: { [string]: boolean } = { ...DEFAULT_CONFIG }; + for (const key of Object.keys(object)) { + if (!BOOLEAN_KEYS.includes(key)) { + throw new ConfigError( + `${source}: unknown option '${key}' (expected: ${BOOLEAN_KEYS.join(', ')})`, + ); + } + const value = object[key]; + if (typeof value !== 'boolean') { + throw new ConfigError(`${source}: option '${key}' must be a boolean`); + } + merged[key] = value; + } + return { + hoverGuard: merged.hoverGuard, + logicalProperties: merged.logicalProperties, + }; +} diff --git a/packages/@stylexjs/codemods/src/core/buildIR.js b/packages/@stylexjs/codemods/src/core/buildIR.js new file mode 100644 index 000000000..c154029e9 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/buildIR.js @@ -0,0 +1,53 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L4 — Build IR. Turns adapter-produced `declarations` (the first seam + * hand-off) into the neutral FileIR. + * + * M1 scope: flat static declarations only — every atom has zero + * conditions. "The flip" (selector-grouped -> property-grouped condition + * nesting) lands here in M2; this module's shape is already the flip's + * home so the seam does not move. + */ + +import type { Atom, Condition, FileIR, StyleRule, Value } from './ir'; + +/** + * One style declaration as handed over by an adapter: a property, a value, + * and the self-targeting conditions it applies under (empty for a flat + * declaration). No StyleX knowledge, no AST nodes. + */ +export type Declaration = { + +property: string, + +value: Value, + +conditions?: $ReadOnlyArray, +}; + +/** + * One style site as handed over by an adapter: its declarations plus a + * suggested name (adapter knows labels/component names; core decides the + * final key). + */ +export type DeclarationGroup = { + +nameHint: string, + +declarations: $ReadOnlyArray, +}; + +export function buildFileIR(groups: $ReadOnlyArray): FileIR { + const rules: Array = groups.map((group) => { + const atoms: Array = group.declarations.map((declaration) => ({ + property: declaration.property, + conditions: declaration.conditions ?? [], + value: declaration.value, + })); + return { name: group.nameHint, atoms }; + }); + return { rules, keyframes: [] }; +} diff --git a/packages/@stylexjs/codemods/src/core/emit.js b/packages/@stylexjs/codemods/src/core/emit.js new file mode 100644 index 000000000..5731c8e87 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/emit.js @@ -0,0 +1,269 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L7 — Emit. Turns the FileIR into the data for a single per-file + * `stylex.create` registry plus the binding map (the second seam + * hand-off: rule #N becomes `styles.`). + * + * Deliberately AST-free: emit produces plain data; the adapter (which owns + * the file's AST) renders it. This keeps `core/` blind to parser nodes. + * + * "The flip" materializes here: atoms (property × condition-stack × value) + * are grouped BY PROPERTY with conditions nested inside — StyleX's required + * property-grouped shape, the inverse of Emotion's selector-grouped input. + * + * Naming (M1 minimal policy, superseded wholesale in M4): the adapter's + * nameHint (label > enclosing component) is sanitized to a camelCase + * identifier; collisions get a numeric suffix starting at 2. + */ + +import type { Atom, Condition, FileIR, StyleRule, Value } from './ir'; +import { conditionKey } from './ir'; + +/** + * A value in a `stylex.create` entry: a static value, a fallback array + * (StyleX's `firstThatWorks`), an identifier reference (rendered as a bare + * identifier, e.g. `animationName: spin`), or a nested condition object. + */ +export type EmittedValue = + | string + | number + | null + | $ReadOnlyArray + | EmittedRef + | EmittedConditions; +/** Sentinel for a bare-identifier reference; `$$ref` is the identifier. */ +export type EmittedRef = { +$$ref: string }; +export type EmittedConditions = { +[condition: string]: EmittedValue }; + +/** A `stylex.create` entry as plain data: property -> value. */ +export type EmittedStyle = { +[property: string]: EmittedValue }; + +export type EmittedRule = { + +key: string, + +style: EmittedStyle, +}; + +/** An emitted `stylex.keyframes` declaration: a variable name and its frames. */ +export type EmittedKeyframes = { + +name: string, + +frames: $ReadOnlyArray<{ +selector: string, +style: EmittedStyle }>, +}; + +export type EmitResult = { + +rules: $ReadOnlyArray, + +keyframes: $ReadOnlyArray, + /** bindings[i] is the create key for FileIR.rules[i]. */ + +bindings: $ReadOnlyArray, +}; + +export type EmitOptions = { + /** Wrap `:hover` in `@media (hover: hover)` (default true). */ + +hoverGuard?: boolean, + /** Style-name keys already taken (e.g. a pre-existing registry we merge + * into); emitted keys avoid collisions with these. */ + +reservedKeys?: $ReadOnlySet, +}; + +export class EmitError extends Error { + constructor(message: string) { + super(`[stylex-codemod emit] ${message}`); + this.name = 'EmitError'; + } +} + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const RESERVED = new Set(['default', 'delete', 'do', 'in', 'new', 'var']); +const HOVER_GUARD = '@media (hover: hover)'; + +/** Sanitizes an adapter name hint into a usable create key. */ +export function sanitizeKey(hint: string): string { + const cleaned = hint + .replace(/[^A-Za-z0-9_$]+(.)?/g, (_, next: string | void) => + next == null ? '' : next.toUpperCase(), + ) + .replace(/^[0-9]+/, ''); + const key = + cleaned === '' ? 'styles' : cleaned[0].toLowerCase() + cleaned.slice(1); + return IDENTIFIER.test(key) && !RESERVED.has(key) ? key : 'styles'; +} + +function leafValue(value: Value): EmittedValue { + switch (value.kind) { + case 'first-that-works': + return value.values; + case 'reference': + return { $$ref: value.name }; + case 'static': + default: + return value.value; + } +} + +/** + * Canonical outer→inner nesting order for a condition stack: at-rules + * outermost, then pseudo-classes, then pseudo-elements innermost; alpha + * within a kind. StyleX sums priorities regardless of nesting order, so a + * canonical order is safe and makes the tree merge deterministically. + * Applies the hover-guard: any stack containing `:hover` gains an outer + * `@media (hover: hover)`. + */ +function canonicalPath( + conditions: $ReadOnlyArray, + hoverGuard: boolean, +): Array { + const atRules = conditions + .filter((c) => c.kind === 'at-rule') + .map(conditionKey); + const pseudoClasses = conditions + .filter((c) => c.kind === 'pseudo-class') + .map(conditionKey); + const pseudoElements = conditions + .filter((c) => c.kind === 'pseudo-element') + .map(conditionKey); + if (hoverGuard && pseudoClasses.includes(':hover')) { + atRules.push(HOVER_GUARD); + } + return [...atRules.sort(), ...pseudoClasses.sort(), ...pseudoElements.sort()]; +} + +type MutableTree = { [key: string]: EmittedValue }; + +function insertAtPath( + node: MutableTree, + path: $ReadOnlyArray, + value: EmittedValue, + rule: StyleRule, +): void { + if (path.length === 0) { + if (node.default !== undefined) { + throw new EmitError(`rule '${rule.name}': duplicate base declaration`); + } + node.default = value; + return; + } + const [head, ...rest] = path; + if (rest.length === 0) { + const existing = node[head]; + if ( + existing != null && + typeof existing === 'object' && + !Array.isArray(existing) + ) { + // A deeper path already created an object here; place value at default. + insertAtPath(existing as $FlowFixMe, [], value, rule); + } else if (existing !== undefined) { + throw new EmitError(`rule '${rule.name}': duplicate condition '${head}'`); + } else { + node[head] = value; + } + return; + } + let child = node[head]; + if (child == null || typeof child !== 'object' || Array.isArray(child)) { + const nested: MutableTree = {}; + if (child !== undefined) { + nested.default = child; // promote a previously-plain value + } + node[head] = nested; + child = nested; + } + insertAtPath(child as $FlowFixMe, rest, value, rule); +} + +/** Ensures every condition object has a `default` and its keys are ordered + * (`default` first, then alphabetical) so the output passes stylex/sort-keys + * with zero autofixes. */ +function normalizeTree(value: EmittedValue): EmittedValue { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return value; + } + const node = value as EmittedConditions; + const ordered: MutableTree = {}; + ordered.default = + node.default === undefined ? null : normalizeTree(node.default); + for (const key of Object.keys(node) + .filter((k) => k !== 'default') + .sort()) { + ordered[key] = normalizeTree(node[key]); + } + return ordered; +} + +function emitProperty( + atoms: $ReadOnlyArray, + rule: StyleRule, + hoverGuard: boolean, +): EmittedValue { + // Flat fast-path: a single unconditional atom stays a plain value. + if (atoms.length === 1 && atoms[0].conditions.length === 0) { + return leafValue(atoms[0].value); + } + const root: MutableTree = {}; + for (const atom of atoms) { + insertAtPath( + root, + canonicalPath(atom.conditions, hoverGuard), + leafValue(atom.value), + rule, + ); + } + return normalizeTree(root); +} + +/** Groups a rule's atoms by property (the flip) into one emitted style + * object, alphabetically ordered for stylex/sort-keys. */ +function emitStyleObject(rule: StyleRule, hoverGuard: boolean): EmittedStyle { + const byProperty: Map> = new Map(); + for (const atom of rule.atoms) { + const list = byProperty.get(atom.property) ?? []; + list.push(atom); + byProperty.set(atom.property, list); + } + const style: { [string]: EmittedValue } = {}; + for (const property of [...byProperty.keys()].sort()) { + style[property] = emitProperty( + byProperty.get(property) ?? [], + rule, + hoverGuard, + ); + } + return style; +} + +export function emitFileIR(ir: FileIR, options?: EmitOptions): EmitResult { + const hoverGuard = options?.hoverGuard ?? true; + const usedKeys = new Set(options?.reservedKeys ?? []); + const rules: Array = []; + const bindings: Array = []; + + for (const rule of ir.rules) { + const style = emitStyleObject(rule, hoverGuard); + const base = sanitizeKey(rule.name); + let key = base; + for (let n = 2; usedKeys.has(key); n++) { + key = `${base}${n}`; + } + usedKeys.add(key); + rules.push({ key, style }); + bindings.push(key); + } + + const keyframes: Array = ir.keyframes.map((kf) => ({ + name: kf.name, + frames: kf.frames.map((frame) => ({ + selector: frame.selector, + // A frame is a flat style object; reuse the same grouping machinery. + style: emitStyleObject({ name: kf.name, atoms: frame.atoms }, hoverGuard), + })), + })); + + return { rules, keyframes, bindings }; +} diff --git a/packages/@stylexjs/codemods/src/core/gates/compile.js b/packages/@stylexjs/codemods/src/core/gates/compile.js new file mode 100644 index 000000000..4fb768305 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/gates/compile.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Compile gate: the output of a conversion must compile through the real + * `@stylexjs/babel-plugin`. On success it also returns the plugin's + * metadata (the injectable CSS rules), which the semantic-diff gate uses + * as the StyleX side's ground truth. + */ + +import * as babel from '@babel/core'; +import styleXPlugin from '@stylexjs/babel-plugin'; + +export type CompileGateResult = + | { +ok: true, +code: string, +metadata: mixed } + | { +ok: false, +errors: $ReadOnlyArray }; + +export function compileGate( + source: string, + options?: { +filename?: string }, +): CompileGateResult { + const filename = options?.filename ?? 'stylex-codemod-gate-input.js'; + // TypeScript files strip types via preset-typescript; everything else + // (JS/JSX/Flow) parses through hermes so bare Flow annotations work without + // a `@flow` pragma. + const isTypeScript = /\.(ts|tsx|mts|cts)$/.test(filename); + const presets = isTypeScript + ? [['@babel/preset-typescript', { allExtensions: true, isTSX: true }]] + : []; + const syntaxPlugins = isTypeScript + ? [] + : [['babel-plugin-syntax-hermes-parser', { flow: 'all' }]]; + try { + const result = babel.transformSync(source, { + filename, + babelrc: false, + configFile: false, + presets, + plugins: [...syntaxPlugins, [styleXPlugin, {}]], + }); + if (result == null || result.code == null) { + return { ok: false, errors: ['Babel produced no output'] }; + } + return { ok: true, code: result.code, metadata: result.metadata }; + } catch (error) { + return { + ok: false, + errors: [error instanceof Error ? error.message : String(error)], + }; + } +} diff --git a/packages/@stylexjs/codemods/src/core/gates/lint.js b/packages/@stylexjs/codemods/src/core/gates/lint.js new file mode 100644 index 000000000..c33abd1a4 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/gates/lint.js @@ -0,0 +1,81 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Lint gate: the output of a conversion must pass every rule of the real + * `@stylexjs/eslint-plugin` at severity `error`, with zero messages — + * which implies zero autofixes still needed (Meta's golden rule for + * StyleX codemods). + */ + +import { Linter } from 'eslint'; +// hermes-eslint is ESM-compiled with no default export — the namespace +// object itself is the parser (`parseForESLint` lives on it). +import * as hermesEslint from 'hermes-eslint'; +// The plugin has named exports only (no default) — import `rules` directly. +import { rules as stylexRules } from '@stylexjs/eslint-plugin'; + +export type LintMessage = { + +ruleId: string | null, + +message: string, + +line: number, + +column: number, + +fixable: boolean, +}; + +export type LintGateResult = + | { +ok: true } + | { +ok: false, +messages: $ReadOnlyArray }; + +const PARSER_NAME = 'hermes-eslint'; + +function buildLinter(): { linter: Linter, rules: { [string]: 'error' } } { + const linter = new Linter(); + linter.defineParser(PARSER_NAME, hermesEslint); + const ruleMap: { +[string]: mixed } = stylexRules; + const rules: { [string]: 'error' } = {}; + for (const ruleName of Object.keys(ruleMap)) { + const qualified = `@stylexjs/${ruleName}`; + linter.defineRule(qualified, ruleMap[ruleName]); + rules[qualified] = 'error'; + } + return { linter, rules }; +} + +export function lintGate( + source: string, + options?: { +filename?: string }, +): LintGateResult { + const { linter, rules } = buildLinter(); + const messages = linter.verify( + source, + { + parser: PARSER_NAME, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules, + }, + { filename: options?.filename ?? 'stylex-codemod-gate-input.js' }, + ); + if (messages.length === 0) { + return { ok: true }; + } + return { + ok: false, + messages: messages.map((m) => ({ + ruleId: m.ruleId ?? null, + message: m.message, + line: m.line ?? 0, + column: m.column ?? 0, + fixable: m.fix != null, + })), + }; +} diff --git a/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js new file mode 100644 index 000000000..c74b4439b --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js @@ -0,0 +1,600 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Semantic-diff gate: the net CSS before and after a conversion must be + * identical across every condition combination, minus an explicit allowlist + * of sanctioned changes (hover-guard, physical->logical). This is the check + * that catches "compiles fine but renders differently". + * + * Ground truths: + * - before: Emotion's own serializer (`@emotion/serialize`) output, + * parsed by `netCssFromSerializedCss`. + * - after: the real `@stylexjs/babel-plugin` metadata (injectable rules), + * parsed by `netCssFromStylexMetadata`. + * + * Both parsers BAIL LOUDLY (throw `UnsupportedCssError`) on any construct + * they cannot provably represent — an unparsable input must never diff + * clean by accident. + */ + +/** One declaration of net CSS: a property+conditions coordinate => value. */ +export type NetDeclaration = { + +property: string, // kebab-case + +conditions: $ReadOnlyArray, // normalized, sorted + +value: string, +}; + +export type NetCss = { +[coordinate: string]: NetDeclaration }; + +export type DiffEntry = { + +coordinate: string, + +property: string, + +conditions: $ReadOnlyArray, + +beforeValue: string | null, + +afterValue: string | null, +}; + +export type AllowlistRule = ( + entry: DiffEntry, + before: NetCss, + after: NetCss, +) => boolean; + +export type SemanticDiffResult = + | { +ok: true, +allowed: $ReadOnlyArray } + | { + +ok: false, + +diffs: $ReadOnlyArray, + +allowed: $ReadOnlyArray, + }; + +export class UnsupportedCssError extends Error { + constructor(message: string) { + super(`[stylex-codemod semantic-diff] unsupported CSS: ${message}`); + this.name = 'UnsupportedCssError'; + } +} + +// --- normalization helpers --------------------------------------------- + +function normalizeAtRule(rule: string): string { + return rule + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/\s*([():,])\s*/g, '$1') + .trim(); +} + +function normalizeValue(value: string): string { + // Comma-whitespace is canonicalized because CSS treats + // `rgb(10, 20, 30)` and `rgb(10,20,30)` as the same value — and the + // StyleX compiler normalizes to the latter. + return value + .trim() + .replace(/\s+/g, ' ') + .replace(/\s*,\s*/g, ','); +} + +function normalizeCondition(raw: string): string { + let s = raw.trim(); + if (s.startsWith('&')) { + s = s.slice(1).trim(); + } + if (s.startsWith('@')) { + return normalizeAtRule(s); + } + if (/^::?[\w-]+(\([^()]*\))?$/.test(s)) { + return s.toLowerCase(); + } + throw new UnsupportedCssError( + `selector context '${raw}' is not a self-targeting pseudo or at-rule`, + ); +} + +function coordinate( + property: string, + conditions: $ReadOnlyArray, +): string { + return conditions.length === 0 + ? property + : `${property} @ ${conditions.join(' && ')}`; +} + +// --- box-shorthand canonicalization ------------------------------------ + +// Emotion writes `margin: 8px 16px` (one shorthand declaration); the codemod +// emits StyleX's expanded logical form (`margin-block` / `margin-inline`). +// Compared property-by-property these look different even though they render +// identically. So before diffing we expand BOTH sides' box shorthands down +// to the four physical per-side longhands, resolving logical → physical in +// LTR (the semantic-diff checks LTR-equivalence; the allowlist owns the +// sanctioned RTL difference). margin/padding only in this slice — inset and +// border keep the allowlist path. + +const BOX_FAMILIES: $ReadOnlyArray = ['margin', 'padding']; + +/** Splits a space-separated value list, keeping parenthesized groups + * (`calc(…)`, `var(…)`) intact. */ +function splitValueList(value: string): Array { + const parts: Array = []; + let depth = 0; + let current = ''; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch === '(') { + depth += 1; + } else if (ch === ')') { + depth -= 1; + } + if (ch === ' ' && depth === 0) { + if (current !== '') { + parts.push(current); + current = ''; + } + } else { + current += ch; + } + } + if (current !== '') { + parts.push(current); + } + return parts; +} + +/** [top, right, bottom, left] from a 1–4 value box shorthand, or null. */ +function fourSides( + values: Array, +): [string, string, string, string] | null { + switch (values.length) { + case 1: + return [values[0], values[0], values[0], values[0]]; + case 2: + return [values[0], values[1], values[0], values[1]]; + case 3: + return [values[0], values[1], values[2], values[1]]; + case 4: + return [values[0], values[1], values[2], values[3]]; + default: + return null; + } +} + +/** Expands a (property, value) into one or more physical-longhand pairs. + * Non-box properties pass through unchanged. */ +function expandToPhysical( + property: string, + value: string, +): Array<[string, string]> { + for (const fam of BOX_FAMILIES) { + if (property === fam) { + const sides = fourSides(splitValueList(value)); + return sides == null + ? [[property, value]] + : [ + [`${fam}-top`, sides[0]], + [`${fam}-right`, sides[1]], + [`${fam}-bottom`, sides[2]], + [`${fam}-left`, sides[3]], + ]; + } + if (property === `${fam}-inline` || property === `${fam}-block`) { + const vals = splitValueList(value); + const start = vals[0]; + const end = vals.length > 1 ? vals[1] : vals[0]; + return property === `${fam}-block` + ? [ + [`${fam}-top`, start], + [`${fam}-bottom`, end], + ] + : [ + [`${fam}-left`, start], + [`${fam}-right`, end], + ]; + } + switch (property) { + case `${fam}-inline-start`: + return [[`${fam}-left`, value]]; + case `${fam}-inline-end`: + return [[`${fam}-right`, value]]; + case `${fam}-block-start`: + return [[`${fam}-top`, value]]; + case `${fam}-block-end`: + return [[`${fam}-bottom`, value]]; + default: + break; + } + } + return [[property, value]]; +} + +/** Rewrites every box shorthand/logical property in a NetCss into physical + * per-side longhands so both sides of a diff share one vocabulary. */ +function canonicalizeNetCss(net: NetCss): NetCss { + const out: { [string]: NetDeclaration } = {}; + for (const coord of Object.keys(net)) { + const decl = net[coord]; + for (const [prop, val] of expandToPhysical(decl.property, decl.value)) { + out[coordinate(prop, decl.conditions)] = { + property: prop, + conditions: decl.conditions, + value: normalizeValue(val), + }; + } + } + return out; +} + +function addDeclaration( + target: { [string]: NetDeclaration }, + property: string, + rawConditions: $ReadOnlyArray, + value: string, +): void { + const conditions = rawConditions.map(normalizeCondition).slice().sort(); + const prop = property.trim().toLowerCase(); + if (!/^--|^[a-z-]+$/.test(prop)) { + throw new UnsupportedCssError(`property '${property}'`); + } + target[coordinate(prop, conditions)] = { + property: prop, + conditions, + value: normalizeValue(value), + }; +} + +// --- before: Emotion serialized CSS ------------------------------------ + +/** + * Parses the `styles` string produced by `@emotion/serialize` (flat + * declarations plus one-or-more levels of `pseudo { ... }` / `@media { ... }` + * nesting) into net CSS. Throws on any selector that is not self-targeting. + */ +export function netCssFromSerializedCss(css: string): NetCss { + const out: { [string]: NetDeclaration } = {}; + const contexts: Array = []; + let buffer = ''; + + const flushDeclaration = (chunk: string) => { + const decl = chunk.trim(); + if (decl === '') { + return; + } + const colon = decl.indexOf(':'); + if (colon <= 0) { + throw new UnsupportedCssError(`declaration '${decl}'`); + } + addDeclaration(out, decl.slice(0, colon), contexts, decl.slice(colon + 1)); + }; + + for (let i = 0; i < css.length; i++) { + const char = css[i]; + if (char === '{') { + const selector = buffer.trim(); + if (selector === '') { + throw new UnsupportedCssError('block with empty selector'); + } + if (selector.includes(',')) { + throw new UnsupportedCssError(`selector list '${selector}'`); + } + contexts.push(selector); + buffer = ''; + } else if (char === '}') { + flushDeclaration(buffer); + buffer = ''; + if (contexts.length === 0) { + throw new UnsupportedCssError('unbalanced closing brace'); + } + contexts.pop(); + } else if (char === ';') { + flushDeclaration(buffer); + buffer = ''; + } else { + buffer += char; + } + } + if (contexts.length !== 0) { + throw new UnsupportedCssError('unbalanced opening brace'); + } + flushDeclaration(buffer); + return out; +} + +// --- after: StyleX babel-plugin metadata -------------------------------- + +/** + * Parses `metadata.stylex` from a `compileGate` run — entries of + * `[className, { ltr, rtl }, priority]` — into net CSS. Rules are applied + * in ascending priority order, mirroring how StyleX resolves collisions. + */ +export function netCssFromStylexMetadata(metadata: mixed): NetCss { + const rules = + metadata != null && + typeof metadata === 'object' && + Array.isArray(metadata.stylex) + ? metadata.stylex + : Array.isArray(metadata) + ? metadata + : null; + if (rules == null) { + throw new UnsupportedCssError( + 'expected StyleX babel-plugin metadata ({ stylex: [...] })', + ); + } + const entries = rules + .map((rule: mixed): { ltr: string, priority: number } => { + if (Array.isArray(rule)) { + const styles = rule[1]; + const priority = rule[2]; + if ( + styles != null && + typeof styles === 'object' && + typeof styles.ltr === 'string' && + typeof priority === 'number' + ) { + return { ltr: styles.ltr, priority }; + } + } + throw new UnsupportedCssError( + `metadata rule ${JSON.stringify(rule) ?? 'undefined'}`, + ); + }) + .sort((a, b) => a.priority - b.priority); + + const out: { [string]: NetDeclaration } = {}; + for (const { ltr } of entries) { + // @keyframes rules are compared separately (frame contents), not as + // per-property net CSS. + if (ltr.trim().startsWith('@keyframes')) { + continue; + } + parseStylexRule(ltr, out); + } + return out; +} + +export type FrameMap = { +[selector: string]: { +[property: string]: string } }; + +/** Extracts `@keyframes NAME{ from{...} to{...} }` rules from StyleX + * metadata as normalized frame maps (name-agnostic). */ +export function keyframesFromStylexMetadata( + metadata: mixed, +): $ReadOnlyArray { + const rules = + metadata != null && + typeof metadata === 'object' && + Array.isArray(metadata.stylex) + ? metadata.stylex + : Array.isArray(metadata) + ? metadata + : []; + const out: Array = []; + for (const rule of rules) { + let ltr = null; + if (Array.isArray(rule)) { + const styles = rule[1]; + if ( + styles != null && + typeof styles === 'object' && + typeof styles.ltr === 'string' + ) { + ltr = styles.ltr; + } + } + if (ltr == null || !ltr.trim().startsWith('@keyframes')) { + continue; + } + const brace = ltr.indexOf('{'); + const inner = ltr.slice(brace + 1, ltr.lastIndexOf('}')); + out.push(parseFrames(inner)); + } + return out; +} + +/** Parses `from{...}to{...}0%{...}` frame text into a normalized frame map. */ +export function parseFrames(css: string): FrameMap { + const frames: { [string]: { [string]: string } } = {}; + const blockRe = /([^{}]+)\{([^{}]*)\}/g; + for (const match of css.trim().matchAll(blockRe)) { + const rawSelector = match[1]; + const body = match[2]; + if (rawSelector == null || body == null) { + continue; + } + const selector = rawSelector.trim().toLowerCase(); + const decls: { [string]: string } = {}; + for (const chunk of body.split(';')) { + const decl = chunk.trim(); + if (decl === '') { + continue; + } + const colon = decl.indexOf(':'); + if (colon <= 0) { + throw new UnsupportedCssError(`keyframe declaration '${decl}'`); + } + decls[decl.slice(0, colon).trim().toLowerCase()] = normalizeValue( + decl.slice(colon + 1), + ); + } + frames[selector] = decls; + } + if (Object.keys(frames).length === 0) { + throw new UnsupportedCssError(`no keyframe blocks in '${css}'`); + } + return frames; +} + +function parseStylexRule(ltr: string, out: { [string]: NetDeclaration }): void { + let rest = ltr.trim(); + const atRules: Array = []; + while (rest.startsWith('@')) { + const brace = rest.indexOf('{'); + if (brace < 0 || !rest.endsWith('}')) { + throw new UnsupportedCssError(`at-rule '${ltr}'`); + } + atRules.push(rest.slice(0, brace)); + rest = rest.slice(brace + 1, -1).trim(); + } + const match = + /^((?:\.[\w-]+)+)((?:::?[\w-]+(?:\([^()]*\))?)*)\{([^{}]*)\}$/.exec(rest); + if (match == null) { + throw new UnsupportedCssError(`rule '${ltr}'`); + } + const pseudoChain = match[2]; + const pseudos = + pseudoChain === '' + ? [] + : (pseudoChain.match(/::?[\w-]+(\([^()]*\))?/g) ?? []); + const conditions = [...atRules, ...pseudos]; + for (const chunk of match[3].split(';')) { + const decl = chunk.trim(); + if (decl === '') { + continue; + } + const colon = decl.indexOf(':'); + if (colon <= 0) { + throw new UnsupportedCssError(`declaration '${decl}' in '${ltr}'`); + } + addDeclaration( + out, + decl.slice(0, colon), + conditions, + decl.slice(colon + 1), + ); + } +} + +// --- the allowlist (sanctioned, intentional diffs) ---------------------- + +const HOVER_GUARD = normalizeAtRule('@media (hover: hover)'); + +const PHYSICAL_TO_LOGICAL: { +[string]: string } = { + 'margin-left': 'margin-inline-start', + 'margin-right': 'margin-inline-end', + 'padding-left': 'padding-inline-start', + 'padding-right': 'padding-inline-end', + 'border-left': 'border-inline-start', + 'border-right': 'border-inline-end', + left: 'inset-inline-start', + right: 'inset-inline-end', +}; + +const LOGICAL_TO_PHYSICAL: { [string]: string } = {}; +for (const physical of Object.keys(PHYSICAL_TO_LOGICAL)) { + LOGICAL_TO_PHYSICAL[PHYSICAL_TO_LOGICAL[physical]] = physical; +} + +/** + * Sanctioned: a physical property on the before side replaced by its + * logical twin (same conditions, same value) on the after side. + */ +export const allowPhysicalToLogical: AllowlistRule = (entry, before, after) => { + if (entry.beforeValue != null && entry.afterValue == null) { + const logical = PHYSICAL_TO_LOGICAL[entry.property]; + return ( + logical != null && + after[coordinate(logical, entry.conditions)]?.value === entry.beforeValue + ); + } + if (entry.afterValue != null && entry.beforeValue == null) { + const physical = LOGICAL_TO_PHYSICAL[entry.property]; + return ( + physical != null && + before[coordinate(physical, entry.conditions)]?.value === entry.afterValue + ); + } + return false; +}; + +/** + * Sanctioned: a `:hover` declaration additionally wrapped in + * `@media (hover: hover)` on the after side (the hover-guard, on by + * default in the codemod). + */ +export const allowHoverGuard: AllowlistRule = (entry, before, after) => { + const hasHover = entry.conditions.some((c) => c.startsWith(':hover')); + if (!hasHover) { + return false; + } + if (entry.beforeValue != null && entry.afterValue == null) { + const guarded = [...entry.conditions, HOVER_GUARD].sort(); + return ( + after[coordinate(entry.property, guarded)]?.value === entry.beforeValue + ); + } + if ( + entry.afterValue != null && + entry.beforeValue == null && + entry.conditions.includes(HOVER_GUARD) + ) { + const unguarded = entry.conditions.filter((c) => c !== HOVER_GUARD); + return ( + before[coordinate(entry.property, unguarded)]?.value === entry.afterValue + ); + } + return false; +}; + +/** + * Sanctioned: `animation-name` present only on the after side. Emotion and + * StyleX generate different opaque keyframes names, so the codemod omits the + * reference from the Emotion "before" and the frame CONTENTS are verified + * separately (see `keyframesFromStylexMetadata`). This only ever fires for a + * converted keyframes reference — a literal animationName string stays in the + * before and is diffed normally. + */ +export const allowGeneratedAnimationName: AllowlistRule = (entry) => + entry.property === 'animation-name' && + entry.beforeValue == null && + entry.afterValue != null; + +export const DEFAULT_ALLOWLIST: $ReadOnlyArray = [ + allowPhysicalToLogical, + allowHoverGuard, + allowGeneratedAnimationName, +]; + +// --- the gate ----------------------------------------------------------- + +export function semanticDiffGate( + beforeRaw: NetCss, + afterRaw: NetCss, + options?: { +allowlist?: $ReadOnlyArray }, +): SemanticDiffResult { + const allowlist = options?.allowlist ?? DEFAULT_ALLOWLIST; + // Expand box shorthands on both sides so a shorthand and its expanded + // longhands compare equal. + const before = canonicalizeNetCss(beforeRaw); + const after = canonicalizeNetCss(afterRaw); + const coordinates = new Set([...Object.keys(before), ...Object.keys(after)]); + const diffs: Array = []; + const allowed: Array = []; + for (const coord of coordinates) { + const b = before[coord]; + const a = after[coord]; + if (b != null && a != null && b.value === a.value) { + continue; + } + const entry: DiffEntry = { + coordinate: coord, + property: (b ?? a)?.property ?? coord, + conditions: (b ?? a)?.conditions ?? [], + beforeValue: b?.value ?? null, + afterValue: a?.value ?? null, + }; + if (allowlist.some((rule) => rule(entry, before, after))) { + allowed.push(entry); + } else { + diffs.push(entry); + } + } + return diffs.length === 0 + ? { ok: true, allowed } + : { ok: false, diffs, allowed }; +} diff --git a/packages/@stylexjs/codemods/src/core/ir.js b/packages/@stylexjs/codemods/src/core/ir.js new file mode 100644 index 000000000..1a0e6a84c --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/ir.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * The neutral intermediate representation (IR) shared by every adapter and + * the core engine. Its shape mirrors StyleX's OUTPUT (property-grouped, with + * conditions nested inside a property), not any source library's input. + * + * A style either maps into this IR (=> convertible) or it does not + * (=> flagged, never guessed). The edge of the IR is the edge of what is + * automatable. + * + * The two open axes — `Condition` and `Value` — are variant types because + * they are the only axes StyleX itself grows along. New condition kinds + * (`@supports`, `@container`, ancestor selectors) and new value kinds + * (dynamic/function-form) are ADDITIVE extensions; the structure never + * changes for a new source library. + */ + +/** + * A single typed condition under which a value applies. + * Only self-targeting conditions are representable — selectors that reach + * outside the element have no encoding here, by design. + */ +export type Condition = + | { +kind: 'pseudo-class', +name: string } // e.g. ':hover' + | { +kind: 'pseudo-element', +name: string } // e.g. '::before' + | { +kind: 'at-rule', +rule: string }; // e.g. '@media (min-width: 600px)' + +/** + * The value of an atom. `static` is the base variant; `first-that-works` is + * a fallback array; `reference` is an identifier reference (e.g. + * `animationName` pointing at a `stylex.keyframes` variable). A `dynamic` + * variant (props-driven, -> StyleX function-form create) is the planned + * v1.1 addition. + */ +export type Value = + | { +kind: 'static', +value: string | number | null } + | { + +kind: 'first-that-works', + +values: $ReadOnlyArray, + } + | { +kind: 'reference', +name: string }; + +/** + * The unit of the IR: one property, under zero or more conditions, + * with one value. e.g. { property: 'color', conditions: [:hover], 'blue' }. + */ +export type Atom = { + +property: string, // camelCase, StyleX-style (e.g. 'backgroundColor') + +conditions: $ReadOnlyArray, + +value: Value, +}; + +/** + * One named entry in a `stylex.create` registry — a group of atoms with the + * key the emitter should try to use for it. + */ +export type StyleRule = { + +name: string, + +atoms: $ReadOnlyArray, +}; + +/** An object-form keyframes declaration (`stylex.keyframes`). */ +export type KeyframesRule = { + +name: string, + +frames: $ReadOnlyArray<{ + +selector: string, // 'from' | 'to' | '0%' ... + +atoms: $ReadOnlyArray, + }>, +}; + +/** + * Everything the engine knows about one file's styles: the rules destined + * for the single per-file `stylex.create`, plus file-level constructs. + * Design tokens (`defineVars`) join in M3. + */ +export type FileIR = { + +rules: $ReadOnlyArray, + +keyframes: $ReadOnlyArray, +}; + +/** Stable string form of a condition, used for grouping and diffing. */ +export function conditionKey(condition: Condition): string { + switch (condition.kind) { + case 'pseudo-class': + case 'pseudo-element': + return condition.name; + case 'at-rule': + default: + return condition.rule; + } +} + +/** + * Stable string form of an atom's (property, conditions) coordinate — + * conditions sorted so ordering differences do not create false diffs. + */ +export function atomCoordinate( + property: string, + conditions: $ReadOnlyArray, +): string { + const keys = conditions.map(conditionKey).slice().sort(); + return keys.length === 0 ? property : `${property} @ ${keys.join(' && ')}`; +} diff --git a/packages/@stylexjs/codemods/src/core/normalize.js b/packages/@stylexjs/codemods/src/core/normalize.js new file mode 100644 index 000000000..c2b30415d --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/normalize.js @@ -0,0 +1,73 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L6 — Normalize. Value & vocabulary normalization on the IR. + * + * M3a: physical → logical property mapping (`marginLeft` → + * `marginInlineStart`, `left` → `insetInlineStart`, …). This is a + * DELIBERATE, doc-sanctioned RTL behavior change — in a right-to-left + * document the logical property flips side where the physical one would + * not. It is covered by the semantic-diff gate's physical→logical allowlist + * (which proves LTR-equivalence), and it matches StyleX's own preference + * for logical properties. On by default; disable with + * `{ logicalProperties: false }` (full config lands in M6). + * + * Runs BEFORE the referee so all downstream layers see one consistent + * vocabulary. Only the inline-axis pairs are mapped — block-axis (`top`, + * `bottom`) does not flip in RTL and is left physical. + * + * Later M3 slices add here: multi-value shorthand expansion + * (`style-value-parser`) and unitless handling. + */ + +import type { Atom, FileIR, StyleRule } from './ir'; + +export type NormalizeOptions = { + /** Map inline-axis physical properties to logical ones (default true). */ + +logicalProperties?: boolean, +}; + +/** Inline-axis physical → logical property names (camelCase, matching the + * semantic-diff gate's allowlist). Block-axis stays physical (no RTL flip). */ +const PHYSICAL_TO_LOGICAL: $ReadOnly<{ [string]: string }> = { + marginLeft: 'marginInlineStart', + marginRight: 'marginInlineEnd', + paddingLeft: 'paddingInlineStart', + paddingRight: 'paddingInlineEnd', + left: 'insetInlineStart', + right: 'insetInlineEnd', +}; + +function mapAtom(atom: Atom): Atom { + const mapped = PHYSICAL_TO_LOGICAL[atom.property]; + return mapped == null ? atom : { ...atom, property: mapped }; +} + +export function normalizeFileIR( + ir: FileIR, + options?: NormalizeOptions, +): FileIR { + const logical = options?.logicalProperties ?? true; + if (!logical) { + return ir; + } + const rules: $ReadOnlyArray = ir.rules.map((rule) => ({ + name: rule.name, + atoms: rule.atoms.map(mapAtom), + })); + const keyframes = ir.keyframes.map((kf) => ({ + name: kf.name, + frames: kf.frames.map((frame) => ({ + selector: frame.selector, + atoms: frame.atoms.map(mapAtom), + })), + })); + return { rules, keyframes }; +} diff --git a/packages/@stylexjs/codemods/src/core/postprocess.js b/packages/@stylexjs/codemods/src/core/postprocess.js new file mode 100644 index 000000000..62360a540 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/postprocess.js @@ -0,0 +1,82 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L10 — Postprocess. Runs StyleX's OWN eslint autofixes over emitted output, + * so the result passes `@stylexjs/eslint-plugin` at error with zero autofixes + * remaining (Meta's golden rule) — and, crucially, so key ordering and + * shorthand handling exactly match StyleX's canonical form rather than a + * heuristic we maintain. + * + * M4: the caller runs this on a SCOPED snippet containing only the emitted + * `stylex.create`/`stylex.keyframes` (plus usage stubs), never the whole + * file — so a user's pre-existing StyleX code is never linted or reordered + * (proven by `__tests__/scoped-postprocess-test.js`). The fixed objects are + * then spliced back into the file. + * + * The sort-keys autofix can reorder sibling media queries, which the StyleX + * compiler treats as semantic — but the semantic-diff gate runs on the final + * output, so any reorder that changes rendering is caught (file refused) + * rather than shipped. + * + * Residual (unfixable) errors mean the output is not clean; the caller + * refuses the whole file. + */ + +import { Linter } from 'eslint'; +import * as hermesEslint from 'hermes-eslint'; +import { rules as stylexRules } from '@stylexjs/eslint-plugin'; + +export type PostprocessResult = { + +code: string, + +residualErrors: $ReadOnlyArray, +}; + +export type PostprocessOptions = { + /** Rule short-names to omit (e.g. 'no-unused' when linting a snippet whose + * styles are used elsewhere). */ + +excludeRules?: $ReadOnlyArray, +}; + +const PARSER_NAME = 'hermes-eslint'; + +export function postprocess( + code: string, + filename: string = 'file.js', + options?: PostprocessOptions, +): PostprocessResult { + const excluded = new Set(options?.excludeRules ?? []); + const linter = new Linter(); + linter.defineParser(PARSER_NAME, hermesEslint); + const ruleMap: { +[string]: mixed } = stylexRules; + const config: { [string]: 'error' } = {}; + for (const ruleName of Object.keys(ruleMap)) { + if (excluded.has(ruleName)) { + continue; + } + const qualified = `@stylexjs/${ruleName}`; + linter.defineRule(qualified, ruleMap[ruleName]); + config[qualified] = 'error'; + } + + const verifyConfig = { + parser: PARSER_NAME, + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + rules: config, + }; + const fixed = linter.verifyAndFix(code, verifyConfig, { filename }); + const residual = linter.verify(fixed.output, verifyConfig, { filename }); + + return { + code: fixed.output, + residualErrors: residual.map( + (m) => `${m.ruleId ?? 'error'}: ${m.message} (line ${m.line ?? 0})`, + ), + }; +} diff --git a/packages/@stylexjs/codemods/src/core/referee.js b/packages/@stylexjs/codemods/src/core/referee.js new file mode 100644 index 000000000..e9fc2bcaf --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/referee.js @@ -0,0 +1,213 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L5 — Referee. The safety check that makes conditional conversion trustworthy. + * + * Emotion emits plain CSS; the browser resolves it by the cascade + * (specificity, then source order). StyleX resolves atomic classes by a + * fixed priority number per condition (imported here from + * `@stylexjs/shared`, never reinvented). StyleX's priorities are DESIGNED to + * replicate the cascade — so the two agree automatically whenever they + * differ in specificity. The one place they can DISAGREE is among conditions + * of EQUAL specificity (e.g. two pseudo-classes both active), where CSS uses + * source order but StyleX uses its fixed priority. + * + * So the rule is: for each property, within each pseudo-element target (a + * pseudo-element styles a different box, so it never competes with the base + * element), the order induced by the cascade must match the order induced by + * StyleX priority. If they match for every property, the conversion renders + * identically under every combination of active conditions. If any property + * disagrees, we REFUSE — never emit a confident-but-wrong cascade. + * + * Two referees "agree" is proven post-hoc by the semantic-diff gate for the + * per-coordinate values; this referee covers the thing that gate cannot see: + * which declaration wins when several are simultaneously active. + */ + +import { getPriority } from '@stylexjs/shared'; +import type { Atom, Condition, StyleRule } from './ir'; +import { conditionKey } from './ir'; + +export type RefereeResult = + | { +ok: true, +rule: StyleRule } + | { +ok: false, +conflicts: $ReadOnlyArray }; + +/** Sum of StyleX condition priorities for an atom (property base cancels + * within a property, so it is intentionally omitted). */ +function conditionPriority(conditions: $ReadOnlyArray): number { + let total = 0; + for (const condition of conditions) { + total += getPriority(conditionKey(condition)); + } + return total; +} + +/** Count of pseudo-classes — the only part of a condition stack that + * changes CSS specificity among competing (same-box) atoms. */ +function pseudoClassCount(conditions: $ReadOnlyArray): number { + return conditions.filter((c) => c.kind === 'pseudo-class').length; +} + +/** The pseudo-element a condition stack targets, or '' for the base box. + * Atoms with different targets never compete. */ +function pseudoElementTarget(conditions: $ReadOnlyArray): string { + return conditions + .filter((c) => c.kind === 'pseudo-element') + .map(conditionKey) + .sort() + .join(''); +} + +function coordinate(atom: Atom): string { + return atom.conditions.map(conditionKey).slice().sort().join('&&'); +} + +/** Signature of an atom's non-at-rule context (pseudo-classes + elements) — + * two atoms with the same signature but different at-rules become sibling + * media keys under one object. */ +function nonAtRuleSignature(conditions: $ReadOnlyArray): string { + return conditions + .filter((c) => c.kind !== 'at-rule') + .map(conditionKey) + .sort() + .join('&&'); +} + +function atRuleSignature(conditions: $ReadOnlyArray): string { + return conditions + .filter((c) => c.kind === 'at-rule') + .map(conditionKey) + .sort() + .join('&&'); +} + +/** + * The sibling-at-rule hazard: when one property has ≥2 atoms that share a + * pseudo context but differ by at-rule, they become sibling media keys in + * the emitted object. StyleX's sort-keys autofix reorders sibling keys, but + * the compiler treats sibling media order as SEMANTIC (last matching wins), + * so a reorder can silently change rendering — and the per-coordinate + * semantic-diff gate cannot see it. Until that upstream inconsistency is + * resolved we refuse (never emit incorrectly). Returns a conflict string or null. + */ +function siblingAtRuleConflict( + property: string, + atoms: $ReadOnlyArray, +): string | null { + const atRulesByContext: Map> = new Map(); + for (const atom of atoms) { + const at = atRuleSignature(atom.conditions); + if (at === '') { + continue; + } + const context = nonAtRuleSignature(atom.conditions); + const set = atRulesByContext.get(context) ?? new Set(); + set.add(at); + atRulesByContext.set(context, set); + } + for (const [, set] of atRulesByContext) { + if (set.size >= 2) { + return ( + `'${property}': ≥2 sibling at-rule (e.g. @media) conditions whose order ` + + 'is semantic to the StyleX compiler but which sort-keys would reorder ' + + `(${[...set].join(', ')})` + ); + } + } + return null; +} + +/** + * Checks one rule. On success returns the rule with same-coordinate + * duplicates collapsed (last-in-source wins, matching both Emotion and + * StyleX). On conflict returns the offending property coordinates. + */ +export function checkRule(rule: StyleRule): RefereeResult { + const conflicts: Array = []; + + // Group atoms by property, preserving source order (array order). + const byProperty: Map< + string, + Array<{ +atom: Atom, +order: number }>, + > = new Map(); + rule.atoms.forEach((atom, order) => { + const list = byProperty.get(atom.property) ?? []; + list.push({ atom, order }); + byProperty.set(atom.property, list); + }); + + const keptAtoms: Array = []; + + for (const [property, entries] of byProperty) { + // Collapse same-coordinate duplicates: keep the last in source order. + const lastByCoord: Map = new Map(); + for (const entry of entries) { + lastByCoord.set(coordinate(entry.atom), entry); + } + const deduped = [...lastByCoord.values()]; + + const siblingConflict = siblingAtRuleConflict( + property, + deduped.map((e) => e.atom), + ); + if (siblingConflict != null) { + conflicts.push(siblingConflict); + } + + // Partition by pseudo-element target; each box is refereed independently. + const byTarget: Map< + string, + Array<{ +atom: Atom, +order: number }>, + > = new Map(); + for (const entry of deduped) { + const target = pseudoElementTarget(entry.atom.conditions); + const list = byTarget.get(target) ?? []; + list.push(entry); + byTarget.set(target, list); + } + + for (const [, group] of byTarget) { + // Cascade order: specificity (pseudo-class count) asc, then source order. + const cascadeOrder = [...group].sort((a, b) => { + const spec = + pseudoClassCount(a.atom.conditions) - + pseudoClassCount(b.atom.conditions); + return spec !== 0 ? spec : a.order - b.order; + }); + // StyleX order: condition priority asc, then source order for ties. + const stylexOrder = [...group].sort((a, b) => { + const pri = + conditionPriority(a.atom.conditions) - + conditionPriority(b.atom.conditions); + return pri !== 0 ? pri : a.order - b.order; + }); + // The two orderings must be identical, or some active-condition combo + // renders differently. + for (let i = 0; i < cascadeOrder.length; i++) { + if (cascadeOrder[i].atom !== stylexOrder[i].atom) { + conflicts.push( + `'${property}': Emotion source-order and StyleX priority disagree ` + + 'on which conditional value wins when several are active ' + + `(${group.map((g) => coordinate(g.atom) || 'base').join(' vs ')})`, + ); + break; + } + } + for (const entry of group) { + keptAtoms.push(entry.atom); + } + } + } + + if (conflicts.length > 0) { + return { ok: false, conflicts }; + } + return { ok: true, rule: { name: rule.name, atoms: keptAtoms } }; +} diff --git a/packages/@stylexjs/codemods/src/core/rewriter.js b/packages/@stylexjs/codemods/src/core/rewriter.js new file mode 100644 index 000000000..ef4179939 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/rewriter.js @@ -0,0 +1,112 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * The single place in the codebase that knows which AST toolkit we use. + * Everything else (core and adapters alike) goes through this wrapper, so + * jscodeshift/recast stays swappable (hermes-parser, ts-morph, plain + * babel+recast) without touching any other module — a maintainer question + * we have deliberately kept open. + * + * jscodeshift is the default: format-preserving printing via recast, and + * parses the Flow/TS/JSX found in user code. Adapters receive `j` and + * `root` from here and never import the toolkit themselves (enforced by + * seam-test); `core/` outside this file never sees an AST node at all. + */ + +import jscodeshift from 'jscodeshift'; +import type { EmittedStyle, EmittedValue } from './emit'; + +// 'flow' also covers plain JS + JSX; 'tsx' also covers plain TS. +export type ParserChoice = 'flow' | 'tsx'; + +export type Rewriter = { + +j: $FlowFixMe, + +root: $FlowFixMe, +}; + +/** Picks a parser from the file extension (TS/TSX vs Flow/JS). */ +export function parserForFile(filename: string): ParserChoice { + return /\.(ts|tsx|mts|cts)$/.test(filename) ? 'tsx' : 'flow'; +} + +/** Parses source into a rewriter handle (a jscodeshift Collection). */ +export function parseSource( + source: string, + options?: { +parser?: ParserChoice }, +): Rewriter { + const j = jscodeshift.withParser(options?.parser ?? 'flow'); + return { j, root: j(source) }; +} + +/** Prints a rewriter handle back to source, format-preserving. */ +export function printSource(rewriter: Rewriter): string { + return rewriter.root.toSource({ quote: 'single' }); +} + +/** + * Builds a JSX comment container node — the braced comment form that is valid + * as a JSX child, unlike a bare block comment which would render as visible + * text. Parses a real one so recast reprints it correctly. + */ +export function jsxComment(j: $FlowFixMe, text: string): $FlowFixMe { + let container = null; + j(`{/*${text}*/}`) + .find(j.JSXExpressionContainer) + .forEach((path: $FlowFixMe) => { + container = path.node; + }); + return container; +} + +/** + * Renders emitted style data (plain values, fallback arrays, or nested + * condition objects) as an ObjectExpression — the bridge that lets + * `core/emit.js` stay AST-free. + */ +export function styleToObjectAst( + j: $FlowFixMe, + style: EmittedStyle, +): $FlowFixMe { + return objectAst(j, style); +} + +const IDENTIFIER_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** A property/condition key: bare identifier where legal, else a string + * literal (`':hover'`, `'@media (min-width: 600px)'`). */ +function keyAst(j: $FlowFixMe, key: string): $FlowFixMe { + return IDENTIFIER_KEY.test(key) ? j.identifier(key) : j.literal(key); +} + +function valueAst(j: $FlowFixMe, value: EmittedValue): $FlowFixMe { + if (Array.isArray(value)) { + return j.arrayExpression(value.map((v) => j.literal(v))); + } + if (value != null && typeof value === 'object') { + // A reference sentinel renders as a bare identifier (e.g. `animationName: + // spin`), not a string literal. + if (typeof value.$$ref === 'string') { + return j.identifier(value.$$ref); + } + return objectAst(j, value); + } + return j.literal(value); +} + +function objectAst( + j: $FlowFixMe, + object: { +[string]: EmittedValue }, +): $FlowFixMe { + return j.objectExpression( + Object.keys(object).map((key) => + j.property('init', keyAst(j, key), valueAst(j, object[key])), + ), + ); +} diff --git a/packages/@stylexjs/codemods/src/core/todos.js b/packages/@stylexjs/codemods/src/core/todos.js new file mode 100644 index 000000000..e4227500e --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/todos.js @@ -0,0 +1,51 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L? — Bail-loudly flagging. When a single style site is unconvertible, the + * codemod no longer skips the whole file: it converts what it safely can and + * leaves a precise `// TODO(stylex-migration): …` marker on the site a human + * must handle. This module owns the marker text and the canonical reasons. + * + * The reason strings that flow from the detect/read/referee layers are + * already human-readable; `REASONS` documents the recurring categories so + * they stay consistent, but any descriptive string is accepted. + */ + +export const TODO_PREFIX: string = 'TODO(stylex-migration)'; + +/** + * Canonical reasons for the recurring unconvertible categories. These land in + * comments committed to the user's source, so they are short, plain, and + * TIMELESS — no roadmap/version references (those go stale and read + * ambiguously). Anything shipped-later belongs in the dry-run report, not the + * committed comment. + */ +export const REASONS: { + +templateLiteral: string, + +dynamicValue: string, + +componentElement: string, + +propConflict: string, +} = { + templateLiteral: 'template-literal styles', + dynamicValue: 'dynamic value (props-driven)', + componentElement: 'css on a component element', + propConflict: 'css mixed with className/style/spread', +}; + +/** The block-comment body for a flagged site (note the surrounding spaces). */ +export function formatTodo(reason: string): string { + return ` ${TODO_PREFIX}: ${reason} `; +} + +/** Whether a comment body is one of our markers (re-run guard: an already + * flagged site is left alone on a second pass). */ +export function isTodoMarker(commentValue: string): boolean { + return commentValue.includes(TODO_PREFIX); +} diff --git a/packages/@stylexjs/codemods/src/index.js b/packages/@stylexjs/codemods/src/index.js new file mode 100644 index 000000000..e454775db --- /dev/null +++ b/packages/@stylexjs/codemods/src/index.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * @stylexjs/codemods — migrate styling libraries to StyleX. + * + * M0: gated harness only. The public surface is the IR types and the three + * correctness gates; transforms land from M1. + */ + +export type { + Atom, + Condition, + Value, + StyleRule, + KeyframesRule, + FileIR, +} from './core/ir'; +export { conditionKey, atomCoordinate } from './core/ir'; + +export { compileGate } from './core/gates/compile'; +export { lintGate } from './core/gates/lint'; +export { + semanticDiffGate, + netCssFromSerializedCss, + netCssFromStylexMetadata, + DEFAULT_ALLOWLIST, + UnsupportedCssError, +} from './core/gates/semanticDiff'; + +export { parseSource, printSource, parserForFile } from './core/rewriter'; diff --git a/packages/@stylexjs/codemods/src/testing/stylexToIR.js b/packages/@stylexjs/codemods/src/testing/stylexToIR.js new file mode 100644 index 000000000..004765676 --- /dev/null +++ b/packages/@stylexjs/codemods/src/testing/stylexToIR.js @@ -0,0 +1,128 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * TEST-ONLY reader: a valid `stylex.create` style object -> IR. + * + * This powers the IR-completeness harness: round-tripping StyleX's own + * valid-input corpus (read -> IR -> re-emit -> still compiles & semantically + * identical) turns "is the IR complete?" from a claim into a measured + * coverage percentage. An IR gap found here is SAFE — in the real pipeline + * the same construct would be flagged, never emitted incorrectly. + * + * M2 coverage: flat static values, fallback arrays, AND condition-in-value + * objects (`{ default, ':hover', '@media …', '::before' }`, including + * nesting) — the StyleX-side proof that the flip round-trips. `null` values + * and keyframes are M3 gaps. + */ + +import type { Atom, Condition, StyleRule, Value } from '../core/ir'; + +export class IRCoverageGapError extends Error { + constructor(message: string) { + super(`[stylex-codemod ir-completeness] ${message}`); + this.name = 'IRCoverageGapError'; + } +} + +// Builds a properly-typed (string | number)[] from an untyped array, or +// null if any entry is not one of those two types. A plain `.every()` +// predicate does not refine an `Array` to +// `Array` for Flow, so this does the narrowing by hand. +function toStaticValueArray( + raw: $ReadOnlyArray, +): Array | null { + const values: Array = []; + for (const entry of raw) { + if (typeof entry !== 'string' && typeof entry !== 'number') { + return null; + } + values.push(entry); + } + return values; +} + +/** Parses a StyleX condition key into a typed IR condition, or throws. */ +function toCondition(context: string, key: string): Condition { + if (key.startsWith('@')) { + return { kind: 'at-rule', rule: key }; + } + if (key.startsWith('::')) { + return { kind: 'pseudo-element', name: key }; + } + if (key.startsWith(':')) { + return { kind: 'pseudo-class', name: key }; + } + throw new IRCoverageGapError( + `${context}: unrecognized condition key '${key}'`, + ); +} + +/** A static leaf value, or null if not one. */ +function toLeafValue(raw: mixed): Value | null { + if (typeof raw === 'string' || typeof raw === 'number') { + return { kind: 'static', value: raw }; + } + if (Array.isArray(raw) && raw.length > 0) { + const values = toStaticValueArray(raw); + return values == null ? null : { kind: 'first-that-works', values }; + } + return null; +} + +/** Walks a property's value — a leaf, or a condition object (possibly + * nested) — emitting one atom per leaf with its accumulated conditions. */ +function readProperty( + context: string, + property: string, + raw: mixed, + conditions: $ReadOnlyArray, + atoms: Array, +): void { + const leaf = toLeafValue(raw); + if (leaf != null) { + atoms.push({ property, conditions, value: leaf }); + return; + } + if (raw == null || typeof raw !== 'object') { + throw new IRCoverageGapError( + `${context}.${property}: value form not representable yet ` + + '(null/keyframes land in M3)', + ); + } + const conditionObject: { +[string]: mixed } = raw; + for (const key of Object.keys(conditionObject)) { + if (key === 'default') { + readProperty(context, property, conditionObject[key], conditions, atoms); + } else { + readProperty( + context, + property, + conditionObject[key], + [...conditions, toCondition(`${context}.${property}`, key)], + atoms, + ); + } + } +} + +export function stylexObjectToIR(name: string, styleObject: mixed): StyleRule { + if ( + styleObject == null || + typeof styleObject !== 'object' || + Array.isArray(styleObject) + ) { + throw new IRCoverageGapError(`'${name}': not a style object`); + } + const atoms: Array = []; + for (const property of Object.keys(styleObject)) { + readProperty(name, property, styleObject[property], [], atoms); + } + return { name, atoms }; +} diff --git a/yarn.lock b/yarn.lock index 5936037d6..86879fe00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -243,6 +243,11 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz" integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== +"@babel/compat-data@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" + integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== + "@babel/core@^7.18.2", "@babel/core@^7.23.9", "@babel/core@^7.24.4", "@babel/core@^7.26.8", "@babel/core@^7.27.4", "@babel/core@^7.28.0", "@babel/core@^7.28.4", "@babel/core@^7.28.5": version "7.28.5" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz" @@ -264,6 +269,27 @@ json5 "^2.2.3" semver "^6.3.1" +"@babel/core@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" + integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-compilation-targets" "^7.29.7" + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helpers" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + "@babel/eslint-parser@^7.26.8": version "7.28.5" resolved "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.5.tgz" @@ -302,6 +328,13 @@ dependencies: "@babel/types" "^7.27.3" +"@babel/helper-annotate-as-pure@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz#c70fe3c6ecbdc3fd2dd1b0f498428b88b82ce47f" + integrity sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw== + dependencies: + "@babel/types" "^7.29.7" + "@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2": version "7.27.2" resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz" @@ -313,6 +346,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042" + integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g== + dependencies: + "@babel/compat-data" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-create-class-features-plugin@^7.27.1", "@babel/helper-create-class-features-plugin@^7.28.3", "@babel/helper-create-class-features-plugin@^7.28.5": version "7.28.5" resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz" @@ -326,6 +370,19 @@ "@babel/traverse" "^7.28.5" semver "^6.3.1" +"@babel/helper-create-class-features-plugin@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz#6eddf286f2ec418f740c91d60a83347c55838ddd" + integrity sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.29.7" + "@babel/helper-member-expression-to-functions" "^7.29.7" + "@babel/helper-optimise-call-expression" "^7.29.7" + "@babel/helper-replace-supers" "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7" + "@babel/traverse" "^7.29.7" + semver "^6.3.1" + "@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.27.1": version "7.28.5" resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz" @@ -364,6 +421,14 @@ "@babel/traverse" "^7.28.5" "@babel/types" "^7.28.5" +"@babel/helper-member-expression-to-functions@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz#8dbdb3ce0b5c487e1aec10e13c9a43a500814df8" + integrity sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/helper-module-imports@^7.18.6", "@babel/helper-module-imports@^7.25.9", "@babel/helper-module-imports@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz" @@ -405,6 +470,13 @@ dependencies: "@babel/types" "^7.27.1" +"@babel/helper-optimise-call-expression@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz#77b0b5b94f1997fa9d6e3125f445227b1faf9d85" + integrity sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong== + dependencies: + "@babel/types" "^7.29.7" + "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz" @@ -433,6 +505,15 @@ "@babel/helper-optimise-call-expression" "^7.27.1" "@babel/traverse" "^7.27.1" +"@babel/helper-replace-supers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz#bc3c3964329043c79112e513c1b198f16589ac21" + integrity sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.29.7" + "@babel/helper-optimise-call-expression" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz" @@ -441,6 +522,14 @@ "@babel/traverse" "^7.27.1" "@babel/types" "^7.27.1" +"@babel/helper-skip-transparent-expression-wrappers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz#50c95c7e4c4f54936cfa0116428edc559862d551" + integrity sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/helper-string-parser@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz" @@ -466,6 +555,11 @@ resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz" integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== +"@babel/helper-validator-option@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a" + integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw== + "@babel/helper-wrap-function@^7.27.1": version "7.28.3" resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz" @@ -483,6 +577,14 @@ "@babel/template" "^7.27.2" "@babel/types" "^7.28.4" +"@babel/helpers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607" + integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== + dependencies: + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/highlight@^7.16.7": version "7.25.9" resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz" @@ -512,7 +614,7 @@ dependencies: "@babel/types" "^7.29.0" -"@babel/parser@^7.29.7": +"@babel/parser@^7.24.7", "@babel/parser@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== @@ -598,6 +700,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-syntax-flow@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz#3f3278c11c896c43bf8098250819785fd1231881" + integrity sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-syntax-import-assertions@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz" @@ -633,6 +742,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-syntax-jsx@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz#622c16f9ad63782fe6e83dadc7e40330744b7f1e" + integrity sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz" @@ -696,6 +812,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-syntax-typescript@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz#7c29388932313ed58413a0343048d75d92fb5b24" + integrity sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-syntax-unicode-sets-regex@^7.18.6": version "7.18.6" resolved "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz" @@ -743,6 +866,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-class-properties@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz#034897b8a21beec163332fac2de235b14409abdf" + integrity sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.29.7" + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-transform-class-properties@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz" @@ -847,6 +978,14 @@ "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-syntax-flow" "^7.27.1" +"@babel/plugin-transform-flow-strip-types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz#911bcb31608c3576510d7e0c95ccf64f9e1812d0" + integrity sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-syntax-flow" "^7.29.7" + "@babel/plugin-transform-for-of@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz" @@ -900,6 +1039,14 @@ "@babel/helper-module-transforms" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-modules-commonjs@^7.24.7", "@babel/plugin-transform-modules-commonjs@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz#70e6835abf2663dafbe94b8ef1f51de7351ef135" + integrity sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ== + dependencies: + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-transform-modules-commonjs@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz" @@ -941,6 +1088,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz#8a54cdf88c3f50433a6173117a286195b67714cc" + integrity sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-transform-nullish-coalescing-operator@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz" @@ -981,6 +1135,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-optional-chaining@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz#b84a1b574b3c73001023092567e16c492b720e51" + integrity sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7" + "@babel/plugin-transform-optional-chaining@^7.27.1", "@babel/plugin-transform-optional-chaining@^7.28.5": version "7.28.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz" @@ -996,6 +1158,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-private-methods@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz#cea8bd3ab99533892897a02999d5b752584ad145" + integrity sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.29.7" + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-transform-private-methods@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz" @@ -1136,6 +1306,17 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" "@babel/plugin-syntax-typescript" "^7.27.1" +"@babel/plugin-transform-typescript@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz#f0449c3df7037bbe232043476851c38f5e4a7615" + integrity sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.29.7" + "@babel/helper-create-class-features-plugin" "^7.29.7" + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7" + "@babel/plugin-syntax-typescript" "^7.29.7" + "@babel/plugin-transform-unicode-escapes@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz" @@ -1243,6 +1424,15 @@ core-js-compat "^3.43.0" semver "^6.3.1" +"@babel/preset-flow@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/preset-flow/-/preset-flow-7.29.7.tgz#ae33812dc267a296bd3709843affbb2d811c4709" + integrity sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + "@babel/plugin-transform-flow-strip-types" "^7.29.7" + "@babel/preset-flow@^7.25.9", "@babel/preset-flow@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz" @@ -1273,6 +1463,17 @@ "@babel/plugin-transform-react-jsx-development" "^7.27.1" "@babel/plugin-transform-react-pure-annotations" "^7.27.1" +"@babel/preset-typescript@^7.24.7", "@babel/preset-typescript@^7.28.5": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz#de9be1f47b785c979ec7b3a71f4cd8bae5267b62" + integrity sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + "@babel/plugin-syntax-jsx" "^7.29.7" + "@babel/plugin-transform-modules-commonjs" "^7.29.7" + "@babel/plugin-transform-typescript" "^7.29.7" + "@babel/preset-typescript@^7.25.7", "@babel/preset-typescript@^7.26.0": version "7.28.5" resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz" @@ -1284,6 +1485,17 @@ "@babel/plugin-transform-modules-commonjs" "^7.27.1" "@babel/plugin-transform-typescript" "^7.28.5" +"@babel/register@^7.24.6": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.29.7.tgz#d5bb4337065512f643b29cb565b6e8ccadcc1ec6" + integrity sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA== + dependencies: + clone-deep "^4.0.1" + find-cache-dir "^2.0.0" + make-dir "^2.1.0" + pirates "^4.0.6" + source-map-support "^0.5.16" + "@babel/register@^7.27.1": version "7.28.3" resolved "https://registry.npmjs.org/@babel/register/-/register-7.28.3.tgz" @@ -1707,6 +1919,37 @@ dependencies: tslib "^2.4.0" +"@emotion/hash@^0.9.2": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b" + integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== + +"@emotion/memoize@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.9.0.tgz#745969d649977776b43fc7648c556aaa462b4102" + integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== + +"@emotion/serialize@^1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.3.3.tgz#d291531005f17d704d0463a032fe679f376509e8" + integrity sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA== + dependencies: + "@emotion/hash" "^0.9.2" + "@emotion/memoize" "^0.9.0" + "@emotion/unitless" "^0.10.0" + "@emotion/utils" "^1.4.2" + csstype "^3.0.2" + +"@emotion/unitless@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.10.0.tgz#2af2f7c7e5150f497bdabd848ce7b218a27cf745" + integrity sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg== + +"@emotion/utils@^1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.4.2.tgz#6df6c45881fcb1c412d6688a311a98b7f59c1b52" + integrity sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA== + "@epic-web/invariant@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz" @@ -6779,7 +7022,7 @@ babel-plugin-react-compiler@1.0.0, babel-plugin-react-compiler@^1.0.0: dependencies: "@babel/types" "^7.26.0" -babel-plugin-syntax-hermes-parser@^0.36.1: +babel-plugin-syntax-hermes-parser@0.36.1, babel-plugin-syntax-hermes-parser@^0.36.1: version "0.36.1" resolved "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz" integrity sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA== @@ -9394,7 +9637,7 @@ fast-glob@3.3.1: merge2 "^1.3.0" micromatch "^4.0.4" -fast-glob@^3.2.9, fast-glob@^3.3.2: +fast-glob@^3.2.9, fast-glob@^3.3.2, fast-glob@^3.3.3: version "3.3.3" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz" integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== @@ -9652,6 +9895,18 @@ flow-enums-runtime@^0.0.6: resolved "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz" integrity sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw== +flow-estree@0.322.0: + version "0.322.0" + resolved "https://registry.yarnpkg.com/flow-estree/-/flow-estree-0.322.0.tgz#8310b5bc825049fe6526969217ee29bb09fdd576" + integrity sha512-v7wiiFWSKbJ1HGVCRKhxl+6pQWTQV6P4c7XEfKjVlH71YkgQyJCq0AG5dhJDD/3/SkJgX/F9lvAj2rbrFzArdQ== + +flow-parser@0.*: + version "0.322.0" + resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.322.0.tgz#7a42cfae08b0cf0a35bd9fdbe84593782f6a5052" + integrity sha512-Qfd8N4sSuWmlf1qk5M60fi8JrvhNvj9fbwMsRkcnm3UhLYukkbm2CFkAhiTD3n4RVXb6TuCHFCp78TMXpO0zcA== + dependencies: + flow-estree "0.322.0" + flow-typed@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/flow-typed/-/flow-typed-4.1.1.tgz" @@ -11542,6 +11797,30 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jscodeshift@^17.3.0: + version "17.4.0" + resolved "https://registry.yarnpkg.com/jscodeshift/-/jscodeshift-17.4.0.tgz#efa0619ddb1d994e85982ec37a7afe8e618ae324" + integrity sha512-i3ESKiiTsGynxzTg5BhsZViD0ai72/6SsI1efDZxG6/5KCoElsmquxtyhXK5lpEgoO7MTNpYkjFEdEL97SkBNg== + dependencies: + "@babel/core" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/plugin-transform-class-properties" "^7.24.7" + "@babel/plugin-transform-modules-commonjs" "^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.24.7" + "@babel/plugin-transform-optional-chaining" "^7.24.7" + "@babel/plugin-transform-private-methods" "^7.24.7" + "@babel/preset-flow" "^7.24.7" + "@babel/preset-typescript" "^7.24.7" + "@babel/register" "^7.24.6" + flow-parser "0.*" + graceful-fs "^4.2.4" + neo-async "^2.5.0" + picocolors "^1.0.1" + picomatch "^4.0.2" + recast "^0.23.11" + tmp "^0.2.3" + write-file-atomic "^5.0.1" + jsdom@^26.1.0: version "26.1.0" resolved "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz" @@ -13056,7 +13335,7 @@ negotiator@~0.6.4: resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz" integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== -neo-async@^2.6.1, neo-async@^2.6.2: +neo-async@^2.5.0, neo-async@^2.6.1, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== @@ -13700,7 +13979,7 @@ pend@~1.2.0: resolved "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz" integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== -picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1, picocolors@~1.1.1: +picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0, picocolors@^1.1.1, picocolors@~1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -14322,6 +14601,17 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +recast@^0.23.11: + version "0.23.12" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.12.tgz#3df7589ae1877d0a634c6c7ccc995a7c053850a9" + integrity sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA== + dependencies: + ast-types "^0.16.1" + esprima "~4.0.0" + source-map "~0.6.1" + tiny-invariant "^1.3.3" + tslib "^2.0.1" + recast@^0.23.5: version "0.23.11" resolved "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz" @@ -16183,6 +16473,11 @@ tmp@^0.2.0: resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz" integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== +tmp@^0.2.3: + version "0.2.7" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059" + integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz"