@planningcenter/tapestry-migration-cli 2.1.1-rc.0 → 2.1.1-rc.2

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,437 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import {
5
+ addImportToExisting,
6
+ createNewImport,
7
+ getImportName,
8
+ hasConflictingImport,
9
+ removeImportFromDeclaration,
10
+ transformElementName,
11
+ } from "./componentTransformUtilities"
12
+
13
+ const j = jscodeshift.withParser("tsx")
14
+
15
+ describe("componentTransformUtilities", () => {
16
+ describe("getImportName", () => {
17
+ it("should return local name for imported component", () => {
18
+ const code = `import { Button } from "@planningcenter/tapestry-react"`
19
+ const source = j(code)
20
+
21
+ const result = getImportName("Button", "@planningcenter/tapestry-react", {
22
+ j,
23
+ source,
24
+ })
25
+
26
+ expect(result).toBe("Button")
27
+ })
28
+
29
+ it("should return aliased name for renamed import", () => {
30
+ const code = `import { Button as TapestryButton } from "@planningcenter/tapestry-react"`
31
+ const source = j(code)
32
+
33
+ const result = getImportName("Button", "@planningcenter/tapestry-react", {
34
+ j,
35
+ source,
36
+ })
37
+
38
+ expect(result).toBe("TapestryButton")
39
+ })
40
+
41
+ it("should return null when component is not imported", () => {
42
+ const code = `import { Link } from "@planningcenter/tapestry-react"`
43
+ const source = j(code)
44
+
45
+ const result = getImportName("Button", "@planningcenter/tapestry-react", {
46
+ j,
47
+ source,
48
+ })
49
+
50
+ expect(result).toBe(null)
51
+ })
52
+
53
+ it("should return null when package is not imported", () => {
54
+ const code = `import { Button } from "other-package"`
55
+ const source = j(code)
56
+
57
+ const result = getImportName("Button", "@planningcenter/tapestry-react", {
58
+ j,
59
+ source,
60
+ })
61
+
62
+ expect(result).toBe(null)
63
+ })
64
+
65
+ it("should handle multiple imports from same package", () => {
66
+ const code = `import { Button, Link as MyLink } from "@planningcenter/tapestry-react"`
67
+ const source = j(code)
68
+
69
+ const buttonResult = getImportName(
70
+ "Button",
71
+ "@planningcenter/tapestry-react",
72
+ { j, source }
73
+ )
74
+ const linkResult = getImportName(
75
+ "Link",
76
+ "@planningcenter/tapestry-react",
77
+ { j, source }
78
+ )
79
+
80
+ expect(buttonResult).toBe("Button")
81
+ expect(linkResult).toBe("MyLink")
82
+ })
83
+
84
+ it("should handle multiple import declarations", () => {
85
+ const code = `
86
+ import { Button } from "@planningcenter/tapestry-react"
87
+ import { useState } from "react"
88
+ import { Link } from "@planningcenter/tapestry-react"
89
+ `
90
+ const source = j(code)
91
+
92
+ const buttonResult = getImportName(
93
+ "Button",
94
+ "@planningcenter/tapestry-react",
95
+ { j, source }
96
+ )
97
+ const linkResult = getImportName(
98
+ "Link",
99
+ "@planningcenter/tapestry-react",
100
+ { j, source }
101
+ )
102
+
103
+ expect(buttonResult).toBe("Button")
104
+ expect(linkResult).toBe("Link")
105
+ })
106
+ })
107
+
108
+ describe("hasConflictingImport", () => {
109
+ it("should return true when component is imported from different package", () => {
110
+ const code = `import { Link } from "react-router-dom"`
111
+ const source = j(code)
112
+
113
+ const result = hasConflictingImport(
114
+ "Link",
115
+ "@planningcenter/tapestry-react",
116
+ { j, source }
117
+ )
118
+
119
+ expect(result).toBe(true)
120
+ })
121
+
122
+ it("should return false when component is only imported from excluded package", () => {
123
+ const code = `import { Link } from "@planningcenter/tapestry-react"`
124
+ const source = j(code)
125
+
126
+ const result = hasConflictingImport(
127
+ "Link",
128
+ "@planningcenter/tapestry-react",
129
+ { j, source }
130
+ )
131
+
132
+ expect(result).toBe(false)
133
+ })
134
+
135
+ it("should return false when component is not imported at all", () => {
136
+ const code = `import { Button } from "@planningcenter/tapestry-react"`
137
+ const source = j(code)
138
+
139
+ const result = hasConflictingImport(
140
+ "Link",
141
+ "@planningcenter/tapestry-react",
142
+ { j, source }
143
+ )
144
+
145
+ expect(result).toBe(false)
146
+ })
147
+
148
+ it("should return true for default import conflicts", () => {
149
+ const code = `import Link from "next/link"`
150
+ const source = j(code)
151
+
152
+ const result = hasConflictingImport(
153
+ "Link",
154
+ "@planningcenter/tapestry-react",
155
+ { j, source }
156
+ )
157
+
158
+ expect(result).toBe(true)
159
+ })
160
+
161
+ it("should handle multiple conflicting sources", () => {
162
+ const code = `
163
+ import { Link } from "react-router-dom"
164
+ import { Link as NextLink } from "next/link"
165
+ import { Button } from "@planningcenter/tapestry-react"
166
+ `
167
+ const source = j(code)
168
+
169
+ const result = hasConflictingImport(
170
+ "Link",
171
+ "@planningcenter/tapestry-react",
172
+ { j, source }
173
+ )
174
+
175
+ expect(result).toBe(true)
176
+ })
177
+ })
178
+
179
+ describe("transformElementName", () => {
180
+ it("should transform JSX element name", () => {
181
+ const code = `<Button>Click me</Button>`
182
+ const source = j(code)
183
+ const elementPath = source.find(j.JSXOpeningElement).at(0)
184
+
185
+ const result = transformElementName(elementPath.get(), "Link")
186
+
187
+ expect(result).toBe(true)
188
+ expect(source.toSource()).toContain("<Link>Click me</Link>")
189
+ })
190
+
191
+ it("should transform both opening and closing tags", () => {
192
+ const code = `<Button className="test">Content</Button>`
193
+ const source = j(code)
194
+ const elementPath = source.find(j.JSXOpeningElement).at(0)
195
+
196
+ const result = transformElementName(elementPath.get(), "Link")
197
+
198
+ expect(result).toBe(true)
199
+ const output = source.toSource()
200
+ expect(output).toContain('<Link className="test">')
201
+ expect(output).toContain("</Link>")
202
+ })
203
+
204
+ it("should handle self-closing tags", () => {
205
+ const code = `<Button />`
206
+ const source = j(code)
207
+ const elementPath = source.find(j.JSXOpeningElement).at(0)
208
+
209
+ const result = transformElementName(elementPath.get(), "Link")
210
+
211
+ expect(result).toBe(true)
212
+ expect(source.toSource()).toContain("<Link />")
213
+ })
214
+
215
+ it("should preserve all attributes", () => {
216
+ const code = `<Button className="test" onClick={handler} disabled>Content</Button>`
217
+ const source = j(code)
218
+ const elementPath = source.find(j.JSXOpeningElement).at(0)
219
+
220
+ const result = transformElementName(elementPath.get(), "Link")
221
+
222
+ expect(result).toBe(true)
223
+ const output = source.toSource()
224
+ expect(output).toContain('className="test"')
225
+ expect(output).toContain("onClick={handler}")
226
+ expect(output).toContain("disabled")
227
+ expect(output).toContain("<Link")
228
+ expect(output).toContain("</Link>")
229
+ })
230
+ })
231
+
232
+ describe("addImportToExisting", () => {
233
+ it("should add new import to existing import declaration", () => {
234
+ const code = `import { Button } from "@planningcenter/tapestry-react"`
235
+ const source = j(code)
236
+ const importPath = source.find(j.ImportDeclaration).at(0)
237
+
238
+ addImportToExisting(importPath.get(), "Link", j)
239
+
240
+ const output = source.toSource()
241
+ expect(output).toContain(
242
+ 'import { Button, Link } from "@planningcenter/tapestry-react"'
243
+ )
244
+ })
245
+
246
+ it("should add aliased import to existing declaration", () => {
247
+ const code = `import { Button } from "@planningcenter/tapestry-react"`
248
+ const source = j(code)
249
+ const importPath = source.find(j.ImportDeclaration).at(0)
250
+
251
+ addImportToExisting(importPath.get(), "Link", j, "TLink")
252
+
253
+ const output = source.toSource()
254
+ expect(output).toContain(
255
+ 'import { Button, Link as TLink } from "@planningcenter/tapestry-react"'
256
+ )
257
+ })
258
+
259
+ it("should not add duplicate imports", () => {
260
+ const code = `import { Button, Link } from "@planningcenter/tapestry-react"`
261
+ const source = j(code)
262
+ const importPath = source.find(j.ImportDeclaration).at(0)
263
+
264
+ addImportToExisting(importPath.get(), "Link", j)
265
+
266
+ const output = source.toSource()
267
+ // Should still only have one Link import
268
+ expect(output).toContain(
269
+ 'import { Button, Link } from "@planningcenter/tapestry-react"'
270
+ )
271
+ })
272
+
273
+ it("should handle empty specifiers list", () => {
274
+ const code = `import {} from "@planningcenter/tapestry-react"`
275
+ const source = j(code)
276
+ const importPath = source.find(j.ImportDeclaration).at(0)
277
+
278
+ addImportToExisting(importPath.get(), "Button", j)
279
+
280
+ const output = source.toSource()
281
+ expect(output).toContain(
282
+ 'import { Button } from "@planningcenter/tapestry-react"'
283
+ )
284
+ })
285
+ })
286
+
287
+ describe("createNewImport", () => {
288
+ it("should create new import declaration", () => {
289
+ const code = `const test = "existing code"`
290
+ const source = j(code)
291
+
292
+ createNewImport(source, "Button", "@planningcenter/tapestry-react", j)
293
+
294
+ const output = source.toSource()
295
+ expect(output).toContain(
296
+ 'import { Button } from "@planningcenter/tapestry-react";'
297
+ )
298
+ expect(output).toContain('const test = "existing code"')
299
+ })
300
+
301
+ it("should create new import with alias", () => {
302
+ const code = `const test = "existing"`
303
+ const source = j(code)
304
+
305
+ createNewImport(
306
+ source,
307
+ "Link",
308
+ "@planningcenter/tapestry-react",
309
+ j,
310
+ "TLink"
311
+ )
312
+
313
+ const output = source.toSource()
314
+ expect(output).toContain(
315
+ 'import { Link as TLink } from "@planningcenter/tapestry-react";'
316
+ )
317
+ })
318
+
319
+ it("should add new import after existing imports", () => {
320
+ const code = `
321
+ import React from "react"
322
+ import { useState } from "react"
323
+
324
+ const component = () => null
325
+ `
326
+ const source = j(code)
327
+
328
+ createNewImport(source, "Button", "@planningcenter/tapestry-react", j)
329
+
330
+ const output = source.toSource()
331
+ const lines = output.split("\n")
332
+ const reactImportIndex = lines.findIndex((line) =>
333
+ line.includes('import React from "react"')
334
+ )
335
+ const useStateImportIndex = lines.findIndex((line) =>
336
+ line.includes('import { useState } from "react"')
337
+ )
338
+ const buttonImportIndex = lines.findIndex((line) =>
339
+ line.includes('import { Button } from "@planningcenter/tapestry-react"')
340
+ )
341
+
342
+ expect(reactImportIndex).toBeLessThan(buttonImportIndex)
343
+ expect(useStateImportIndex).toBeLessThan(buttonImportIndex)
344
+ })
345
+
346
+ it("should handle empty file", () => {
347
+ const code = ``
348
+ const source = j(code)
349
+
350
+ createNewImport(source, "Button", "@planningcenter/tapestry-react", j)
351
+
352
+ const output = source.toSource()
353
+ expect(output).toBe(
354
+ 'import { Button } from "@planningcenter/tapestry-react";'
355
+ )
356
+ })
357
+ })
358
+
359
+ describe("removeImportFromDeclaration", () => {
360
+ it("should remove import specifier", () => {
361
+ const code = `import { Button, Link } from "@planningcenter/tapestry-react"`
362
+ const source = j(code)
363
+ const importPath = source.find(j.ImportDeclaration).at(0)
364
+
365
+ const result = removeImportFromDeclaration(importPath.get(), "Button")
366
+
367
+ expect(result).toBe(true)
368
+ const output = source.toSource()
369
+ expect(output).toContain(
370
+ 'import { Link } from "@planningcenter/tapestry-react"'
371
+ )
372
+ expect(output).not.toContain("Button")
373
+ })
374
+
375
+ it("should remove entire import if no specifiers left", () => {
376
+ const code = `
377
+ import React from "react"
378
+ import { Button } from "@planningcenter/tapestry-react"
379
+ const component = () => null
380
+ `
381
+ const source = j(code)
382
+ const tapestryImport = source
383
+ .find(j.ImportDeclaration)
384
+ .filter(
385
+ (path) => path.value.source.value === "@planningcenter/tapestry-react"
386
+ )
387
+ .at(0)
388
+
389
+ const result = removeImportFromDeclaration(tapestryImport.get(), "Button")
390
+
391
+ expect(result).toBe(true)
392
+ const output = source.toSource()
393
+ expect(output).not.toContain("@planningcenter/tapestry-react")
394
+ expect(output).toContain('import React from "react"') // Other imports should remain
395
+ })
396
+
397
+ it("should return false if import not found", () => {
398
+ const code = `import { Button } from "@planningcenter/tapestry-react"`
399
+ const source = j(code)
400
+ const importPath = source.find(j.ImportDeclaration).at(0)
401
+
402
+ const result = removeImportFromDeclaration(importPath.get(), "Link")
403
+
404
+ expect(result).toBe(false)
405
+ const output = source.toSource()
406
+ expect(output).toContain("Button") // Should be unchanged
407
+ })
408
+
409
+ it("should handle single import removal", () => {
410
+ const code = `import { Button } from "@planningcenter/tapestry-react"`
411
+ const source = j(code)
412
+ const importPath = source.find(j.ImportDeclaration).at(0)
413
+
414
+ const result = removeImportFromDeclaration(importPath.get(), "Button")
415
+
416
+ expect(result).toBe(true)
417
+ expect(source.find(j.ImportDeclaration).length).toBe(0)
418
+ })
419
+
420
+ it("should preserve other specifiers", () => {
421
+ const code = `import { Button, Link, Input } from "@planningcenter/tapestry-react"`
422
+ const source = j(code)
423
+ const importPath = source.find(j.ImportDeclaration).at(0)
424
+
425
+ const result = removeImportFromDeclaration(importPath.get(), "Link")
426
+
427
+ expect(result).toBe(true)
428
+ const output = source.toSource()
429
+ expect(output).toContain("Button")
430
+ expect(output).toContain("Input")
431
+ expect(output).not.toContain("Link")
432
+ expect(output).toContain(
433
+ 'import { Button, Input } from "@planningcenter/tapestry-react"'
434
+ )
435
+ })
436
+ })
437
+ })
@@ -0,0 +1,280 @@
1
+ import {
2
+ ASTPath,
3
+ Collection,
4
+ ImportSpecifier,
5
+ JSCodeshift,
6
+ JSXOpeningElement,
7
+ Transform,
8
+ } from "jscodeshift"
9
+
10
+ import { ComponentTransformConfig } from "./transformConfig"
11
+
12
+ /**
13
+ * Generic import name getter that works with any package
14
+ */
15
+ export function getImportName(
16
+ importName: string,
17
+ packageName: string,
18
+ { source, j }: { j: JSCodeshift; source: Collection }
19
+ ): string | null {
20
+ let localName: string | null = null
21
+
22
+ source
23
+ .find(j.ImportDeclaration, {
24
+ source: { value: packageName },
25
+ })
26
+ .forEach((path) => {
27
+ const specifiers = path.value.specifiers || []
28
+
29
+ specifiers.forEach((spec) => {
30
+ if (
31
+ spec.type === "ImportSpecifier" &&
32
+ spec.imported?.name === importName
33
+ ) {
34
+ localName = (spec.local?.name as string) || importName
35
+ }
36
+ })
37
+ })
38
+
39
+ return localName
40
+ }
41
+
42
+ /**
43
+ * Checks if a component is already imported from sources other than the specified package
44
+ */
45
+ export function hasConflictingImport(
46
+ componentName: string,
47
+ excludePackage: string,
48
+ { source, j }: { j: JSCodeshift; source: Collection }
49
+ ): boolean {
50
+ const conflictingImport = source.find(j.ImportDeclaration).filter((path) => {
51
+ if (path.value.source.value === excludePackage) {
52
+ return false
53
+ }
54
+
55
+ return (
56
+ path.value.specifiers?.some(
57
+ (spec) =>
58
+ (spec.type === "ImportSpecifier" &&
59
+ spec.imported?.name === componentName) ||
60
+ (spec.type === "ImportDefaultSpecifier" &&
61
+ spec.local?.name === componentName)
62
+ ) || false
63
+ )
64
+ })
65
+
66
+ return conflictingImport.length > 0
67
+ }
68
+
69
+ /**
70
+ * Transforms JSX element names (both opening and closing tags)
71
+ */
72
+ export function transformElementName(
73
+ elementPath: ASTPath<JSXOpeningElement>,
74
+ newName: string
75
+ ): boolean {
76
+ if (elementPath.value.name.type === "JSXIdentifier") {
77
+ elementPath.value.name.name = newName
78
+
79
+ // Update closing tag if it exists
80
+ const parent = elementPath.parent
81
+ if (
82
+ parent &&
83
+ parent.value.type === "JSXElement" &&
84
+ parent.value.closingElement
85
+ ) {
86
+ if (parent.value.closingElement.name.type === "JSXIdentifier") {
87
+ parent.value.closingElement.name.name = newName
88
+ }
89
+ }
90
+ return true
91
+ }
92
+ return false
93
+ }
94
+
95
+ /**
96
+ * Adds an import to an existing import declaration
97
+ */
98
+ export function addImportToExisting(
99
+ importPath: Collection,
100
+ componentName: string,
101
+ j: JSCodeshift,
102
+ alias?: string
103
+ ): void {
104
+ const specifiers = importPath.get().value.specifiers || []
105
+
106
+ const hasImport = specifiers.some(
107
+ (spec: ImportSpecifier) =>
108
+ spec.type === "ImportSpecifier" && spec.imported?.name === componentName
109
+ )
110
+
111
+ if (!hasImport) {
112
+ const importSpecifier = alias
113
+ ? j.importSpecifier(j.identifier(componentName), j.identifier(alias))
114
+ : j.importSpecifier(j.identifier(componentName))
115
+
116
+ specifiers.push(importSpecifier)
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Creates a new import declaration
122
+ */
123
+ export function createNewImport(
124
+ source: Collection,
125
+ componentName: string,
126
+ packageName: string,
127
+ j: JSCodeshift,
128
+ alias?: string
129
+ ): void {
130
+ const importSpecifier = alias
131
+ ? j.importSpecifier(j.identifier(componentName), j.identifier(alias))
132
+ : j.importSpecifier(j.identifier(componentName))
133
+
134
+ const lastImport = source.find(j.ImportDeclaration).at(-1)
135
+ if (lastImport.length > 0) {
136
+ lastImport.insertAfter(
137
+ j.importDeclaration([importSpecifier], j.stringLiteral(packageName))
138
+ )
139
+ } else {
140
+ const program = source.find(j.Program)
141
+ if (program.length > 0) {
142
+ program
143
+ .get("body", 0)
144
+ .insertBefore(
145
+ j.importDeclaration([importSpecifier], j.stringLiteral(packageName))
146
+ )
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Removes an import from an import declaration and cleans up empty imports
153
+ */
154
+ export function removeImportFromDeclaration(
155
+ importPath: Collection,
156
+ componentName: string
157
+ ): boolean {
158
+ const specifiers = importPath.get().value.specifiers || []
159
+
160
+ const importIndex = specifiers.findIndex(
161
+ (spec: ImportSpecifier) =>
162
+ spec.type === "ImportSpecifier" && spec.imported?.name === componentName
163
+ )
164
+
165
+ if (importIndex >= 0) {
166
+ specifiers.splice(importIndex, 1)
167
+
168
+ // Remove entire import if no specifiers left
169
+ if (specifiers.length === 0) {
170
+ importPath.get().replace()
171
+ }
172
+ return true
173
+ }
174
+ return false
175
+ }
176
+
177
+ /**
178
+ * Main function to create a component transform based on configuration
179
+ */
180
+ export function createComponentTransform(
181
+ config: ComponentTransformConfig
182
+ ): Transform {
183
+ return (fileInfo, api) => {
184
+ const j = api.jscodeshift
185
+ const source = j(fileInfo.source)
186
+ let hasChanges = false
187
+
188
+ // Get the local name of the source component
189
+ const sourceComponentName = getImportName(
190
+ config.fromComponent,
191
+ config.fromPackage,
192
+ { j, source }
193
+ )
194
+
195
+ // Only proceed if source component is imported
196
+ if (!sourceComponentName) {
197
+ return null
198
+ }
199
+
200
+ // Check for conflicting imports
201
+ const hasConflict = hasConflictingImport(
202
+ config.toComponent,
203
+ config.toPackage,
204
+ { j, source }
205
+ )
206
+
207
+ const targetComponentName = hasConflict
208
+ ? config.conflictAlias || `T${config.toComponent}`
209
+ : config.toComponent
210
+
211
+ // Transform matching JSX elements
212
+ source
213
+ .find(j.JSXOpeningElement, { name: { name: sourceComponentName } })
214
+ .forEach((path) => {
215
+ const element = path.parent.value
216
+
217
+ if (config.condition(element)) {
218
+ if (transformElementName(path, targetComponentName)) {
219
+ hasChanges = true
220
+ }
221
+ }
222
+ })
223
+
224
+ if (hasChanges) {
225
+ // Handle import management
226
+ manageImports(source, config, sourceComponentName, targetComponentName, j)
227
+ }
228
+
229
+ return hasChanges ? source.toSource() : null
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Manages imports after transformation - adds target imports and removes unused source imports
235
+ */
236
+ function manageImports(
237
+ source: Collection,
238
+ config: ComponentTransformConfig,
239
+ sourceComponentName: string,
240
+ targetComponentName: string,
241
+ j: JSCodeshift
242
+ ): void {
243
+ // Check if source component is still being used
244
+ const stillUsesSource =
245
+ source.find(j.JSXOpeningElement, {
246
+ name: { name: sourceComponentName },
247
+ }).length > 0
248
+
249
+ // Handle source package import cleanup
250
+ const sourceImport = source
251
+ .find(j.ImportDeclaration, {
252
+ source: { value: config.fromPackage },
253
+ })
254
+ .at(0)
255
+
256
+ if (sourceImport.length > 0 && !stillUsesSource) {
257
+ removeImportFromDeclaration(sourceImport, config.fromComponent)
258
+ }
259
+
260
+ // Handle target package import addition
261
+ const targetImport = source
262
+ .find(j.ImportDeclaration, {
263
+ source: { value: config.toPackage },
264
+ })
265
+ .at(0)
266
+
267
+ if (targetImport.length > 0) {
268
+ const alias =
269
+ targetComponentName !== config.toComponent
270
+ ? targetComponentName
271
+ : undefined
272
+ addImportToExisting(targetImport, config.toComponent, j, alias)
273
+ } else {
274
+ const alias =
275
+ targetComponentName !== config.toComponent
276
+ ? targetComponentName
277
+ : undefined
278
+ createNewImport(source, config.toComponent, config.toPackage, j, alias)
279
+ }
280
+ }