styled-components-to-stylex-codemod 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -143
- package/dist/bridge-consumer-patcher-D3fRIEkZ.mjs +122 -0
- package/dist/index.d.mts +26 -4
- package/dist/index.mjs +178 -39
- package/dist/{logger-kU4pnRpt.d.mts → logger-B7SOfCti.d.mts} +41 -3
- package/dist/{logger-D3j-qxgZ.mjs → logger-D-R2KB6I.mjs} +29 -4
- package/dist/resolve-imports-BDk6Ms09.mjs +66 -0
- package/dist/run-prepass-BcidTT9f.mjs +885 -0
- package/dist/selector-context-heuristic-CGwiJ3HL.mjs +39 -0
- package/dist/styled-css-DBryFqQM.mjs +38 -0
- package/dist/transform.d.mts +43 -2
- package/dist/transform.mjs +1404 -300
- package/package.json +17 -15
package/README.md
CHANGED
|
@@ -4,10 +4,6 @@ Transform styled-components to StyleX.
|
|
|
4
4
|
|
|
5
5
|
**[Try it in the online playground](https://skovhus.github.io/styled-components-to-stylex-codemod/)** — experiment with the transform in your browser.
|
|
6
6
|
|
|
7
|
-
> [!WARNING]
|
|
8
|
-
>
|
|
9
|
-
> **Very much under construction (alpha):** this codemod is still early in development — expect rough edges! 🚧
|
|
10
|
-
|
|
11
7
|
## Installation
|
|
12
8
|
|
|
13
9
|
```bash
|
|
@@ -24,10 +20,46 @@ Use `runTransform` to transform files matching a glob pattern:
|
|
|
24
20
|
import { runTransform, defineAdapter } from "styled-components-to-stylex-codemod";
|
|
25
21
|
|
|
26
22
|
const adapter = defineAdapter({
|
|
23
|
+
// Map theme paths and CSS variables to StyleX expressions
|
|
24
|
+
resolveValue(ctx) {
|
|
25
|
+
return null;
|
|
26
|
+
},
|
|
27
|
+
// Map helper function calls to StyleX expressions
|
|
28
|
+
resolveCall(ctx) {
|
|
29
|
+
return null;
|
|
30
|
+
},
|
|
31
|
+
// Control which components accept external className/style and polymorphic `as`
|
|
32
|
+
externalInterface(ctx) {
|
|
33
|
+
return { style: false, as: false };
|
|
34
|
+
},
|
|
35
|
+
// Optional: use a helper for merging StyleX styles with external className/style
|
|
36
|
+
styleMerger: null,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await runTransform({
|
|
40
|
+
files: "src/**/*.tsx",
|
|
41
|
+
consumerPaths: null, // set to a glob to enable cross-file selector support
|
|
42
|
+
adapter,
|
|
43
|
+
dryRun: false,
|
|
44
|
+
parser: "tsx",
|
|
45
|
+
formatterCommands: ["pnpm prettier --write"],
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
<details>
|
|
50
|
+
<summary>Full adapter example</summary>
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { runTransform, defineAdapter } from "styled-components-to-stylex-codemod";
|
|
54
|
+
|
|
55
|
+
const adapter = defineAdapter({
|
|
56
|
+
/**
|
|
57
|
+
* Resolve dynamic values in styled template literals to StyleX expressions.
|
|
58
|
+
* Called for theme access (`props.theme.x`), CSS variables (`var(--x)`),
|
|
59
|
+
* and imported values. Return `{ expr, imports }` or `null` to skip.
|
|
60
|
+
*/
|
|
27
61
|
resolveValue(ctx) {
|
|
28
62
|
if (ctx.kind === "theme") {
|
|
29
|
-
// Called for patterns like: ${(props) => props.theme.color.primary}
|
|
30
|
-
// `ctx.path` is the dotted path after `theme.`
|
|
31
63
|
const varName = ctx.path.replace(/\./g, "_");
|
|
32
64
|
return {
|
|
33
65
|
expr: `tokens.${varName}`,
|
|
@@ -41,39 +73,11 @@ const adapter = defineAdapter({
|
|
|
41
73
|
}
|
|
42
74
|
|
|
43
75
|
if (ctx.kind === "cssVariable") {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const { name, fallback, definedValue } = ctx;
|
|
48
|
-
|
|
49
|
-
// Example: lift `var(--base-size)` to StyleX vars, and optionally drop a matching local definition.
|
|
50
|
-
if (name === "--base-size") {
|
|
51
|
-
return {
|
|
52
|
-
expr: "calcVars.baseSize",
|
|
53
|
-
imports: [
|
|
54
|
-
{
|
|
55
|
-
from: { kind: "specifier", value: "./css-calc.stylex" },
|
|
56
|
-
names: [{ imported: "calcVars" }],
|
|
57
|
-
},
|
|
58
|
-
],
|
|
59
|
-
...(definedValue === "16px" ? { dropDefinition: true } : {}),
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Generic mapping: `--kebab-case` -> `vars.kebabCase`
|
|
64
|
-
// e.g. `--color-primary` -> `vars.colorPrimary`
|
|
65
|
-
const toCamelCase = (cssVarName: string) =>
|
|
66
|
-
cssVarName
|
|
67
|
-
.replace(/^--/, "")
|
|
68
|
-
.split("-")
|
|
69
|
-
.filter(Boolean)
|
|
70
|
-
.map((part, i) => (i === 0 ? part : part[0]?.toUpperCase() + part.slice(1)))
|
|
71
|
-
.join("");
|
|
72
|
-
|
|
73
|
-
// If you care about fallbacks, you can use `fallback` here to decide whether to resolve or not.
|
|
74
|
-
void fallback;
|
|
76
|
+
const toCamelCase = (s: string) =>
|
|
77
|
+
s.replace(/^--/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
78
|
+
|
|
75
79
|
return {
|
|
76
|
-
expr: `vars.${toCamelCase(name)}`,
|
|
80
|
+
expr: `vars.${toCamelCase(ctx.name)}`,
|
|
77
81
|
imports: [
|
|
78
82
|
{
|
|
79
83
|
from: { kind: "specifier", value: "./css-variables.stylex" },
|
|
@@ -86,19 +90,12 @@ const adapter = defineAdapter({
|
|
|
86
90
|
return null;
|
|
87
91
|
},
|
|
88
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Resolve helper function calls in template interpolations.
|
|
95
|
+
* e.g. `${transitionSpeed("slow")}` → `transitionSpeedVars.slow`
|
|
96
|
+
* Return `{ expr, imports }` or `null` to bail the file with a warning.
|
|
97
|
+
*/
|
|
89
98
|
resolveCall(ctx) {
|
|
90
|
-
// Called for template interpolations like: ${transitionSpeed("slowTransition")}
|
|
91
|
-
// `calleeImportedName` is the imported symbol name (works even with aliasing).
|
|
92
|
-
// `calleeSource` tells you where it came from:
|
|
93
|
-
// - { kind: "absolutePath", value: "/abs/path" } for relative imports
|
|
94
|
-
// - { kind: "specifier", value: "some-package/foo" } for package imports
|
|
95
|
-
//
|
|
96
|
-
// The codemod determines how to use the result based on context:
|
|
97
|
-
// - If `ctx.cssProperty` exists (e.g., `border: ${helper()}`) → result is used as a CSS value
|
|
98
|
-
// - If `ctx.cssProperty` is undefined (e.g., `${helper()}`) → result is used as a StyleX style object
|
|
99
|
-
//
|
|
100
|
-
// Use `ctx.cssProperty` to return the appropriate expression for the context.
|
|
101
|
-
|
|
102
99
|
const arg0 = ctx.args[0];
|
|
103
100
|
const key = arg0?.kind === "literal" && typeof arg0.value === "string" ? arg0.value : null;
|
|
104
101
|
if (ctx.calleeImportedName !== "transitionSpeed" || !key) {
|
|
@@ -116,27 +113,43 @@ const adapter = defineAdapter({
|
|
|
116
113
|
};
|
|
117
114
|
},
|
|
118
115
|
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Control which exported components accept external className/style
|
|
118
|
+
* and/or polymorphic `as` prop. Return `{ styles, as }` flags.
|
|
119
|
+
*/
|
|
120
|
+
externalInterface(ctx) {
|
|
121
|
+
if (ctx.filePath.includes("/shared/components/")) {
|
|
122
|
+
return { styles: true, as: true };
|
|
123
|
+
}
|
|
124
|
+
return { styles: false, as: false };
|
|
121
125
|
},
|
|
122
126
|
|
|
123
|
-
|
|
127
|
+
/**
|
|
128
|
+
* When `externalInterface` enables styles, use a helper to merge
|
|
129
|
+
* StyleX styles with external className/style props.
|
|
130
|
+
* See test-cases/lib/mergedSx.ts for a reference implementation.
|
|
131
|
+
*/
|
|
132
|
+
styleMerger: {
|
|
133
|
+
functionName: "mergedSx",
|
|
134
|
+
importSource: { kind: "specifier", value: "./lib/mergedSx" },
|
|
135
|
+
},
|
|
124
136
|
});
|
|
125
137
|
|
|
126
|
-
|
|
138
|
+
await runTransform({
|
|
127
139
|
files: "src/**/*.tsx",
|
|
140
|
+
consumerPaths: null,
|
|
128
141
|
adapter,
|
|
129
142
|
dryRun: false,
|
|
130
|
-
parser: "tsx",
|
|
131
|
-
formatterCommands: ["pnpm prettier --write"],
|
|
143
|
+
parser: "tsx",
|
|
144
|
+
formatterCommands: ["pnpm prettier --write"],
|
|
132
145
|
});
|
|
133
|
-
|
|
134
|
-
console.log(result);
|
|
135
146
|
```
|
|
136
147
|
|
|
148
|
+
</details>
|
|
149
|
+
|
|
137
150
|
### Adapter
|
|
138
151
|
|
|
139
|
-
Adapters are the main extension point. They let you control:
|
|
152
|
+
Adapters are the main extension point, see full example above. They let you control:
|
|
140
153
|
|
|
141
154
|
- how theme paths, CSS variables, and imported values are turned into StyleX-compatible JS values (`resolveValue`)
|
|
142
155
|
- what extra imports to inject into transformed files (returned from `resolveValue`)
|
|
@@ -144,109 +157,56 @@ Adapters are the main extension point. They let you control:
|
|
|
144
157
|
- which exported components should support external className/style extension and/or polymorphic `as` prop (`externalInterface`)
|
|
145
158
|
- how className/style merging is handled for components accepting external styling (`styleMerger`)
|
|
146
159
|
|
|
147
|
-
####
|
|
148
|
-
|
|
149
|
-
When a component accepts external `className` and/or `style` props (e.g., via `shouldSupportExternalStyling`, or when wrapping a base component that already accepts these props), the generated code needs to merge StyleX styles with externally passed values.
|
|
160
|
+
#### Cross-file selectors (`consumerPaths`)
|
|
150
161
|
|
|
151
|
-
|
|
162
|
+
`consumerPaths` is required. Pass `null` to opt out, or a glob pattern to enable cross-file selector scanning.
|
|
152
163
|
|
|
153
|
-
|
|
164
|
+
When transforming a subset of files, other files may reference your styled components as CSS selectors (e.g. `${Icon} { fill: red }`). Pass `consumerPaths` to scan those files and wire up cross-file selectors automatically:
|
|
154
165
|
|
|
155
166
|
```ts
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
},
|
|
161
|
-
|
|
162
|
-
resolveCall() {
|
|
163
|
-
return null;
|
|
164
|
-
},
|
|
165
|
-
|
|
166
|
-
externalInterface(ctx) {
|
|
167
|
-
if (ctx.filePath.includes("/shared/components/")) {
|
|
168
|
-
return { styles: true, as: true };
|
|
169
|
-
}
|
|
170
|
-
return { styles: false, as: false };
|
|
171
|
-
},
|
|
172
|
-
|
|
173
|
-
// Use a custom merger function for cleaner output
|
|
174
|
-
styleMerger: {
|
|
175
|
-
functionName: "mergedSx",
|
|
176
|
-
importSource: { kind: "specifier", value: "./lib/mergedSx" },
|
|
177
|
-
},
|
|
167
|
+
await runTransform({
|
|
168
|
+
files: "src/components/**/*.tsx", // files to transform
|
|
169
|
+
consumerPaths: "src/**/*.tsx", // additional files to scan for cross-file usage
|
|
170
|
+
adapter,
|
|
178
171
|
});
|
|
179
172
|
```
|
|
180
173
|
|
|
181
|
-
|
|
174
|
+
- Files in **both** `files` and `consumerPaths` use the **marker sidecar** strategy (both consumer and target are transformed, using `stylex.defineMarker()`).
|
|
175
|
+
- Files in `consumerPaths` but **not** in `files` use the **bridge** strategy (a stable `className` is added to the converted component so unconverted consumers' selectors still work).
|
|
182
176
|
|
|
183
|
-
|
|
184
|
-
function mergedSx(
|
|
185
|
-
styles: StyleXStyles,
|
|
186
|
-
className?: string,
|
|
187
|
-
style?: React.CSSProperties,
|
|
188
|
-
): ReturnType<typeof stylex.props>;
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
See [`test-cases/lib/mergedSx.ts`](./test-cases/lib/mergedSx.ts) for a reference implementation.
|
|
177
|
+
#### Auto-detecting external interface usage (experimental)
|
|
192
178
|
|
|
193
|
-
|
|
179
|
+
Instead of manually specifying which components need `styles` or `as` support, set `externalInterface: "auto"` to auto-detect usage by scanning consumer code.
|
|
194
180
|
|
|
195
|
-
|
|
181
|
+
> [!NOTE]
|
|
182
|
+
> Experimental. Requires `consumerPaths` and a successful prepass scan.
|
|
183
|
+
> If prepass fails, `runTransform()` throws (fail-fast) when `externalInterface: "auto"` is used.
|
|
196
184
|
|
|
197
185
|
```ts
|
|
198
|
-
|
|
199
|
-
resolveValue(ctx) {
|
|
200
|
-
// ... value resolution logic
|
|
201
|
-
return null;
|
|
202
|
-
},
|
|
203
|
-
|
|
204
|
-
resolveCall() {
|
|
205
|
-
return null;
|
|
206
|
-
},
|
|
207
|
-
|
|
208
|
-
externalInterface(ctx) {
|
|
209
|
-
// ctx: { filePath, componentName, exportName, isDefaultExport }
|
|
210
|
-
|
|
211
|
-
// Example: Enable styles and `as` for all exports in shared components folder
|
|
212
|
-
if (ctx.filePath.includes("/shared/components/")) {
|
|
213
|
-
return { styles: true, as: true };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Example: Enable only styles (no `as` prop)
|
|
217
|
-
if (ctx.filePath.includes("/design-system/")) {
|
|
218
|
-
return { styles: true, as: false };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Example: Enable only `as` prop (no style merging)
|
|
222
|
-
if (ctx.componentName === "Typography") {
|
|
223
|
-
return { styles: false, as: true };
|
|
224
|
-
}
|
|
186
|
+
import { runTransform, defineAdapter } from "styled-components-to-stylex-codemod";
|
|
225
187
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
188
|
+
const adapter = defineAdapter({
|
|
189
|
+
// ...
|
|
190
|
+
externalInterface: "auto",
|
|
191
|
+
});
|
|
229
192
|
|
|
230
|
-
|
|
193
|
+
await runTransform({
|
|
194
|
+
files: "src/**/*.tsx",
|
|
195
|
+
consumerPaths: "src/**/*.tsx", // required for auto-detection
|
|
196
|
+
adapter,
|
|
231
197
|
});
|
|
232
198
|
```
|
|
233
199
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
- `{ styles: false, as: false }` — no external interface
|
|
237
|
-
- `{ styles: true, as: false }` — accept className/style props only
|
|
238
|
-
- `{ styles: true, as: true }` — accept className/style props AND polymorphic `as` prop
|
|
239
|
-
- `{ styles: false, as: true }` — accept only polymorphic `as` prop (no style merging)
|
|
240
|
-
|
|
241
|
-
When `styles: true`, the generated component will:
|
|
200
|
+
When `externalInterface: "auto"` is set, `runTransform()` scans `files` and `consumerPaths` for `styled(Component)` calls and `<Component as={...}>` JSX usage, resolves imports back to the component definition files, and returns the appropriate `{ styles, as }` flags automatically.
|
|
242
201
|
|
|
243
|
-
|
|
244
|
-
- Merge them with the StyleX-generated styles
|
|
245
|
-
- Forward remaining props via `...rest`
|
|
202
|
+
If that prepass scan fails, `runTransform()` stops and throws an actionable error rather than silently falling back to non-auto behavior.
|
|
246
203
|
|
|
247
|
-
|
|
204
|
+
Troubleshooting prepass failures with `"auto"`:
|
|
248
205
|
|
|
249
|
-
|
|
206
|
+
- verify `consumerPaths` globs match the files you expect
|
|
207
|
+
- confirm the selected parser matches your source syntax (`parser: "tsx"`, `parser: "ts"`, etc.)
|
|
208
|
+
- check resolver inputs (import paths, tsconfig path aliases, and related module resolution config)
|
|
209
|
+
- if needed, switch to a manual `externalInterface(ctx)` function to continue migration while you fix prepass inputs
|
|
250
210
|
|
|
251
211
|
#### Dynamic interpolations
|
|
252
212
|
|
|
@@ -263,7 +223,7 @@ When the codemod encounters an interpolation inside a styled template literal, i
|
|
|
263
223
|
- helper calls applied to prop values (e.g. `shadow(props.shadow)`) by emitting a StyleX style function that calls the helper at runtime
|
|
264
224
|
- conditional CSS blocks via ternary (e.g. `props.$dim ? "opacity: 0.5;" : ""`)
|
|
265
225
|
|
|
266
|
-
If the pipeline can
|
|
226
|
+
If the pipeline can't resolve an interpolation:
|
|
267
227
|
|
|
268
228
|
- for some dynamic value cases, the transform preserves the value as a wrapper inline style so output keeps visual parity (at the cost of using `style={...}` for that prop)
|
|
269
229
|
- otherwise, the declaration containing that interpolation is **dropped** and a warning is produced (manual follow-up required)
|
|
@@ -274,6 +234,39 @@ If the pipeline can’t resolve an interpolation:
|
|
|
274
234
|
- **createGlobalStyle**: detected usage is reported as an **unsupported-feature** warning (StyleX does not support global styles in the same way).
|
|
275
235
|
- **Theme prop overrides**: passing a `theme` prop directly to styled components (e.g. `<Button theme={...} />`) is not supported and will bail with a warning.
|
|
276
236
|
|
|
237
|
+
## Migration game plan
|
|
238
|
+
|
|
239
|
+
### 1. Define your theme and mixins as StyleX
|
|
240
|
+
|
|
241
|
+
Before running the codemod, convert your theme object and shared style helpers into StyleX equivalents:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
// tokens.stylex.ts — theme variables
|
|
245
|
+
import * as stylex from "@stylexjs/stylex";
|
|
246
|
+
|
|
247
|
+
// Before: { colors: { primary: "#0066cc" }, spacing: { sm: "8px" } }
|
|
248
|
+
export const colors = stylex.defineVars({ primary: "#0066cc" });
|
|
249
|
+
export const spacing = stylex.defineVars({ sm: "8px" });
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
// helpers.stylex.ts — shared mixins
|
|
254
|
+
import * as stylex from "@stylexjs/stylex";
|
|
255
|
+
|
|
256
|
+
// Before: export const truncate = () => `white-space: nowrap; overflow: hidden; ...`
|
|
257
|
+
export const truncate = stylex.create({
|
|
258
|
+
base: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### 2. Write an adapter and run the codemod
|
|
263
|
+
|
|
264
|
+
The adapter maps your project's `props.theme.*` access, CSS variables, and helper calls to the StyleX equivalents from step 1. See [Usage](#usage) for the full API.
|
|
265
|
+
|
|
266
|
+
### 3. Verify, iterate, clean up
|
|
267
|
+
|
|
268
|
+
Build and test your project. Review warnings — they tell you which files were skipped and why. Fix adapter gaps, re-run on remaining files, and repeat until done. [Report issues](https://github.com/skovhus/styled-components-to-stylex-codemod/issues) with input/output examples if the codemod produces incorrect results.
|
|
269
|
+
|
|
277
270
|
## License
|
|
278
271
|
|
|
279
272
|
MIT
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { t as isSelectorContext } from "./selector-context-heuristic-CGwiJ3HL.mjs";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/bridge-consumer-patcher.ts
|
|
5
|
+
/**
|
|
6
|
+
* Post-transform consumer patching for global selector bridges.
|
|
7
|
+
*
|
|
8
|
+
* After the target component is transformed and gets a bridge className,
|
|
9
|
+
* patch unconverted consumer files to:
|
|
10
|
+
* 1. Import the bridge's GlobalSelector variable from the target module
|
|
11
|
+
* 2. Replace `${Component}` selector references with `${ComponentGlobalSelector}`
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Build a mapping from consumer file paths to their required replacements,
|
|
15
|
+
* cross-referencing prepass selector usages with successful bridge results.
|
|
16
|
+
*/
|
|
17
|
+
function buildConsumerReplacements(selectorUsages, bridgeResults) {
|
|
18
|
+
const consumerReplacements = /* @__PURE__ */ new Map();
|
|
19
|
+
const bridgeLookup = /* @__PURE__ */ new Map();
|
|
20
|
+
for (const [targetPath, results] of bridgeResults) for (const result of results) {
|
|
21
|
+
bridgeLookup.set(`${targetPath}:${result.componentName}`, result);
|
|
22
|
+
if (result.exportName && result.exportName !== result.componentName) bridgeLookup.set(`${targetPath}:${result.exportName}`, result);
|
|
23
|
+
}
|
|
24
|
+
for (const [consumerPath, usages] of selectorUsages) for (const usage of usages) {
|
|
25
|
+
if (usage.consumerIsTransformed) continue;
|
|
26
|
+
const bridge = bridgeLookup.get(`${usage.resolvedPath}:${usage.importedName}`);
|
|
27
|
+
if (!bridge) continue;
|
|
28
|
+
let replacements = consumerReplacements.get(consumerPath);
|
|
29
|
+
if (!replacements) {
|
|
30
|
+
replacements = [];
|
|
31
|
+
consumerReplacements.set(consumerPath, replacements);
|
|
32
|
+
}
|
|
33
|
+
replacements.push({
|
|
34
|
+
localName: usage.localName,
|
|
35
|
+
importSource: usage.importSource,
|
|
36
|
+
globalSelectorVarName: bridge.globalSelectorVarName,
|
|
37
|
+
importedName: usage.importedName
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return consumerReplacements;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Patch a single consumer file:
|
|
44
|
+
* 1. Add import for each GlobalSelector variable
|
|
45
|
+
* 2. Replace `${Component}` in styled template selectors with `${ComponentGlobalSelector}`
|
|
46
|
+
*
|
|
47
|
+
* Returns the patched source or null if no changes were made.
|
|
48
|
+
*/
|
|
49
|
+
function patchConsumerFile(filePath, replacements) {
|
|
50
|
+
let source;
|
|
51
|
+
try {
|
|
52
|
+
source = readFileSync(filePath, "utf-8");
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (replacements.length === 0) return null;
|
|
57
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
58
|
+
for (const r of replacements) {
|
|
59
|
+
let list = bySource.get(r.importSource);
|
|
60
|
+
if (!list) {
|
|
61
|
+
list = [];
|
|
62
|
+
bySource.set(r.importSource, list);
|
|
63
|
+
}
|
|
64
|
+
list.push(r);
|
|
65
|
+
}
|
|
66
|
+
let modified = source;
|
|
67
|
+
for (const [importSource, reps] of bySource) {
|
|
68
|
+
const varNames = reps.map((r) => r.globalSelectorVarName);
|
|
69
|
+
const importRegex = new RegExp(`(import\\s+(?:(?:\\{[^}]*\\}|[^;{]+)\\s+from\\s+['"]${escapeRegExp(importSource)}['"])\\s*;?)`);
|
|
70
|
+
if (modified.match(importRegex)) {
|
|
71
|
+
const namedImportRegex = new RegExp(`(import\\s+(?:[\\w$]+\\s*,\\s*)?\\{)([^}]*)(\\}\\s+from\\s+['"]${escapeRegExp(importSource)}['"]\\s*;?)`);
|
|
72
|
+
const namedMatch = modified.match(namedImportRegex);
|
|
73
|
+
if (namedMatch) {
|
|
74
|
+
const existingNames = namedMatch[2];
|
|
75
|
+
const newNames = varNames.filter((name) => !hasExactImportName(existingNames, name));
|
|
76
|
+
if (newNames.length > 0) {
|
|
77
|
+
const separator = existingNames.trimEnd().endsWith(",") ? " " : ", ";
|
|
78
|
+
modified = modified.replace(namedImportRegex, `$1${existingNames.trimEnd()}${separator}${newNames.join(", ")} $3`);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
const newImport = `import { ${varNames.join(", ")} } from "${importSource}";`;
|
|
82
|
+
modified = modified.replace(importRegex, `$1\n${newImport}`);
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
const newImport = `import { ${varNames.join(", ")} } from "${importSource}";`;
|
|
86
|
+
const lastImportIdx = modified.lastIndexOf("\nimport ");
|
|
87
|
+
if (lastImportIdx !== -1) {
|
|
88
|
+
const importEnd = findImportEnd(modified, lastImportIdx + 1);
|
|
89
|
+
modified = modified.slice(0, importEnd) + "\n" + newImport + modified.slice(importEnd);
|
|
90
|
+
} else modified = newImport + "\n" + modified;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const r of replacements) {
|
|
94
|
+
const templateExprRegex = new RegExp(`(\\$\\{\\s*)${escapeRegExp(r.localName)}(\\s*\\})`, "g");
|
|
95
|
+
modified = modified.replace(templateExprRegex, (match, prefix, suffix, offset) => {
|
|
96
|
+
if (isInStyledTemplateSelectorContext(modified, offset, match.length)) return `${prefix}${r.globalSelectorVarName}${suffix}`;
|
|
97
|
+
return match;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return modified !== source ? modified : null;
|
|
101
|
+
}
|
|
102
|
+
/** Find the end position of an import statement starting at startIdx (handles multi-line imports). */
|
|
103
|
+
function findImportEnd(source, startIdx) {
|
|
104
|
+
const semiIdx = source.indexOf(";", startIdx);
|
|
105
|
+
if (semiIdx === -1) return source.indexOf("\n", startIdx);
|
|
106
|
+
return semiIdx + 1;
|
|
107
|
+
}
|
|
108
|
+
/** Check if a template expression at the given position is in a CSS selector context. */
|
|
109
|
+
function isInStyledTemplateSelectorContext(source, offset, length) {
|
|
110
|
+
const after = source.slice(offset + length).trimStart();
|
|
111
|
+
return isSelectorContext(source.slice(Math.max(0, offset - 200), offset).trimEnd(), after);
|
|
112
|
+
}
|
|
113
|
+
/** Check if a name appears as a distinct identifier in an import specifier list string. */
|
|
114
|
+
function hasExactImportName(importSpecifiers, name) {
|
|
115
|
+
return new RegExp(`(?:^|[^A-Za-z0-9_$])${escapeRegExp(name)}(?:$|[^A-Za-z0-9_$])`).test(importSpecifiers);
|
|
116
|
+
}
|
|
117
|
+
function escapeRegExp(s) {
|
|
118
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
//#endregion
|
|
122
|
+
export { buildConsumerReplacements, patchConsumerFile };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as defineAdapter, i as AdapterInput, t as CollectedWarning } from "./logger-B7SOfCti.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/run.d.ts
|
|
4
4
|
interface RunTransformOptions {
|
|
@@ -7,11 +7,32 @@ interface RunTransformOptions {
|
|
|
7
7
|
* @example "src/**\/*.tsx" or ["src/**\/*.ts", "src/**\/*.tsx"]
|
|
8
8
|
*/
|
|
9
9
|
files: string | string[];
|
|
10
|
+
/**
|
|
11
|
+
* File glob(s) to scan for cross-file component selector usage, or `null` to opt out.
|
|
12
|
+
*
|
|
13
|
+
* When set to a glob pattern, files matching this glob that are NOT in `files` trigger
|
|
14
|
+
* the bridge strategy (stable bridge className for incremental migration when consumers
|
|
15
|
+
* are not transformed). Files in both globs use the marker sidecar strategy (both
|
|
16
|
+
* consumer and target are transformed).
|
|
17
|
+
*
|
|
18
|
+
* Required when `externalInterface` is `"auto"`.
|
|
19
|
+
*
|
|
20
|
+
* @example "src/**\/*.tsx"
|
|
21
|
+
* @example null // opt out of cross-file scanning
|
|
22
|
+
*/
|
|
23
|
+
consumerPaths: string | string[] | null;
|
|
10
24
|
/**
|
|
11
25
|
* Adapter for customizing the transform.
|
|
12
26
|
* Controls value resolution and resolver-provided imports.
|
|
27
|
+
*
|
|
28
|
+
* Use `externalInterface: "auto"` to auto-detect which exported components
|
|
29
|
+
* need external className/style and polymorphic `as` support by scanning
|
|
30
|
+
* consumer code specified via `consumerPaths` (or `files`).
|
|
31
|
+
*
|
|
32
|
+
* Note: `"auto"` requires prepass scanning to succeed. If prepass fails,
|
|
33
|
+
* runTransform throws instead of silently falling back.
|
|
13
34
|
*/
|
|
14
|
-
adapter:
|
|
35
|
+
adapter: AdapterInput;
|
|
15
36
|
/**
|
|
16
37
|
* Dry run - don't write changes to files
|
|
17
38
|
* @default false
|
|
@@ -35,7 +56,7 @@ interface RunTransformOptions {
|
|
|
35
56
|
formatterCommands?: string[];
|
|
36
57
|
/**
|
|
37
58
|
* Maximum number of examples shown per warning category in the summary.
|
|
38
|
-
* @default
|
|
59
|
+
* @default 3
|
|
39
60
|
*/
|
|
40
61
|
maxExamples?: number;
|
|
41
62
|
}
|
|
@@ -73,6 +94,7 @@ interface RunTransformResult {
|
|
|
73
94
|
*
|
|
74
95
|
* await runTransform({
|
|
75
96
|
* files: 'src/**\/*.tsx',
|
|
97
|
+
* consumerPaths: null,
|
|
76
98
|
* adapter,
|
|
77
99
|
* dryRun: true,
|
|
78
100
|
* });
|
|
@@ -80,4 +102,4 @@ interface RunTransformResult {
|
|
|
80
102
|
*/
|
|
81
103
|
declare function runTransform(options: RunTransformOptions): Promise<RunTransformResult>;
|
|
82
104
|
//#endregion
|
|
83
|
-
export { defineAdapter, runTransform };
|
|
105
|
+
export { type AdapterInput, defineAdapter, runTransform };
|