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..2f5721dc4 --- /dev/null +++ b/packages/@stylexjs/codemods/README.md @@ -0,0 +1,59 @@ +# @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: pre-release, milestone M3 of the v1.0 (MVP) ladder.** The +> correctness harness — fixture tests plus three gates (compile, lint, +> semantic-diff) — is in place and proven; transforms land milestone by +> milestone. Converting today: static styles, self-targeting conditions +> (pseudo-classes/elements, media queries), physical→logical properties, +> multi-value shorthands, and object-form keyframes. Nothing here is published +> yet, and the package name/location is pending maintainer confirmation. + +## 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. + +## 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/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/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/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/skip-component-css/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/expected.js new file mode 100644 index 000000000..856de0164 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/expected.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/skip-component-css/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/input.js new file mode 100644 index 000000000..856de0164 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-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/skip-descendant-selector/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/expected.js new file mode 100644 index 000000000..79bcb3666 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/expected.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/skip-descendant-selector/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/input.js new file mode 100644 index 000000000..79bcb3666 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-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/skip-existing-stylex/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/expected.js new file mode 100644 index 000000000..56dc390c8 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/expected.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/skip-existing-stylex/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/input.js new file mode 100644 index 000000000..56dc390c8 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/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/skip-referee-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/expected.js new file mode 100644 index 000000000..c38b05faa --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/expected.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/skip-referee-conflict/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/input.js new file mode 100644 index 000000000..c38b05faa --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-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/skip-shorthand-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/expected.js new file mode 100644 index 000000000..fc139e9f0 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/expected.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/skip-shorthand-conflict/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/input.js new file mode 100644 index 000000000..fc139e9f0 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-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/skip-sibling-media/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/expected.js new file mode 100644 index 000000000..b53202b41 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/expected.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/skip-sibling-media/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/input.js new file mode 100644 index 000000000..b53202b41 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-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/skip-template-literal/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/expected.js new file mode 100644 index 000000000..3ed42dc92 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/expected.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/skip-template-literal/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/input.js new file mode 100644 index 000000000..3ed42dc92 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-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/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__/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..f0f8e07bf --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js @@ -0,0 +1,166 @@ +/** + * 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: Emotion's own 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 } = {}; + 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__/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__/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/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..dec715445 --- /dev/null +++ b/packages/@stylexjs/codemods/package.json @@ -0,0 +1,38 @@ +{ + "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" + }, + "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", + "hermes-eslint": "0.36.1", + "jscodeshift": "^17.3.0", + "style-value-parser": "0.19.0" + }, + "devDependencies": { + "@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..7a8a8e791 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js @@ -0,0 +1,220 @@ +/** + * 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. + */ + +export type StyleSite = { + +attrPath: $FlowFixMe, // JSXAttribute path + +objectNode: $FlowFixMe, // the style ObjectExpression + +tagName: string, +}; + +export type Detection = { + +sites: Array, + +blockers: Array, +}; + +export function detectSites( + j: $FlowFixMe, + root: $FlowFixMe, + cssLocalName: string | null, +): Detection { + const sites: Array = []; + const blockers: Array = []; + const siteCallees = new Set<$FlowFixMe>(); + + root + .find(j.JSXAttribute, { name: { name: 'css' } }) + .forEach((path: $FlowFixMe) => { + const opening = path.parent.node; + const nameNode = opening.name; + if (nameNode.type !== 'JSXIdentifier' || !/^[a-z]/.test(nameNode.name)) { + blockers.push( + 'css prop on a component element (className forwarding is not provable)', + ); + return; + } + const conflicting = (opening.attributes ?? []).find( + (attr: $FlowFixMe) => + attr.type === 'JSXSpreadAttribute' || + attr.name?.name === 'className' || + attr.name?.name === 'style', + ); + if (conflicting != null) { + blockers.push( + `<${nameNode.name}> mixes css with className/style/spread props`, + ); + return; + } + const container = path.node.value; + if (container?.type !== 'JSXExpressionContainer') { + blockers.push(`css prop on <${nameNode.name}> is not an expression`); + return; + } + const expression = container.expression; + if (expression.type === 'ObjectExpression') { + sites.push({ + attrPath: path, + objectNode: expression, + tagName: nameNode.name, + }); + return; + } + if ( + expression.type === 'CallExpression' && + expression.callee.type === 'Identifier' && + cssLocalName != null && + expression.callee.name === cssLocalName && + expression.arguments.length === 1 && + expression.arguments[0].type === 'ObjectExpression' + ) { + siteCallees.add(expression.callee); + sites.push({ + attrPath: path, + objectNode: expression.arguments[0], + tagName: nameNode.name, + }); + return; + } + blockers.push( + `css prop value form on <${nameNode.name}> is not convertible yet ` + + '(template literals, arrays, references and dynamic styles land in later milestones)', + ); + }); + + if (cssLocalName != null) { + root + .find(j.Identifier, { name: cssLocalName }) + .forEach((path: $FlowFixMe) => { + const parentNode = path.parent.node; + if ( + parentNode.type === 'ImportSpecifier' || + siteCallees.has(path.node) || + (parentNode.type === 'Property' && parentNode.key === path.node) || + parentNode.type === 'JSXAttribute' + ) { + return; + } + blockers.push( + `'${cssLocalName}' is used outside a convertible css prop ` + + '(tagged templates / shared style variables land in later milestones)', + ); + }); + } + + if ( + root + .find(j.ImportDeclaration) + .some( + (path: $FlowFixMe) => + String(path.node.source.value) === '@stylexjs/stylex', + ) + ) { + blockers.push( + 'file already imports @stylexjs/stylex (registry merge lands in M4)', + ); + } + + return { sites, 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..27bf67ac3 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/imports.js @@ -0,0 +1,153 @@ +/** + * 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, + }; +} + +function allCommentSlots(j: $FlowFixMe, root: $FlowFixMe): Array<$FlowFixMe> { + const program = root.get().node.program; + const slots = [program]; + if (program.body.length > 0) { + slots.push(program.body[0]); + } + return slots; +} + +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 comment. */ +export function removePragma(j: $FlowFixMe, root: $FlowFixMe): void { + for (const slot of allCommentSlots(j, root)) { + if (slot.comments != null) { + slot.comments = slot.comments.filter( + (comment: $FlowFixMe) => !PRAGMA_PATTERN.test(comment.value), + ); + } + } +} + +/** + * Removes the converted specifiers (`css`, `keyframes`) from the + * `@emotion/react` import (the whole declaration when nothing else 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) + ), + ); + 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..f2e69ac33 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/read.js @@ -0,0 +1,395 @@ +/** + * 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 ( + `value of '${key}' is not a static string/number/fallback-array ` + + '(dynamic values land in v1.1)' + ); + } + 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}) needs the M2 referee`, + }; + } + } + + 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..ef3c3a3b8 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js @@ -0,0 +1,283 @@ +/** + * 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). + * + * M1 policy (user-ratified): whole-file-or-nothing. A file is rewritten + * only when every style site is convertible; any blocker returns + * `skipped` with the reasons and the source untouched. TODO-comment + * flagging machinery lands in M5. + */ + +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 { checkRule } from '../../core/referee'; +import { + parseSource, + printSource, + parserForFile, + styleToObjectAst, +} from '../../core/rewriter'; +import { + analyzeEmotionWiring, + removePragma, + removeCssImport, + insertStylexImport, +} from './imports'; +import { detectSites, detectKeyframes } 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 } }, + }>, + } + | { +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 blockers = [ + ...wiring.blockers, + ...detection.blockers, + ...kfDetection.blockers, + ]; + const reads = detection.sites.map((s) => readSite(s, kfDetection.names)); + for (const read of reads) { + if (!read.ok) { + blockers.push(read.blocker); + } + } + for (const kf of kfReads) { + if (!kf.ok) { + blockers.push(kf.blocker); + } + } + if (blockers.length > 0) { + return { status: 'skipped', reasons: blockers }; + } + if (detection.sites.length === 0 && kfDetection.sites.length === 0) { + return { status: 'unchanged' }; + } + + // Adapter -> core: declarations in, create data + binding map out. + const groups = reads.map((read) => { + if (!read.ok) { + throw new Error('unreachable: blockers were checked above'); + } + return { nameHint: read.nameHint, declarations: read.declarations }; + }); + const keyframeRules = kfReads.map((kf) => { + if (!kf.ok) { + throw new Error('unreachable: blockers were checked above'); + } + return kf.rule; + }); + // L6 Normalize runs before the referee so every downstream layer sees one + // vocabulary (physical→logical is a sanctioned RTL change). + const fileIR = normalizeFileIR( + { rules: buildFileIR(groups).rules, keyframes: keyframeRules }, + { logicalProperties: options?.logicalProperties ?? true }, + ); + + // L5 Referee: convert only when Emotion's cascade and StyleX's priority + // agree on every simultaneously-active condition; otherwise refuse. + const refereed = fileIR.rules.map(checkRule); + const conflicts = refereed.flatMap((r) => (r.ok ? [] : r.conflicts)); + if (conflicts.length > 0) { + return { status: 'skipped', reasons: conflicts }; + } + const { rules, keyframes, bindings } = emitFileIR( + { + rules: refereed.map((r) => { + if (!r.ok) { + throw new Error('unreachable: conflicts were checked above'); + } + return r.rule; + }), + keyframes: fileIR.keyframes, + }, + { hoverGuard: options?.hoverGuard ?? true }, + ); + + // Core -> adapter: place the StyleX back into the file's idiom. + const stylesLocalName = pickStylesName(j, root); + detection.sites.forEach((site, i) => { + rewriteSite(j, site, stylesLocalName, bindings[i]); + }); + rewriteKeyframes(j, kfDetection.sites, keyframes); + if (rules.length > 0) { + insertRegistry(j, root, detection.sites[0], stylesLocalName, rules); + } + insertStylexImport(j, root); + removeCssImport(j, root); + removePragma(j, root); + + // L10 Postprocess: run StyleX's own eslint autofixes so key ordering and + // shorthands match its canonical form. Unfixable residual errors mean the + // output is not clean at error → refuse the whole file. + const { code, residualErrors } = postprocess( + printSource({ j, root }), + filename, + ); + if (residualErrors.length > 0) { + return { status: 'skipped', reasons: residualErrors }; + } + + return { + status: 'converted', + code, + sites: detection.sites.map((site, i) => { + const read = reads[i]; + if (!read.ok) { + throw new Error('unreachable: blockers were checked above'); + } + return { key: bindings[i], cssObject: read.cssObject }; + }), + keyframes: kfReads.map((kf) => { + if (!kf.ok) { + throw new Error('unreachable: blockers were checked above'); + } + return { framesObject: kf.framesObject }; + }), + }; +} + +/** + * Rewrites each `keyframes({...})` call in place to + * `stylex.keyframes({...})`, replacing the frames object with the emitted + * (normalized) one. Emitted keyframes are matched to detected sites by the + * bound variable name. + */ +function rewriteKeyframes( + j: $FlowFixMe, + sites: $ReadOnlyArray<{ +callPath: $FlowFixMe, +varName: string, ... }>, + emitted: $ReadOnlyArray, +): void { + const byName: Map = new Map( + emitted.map((kf) => [kf.name, kf]), + ); + for (const site of sites) { + const kf = byName.get(site.varName); + if (kf == null) { + continue; + } + const framesObject: { [string]: EmittedStyle } = {}; + for (const frame of kf.frames) { + framesObject[frame.selector] = frame.style; + } + j(site.callPath).replaceWith( + j.callExpression( + j.memberExpression(j.identifier('stylex'), j.identifier('keyframes')), + [styleToObjectAst(j, 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, + rules: $ReadOnlyArray, +): void { + const createObject = j.objectExpression( + rules.map((rule) => + j.property( + 'init', + j.identifier(rule.key), + styleToObjectAst(j, rule.style), + ), + ), + ); + 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/README.md b/packages/@stylexjs/codemods/src/cli/README.md new file mode 100644 index 000000000..c04d3f01c --- /dev/null +++ b/packages/@stylexjs/codemods/src/cli/README.md @@ -0,0 +1,5 @@ +# CLI + +Lands in **M6**: `stylex-codemod emotion "" [--dry-run]` (dry-run is +the default), config loading (`resolveValue` glossary, `hoverGuard`, +logical-properties opt-out), and the convert/flag/refuse report. 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..d1349784d --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/emit.js @@ -0,0 +1,266 @@ +/** + * 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, +}; + +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(); + 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..17a563335 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/gates/compile.js @@ -0,0 +1,49 @@ +/** + * 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'; + try { + const result = babel.transformSync(source, { + filename, + babelrc: false, + configFile: false, + plugins: [ + ['babel-plugin-syntax-hermes-parser', { flow: 'detect' }], + [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..7462f2dcb --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/postprocess.js @@ -0,0 +1,70 @@ +/** + * 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 the 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. + * + * M3: run file-wide. This is safe *now* because a converted file has no + * pre-existing user `stylex.create` (files that do are refused upstream), + * so the only nodes the autofix can touch are the ones we emitted. M4 adds + * the scoping needed once we merge into a user's existing registry. + * + * The sort-keys autofix can reorder sibling media queries, which the StyleX + * compiler treats as semantic — but the semantic-diff gate runs on this + * function's OUTPUT, so any reorder that changes rendering is caught (the + * file is 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, +}; + +const PARSER_NAME = 'hermes-eslint'; + +export function postprocess( + code: string, + filename: string = 'file.js', +): PostprocessResult { + 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)) { + 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..8e6d60f8e --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/rewriter.js @@ -0,0 +1,97 @@ +/** + * 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' }); +} + +/** + * 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/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..32d6175b3 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": + 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== @@ -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"