@planningcenter/tapestry-migration-cli 3.2.2-rc.9 → 3.2.3-rc.0

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.
Files changed (29) hide show
  1. package/package.json +3 -3
  2. package/src/components/shared/helpers/unsupportedPropsHelpers.ts +24 -0
  3. package/src/components/shared/transformFactories/stylePropTransformFactory.test.ts +330 -0
  4. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +156 -15
  5. package/src/components/shared/transformFactories/ternaryConditionalToPropFactory.ts +17 -12
  6. package/src/components/time-field/index.ts +48 -0
  7. package/src/components/time-field/transforms/auditSpreadProps.test.ts +76 -0
  8. package/src/components/time-field/transforms/auditSpreadProps.ts +10 -0
  9. package/src/components/time-field/transforms/convertStyleProps.test.ts +43 -0
  10. package/src/components/time-field/transforms/convertStyleProps.ts +10 -0
  11. package/src/components/time-field/transforms/flagMinMax.test.ts +103 -0
  12. package/src/components/time-field/transforms/flagMinMax.ts +31 -0
  13. package/src/components/time-field/transforms/mergeFieldIntoTimeField.test.ts +106 -0
  14. package/src/components/time-field/transforms/mergeFieldIntoTimeField.ts +5 -0
  15. package/src/components/time-field/transforms/moveTimeFieldImport.test.ts +153 -0
  16. package/src/components/time-field/transforms/moveTimeFieldImport.ts +14 -0
  17. package/src/components/time-field/transforms/sizeMapping.test.ts +173 -0
  18. package/src/components/time-field/transforms/sizeMapping.ts +15 -0
  19. package/src/components/time-field/transforms/stateToInvalid.test.ts +87 -0
  20. package/src/components/time-field/transforms/stateToInvalid.ts +56 -0
  21. package/src/components/time-field/transforms/stateToInvalidTernary.test.ts +100 -0
  22. package/src/components/time-field/transforms/stateToInvalidTernary.ts +11 -0
  23. package/src/components/time-field/transforms/tupleToTime.test.ts +182 -0
  24. package/src/components/time-field/transforms/tupleToTime.ts +107 -0
  25. package/src/components/time-field/transforms/twelveHourClockToHourCycle.test.ts +117 -0
  26. package/src/components/time-field/transforms/twelveHourClockToHourCycle.ts +65 -0
  27. package/src/components/time-field/transforms/unsupportedProps.test.ts +160 -0
  28. package/src/components/time-field/transforms/unsupportedProps.ts +37 -0
  29. package/src/index.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/tapestry-migration-cli",
3
- "version": "3.2.2-rc.9",
3
+ "version": "3.2.3-rc.0",
4
4
  "description": "CLI tool for Tapestry migrations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@emotion/react": "^11.14.0",
35
- "@planningcenter/tapestry": "^3.2.2-rc.9",
35
+ "@planningcenter/tapestry": "^3.2.3-rc.0",
36
36
  "@planningcenter/tapestry-react": "^4.11.5",
37
37
  "@types/jscodeshift": "^17.3.0",
38
38
  "@types/node": "^20.0.0",
@@ -52,5 +52,5 @@
52
52
  "publishConfig": {
53
53
  "access": "public"
54
54
  },
55
- "gitHead": "4810e0377bd9ec8ee2c3ea474741af940ec9406e"
55
+ "gitHead": "e944c237ec8b2e0318f53f8a8a2a3b31b6e28a81"
56
56
  }
@@ -151,3 +151,27 @@ export const TEXTAREA_SUPPORTED_PROPS = [
151
151
  ...TEXTAREA_SPECIFIC_PROPS,
152
152
  ...STYLE_PROP_NAMES_WITHOUT_CSS,
153
153
  ]
