@planningcenter/tapestry-migration-cli 3.6.0 → 3.7.0-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 (34) hide show
  1. package/dist/tapestry-react-shim.cjs +195 -11
  2. package/package.json +3 -3
  3. package/src/components/flex/index.test.ts +181 -0
  4. package/src/components/flex/index.ts +16 -0
  5. package/src/components/flex/transforms/alignmentToAlign.test.ts +101 -0
  6. package/src/components/flex/transforms/alignmentToAlign.ts +74 -20
  7. package/src/components/flex/transforms/auditSpreadProps.test.ts +93 -0
  8. package/src/components/flex/transforms/auditSpreadProps.ts +6 -0
  9. package/src/components/flex/transforms/axisToDirection.test.ts +15 -0
  10. package/src/components/flex/transforms/axisToDirection.ts +26 -0
  11. package/src/components/flex/transforms/constants.ts +4 -0
  12. package/src/components/flex/transforms/convertStyleProps.test.ts +147 -0
  13. package/src/components/flex/transforms/convertStyleProps.ts +23 -0
  14. package/src/components/flex/transforms/cssFlexAliasesToProps.test.ts +202 -0
  15. package/src/components/flex/transforms/cssFlexAliasesToProps.ts +106 -0
  16. package/src/components/flex/transforms/dedupeAttributes.test.ts +120 -0
  17. package/src/components/flex/transforms/dedupeAttributes.ts +49 -0
  18. package/src/components/flex/transforms/distributionToJustify.test.ts +59 -0
  19. package/src/components/flex/transforms/distributionToJustify.ts +69 -20
  20. package/src/components/flex/transforms/flexPropToExpand.test.ts +172 -0
  21. package/src/components/flex/transforms/flexPropToExpand.ts +100 -0
  22. package/src/components/flex/transforms/mediaQueriesToResponsive.test.ts +795 -0
  23. package/src/components/flex/transforms/mediaQueriesToResponsive.ts +562 -0
  24. package/src/components/flex/transforms/paddingPropsToFlex.test.ts +252 -0
  25. package/src/components/flex/transforms/paddingPropsToFlex.ts +150 -0
  26. package/src/components/flex/transforms/setDefaultAxis.test.ts +12 -0
  27. package/src/components/flex/transforms/setDefaultAxis.ts +1 -0
  28. package/src/components/flex/transforms/spacingToGap.test.ts +87 -0
  29. package/src/components/flex/transforms/spacingToGap.ts +28 -10
  30. package/src/components/flex/transforms/unsupportedProps.test.ts +141 -0
  31. package/src/components/flex/transforms/unsupportedProps.ts +17 -0
  32. package/src/components/shared/helpers/unsupportedPropsHelpers.ts +41 -0
  33. package/src/components/shared/transformFactories/stylePropTransformFactory.test.ts +47 -0
  34. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +16 -0
