@planningcenter/tapestry-migration-cli 2.3.0-rc.4 → 2.3.0-rc.6

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.
@@ -0,0 +1,374 @@
1
+ import jscodeshift, {
2
+ Collection,
3
+ JSCodeshift,
4
+ JSXAttribute,
5
+ JSXElement,
6
+ StringLiteral,
7
+ } from "jscodeshift"
8
+ import { describe, expect, it } from "vitest"
9
+
10
+ import {
11
+ attributeCombineFactory,
12
+ AttributeMappingConfig,
13
+ } from "./attributeCombineFactory"
14
+
15
+ const j = jscodeshift.withParser("tsx")
16
+
17
+ function applyTransform(source: string, config: AttributeMappingConfig) {
18
+ const transform = attributeCombineFactory(config)
19
+ const fileInfo = { path: "test.tsx", source }
20
+ return transform(
21
+ fileInfo,
22
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
23
+ {}
24
+ ) as string | null
25
+ }
26
+
27
+ describe("attributeCombineFactory", () => {
28
+ const basicConfig = {
29
+ component: "TestComponent",
30
+ defaults: {
31
+ first: "default1",
32
+ second: "default2",
33
+ },
34
+ mappingTable: {
35
+ "": {
36
+ default2: "emptyDefault2",
37
+ valueA: "emptyA",
38
+ },
39
+ default1: {
40
+ "": "defaultEmpty",
41
+ default2: "defaultResult",
42
+ valueA: "defaultResult",
43
+ valueB: "default1B",
44
+ },
45
+ value1: {
46
+ default2: "result1B",
47
+ valueA: "result1A",
48
+ valueB: "result1B",
49
+ },
50
+ value1Empty: {
51
+ "": "emptyResult",
52
+ default2: "emptyDefault",
53
+ },
54
+ value2: {
55
+ default2: "result2B",
56
+ valueA: "result2A",
57
+ valueB: "result2B",
58
+ },
59
+ },
60
+ onUnsupported: ({
61
+ element,
62
+ attributes,
63
+ j,
64
+ }: {
65
+ attributes: { attribute: JSXAttribute | undefined; name: string }[]
66
+ element: JSXElement
67
+ j: JSCodeshift
68
+ source: Collection
69
+ }) => {
70
+ attributes.forEach(
71
+ ({
72
+ attribute,
73
+ name,
74
+ }: {
75
+ attribute: JSXAttribute | undefined
76
+ name: string
77
+ }) => {
78
+ if (attribute) {
79
+ const attrValue =
80
+ (attribute.value as StringLiteral)?.value || "unknown"
81
+ const comment = j.jsxText(
82
+ `/* TODO: ${name}="${attrValue}" unsupported */`
83
+ )
84
+ if (!element.children) element.children = []
85
+ element.children.unshift(comment)
86
+ }
87
+ }
88
+ )
89
+ return true
90
+ },
91
+ package: "@test/components",
92
+ sourceAttributes: ["first", "second"] as [string, string],
93
+ targetAttribute: "result",
94
+ }
95
+
96
+ describe("static values", () => {
97
+ it("should combine two static attributes", () => {
98
+ const source = `
99
+ import { TestComponent } from "@test/components"
100
+ <TestComponent first="value1" second="valueA">Content</TestComponent>
101
+ `
102
+
103
+ const result = applyTransform(source, basicConfig)
104
+
105
+ expect(result).toContain('result="result1A"')
106
+ expect(result).not.toContain('first="value1"')
107
+ expect(result).not.toContain('second="valueA"')
108
+ })
109
+
110
+ it("should handle missing attributes with defaults", () => {
111
+ const source = `
112
+ import { TestComponent } from "@test/components"
113
+ <TestComponent first="value1">Content</TestComponent>
114
+ `
115
+
116
+ const result = applyTransform(source, basicConfig)
117
+
118
+ expect(result).toContain('result="result1B"')
119
+ })
120
+
121
+ it("should handle both missing attributes with defaults", () => {
122
+ const source = `
123
+ import { TestComponent } from "@test/components"
124
+ <TestComponent>Content</TestComponent>
125
+ `
126
+
127
+ const result = applyTransform(source, basicConfig)
128
+
129
+ expect(result).toBe(null)
130
+ })
131
+
132
+ it("should handle empty string values", () => {
133
+ const source = `
134
+ import { TestComponent } from "@test/components"
135
+ <TestComponent first="value1Empty" second="">Content</TestComponent>
136
+ `
137
+
138
+ const result = applyTransform(source, basicConfig)
139
+
140
+ expect(result).toContain('result="emptyDefault"')
141
+ })
142
+ })
143
+
144
+ describe("conditional values", () => {
145
+ it("should handle conditional in first attribute", () => {
146
+ const source = `
147
+ import { TestComponent } from "@test/components"
148
+ <TestComponent first={condition ? "value1" : "value2"} second="valueA">Content</TestComponent>
149
+ `
150
+
151
+ const result = applyTransform(source, basicConfig)
152
+
153
+ expect(result).toContain('result={condition ? "result1A" : "result2A"}')
154
+ expect(result).not.toContain("first=")
155
+ expect(result).not.toContain("second=")
156
+ })
157
+
158
+ it("should handle conditional in second attribute", () => {
159
+ const source = `
160
+ import { TestComponent } from "@test/components"
161
+ <TestComponent first="value1" second={condition ? "valueA" : "valueB"}>Content</TestComponent>
162
+ `
163
+
164
+ const result = applyTransform(source, basicConfig)
165
+
166
+ expect(result).toContain('result={condition ? "result1A" : "result1B"}')
167
+ })
168
+
169
+ it("should handle conditionals in both attributes with same test", () => {
170
+ const source = `
171
+ import { TestComponent } from "@test/components"
172
+ <TestComponent first={condition ? "value1" : "value2"} second={condition ? "valueA" : "valueB"}>Content</TestComponent>
173
+ `
174
+
175
+ const result = applyTransform(source, basicConfig)
176
+
177
+ expect(result).toContain('result={condition ? "result1A" : "result2B"}')
178
+ })
179
+
180
+ it("should handle conditionals with undefined values", () => {
181
+ const source = `
182
+ import { TestComponent } from "@test/components"
183
+ <TestComponent first={condition ? "value1" : undefined} second="valueA">Content</TestComponent>
184
+ `
185
+
186
+ const result = applyTransform(source, basicConfig)
187
+
188
+ expect(result).toContain(
189
+ 'result={condition ? "result1A" : "defaultResult"}'
190
+ )
191
+ })
192
+
193
+ it("should handle conditionals with null values", () => {
194
+ const source = `
195
+ import { TestComponent } from "@test/components"
196
+ <TestComponent first={condition ? "value1" : null} second="valueA">Content</TestComponent>
197
+ `
198
+
199
+ const result = applyTransform(source, basicConfig)
200
+
201
+ expect(result).toContain(
202
+ 'result={condition ? "result1A" : "defaultResult"}'
203
+ )
204
+ })
205
+
206
+ it("should handle conditional with null in both branches", () => {
207
+ const source = `
208
+ import { TestComponent } from "@test/components"
209
+ <TestComponent first={condition ? null : null} second="valueA">Content</TestComponent>
210
+ `
211
+
212
+ const result = applyTransform(source, basicConfig)
213
+
214
+ expect(result).toContain(
215
+ 'result={condition ? "defaultResult" : "defaultResult"}'
216
+ )
217
+ })
218
+ })
219
+
220
+ describe("unsupported cases", () => {
221
+ it("should handle unsupported attribute values", () => {
222
+ const source = `
223
+ import { TestComponent } from "@test/components"
224
+ <TestComponent first="unsupported" second="valueA">Content</TestComponent>
225
+ `
226
+
227
+ const result = applyTransform(source, basicConfig)
228
+
229
+ expect(result).toContain('/* TODO: first="unsupported" unsupported */')
230
+ expect(result).not.toContain("result=")
231
+ })
232
+
233
+ it("should handle JSX expression containers with string literals", () => {
234
+ const source = `
235
+ import { TestComponent } from "@test/components"
236
+ <TestComponent first={"value1"} second="valueA">Content</TestComponent>
237
+ `
238
+
239
+ const result = applyTransform(source, basicConfig)
240
+
241
+ expect(result).toContain("/* TODO:")
242
+ expect(result).not.toContain("result=")
243
+ })
244
+
245
+ it("should handle complex expressions", () => {
246
+ const source = `
247
+ import { TestComponent } from "@test/components"
248
+ <TestComponent first={getValue()} second="valueA">Content</TestComponent>
249
+ `
250
+
251
+ const result = applyTransform(source, basicConfig)
252
+
253
+ expect(result).toContain("/* TODO:")
254
+ expect(result).not.toContain("result=")
255
+ })
256
+
257
+ it("should handle conditionals with different test names", () => {
258
+ const source = `
259
+ import { TestComponent } from "@test/components"
260
+ <TestComponent first={condition1 ? "value1" : "value2"} second={condition2 ? "valueA" : "valueB"}>Content</TestComponent>
261
+ `
262
+
263
+ const result = applyTransform(source, basicConfig)
264
+
265
+ expect(result).toContain("/* TODO:")
266
+ expect(result).not.toContain("result=")
267
+ })
268
+ })
269
+
270
+ describe("value normalization", () => {
271
+ it("should apply value normalizers", () => {
272
+ const configWithNormalizer = {
273
+ ...basicConfig,
274
+ valueNormalizers: {
275
+ first: (value: string | null | undefined) => {
276
+ if (value === "oldValue") return "value1"
277
+ return value || basicConfig.defaults.first
278
+ },
279
+ },
280
+ }
281
+
282
+ const source = `
283
+ import { TestComponent } from "@test/components"
284
+ <TestComponent first="oldValue" second="valueA">Content</TestComponent>
285
+ `
286
+
287
+ const result = applyTransform(source, configWithNormalizer)
288
+
289
+ expect(result).toContain('result="result1A"')
290
+ })
291
+
292
+ it("should apply normalizers to conditional values", () => {
293
+ const configWithNormalizer = {
294
+ ...basicConfig,
295
+ valueNormalizers: {
296
+ first: (value: string | null | undefined) => {
297
+ if (value === "old1") return "value1"
298
+ if (value === "old2") return "value2"
299
+ return value || basicConfig.defaults.first
300
+ },
301
+ },
302
+ }
303
+
304
+ const source = `
305
+ import { TestComponent } from "@test/components"
306
+ <TestComponent first={condition ? "old1" : "old2"} second="valueA">Content</TestComponent>
307
+ `
308
+
309
+ const result = applyTransform(source, configWithNormalizer)
310
+
311
+ expect(result).toContain('result={condition ? "result1A" : "result2A"}')
312
+ })
313
+ })
314
+
315
+ describe("edge cases", () => {
316
+ it("should handle components without matching attributes", () => {
317
+ const source = `
318
+ import { TestComponent } from "@test/components"
319
+ <TestComponent other="value">Content</TestComponent>
320
+ `
321
+
322
+ const result = applyTransform(source, basicConfig)
323
+
324
+ expect(result).toBe(null)
325
+ })
326
+
327
+ it("should handle boolean attribute values", () => {
328
+ const source = `
329
+ import { TestComponent } from "@test/components"
330
+ <TestComponent first={true} second="valueA">Content</TestComponent>
331
+ `
332
+
333
+ const result = applyTransform(source, basicConfig)
334
+
335
+ expect(result).toContain("/* TODO:")
336
+ expect(result).not.toContain("result=")
337
+ })
338
+
339
+ it("should handle null literal attributes", () => {
340
+ const source = `
341
+ import { TestComponent } from "@test/components"
342
+ <TestComponent first={null} second="valueA">Content</TestComponent>
343
+ `
344
+
345
+ const result = applyTransform(source, basicConfig)
346
+
347
+ expect(result).toContain('result="defaultResult"')
348
+ })
349
+
350
+ it("should handle false boolean attributes", () => {
351
+ const source = `
352
+ import { TestComponent } from "@test/components"
353
+ <TestComponent first={false} second="valueA">Content</TestComponent>
354
+ `
355
+
356
+ const result = applyTransform(source, basicConfig)
357
+
358
+ expect(result).toContain('result="defaultResult"')
359
+ })
360
+
361
+ it("should handle empty string in conditionals", () => {
362
+ const source = `
363
+ import { TestComponent } from "@test/components"
364
+ <TestComponent first={condition ? "" : "value1"} second="valueA">Content</TestComponent>
365
+ `
366
+
367
+ const result = applyTransform(source, basicConfig)
368
+
369
+ expect(result).toContain(
370
+ 'result={condition ? "defaultResult" : "result1A"}'
371
+ )
372
+ })
373
+ })
374
+ })
@@ -0,0 +1,300 @@
1
+ import {
2
+ Collection,
3
+ JSCodeshift,
4
+ JSXAttribute,
5
+ JSXElement,
6
+ Transform,
7
+ } from "jscodeshift"
8
+
9
+ import { addAttribute, Conditional } from "../actions/addAttribute"
10
+ import { getAttribute } from "../actions/getAttribute"
11
+ import { removeAttribute } from "../actions/removeAttribute"
12
+ import { hasAttribute } from "../conditions/hasAttribute"
13
+ import { orConditions } from "../conditions/orConditions"
14
+ import { TransformCondition } from "../types"
15
+ import { attributeTransformFactory } from "./attributeTransformFactory"
16
+
17
+ export interface MappingTable {
18
+ [key: string]: {
19
+ [key: string]: string
20
+ }
21
+ }
22
+
23
+ export interface AttributeDefaults {
24
+ [key: string]: string
25
+ }
26
+
27
+ export interface ValueNormalizer {
28
+ (value: string | null | undefined): string
29
+ }
30
+
31
+ export interface AttributeMappingConfig {
32
+ /** Target component name */
33
+ component: string
34
+ /** Custom condition (optional, will use hasAttribute check by default) */
35
+ condition?: TransformCondition
36
+ /** Default values for missing attributes */
37
+ defaults: AttributeDefaults
38
+ /** Multi-dimensional mapping table */
39
+ mappingTable: MappingTable
40
+ onUnsupported: (resources: {
41
+ attributes: { attribute: JSXAttribute | undefined; name: string }[]
42
+ element: JSXElement
43
+ j: JSCodeshift
44
+ source: Collection
45
+ }) => boolean
46
+ /** Package the component is imported from */
47
+ package: string
48
+ sourceAttributes: [string, string]
49
+ /** Target attribute to map to (e.g., 'kind') */
50
+ targetAttribute: string
51
+ /** Optional value normalizers for each attribute */
52
+ valueNormalizers?: Record<string, ValueNormalizer>
53
+ }
54
+
55
+ /**
56
+ * Extract string value from a JSX attribute
57
+ */
58
+ function getAttributeStringValue(
59
+ attr: JSXAttribute | undefined,
60
+ defaultValue: string,
61
+ normalizer?: ValueNormalizer
62
+ ): string | null {
63
+ if (!attr?.value) {
64
+ return normalizer ? normalizer(defaultValue) : defaultValue
65
+ }
66
+
67
+ if (attr.value.type === "StringLiteral") {
68
+ const value = attr.value.value || defaultValue
69
+ return normalizer ? normalizer(value) : value
70
+ }
71
+
72
+ if (attr.value.type === "JSXExpressionContainer") {
73
+ const { expression } = attr.value
74
+
75
+ // Handle null, boolean false as default
76
+ if (
77
+ expression.type === "NullLiteral" ||
78
+ (expression.type === "BooleanLiteral" && !expression.value)
79
+ ) {
80
+ return normalizer ? normalizer(defaultValue) : defaultValue
81
+ }
82
+
83
+ // String literals in expressions are unsupported
84
+ if (expression.type === "StringLiteral") {
85
+ return null // Unsupported
86
+ }
87
+ }
88
+
89
+ return null // Unsupported
90
+ }
91
+
92
+ function getAttributeConditionalValue(
93
+ attr: JSXAttribute | undefined,
94
+ defaultValue: string,
95
+ normalizer?: ValueNormalizer
96
+ ): { alternate: string; consequent: string; test: string } | null {
97
+ if (
98
+ attr?.value?.type === "JSXExpressionContainer" &&
99
+ attr.value.expression.type === "ConditionalExpression"
100
+ ) {
101
+ const expr = attr.value.expression
102
+
103
+ if (expr.test.type !== "Identifier") return null
104
+
105
+ const test = expr.test.name
106
+
107
+ let consequent: string
108
+ if (expr.consequent.type === "StringLiteral") {
109
+ consequent = expr.consequent.value || defaultValue
110
+ } else if (
111
+ expr.consequent.type === "Identifier" &&
112
+ expr.consequent.name === "undefined"
113
+ ) {
114
+ consequent = defaultValue
115
+ } else if (expr.consequent.type === "NullLiteral") {
116
+ consequent = defaultValue
117
+ } else {
118
+ return null
119
+ }
120
+
121
+ let alternate: string
122
+ if (expr.alternate.type === "StringLiteral") {
123
+ alternate = expr.alternate.value || defaultValue
124
+ } else if (
125
+ expr.alternate.type === "Identifier" &&
126
+ expr.alternate.name === "undefined"
127
+ ) {
128
+ alternate = defaultValue
129
+ } else if (expr.alternate.type === "NullLiteral") {
130
+ alternate = defaultValue
131
+ } else {
132
+ return null
133
+ }
134
+
135
+ if (normalizer) {
136
+ return {
137
+ alternate: normalizer(alternate),
138
+ consequent: normalizer(consequent),
139
+ test,
140
+ }
141
+ }
142
+
143
+ return { alternate, consequent, test }
144
+ }
145
+
146
+ return null
147
+ }
148
+
149
+ /**
150
+ * Check if attribute is conditional
151
+ */
152
+ function isConditionalAttribute(attr: JSXAttribute | undefined): boolean {
153
+ return (
154
+ attr?.value?.type === "JSXExpressionContainer" &&
155
+ attr.value.expression.type === "ConditionalExpression"
156
+ )
157
+ }
158
+
159
+ /**
160
+ * Combines two JSX attributes into a mapped result using the provided mapping table
161
+ */
162
+ function combineAttributes(
163
+ attr1: JSXAttribute | undefined,
164
+ attr2: JSXAttribute | undefined,
165
+ {
166
+ mappingTable,
167
+ defaults,
168
+ valueNormalizers = {},
169
+ attributeNames,
170
+ }: {
171
+ attributeNames: [string, string]
172
+ defaults: AttributeDefaults
173
+ mappingTable: MappingTable
174
+ valueNormalizers?: Record<string, ValueNormalizer>
175
+ }
176
+ ): string | Conditional | null {
177
+ const [firstName, secondName] = attributeNames
178
+ const normalizer1 = valueNormalizers[firstName]
179
+ const normalizer2 = valueNormalizers[secondName]
180
+ const default1 = defaults[firstName] || ""
181
+ const default2 = defaults[secondName] || ""
182
+
183
+ const isConditional1 = isConditionalAttribute(attr1)
184
+ const isConditional2 = isConditionalAttribute(attr2)
185
+
186
+ // Handle conditional cases
187
+ if (isConditional1 || isConditional2) {
188
+ const cond1 = isConditional1
189
+ ? getAttributeConditionalValue(attr1, default1, normalizer1)
190
+ : null
191
+ const cond2 = isConditional2
192
+ ? getAttributeConditionalValue(attr2, default2, normalizer2)
193
+ : null
194
+ const static1 = !isConditional1
195
+ ? getAttributeStringValue(attr1, default1, normalizer1)
196
+ : null
197
+ const static2 = !isConditional2
198
+ ? getAttributeStringValue(attr2, default2, normalizer2)
199
+ : null
200
+
201
+ // If both are conditional, they must use the same test
202
+ if (cond1 && cond2 && cond1.test !== cond2.test) return null
203
+
204
+ const consequentValue1 = cond1?.consequent ?? static1
205
+ const alternateValue1 = cond1?.alternate ?? static1
206
+ const consequentValue2 = cond2?.consequent ?? static2
207
+ const alternateValue2 = cond2?.alternate ?? static2
208
+
209
+ // Check that we have all required values (null means unsupported, empty string is valid)
210
+ if (
211
+ consequentValue1 === null ||
212
+ consequentValue2 === null ||
213
+ alternateValue1 === null ||
214
+ alternateValue2 === null
215
+ ) {
216
+ return null
217
+ }
218
+
219
+ const consequent = mappingTable[consequentValue1]?.[consequentValue2]
220
+ const alternate = mappingTable[alternateValue1]?.[alternateValue2]
221
+
222
+ if (!consequent || !alternate) return null
223
+
224
+ // Use the test from whichever attribute is conditional
225
+ const test = cond1?.test ?? cond2?.test
226
+ if (!test) return null
227
+
228
+ return { alternate, consequent, test }
229
+ }
230
+
231
+ const static1 = getAttributeStringValue(attr1, default1, normalizer1)
232
+ const static2 = getAttributeStringValue(attr2, default2, normalizer2)
233
+
234
+ if (!static1 || !static2) return null
235
+
236
+ const result = mappingTable[static1]?.[static2]
237
+ return result || null
238
+ }
239
+
240
+ export function attributeCombineFactory({
241
+ sourceAttributes,
242
+ targetAttribute,
243
+ mappingTable,
244
+ defaults,
245
+ valueNormalizers = {},
246
+ component,
247
+ package: packageName,
248
+ condition,
249
+ onUnsupported,
250
+ }: AttributeMappingConfig): Transform {
251
+ const defaultCondition = orConditions(
252
+ ...sourceAttributes.map((attr: string) => hasAttribute(attr))
253
+ )
254
+
255
+ type AttributeWithName = {
256
+ attribute: JSXAttribute | undefined
257
+ name: string
258
+ }
259
+
260
+ return attributeTransformFactory({
261
+ condition: condition || defaultCondition,
262
+ targetComponent: component,
263
+ targetPackage: packageName,
264
+ transform: (
265
+ element: JSXElement,
266
+ { j, source }: { j: JSCodeshift; source: Collection }
267
+ ) => {
268
+ const attributes: AttributeWithName[] = sourceAttributes.map(
269
+ (name: string) => ({
270
+ attribute: getAttribute({ element, name }),
271
+ name,
272
+ })
273
+ )
274
+
275
+ const [attr1, attr2] = attributes.map(
276
+ ({ attribute }: { attribute: JSXAttribute | undefined }) => attribute
277
+ )
278
+
279
+ const mappingResult = combineAttributes(attr1, attr2, {
280
+ attributeNames: sourceAttributes,
281
+ defaults,
282
+ mappingTable,
283
+ valueNormalizers,
284
+ })
285
+
286
+ attributes.forEach(({ attribute, name }) => {
287
+ if (attribute) {
288
+ removeAttribute(name, { element, j, source })
289
+ }
290
+ })
291
+
292
+ if (mappingResult === null) {
293
+ return onUnsupported({ attributes, element, j, source })
294
+ }
295
+ addAttribute({ element, j, name: targetAttribute, value: mappingResult })
296
+
297
+ return true
298
+ },
299
+ })
300
+ }