154
+
155
+ export const TIME_FIELD_SPECIFIC_PROPS = [
156
+ "defaultValue",
157
+ "description",
158
+ "disabled",
159
+ "forceLeadingZeros",
160
+ "form",
161
+ "hideLabel",
162
+ "hideTimeZone",
163
+ "hourCycle",
164
+ "invalid",
165
+ "max",
166
+ "min",
167
+ "name",
168
+ "onChange",
169
+ "readOnly",
170
+ "required",
171
+ "value",
172
+ ]
173
+
174
+ export const TIME_FIELD_SUPPORTED_PROPS = [
175
+ ...COMMON_PROPS,
176
+ ...TIME_FIELD_SPECIFIC_PROPS,
177
+ ]
@@ -0,0 +1,330 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import { stackViewPlugin } from "../../../stubs/stackViewPlugin"
5
+ import { stylePropTransformFactory } from "./stylePropTransformFactory"
6
+
7
+ const j = jscodeshift.withParser("tsx")
8
+
9
+ function applyTransform(
10
+ transform: ReturnType<typeof stylePropTransformFactory>,
11
+ source: string
12
+ ): string {
13
+ const fileInfo = { path: "test.tsx", source }
14
+ const result = transform(
15
+ fileInfo,
16
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
17
+ {}
18
+ ) as string | null
19
+ return result || source
20
+ }
21
+
22
+ describe("stylePropTransformFactory", () => {
23
+ describe("grid alias remapping", () => {
24
+ const transform = stylePropTransformFactory({
25
+ stylesToRemove: [],
26
+ targetComponent: "Box",
27
+ targetPackage: "@planningcenter/tapestry-react",
28
+ })
29
+
30
+ it("remaps column/row string props to gridColumn/gridRow via splitStyles", () => {
31
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box column="1 / 3" row="2 / 4" />`
32
+ const result = applyTransform(transform, input)
33
+ expect(result).toMatchInlineSnapshot(`
34
+ "import { Box } from "@planningcenter/tapestry-react";<Box
35
+ style={{
36
+ gridColumn: "1 / 3",
37
+ gridRow: "2 / 4"
38
+ }} />"
39
+ `)
40
+ })
41
+
42
+ it("remaps column/row expression props to gridColumn/gridRow", () => {
43
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box column={1} row={2} />`
44
+ const result = applyTransform(transform, input)
45
+ expect(result).toMatchInlineSnapshot(`
46
+ "import { Box } from "@planningcenter/tapestry-react";<Box
47
+ style={{
48
+ gridColumn: 1,
49
+ gridRow: 2
50
+ }} />"
51
+ `)
52
+ })
53
+
54
+ it("remaps column/row identifier expression props to gridColumn/gridRow", () => {
55
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box column={col} row={r} />`
56
+ const result = applyTransform(transform, input)
57
+ expect(result).toMatchInlineSnapshot(`
58
+ "import { Box } from "@planningcenter/tapestry-react";<Box
59
+ style={{
60
+ gridColumn: col,
61
+ gridRow: r
62
+ }} />"
63
+ `)
64
+ })
65
+
66
+ it("remaps columnStart/columnEnd/rowStart/rowEnd expression props", () => {
67
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box columnStart={1} columnEnd={3} rowStart={2} rowEnd={4} />`
68
+ const result = applyTransform(transform, input)
69
+ expect(result).toMatchInlineSnapshot(`
70
+ "import { Box } from "@planningcenter/tapestry-react";<Box
71
+ style={{
72
+ gridColumnStart: 1,
73
+ gridColumnEnd: 3,
74
+ gridRowStart: 2,
75
+ gridRowEnd: 4
76
+ }} />"
77
+ `)
78
+ })
79
+
80
+ it("remaps direction expression prop to flexDirection", () => {
81
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box direction={dir} />`
82
+ const result = applyTransform(transform, input)
83
+ expect(result).toMatchInlineSnapshot(`
84
+ "import { Box } from "@planningcenter/tapestry-react";<Box style={{
85
+ flexDirection: dir
86
+ }} />"
87
+ `)
88
+ })
89
+
90
+ // Conditional expressions can't be evaluated at codemod time, so they
91
+ // bypass splitStyles and land in directProps as raw source. Without the
92
+ // alias map, the resulting `style` object would contain non-CSS keys
93
+ // like `column` / `row` instead of `gridColumn` / `gridRow`.
94
+ it("remaps column/row ternary expression props to gridColumn/gridRow", () => {
95
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box column={filter ? '1' : '2'} row={filter ? '2' : undefined} />`
96
+ const result = applyTransform(transform, input)
97
+ expect(result).toMatchInlineSnapshot(`
98
+ "import { Box } from "@planningcenter/tapestry-react";<Box
99
+ style={{
100
+ gridColumn: filter ? '1' : '2',
101
+ gridRow: filter ? '2' : undefined
102
+ }} />"
103
+ `)
104
+ })
105
+ })
106
+
107
+ describe("non-aliased style props", () => {
108
+ const transform = stylePropTransformFactory({
109
+ stylesToRemove: [],
110
+ targetComponent: "Box",
111
+ targetPackage: "@planningcenter/tapestry-react",
112
+ })
113
+
114
+ it("preserves the prop name for non-aliased identifier expression props", () => {
115
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box marginTop={spacing} />`
116
+ const result = applyTransform(transform, input)
117
+ expect(result).toMatchInlineSnapshot(`
118
+ "import { Box } from "@planningcenter/tapestry-react";<Box style={{
119
+ marginTop: spacing
120
+ }} />"
121
+ `)
122
+ })
123
+ })
124
+
125
+ describe("value-transforming aliases", () => {
126
+ const transform = stylePropTransformFactory({
127
+ stylesToRemove: [],
128
+ targetComponent: "Box",
129
+ targetPackage: "@planningcenter/tapestry-react",
130
+ })
131
+
132
+ it("comments out the renamed `wrap` → `flexWrap` and emits a TODO for expression values", () => {
133
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box wrap={someBool} />`
134
+ const result = applyTransform(transform, input)
135
+ expect(result).toMatchInlineSnapshot(`
136
+ "import { Box } from "@planningcenter/tapestry-react";/* TODO: tapestry-migration (styleProp): migrated from \`wrap\`; value may need manual transformation: flexWrap: someBool */
137
+ <Box />"
138
+ `)
139
+ })
140
+
141
+ it("comments out renamed `grow`/`shrink`/`basis` props with TODOs", () => {
142
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box grow={g} shrink={s} basis={b} />`
143
+ const result = applyTransform(transform, input)
144
+ expect(result).toMatchInlineSnapshot(`
145
+ "import { Box } from "@planningcenter/tapestry-react";/* TODO: tapestry-migration (styleProp): migrated from \`grow\`; value may need manual transformation: flexGrow: g */
146
+ /* TODO: tapestry-migration (styleProp): migrated from \`shrink\`; value may need manual transformation: flexShrink: s */
147
+ /* TODO: tapestry-migration (styleProp): migrated from \`basis\`; value may need manual transformation: flexBasis: b */
148
+ <Box />"
149
+ `)
150
+ })
151
+
152
+ it("comments out fanned-out `marginHorizontal` → marginLeft/marginRight with a TODO", () => {
153
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box marginHorizontal={spacing} />`
154
+ const result = applyTransform(transform, input)
155
+ expect(result).toMatchInlineSnapshot(`
156
+ "import { Box } from "@planningcenter/tapestry-react";/* TODO: tapestry-migration (styleProp): migrated from \`marginHorizontal\`; value may need manual transformation: marginLeft: spacing; marginRight: spacing */
157
+ <Box />"
158
+ `)
159
+ })
160
+
161
+ it("anchors commented-out renamed-key props inline when a real style key is present", () => {
162
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box wrap={someBool} marginTop={spacing} />`
163
+ const result = applyTransform(transform, input)
164
+ expect(result).toMatchInlineSnapshot(`
165
+ "import { Box } from "@planningcenter/tapestry-react";<Box
166
+ style={{
167
+ /* TODO: tapestry-migration (styleProp): migrated from \`wrap\`; value may need manual transformation */
168
+ // flexWrap: someBool,
169
+ marginTop: spacing
170
+ }} />"
171
+ `)
172
+ })
173
+
174
+ it("comments out fanned-out `paddingVertical` → paddingTop/paddingBottom", () => {
175
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box paddingVertical={p} />`
176
+ const result = applyTransform(transform, input)
177
+ expect(result).toMatchInlineSnapshot(`
178
+ "import { Box } from "@planningcenter/tapestry-react";/* TODO: tapestry-migration (styleProp): migrated from \`paddingVertical\`; value may need manual transformation: paddingTop: p; paddingBottom: p */
179
+ <Box />"
180
+ `)
181
+ })
182
+
183
+ it("comments out fanned-out `radius` → all four borderRadius corners", () => {
184
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box radius={r} />`
185
+ const result = applyTransform(transform, input)
186
+ expect(result).toMatchInlineSnapshot(`
187
+ "import { Box } from "@planningcenter/tapestry-react";/* TODO: tapestry-migration (styleProp): migrated from \`radius\`; value may need manual transformation: borderTopLeftRadius: r; borderTopRightRadius: r; borderBottomRightRadius: r; borderBottomLeftRadius: r */
188
+ <Box />"
189
+ `)
190
+ })
191
+
192
+ it("comments out fanned-out `radiusTop` → the two top corners only", () => {
193
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box radiusTop={rt} />`
194
+ const result = applyTransform(transform, input)
195
+ expect(result).toMatchInlineSnapshot(`
196
+ "import { Box } from "@planningcenter/tapestry-react";/* TODO: tapestry-migration (styleProp): migrated from \`radiusTop\`; value may need manual transformation: borderTopLeftRadius: rt; borderTopRightRadius: rt */
197
+ <Box />"
198
+ `)
199
+ })
200
+
201
+ // `x`, `y`, `rotate`, `scale`, `elevation` produce CSS by combining
202
+ // multiple values or by theme lookup, so we comment out a draft of the
203
+ // target CSS property under the migrated name.
204
+ it("comments out `x` → `transform` anchored to the next style key", () => {
205
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box x={dx} marginLeft={ml} />`
206
+ const result = applyTransform(transform, input)
207
+ expect(result).toMatchInlineSnapshot(`
208
+ "import { Box } from "@planningcenter/tapestry-react";<Box
209
+ style={{
210
+ /* TODO: tapestry-migration (styleProp): migrated from \`x\`; value may need manual transformation */
211
+ // transform: dx,
212
+ marginLeft: ml
213
+ }} />"
214
+ `)
215
+ })
216
+
217
+ it("commented-out-only elements emit JSX-level TODO comments and no style attribute", () => {
218
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box x={dx} y={dy} rotate={r} elevation={e} />`
219
+ const result = applyTransform(transform, input)
220
+ expect(result).toMatchInlineSnapshot(`
221
+ "import { Box } from "@planningcenter/tapestry-react";/* TODO: tapestry-migration (styleProp): migrated from \`x\`; value may need manual transformation: transform: dx */
222
+ /* TODO: tapestry-migration (styleProp): migrated from \`y\`; value may need manual transformation: transform: dy */
223
+ /* TODO: tapestry-migration (styleProp): migrated from \`rotate\`; value may need manual transformation: transform: r */
224
+ /* TODO: tapestry-migration (styleProp): migrated from \`elevation\`; value may need manual transformation: boxShadow: e */
225
+ <Box />"
226
+ `)
227
+ })
228
+
229
+ it("primitive `x` literals still go through splitStyles and produce a transform string", () => {
230
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box x={5} />`
231
+ const result = applyTransform(transform, input)
232
+ expect(result).toMatchInlineSnapshot(`
233
+ "import { Box } from "@planningcenter/tapestry-react";<Box style={{
234
+ transform: "translateX(5) "
235
+ }} />"
236
+ `)
237
+ })
238
+
239
+ it("does not emit a TODO comment for safely renamed grid aliases", () => {
240
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box column={col} />`
241
+ const result = applyTransform(transform, input)
242
+ expect(result).toMatchInlineSnapshot(`
243
+ "import { Box } from "@planningcenter/tapestry-react";<Box style={{
244
+ gridColumn: col
245
+ }} />"
246
+ `)
247
+ })
248
+
249
+ // Literal values flow through splitStyles, which expands shorthands like
250
+ // `marginHorizontal` into the underlying CSS properties. No TODO needed.
251
+ it("expands `marginHorizontal` string literals via splitStyles without a TODO", () => {
252
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box marginHorizontal="8px" />`
253
+ const result = applyTransform(transform, input)
254
+ expect(result).toMatchInlineSnapshot(`
255
+ "import { Box } from "@planningcenter/tapestry-react";<Box style={{
256
+ marginRight: "8px",
257
+ marginLeft: "8px"
258
+ }} />"
259
+ `)
260
+ })
261
+
262
+ it("expands `paddingHorizontal` numeric literals via splitStyles without a TODO", () => {
263
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box paddingHorizontal={1} />`
264
+ const result = applyTransform(transform, input)
265
+ expect(result).toMatchInlineSnapshot(`
266
+ "import { Box } from "@planningcenter/tapestry-react";<Box style={{
267
+ paddingRight: "8px",
268
+ paddingLeft: "8px"
269
+ }} />"
270
+ `)
271
+ })
272
+
273
+ it("transforms `wrap`/`grow` boolean literals via splitStyles without a TODO", () => {
274
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box wrap={true} grow={true} />`
275
+ const result = applyTransform(transform, input)
276
+ expect(result).toMatchInlineSnapshot(`
277
+ "import { Box } from "@planningcenter/tapestry-react";<Box
278
+ style={{
279
+ flexWrap: "wrap",
280
+ flexGrow: 1
281
+ }} />"
282
+ `)
283
+ })
284
+
285
+ it("expands `radius` shortcut strings via splitStyles without a TODO", () => {
286
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box radius="pill" />`
287
+ const result = applyTransform(transform, input)
288
+ expect(result).toMatchInlineSnapshot(`
289
+ "import { Box } from "@planningcenter/tapestry-react";<Box style={{
290
+ borderRadius: "10em"
291
+ }} />"
292
+ `)
293
+ })
294
+ })
295
+
296
+ describe("non-inlineable plugin output", () => {
297
+ // stackViewPlugin maps `distribution: "fill"` to `{ "& > *": { flex: "1 0 0px" } }`,
298
+ // a nested CSS selector that cannot be represented as a React `style={}` object.
299
+ // The transform must not crash, must not silently drop the source props, and must
300
+ // leave an audit trail for manual review.
301
+ const transform = stylePropTransformFactory({
302
+ plugin: stackViewPlugin,
303
+ stylesToRemove: [],
304
+ targetComponent: "Stack",
305
+ targetPackage: "@planningcenter/tapestry-react",
306
+ })
307
+
308
+ it("preserves source props and emits an audit comment when plugin output includes nested CSS selectors", () => {
309
+ const input = `import { Stack } from "@planningcenter/tapestry-react";<Stack axis="horizontal" spacing={1} distribution="fill" />`
310
+ const result = applyTransform(transform, input)
311
+
312
+ // Source props must remain — they cannot be safely inlined.
313
+ expect(result).toContain('axis="horizontal"')
314
+ expect(result).toContain("spacing={1}")
315
+ expect(result).toContain('distribution="fill"')
316
+ // Must not have produced a broken inline style with a nested object.
317
+ expect(result).not.toContain('"& > *"')
318
+ // Must leave a visible audit trail so the user knows to review.
319
+ expect(result).toMatch(/TODO:\s*tapestry-migration/)
320
+ })
321
+
322
+ it("does not crash on distribution='fill' alongside other plugin props", () => {
323
+ const input = `import { Stack } from "@planningcenter/tapestry-react";<Stack distribution="fill" axis="horizontal" />`
324
+ const result = applyTransform(transform, input)
325
+
326
+ expect(result).toContain('distribution="fill"')
327
+ expect(result).toContain('axis="horizontal"')
328
+ })
329
+ })
330
+ })
@@ -12,7 +12,7 @@ import {
12
12
  splitStyles,
13
13
  stylePropNames,
14
14
  } from "../../../../dist/tapestry-react-shim.cjs"