@@ -0,0 +1,562 @@
1
+ import { JSCodeshift, JSXAttribute, Transform } from "jscodeshift"
2
+
3
+ import { formatComment } from "../../shared/actions/addComment"
4
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
5
+ import { getAttribute } from "../../shared/actions/getAttribute"
6
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
7
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
8
+ import { FLEX_START_END_MAP } from "./constants"
9
+
10
+ function commentOnAttr(
11
+ attr: JSXAttribute,
12
+ scope: string,
13
+ text: string,
14
+ j: JSCodeshift
15
+ ) {
16
+ const comment = j.commentBlock(formatComment(text, scope), true, false)
17
+ attr.comments = attr.comments ? [...attr.comments, comment] : [comment]
18
+ }
19
+
20
+ const VALID_FLEX_BREAKPOINTS = new Set(["xs", "sm", "md", "lg", "xl"])
21
+
22
+ const CONSTANT_AXIS_MAP: Record<string, string> = {
23
+ HORIZONTAL: "horizontal",
24
+ VERTICAL: "vertical",
25
+ }
26
+
27
+ const AXIS_DIRECTION_MAP: Record<string, string> = {
28
+ horizontal: "row",
29
+ vertical: "column",
30
+ }
31
+
32
+ const VALID_ALIGN_VALUES = new Set([
33
+ "start",
34
+ "center",
35
+ "end",
36
+ "stretch",
37
+ "baseline",
38
+ ])
39
+
40
+ const CONSTANT_ALIGN_MAP: Record<string, string> = {
41
+ BASELINE: "baseline",
42
+ CENTER: "center",
43
+ END: "end",
44
+ FILL: "fill",
45
+ START: "start",
46
+ STRETCH: "stretch",
47
+ }
48
+
49
+ const VALID_JUSTIFY_VALUES = new Set([
50
+ "start",
51
+ "center",
52
+ "end",
53
+ "space-between",
54
+ "space-evenly",
55
+ ])
56
+
57
+ const CONSTANT_DISTRIBUTION_MAP: Record<string, string> = {
58
+ CENTER: "center",
59
+ END: "end",
60
+ FILL: "fill",
61
+ SPACE_BETWEEN: "space-between",
62
+ SPACE_EVENLY: "space-evenly",
63
+ START: "start",
64
+ }
65
+
66
+ const VALID_GAP_VALUES = new Set([0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 7])
67
+
68
+ const PADDING_SCALE = new Set([0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 7])
69
+
70
+ const PIXEL_TO_TOKEN: Record<string, number> = {
71
+ "0": 0,
72
+ "0px": 0,
73
+ "2px": 0.25,
74
+ "4px": 0.5,
75
+ "8px": 1,
76
+ "12px": 1.5,
77
+ "16px": 2,
78
+ "24px": 3,
79
+ "32px": 4,
80
+ "40px": 5,
81
+ "48px": 6,
82
+ "56px": 7,
83
+ }
84
+
85
+ const PADDING_SCALE_LIST = [...PADDING_SCALE].join(", ")
86
+
87
+ const PADDING_TODO =
88
+ "could not migrate padding automatically; " +
89
+ `use a Tapestry spacing scale number (${PADDING_SCALE_LIST}) ` +
90
+ "for Flex padding, or use style={{ padding: ... }} for arbitrary CSS values"
91
+
92
+ const FLEX_RESPONSIVE_PASSTHROUGH = new Set([
93
+ "align",
94
+ "alignSelf",
95
+ "basis",
96
+ "columnGap",
97
+ "direction",
98
+ "gap",
99
+ "grow",
100
+ "justify",
101
+ "paddingBlock",
102
+ "paddingInline",
103
+ "rowGap",
104
+ "shrink",
105
+ "wrap",
106
+ ])
107
+
108
+ type InnerPropEntry = {
109
+ newKey: string
110
+ newValue:
111
+ | ReturnType<JSCodeshift["stringLiteral"]>
112
+ | ReturnType<JSCodeshift["numericLiteral"]>
113
+ }
114
+
115
+ type InnerPropPlan = InnerPropEntry | { newEntries: InnerPropEntry[] }
116
+
117
+ type InnerPropResult = InnerPropPlan | { todo: string } | null
118
+
119
+ function planInnerProp(
120
+ propName: string,
121
+ propValue: unknown,
122
+ j: JSCodeshift
123
+ ): InnerPropResult {
124
+ if (typeof propValue !== "object" || propValue === null)
125
+ return { todo: `could not resolve value; review manually` }
126
+ const node = propValue as {
127
+ property?: unknown
128
+ type: string
129
+ value?: unknown
130
+ }
131
+
132
+ if (propName === "axis") {
133
+ let axisValue: string | null = null
134
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
135
+ axisValue = node.value
136
+ } else if (node.type === "MemberExpression") {
137
+ const prop = node.property as { name?: string; type?: string } | undefined
138
+ if (prop?.type === "Identifier" && typeof prop.name === "string") {
139
+ axisValue = CONSTANT_AXIS_MAP[prop.name] ?? null
140
+ }
141
+ }
142
+ const direction = axisValue !== null ? AXIS_DIRECTION_MAP[axisValue] : null
143
+ if (!direction)
144
+ return {
145
+ todo: `could not migrate to direction automatically; specify "horizontal" or "vertical"`,
146
+ }
147
+ return { newKey: "direction", newValue: j.stringLiteral(direction) }
148
+ }
149
+
150
+ if (propName === "alignment") {
151
+ let alignValue: string | null = null
152
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
153
+ alignValue = FLEX_START_END_MAP[node.value] ?? node.value
154
+ } else if (node.type === "MemberExpression") {
155
+ const prop = node.property as { name?: string; type?: string } | undefined
156
+ if (prop?.type === "Identifier" && typeof prop.name === "string") {
157
+ alignValue = CONSTANT_ALIGN_MAP[prop.name] ?? null
158
+ }
159
+ }
160
+ if (alignValue === "fill")
161
+ return {
162
+ todo: '"fill" has no Flex equivalent; use expand on the child element instead',
163
+ }
164
+ if (alignValue === null || !VALID_ALIGN_VALUES.has(alignValue))
165
+ return {
166
+ todo: `could not migrate to align automatically; valid values are: ${[...VALID_ALIGN_VALUES].join(", ")}`,
167
+ }
168
+ return { newKey: "align", newValue: j.stringLiteral(alignValue) }
169
+ }
170
+
171
+ if (propName === "distribution") {
172
+ let distValue: string | null = null
173
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
174
+ distValue = node.value
175
+ } else if (node.type === "MemberExpression") {
176
+ const prop = node.property as { name?: string; type?: string } | undefined
177
+ if (prop?.type === "Identifier" && typeof prop.name === "string") {
178
+ distValue = CONSTANT_DISTRIBUTION_MAP[prop.name] ?? null
179
+ }
180
+ }
181
+ if (distValue === "fill")
182
+ return {
183
+ todo: '"fill" has no Flex equivalent; use expand on child elements instead',
184
+ }
185
+ if (distValue === null || !VALID_JUSTIFY_VALUES.has(distValue))
186
+ return {
187
+ todo: `could not migrate to justify automatically; valid values are: ${[...VALID_JUSTIFY_VALUES].join(", ")}`,
188
+ }
189
+ return { newKey: "justify", newValue: j.stringLiteral(distValue) }
190
+ }
191
+
192
+ if (propName === "spacing") {
193
+ if (
194
+ node.type === "NumericLiteral" &&
195
+ typeof node.value === "number" &&
196
+ VALID_GAP_VALUES.has(node.value)
197
+ ) {
198
+ return { newKey: "gap", newValue: j.numericLiteral(node.value) }
199
+ }
200
+ return {
201
+ todo: `could not migrate to gap automatically; use a Tapestry spacing scale number (${[...VALID_GAP_VALUES].join(", ")})`,
202
+ }
203
+ }
204
+
205
+ if (propName === "justifyContent") {
206
+ let value: string | null = null
207
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
208
+ const normalized = FLEX_START_END_MAP[node.value] ?? node.value
209
+ value = VALID_JUSTIFY_VALUES.has(normalized) ? normalized : null
210
+ }
211
+ if (value !== null)
212
+ return { newKey: "justify", newValue: j.stringLiteral(value) }
213
+ return {
214
+ todo: `could not migrate to justify automatically; valid values are: ${[...VALID_JUSTIFY_VALUES].join(", ")}`,
215
+ }
216
+ }
217
+
218
+ if (propName === "alignItems") {
219
+ let value: string | null = null
220
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
221
+ const normalized = FLEX_START_END_MAP[node.value] ?? node.value
222
+ value = VALID_ALIGN_VALUES.has(normalized) ? normalized : null
223
+ }
224
+ if (value !== null)
225
+ return { newKey: "align", newValue: j.stringLiteral(value) }
226
+ return {
227
+ todo: `could not migrate to align automatically; valid values are: ${[...VALID_ALIGN_VALUES].join(", ")}`,
228
+ }
229
+ }
230
+
231
+ if (propName === "flexDirection") {
232
+ const VALID_DIRECTION_VALUES = new Set([
233
+ "row",
234
+ "column",
235
+ "row-reverse",
236
+ "column-reverse",
237
+ ])
238
+ let value: string | null = null
239
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
240
+ value = VALID_DIRECTION_VALUES.has(node.value) ? node.value : null
241
+ }
242
+ if (value !== null)
243
+ return { newKey: "direction", newValue: j.stringLiteral(value) }
244
+ return {
245
+ todo: `could not migrate to direction automatically; valid values are: row, column, row-reverse, column-reverse`,
246
+ }
247
+ }
248
+
249
+ if (propName === "flexGrow" || propName === "flexShrink") {
250
+ const newKey = propName === "flexGrow" ? "grow" : "shrink"
251
+ if (node.type === "NumericLiteral" && typeof node.value === "number") {
252
+ return { newKey, newValue: j.numericLiteral(node.value) }
253
+ }
254
+ return {
255
+ todo: `could not migrate to ${newKey} automatically; use a numeric value`,
256
+ }
257
+ }
258
+
259
+ if (propName === "flexBasis") {
260
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
261
+ return { newKey: "basis", newValue: j.stringLiteral(node.value) }
262
+ }
263
+ if (node.type === "NumericLiteral" && typeof node.value === "number") {
264
+ return { newKey: "basis", newValue: j.numericLiteral(node.value) }
265
+ }
266
+ return {
267
+ todo: "could not migrate to basis automatically; use a string or numeric value",
268
+ }
269
+ }
270
+
271
+ if (propName === "flexWrap") {
272
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
273
+ return { newKey: "wrap", newValue: j.stringLiteral(node.value) }
274
+ }
275
+ return {
276
+ todo: "could not migrate to wrap automatically; use a string value",
277
+ }
278
+ }
279
+
280
+ if (propName === "padding") {
281
+ if (
282
+ node.type === "NumericLiteral" &&
283
+ typeof node.value === "number" &&
284
+ PADDING_SCALE.has(node.value)
285
+ ) {
286
+ return null
287
+ }
288
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
289
+ const mapped = PIXEL_TO_TOKEN[node.value]
290
+ if (mapped !== undefined)
291
+ return { newKey: "padding", newValue: j.numericLiteral(mapped) }
292
+ }
293
+ return { todo: PADDING_TODO }
294
+ }
295
+
296
+ if (propName === "paddingHorizontal") {
297
+ if (
298
+ node.type === "NumericLiteral" &&
299
+ typeof node.value === "number" &&
300
+ PADDING_SCALE.has(node.value)
301
+ ) {
302
+ return { newKey: "paddingInline", newValue: j.numericLiteral(node.value) }
303
+ }
304
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
305
+ const mapped = PIXEL_TO_TOKEN[node.value]
306
+ if (mapped !== undefined)
307
+ return { newKey: "paddingInline", newValue: j.numericLiteral(mapped) }
308
+ }
309
+ return { todo: PADDING_TODO }
310
+ }
311
+
312
+ if (propName === "paddingVertical") {
313
+ if (
314
+ node.type === "NumericLiteral" &&
315
+ typeof node.value === "number" &&
316
+ PADDING_SCALE.has(node.value)
317
+ ) {
318
+ return { newKey: "paddingBlock", newValue: j.numericLiteral(node.value) }
319
+ }
320
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
321
+ const mapped = PIXEL_TO_TOKEN[node.value]
322
+ if (mapped !== undefined)
323
+ return { newKey: "paddingBlock", newValue: j.numericLiteral(mapped) }
324
+ }
325
+ return { todo: PADDING_TODO }
326
+ }
327
+
328
+ if (propName === "flex") {
329
+ if (node.type === "StringLiteral" && typeof node.value === "string") {
330
+ const raw = (node.value as string).trim()
331
+ const parts = raw.split(/\s+/)
332
+ if (parts.length === 3) {
333
+ const grow = Number(parts[0])
334
+ const shrink = Number(parts[1])
335
+ const basis = parts[2]
336
+ if (Number.isFinite(grow) && Number.isFinite(shrink)) {
337
+ return {
338
+ newEntries: [
339
+ { newKey: "grow", newValue: j.numericLiteral(grow) },
340
+ { newKey: "shrink", newValue: j.numericLiteral(shrink) },
341
+ { newKey: "basis", newValue: j.stringLiteral(basis) },
342
+ ],
343
+ }
344
+ }
345
+ }
346
+ if (raw === "1") {
347
+ return {
348
+ todo: "expand is not supported in responsive; use grow, shrink, and basis individually",
349
+ }
350
+ }
351
+ return {
352
+ todo: "could not migrate flex automatically; use grow, shrink, and basis individually",
353
+ }
354
+ }
355
+ if (
356
+ node.type === "NumericLiteral" &&
357
+ typeof node.value === "number" &&
358
+ node.value === 0
359
+ ) {
360
+ return {
361
+ newEntries: [
362
+ { newKey: "grow", newValue: j.numericLiteral(0) },
363
+ { newKey: "shrink", newValue: j.numericLiteral(1) },
364
+ { newKey: "basis", newValue: j.stringLiteral("0") },
365
+ ],
366
+ }
367
+ }
368
+ return {
369
+ todo: "expand is not supported in responsive; use grow, shrink, and basis individually",
370
+ }
371
+ }
372
+
373
+ if (FLEX_RESPONSIVE_PASSTHROUGH.has(propName)) return null
374
+
375
+ return {
376
+ todo: `not a valid Flex responsive prop; remove or replace manually`,
377
+ }
378
+ }
379
+
380
+ const TODO_TEXT =
381
+ "could not migrate mediaQueries to responsive automatically; " +
382
+ "ensure breakpoints are xs/sm/md/lg/xl with static object values"
383
+
384
+ const transform: Transform = attributeTransformFactory({
385
+ condition: hasAttribute("mediaQueries"),
386
+ targetComponent: "StackView",
387
+ targetPackage: "@planningcenter/tapestry-react",
388
+ transform: (element, { j }) => {
389
+ const mediaQueriesAttr = getAttribute({ element, name: "mediaQueries" })
390
+ if (!mediaQueriesAttr) return false
391
+
392
+ const attrValue = mediaQueriesAttr.value
393
+
394
+ if (attrValue?.type !== "JSXExpressionContainer") {
395
+ addCommentToAttribute({ attribute: mediaQueriesAttr, j, text: TODO_TEXT })
396
+ return true
397
+ }
398
+
399
+ if (attrValue.expression.type !== "ObjectExpression") {
400
+ addCommentToAttribute({ attribute: mediaQueriesAttr, j, text: TODO_TEXT })
401
+ return true
402
+ }
403
+
404
+ const outerObj = attrValue.expression
405
+ const mutations: Array<() => void> = []
406
+ const innerTodos: Array<{ key: string; message: string }> = []
407
+
408
+ for (const outerProp of outerObj.properties) {
409
+ if (outerProp.type !== "ObjectProperty") {
410
+ addCommentToAttribute({
411
+ attribute: mediaQueriesAttr,
412
+ j,
413
+ text: TODO_TEXT,
414
+ })
415
+ return true
416
+ }
417
+
418
+ const outerKey = outerProp.key
419
+ if (outerProp.computed || outerKey.type !== "Identifier") {
420
+ addCommentToAttribute({
421
+ attribute: mediaQueriesAttr,
422
+ j,
423
+ text: TODO_TEXT,
424
+ })
425
+ return true
426
+ }
427
+
428
+ if (!VALID_FLEX_BREAKPOINTS.has(outerKey.name)) {
429
+ addCommentToAttribute({
430
+ attribute: mediaQueriesAttr,
431
+ j,
432
+ text: TODO_TEXT,
433
+ })
434
+ return true
435
+ }
436
+
437
+ const outerVal = outerProp.value
438
+ if (outerVal.type !== "ObjectExpression") {
439
+ addCommentToAttribute({
440
+ attribute: mediaQueriesAttr,
441
+ j,
442
+ text: TODO_TEXT,
443
+ })
444
+ return true
445
+ }
446
+
447
+ const pendingRenames: Array<{
448
+ apply: () => void
449
+ name: string
450
+ newKeys: string[]
451
+ }> = []
452
+ const finalKeyCounts = new Map<string, number>()
453
+ const countKey = (key: string) =>
454
+ finalKeyCounts.set(key, (finalKeyCounts.get(key) ?? 0) + 1)
455
+
456
+ for (const innerProp of outerVal.properties) {
457
+ if (innerProp.type !== "ObjectProperty") {
458
+ innerTodos.push({
459
+ key: "[spread]",
460
+ message:
461
+ "spread elements inside a breakpoint object cannot be migrated automatically; review manually",
462
+ })
463
+ continue
464
+ }
465
+
466
+ const innerKey = innerProp.key
467
+ if (innerProp.computed || innerKey.type !== "Identifier") {
468
+ innerTodos.push({
469
+ key: "[computed key]",
470
+ message:
471
+ "computed property keys inside a breakpoint object cannot be migrated automatically; review manually",
472
+ })
473
+ continue
474
+ }
475
+
476
+ const result = planInnerProp(innerKey.name, innerProp.value, j)
477
+ if (result === null) {
478
+ countKey(innerKey.name)
479
+ continue
480
+ }
481
+ if ("todo" in result) {
482
+ countKey(innerKey.name)
483
+ innerTodos.push({ key: innerKey.name, message: result.todo })
484
+ continue
485
+ }
486
+
487
+ const newKeys =
488
+ "newEntries" in result
489
+ ? result.newEntries.map(({ newKey }) => newKey)
490
+ : [result.newKey]
491
+ newKeys.forEach(countKey)
492
+
493
+ pendingRenames.push({
494
+ apply: () => {
495
+ if ("newEntries" in result) {
496
+ const entries = result.newEntries
497
+ const idx = outerVal.properties.indexOf(innerProp)
498
+ outerVal.properties.splice(
499
+ idx,
500
+ 1,
501
+ ...entries.map(({ newKey, newValue }) =>
502
+ j.objectProperty(j.identifier(newKey), newValue)
503
+ )
504
+ )
505
+ } else {
506
+ innerKey.name = result.newKey
507
+ innerProp.value = result.newValue
508
+ }
509
+ },
510
+ name: innerKey.name,
511
+ newKeys,
512
+ })
513
+ }
514
+
515
+ for (const plan of pendingRenames) {
516
+ const collidingKeys = plan.newKeys.filter(
517
+ (key) => (finalKeyCounts.get(key) ?? 0) > 1
518
+ )
519
+
520
+ if (collidingKeys.length > 0) {
521
+ innerTodos.push({
522
+ key: plan.name,
523
+ message: `could not migrate ${plan.name} automatically — it would duplicate the ${collidingKeys.join(", ")} key already present (or produced by another prop) in this breakpoint; set the target prop(s) individually instead`,
524
+ })
525
+ continue
526
+ }
527
+
528
+ mutations.push(plan.apply)
529
+ }
530
+ }
531
+
532
+ mediaQueriesAttr.name.name = "responsive"
533
+ for (const mutation of mutations) {
534
+ mutation()
535
+ }
536
+
537
+ if (innerTodos.length > 0) {
538
+ const messageToKeys = new Map<string, string[]>()
539
+ for (const { key, message } of innerTodos) {
540
+ const existing = messageToKeys.get(message)
541
+ if (existing) {
542
+ if (!existing.includes(key)) existing.push(key)
543
+ } else {
544
+ messageToKeys.set(message, [key])
545
+ }
546
+ }
547
+ const parts = [...messageToKeys].map(
548
+ ([message, keys]) => `${keys.join(", ")}: ${message}`
549
+ )
550
+ commentOnAttr(
551
+ mediaQueriesAttr,
552
+ "responsive",
553
+ `could not automatically migrate all breakpoint props — ${parts.join("; ")}`,
554
+ j
555
+ )
556
+ }
557
+
558
+ return true
559
+ },
560
+ })
561
+
562
+ export default transform