15
- import { addComment } from "../../shared/actions/addComment"
15
+ import { addComment, formatComment } from "../../shared/actions/addComment"
16
16
  import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
17
17
  import { getAttribute } from "../../shared/actions/getAttribute"
18
18
  import { removeAttribute } from "../../shared/actions/removeAttribute"
@@ -27,6 +27,49 @@ type StylePropMapping = Record<
27
27
  }
28
28
  >
29
29
 
30
+ // JSX expression values bypass splitStyles, so we mirror its plugin behavior
31
+ // for props that bypass it. Pure key renames pass through live; everything
32
+ // else gets commented out under the css target(s) so the developer can verify
33
+ // the value before enabling.
34
+ const liveAliases: Record<string, string> = {
35
+ column: "gridColumn",
36
+ columnEnd: "gridColumnEnd",
37
+ columnStart: "gridColumnStart",
38
+ direction: "flexDirection",
39
+ row: "gridRow",
40
+ rowEnd: "gridRowEnd",
41
+ rowStart: "gridRowStart",
42
+ }
43
+
44
+ const commentedAliases: Record<string, string[]> = {
45
+ basis: ["flexBasis"],
46
+ elevation: ["boxShadow"],
47
+ grow: ["flexGrow"],
48
+ marginHorizontal: ["marginLeft", "marginRight"],
49
+ marginVertical: ["marginTop", "marginBottom"],
50
+ paddingHorizontal: ["paddingLeft", "paddingRight"],
51
+ paddingVertical: ["paddingTop", "paddingBottom"],
52
+ radius: [
53
+ "borderTopLeftRadius",
54
+ "borderTopRightRadius",
55
+ "borderBottomRightRadius",
56
+ "borderBottomLeftRadius",
57
+ ],
58
+ radiusBottom: ["borderBottomRightRadius", "borderBottomLeftRadius"],
59
+ radiusLeft: ["borderTopLeftRadius", "borderBottomLeftRadius"],
60
+ radiusRight: ["borderTopRightRadius", "borderBottomRightRadius"],
61
+ radiusTop: ["borderTopLeftRadius", "borderTopRightRadius"],
62
+ rotate: ["transform"],
63
+ scale: ["transform"],
64
+ shrink: ["flexShrink"],
65
+ strokeAlign: ["strokeAlign"],
66
+ strokeWeight: ["strokeWeight"],
67
+ uppercase: ["textTransform"],
68
+ wrap: ["flexWrap"],
69
+ x: ["transform"],
70
+ y: ["transform"],
71
+ }
72
+
30
73
  function extractPropValue(attr: JSXAttribute, j: JSCodeshift) {
31
74
  if (!attr.value) {
32
75
  return true
@@ -50,22 +93,36 @@ function extractPropValue(attr: JSXAttribute, j: JSCodeshift) {
50
93
  }
51
94
  }
52
95
 
96
+ type ManualReviewEntry = {
97
+ cssTargets: string[]
98
+ originalProp: string
99
+ originalValue: string
100
+ }
101
+
102
+ function reviewHeading(entry: ManualReviewEntry) {
103
+ return `migrated from \`${entry.originalProp}\`; value may need manual transformation`
104
+ }
105
+
106
+ function reviewBody(entry: ManualReviewEntry) {
107
+ return entry.cssTargets.map((key) => `${key}: ${entry.originalValue}`)
108
+ }
109
+
53
110
  function processKeepStyleProps({
54
111
  attributes,
55
112
  j,
56
- element,
57
- source,
58
113
  }: {
59
114
  attributes: JSXAttribute[]
60
- element: JSXElement
61
115
  j: JSCodeshift
62
- source: Collection
63
116
  }): {
64
117
  directProps: Record<string, unknown>
118
+ manualReviewEntries: ManualReviewEntry[]
119
+ processedNames: string[]
65
120
  themeProps: Record<string, unknown>
66
121
  } {
67
122
  const themeProps: Record<string, unknown> = {}
68
123
  const directProps: Record<string, unknown> = {}
124
+ const manualReviewEntries: ManualReviewEntry[] = []
125
+ const processedNames: string[] = []
69
126
 
70
127
  attributes.forEach((attr) => {
71
128
  const propName = attr.name.name as string
@@ -76,15 +133,32 @@ function processKeepStyleProps({
76
133
  propValue.startsWith("{") &&
77
134
  propValue.endsWith("}")
78
135
  ) {
79
- directProps[propName] = propValue
136
+ if (propName in commentedAliases) {
137
+ manualReviewEntries.push({
138
+ cssTargets: commentedAliases[propName],
139
+ originalProp: propName,
140
+ originalValue: propValue.slice(1, -1),
141
+ })
142
+ } else {
143
+ directProps[liveAliases[propName] ?? propName] = propValue
144
+ }
80
145
  } else {
81
146
  themeProps[propName] = propValue
82
147
  }
83
148
 
84
- removeAttribute(propName, { element, j, source })
149
+ processedNames.push(propName)
85
150
  })
86
151
 
87
- return { directProps, themeProps }
152
+ return { directProps, manualReviewEntries, processedNames, themeProps }
153
+ }
154
+
155
+ function isInlineableStyleValue(value: unknown): boolean {
156
+ return (
157
+ value === null ||
158
+ typeof value === "string" ||
159
+ typeof value === "number" ||
160
+ typeof value === "boolean"
161
+ )
88
162
  }
89
163
 
90
164
  function processRemoveStyleProps({
@@ -160,20 +234,36 @@ function processStylePropMappings({
160
234
  return styleProps
161
235
  }
162
236
 
237
+ function buildManualReviewComments(
238
+ entries: ManualReviewEntry[],
239
+ j: JSCodeshift
240
+ ) {
241
+ return entries.flatMap((entry) => [
242
+ j.commentBlock(
243
+ formatComment(reviewHeading(entry), "styleProp"),
244
+ true,
245
+ false
246
+ ),
247
+ ...reviewBody(entry).map((line) => j.commentLine(` ${line},`)),
248
+ ])
249
+ }
250
+
163
251
  function applyStylesToComponent({
164
252
  j,
165
253
  element,
166
254
  styles,
255
+ manualReviewEntries = [],
167
256
  }: {
168
257
  element: JSXElement
169
258
  j: JSCodeshift
259
+ manualReviewEntries?: ManualReviewEntry[]
170
260
  source: Collection
171
261
  styles: Record<string, unknown>
172
262
  }) {
173
263
  const styleAttr = getAttribute({ element, name: "style" })
174
264
  const styleValue = j.jsxExpressionContainer(
175
265
  j.objectExpression(
176
- Object.entries(styles).map(([key, value]) => {
266
+ Object.entries(styles).map(([key, value], index) => {
177
267
  let valueNode
178
268
  if (
179
269
  typeof value === "string" &&
@@ -198,7 +288,11 @@ function applyStylesToComponent({
198
288
  valueNode = j.literal(value as string | number | boolean | null)
199
289
  }
200
290
 
201
- return j.objectProperty(j.identifier(key), valueNode)
291
+ const property = j.objectProperty(j.identifier(key), valueNode)
292
+ if (index === 0 && manualReviewEntries.length > 0) {
293
+ property.comments = buildManualReviewComments(manualReviewEntries, j)
294
+ }
295
+ return property
202
296
  })
203
297
  )
204
298
  )
@@ -286,6 +380,7 @@ export function stylePropTransformFactory(config: {
286
380
  const allAttributes = element.openingElement.attributes || []
287
381
  let allStyleProps: Record<string, unknown> = {}
288
382
  let directStyleProps: Record<string, unknown> = {}
383
+ const manualReviewEntries: ManualReviewEntry[] = []
289
384
  const attributes = allAttributes.filter((attr) => {
290
385
  if (attr.type !== "JSXAttribute") return false
291
386
  const name = attr.name?.name as string
@@ -307,9 +402,7 @@ export function stylePropTransformFactory(config: {
307
402
  )
308
403
  const keepResult = processKeepStyleProps({
309
404
  attributes: keepAttributes,
310
- element,
311
405
  j,
312
- source,
313
406
  })
314
407
  allStyleProps = {
315
408
  ...allStyleProps,
@@ -319,6 +412,7 @@ export function stylePropTransformFactory(config: {
319
412
  ...directStyleProps,
320
413
  ...keepResult.directProps,
321
414
  }
415
+ manualReviewEntries.push(...keepResult.manualReviewEntries)
322
416
 
323
417
  allStyleProps = {
324
418
  ...allStyleProps,
@@ -343,7 +437,8 @@ export function stylePropTransformFactory(config: {
343
437
 
344
438
  if (
345
439
  Object.keys(allStyleProps).length > 0 ||
346
- Object.keys(directStyleProps).length > 0
440
+ Object.keys(directStyleProps).length > 0 ||
441
+ manualReviewEntries.length > 0
347
442
  ) {
348
443
  try {
349
444
  let styles: Record<string, unknown> = {}
@@ -360,8 +455,39 @@ export function stylePropTransformFactory(config: {
360
455
  styles = { ...styles, ...directStyleProps }
361
456
  if (options.verbose) console.log("Final generated styles:", styles)
362
457
 
363
- if (Object.keys(styles).length > 0) {
364
- applyStylesToComponent({ element, j, source, styles })
458
+ // Plugins like stackViewPlugin can emit nested CSS selectors (e.g.
459
+ // `{ "& > *": { flex: "1 0 0px" } }` from `distribution: "fill"`)
460
+ // that can't be represented in React's inline `style={}`. Preserve
461
+ // the source props so nothing is silently dropped and flag for review.
462
+ const nonInlineableKeys = Object.entries(styles)
463
+ .filter(([, v]) => !isInlineableStyleValue(v))
464
+ .map(([k]) => k)
465
+ if (nonInlineableKeys.length > 0) {
466
+ addComment({
467
+ element,
468
+ j,
469
+ scope: "styleProp",
470
+ source,
471
+ text: `Could not migrate style props (${keepResult.processedNames.join(
472
+ ", "
473
+ )}) — produced non-inlineable style values (${nonInlineableKeys.join(
474
+ ", "
475
+ )}) that don't fit in inline style (for example, nested selectors). Manual review required.`,
476
+ })
477
+ } else {
478
+ for (const propName of keepResult.processedNames) {
479
+ removeAttribute(propName, { element, j, source })
480
+ }
481
+ }
482
+
483
+ if (nonInlineableKeys.length === 0 && Object.keys(styles).length > 0) {
484
+ applyStylesToComponent({
485
+ element,
486
+ j,
487
+ manualReviewEntries,
488
+ source,
489
+ styles,
490
+ })
365
491
 
366
492
  if (options.verbose) {
367
493
  const styleAttr = getAttribute({ element, name: "style" })
@@ -381,6 +507,21 @@ export function stylePropTransformFactory(config: {
381
507
  }
382
508
  }
383
509
  }
510
+ } else if (
511
+ nonInlineableKeys.length === 0 &&
512
+ manualReviewEntries.length > 0
513
+ ) {
514
+ // No anchoring property in the style block — emit each manual-review
515
+ // entry as a JSX-level TODO comment instead.
516
+ for (const entry of manualReviewEntries) {
517
+ addComment({
518
+ element,
519
+ j,
520
+ scope: "styleProp",
521
+ source,
522
+ text: `${reviewHeading(entry)}: ${reviewBody(entry).join("; ")}`,
523
+ })
524
+ }
384
525
  }
385
526
  } catch (error) {
386
527
  console.log("Error processing style props:", error)