@planningcenter/tapestry-migration-cli 2.3.0-rc.14 → 2.3.0-rc.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/tapestry-migration-cli",
3
- "version": "2.3.0-rc.14",
3
+ "version": "2.3.0-rc.16",
4
4
  "description": "CLI tool for Tapestry migrations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -51,5 +51,5 @@
51
51
  "publishConfig": {
52
52
  "access": "public"
53
53
  },
54
- "gitHead": "4d57f3d8f77f8595eb8edb6a486a6f3f5063fcf0"
54
+ "gitHead": "0768b8999312c4fc5e329ec1f1d68529b5de4603"
55
55
  }
@@ -1,13 +1,18 @@
1
1
  import { Transform } from "jscodeshift"
2
2
 
3
3
  import inlineToKind from "./transforms/inlineToKind"
4
+ import targetBlankToExternal from "./transforms/targetBlankToExternal"
4
5
  import toToHref from "./transforms/toToHref"
5
6
 
6
7
  const transform: Transform = (fileInfo, api, options) => {
7
8
  let currentSource = fileInfo.source
8
9
  let hasAnyChanges = false
9
10
 
10
- const transforms: Transform[] = [inlineToKind, toToHref]
11
+ const transforms: Transform[] = [
12
+ inlineToKind,
13
+ toToHref,
14
+ targetBlankToExternal,
15
+ ]
11
16
 
12
17
  for (const individualTransform of transforms) {
13
18
  const result = individualTransform(
@@ -0,0 +1,191 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import targetBlankToExternal from "./targetBlankToExternal"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyTransform(source: string): string | null {
9
+ return targetBlankToExternal(
10
+ { path: "test.tsx", source },
11
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
12
+ {}
13
+ ) as string | null
14
+ }
15
+
16
+ describe("targetBlankToExternal transform", () => {
17
+ describe("basic transformations", () => {
18
+ it("should transform Link with target='_blank' to external", () => {
19
+ const input = `
20
+ import { Link } from "@planningcenter/tapestry-react"
21
+
22
+ export default function Test() {
23
+ return <Link href="/external" target="_blank">External Link</Link>
24
+ }
25
+ `.trim()
26
+
27
+ const expected = `
28
+ import { Link } from "@planningcenter/tapestry-react"
29
+
30
+ export default function Test() {
31
+ return <Link href="/external" external>External Link</Link>;
32
+ }
33
+ `.trim()
34
+
35
+ const result = applyTransform(input)
36
+ expect(result?.trim()).toBe(expected)
37
+ })
38
+
39
+ it("should remove target and rel attributes while adding external", () => {
40
+ const input = `
41
+ import { Link } from "@planningcenter/tapestry-react"
42
+
43
+ export default function Test() {
44
+ return (
45
+ <Link
46
+ href="/external"
47
+ target="_blank"
48
+ rel="noopener noreferrer"
49
+ className="external-link"
50
+ >
51
+ External Link
52
+ </Link>
53
+ )
54
+ }
55
+ `.trim()
56
+
57
+ const result = applyTransform(input)
58
+ expect(result).toContain("external")
59
+ expect(result).toContain('className="external-link"')
60
+ expect(result).not.toContain('target="_blank"')
61
+ expect(result).not.toContain('rel="noopener noreferrer"')
62
+ })
63
+
64
+ it("should only transform Links with target='_blank'", () => {
65
+ const input = `
66
+ import { Link } from "@planningcenter/tapestry-react"
67
+
68
+ export default function Test() {
69
+ return (
70
+ <div>
71
+ <Link href="/internal" target="_self">Internal Link</Link>
72
+ <Link href="/external" target="_blank">External Link</Link>
73
+ </div>
74
+ )
75
+ }
76
+ `.trim()
77
+
78
+ const result = applyTransform(input)
79
+ expect(result).toContain('target="_self"') // Should remain
80
+ expect(result).toContain("external") // Should be added
81
+ expect(result).not.toContain('target="_blank"') // Should be removed
82
+ })
83
+
84
+ it("should handle Links without rel attribute", () => {
85
+ const input = `
86
+ import { Link } from "@planningcenter/tapestry-react"
87
+
88
+ export default function Test() {
89
+ return <Link href="/external" target="_blank">External Link</Link>
90
+ }
91
+ `.trim()
92
+
93
+ const result = applyTransform(input)
94
+ expect(result).toContain("external")
95
+ expect(result).not.toContain('target="_blank"')
96
+ })
97
+
98
+ it("should handle Links with only rel attribute (no target)", () => {
99
+ const input = `
100
+ import { Link } from "@planningcenter/tapestry-react"
101
+
102
+ export default function Test() {
103
+ return <Link href="/internal" rel="noopener">Internal Link</Link>
104
+ }
105
+ `.trim()
106
+
107
+ const result = applyTransform(input)
108
+ expect(result).toBeNull() // No changes should be made, transform returns null
109
+ })
110
+ })
111
+
112
+ describe("edge cases", () => {
113
+ it("should handle multiple Link components", () => {
114
+ const input = `
115
+ import { Link } from "@planningcenter/tapestry-react"
116
+
117
+ export default function Test() {
118
+ return (
119
+ <div>
120
+ <Link href="/page1" target="_blank">Page 1</Link>
121
+ <Link href="/page2" target="_blank" rel="noopener">Page 2</Link>
122
+ <Link href="/page3">Page 3</Link>
123
+ </div>
124
+ )
125
+ }
126
+ `.trim()
127
+
128
+ const result = applyTransform(input)
129
+ expect(result).toContain("external") // Should appear twice
130
+ expect(result).not.toContain('target="_blank"')
131
+ expect(result).not.toContain('rel="noopener"')
132
+ expect(result).toContain('href="/page3"') // Should remain unchanged
133
+ })
134
+
135
+ it("should handle dynamic target values", () => {
136
+ const input = `
137
+ import { Link } from "@planningcenter/tapestry-react"
138
+
139
+ export default function Test() {
140
+ return <Link href="/external" target={isExternal ? "_blank" : "_self"}>Link</Link>
141
+ }
142
+ `.trim()
143
+
144
+ const result = applyTransform(input)
145
+ expect(result).toBeNull() // No changes should be made for dynamic values
146
+ })
147
+
148
+ it("should handle self-closing Link components", () => {
149
+ const input = `
150
+ import { Link } from "@planningcenter/tapestry-react"
151
+
152
+ export default function Test() {
153
+ return <Link href="/external" target="_blank" />
154
+ }
155
+ `.trim()
156
+
157
+ const result = applyTransform(input)
158
+ expect(result).toContain("external")
159
+ expect(result).not.toContain('target="_blank"')
160
+ })
161
+ })
162
+
163
+ describe("import handling", () => {
164
+ it("should only transform when Link is imported from correct package", () => {
165
+ const input = `
166
+ import { Link } from "react-router-dom"
167
+
168
+ export default function Test() {
169
+ return <Link to="/external" target="_blank">External Link</Link>
170
+ }
171
+ `.trim()
172
+
173
+ const result = applyTransform(input)
174
+ expect(result).toBeNull() // No changes should be made
175
+ })
176
+
177
+ it("should handle aliased imports", () => {
178
+ const input = `
179
+ import { Link as TapestryLink } from "@planningcenter/tapestry-react"
180
+
181
+ export default function Test() {
182
+ return <TapestryLink href="/external" target="_blank">External Link</TapestryLink>
183
+ }
184
+ `.trim()
185
+
186
+ const result = applyTransform(input)
187
+ expect(result).toContain("external")
188
+ expect(result).not.toContain('target="_blank"')
189
+ })
190
+ })
191
+ })
@@ -0,0 +1,30 @@
1
+ import { addAttribute } from "../../shared/actions/addAttribute"
2
+ import { removeAttribute } from "../../shared/actions/removeAttribute"
3
+ import { hasAttributeValue } from "../../shared/conditions/hasAttributeValue"
4
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
5
+
6
+ const transform = attributeTransformFactory({
7
+ condition: hasAttributeValue("target", "_blank"),
8
+ targetComponent: "Link",
9
+ targetPackage: "@planningcenter/tapestry-react",
10
+ transform: (element, { j, source }) => {
11
+ // Remove target attribute
12
+ removeAttribute("target", { element, j, source })
13
+
14
+ // Remove rel attribute if it exists
15
+ removeAttribute("rel", { element, j, source })
16
+
17
+ // Add external attribute as shorthand boolean
18
+ addAttribute({
19
+ booleanAsShorthand: true,
20
+ element,
21
+ j,
22
+ name: "external",
23
+ value: true, // This will create just "external" not "external={true}"
24
+ })
25
+
26
+ return true
27
+ },
28
+ })
29
+
30
+ export default transform
@@ -266,6 +266,94 @@ describe("addAttribute", () => {
266
266
  })
267
267
  })
268
268
 
269
+ describe("boolean shorthand values", () => {
270
+ it("should add boolean true attribute as shorthand", () => {
271
+ const element = createElementFromCode("<Button>Save</Button>")
272
+
273
+ addAttribute({
274
+ booleanAsShorthand: true,
275
+ element,
276
+ j,
277
+ name: "external",
278
+ value: true,
279
+ })
280
+
281
+ const externalAttr = getAttributeFromElement(element, "external")
282
+ expect(externalAttr).not.toBeNull()
283
+ expect(externalAttr?.value).toBeNull() // Shorthand boolean has no value
284
+ })
285
+
286
+ it("should not add boolean false attribute as shorthand", () => {
287
+ const element = createElementFromCode("<Button>Save</Button>")
288
+ const initialAttrCount = (element.openingElement.attributes || []).length
289
+
290
+ addAttribute({
291
+ booleanAsShorthand: true,
292
+ element,
293
+ j,
294
+ name: "external",
295
+ value: false,
296
+ })
297
+
298
+ const finalAttrCount = (element.openingElement.attributes || []).length
299
+ expect(finalAttrCount).toBe(initialAttrCount)
300
+
301
+ const externalAttr = getAttributeFromElement(element, "external")
302
+ expect(externalAttr).toBeNull()
303
+ })
304
+
305
+ it("should render shorthand boolean correctly in source", () => {
306
+ const source = j("<Button>Save</Button>")
307
+ const element = source.find(j.JSXElement).at(0).get().value as JSXElement
308
+
309
+ addAttribute({
310
+ booleanAsShorthand: true,
311
+ element,
312
+ j,
313
+ name: "external",
314
+ value: true,
315
+ })
316
+
317
+ const result = source.toSource()
318
+ expect(result).toContain("external") // Should be just "external", not "external={true}"
319
+ expect(result).not.toContain("external={true}")
320
+ })
321
+
322
+ it("should handle multiple shorthand boolean attributes", () => {
323
+ const element = createElementFromCode("<Button>Save</Button>")
324
+
325
+ addAttribute({
326
+ booleanAsShorthand: true,
327
+ element,
328
+ j,
329
+ name: "external",
330
+ value: true,
331
+ })
332
+ addAttribute({
333
+ booleanAsShorthand: true,
334
+ element,
335
+ j,
336
+ name: "loading",
337
+ value: false,
338
+ })
339
+ addAttribute({
340
+ booleanAsShorthand: true,
341
+ element,
342
+ j,
343
+ name: "disabled",
344
+ value: true,
345
+ })
346
+
347
+ const externalAttr = getAttributeFromElement(element, "external")
348
+ const loadingAttr = getAttributeFromElement(element, "loading")
349
+ const disabledAttr = getAttributeFromElement(element, "disabled")
350
+
351
+ expect(externalAttr).not.toBeNull()
352
+ expect(loadingAttr).toBeNull() // false values should not be added
353
+ expect(disabledAttr).not.toBeNull()
354
+ })
355
+ })
356
+
269
357
  describe("integration with JSCodeshift", () => {
270
358
  it("should render correctly in transformed source", () => {
271
359
  const source = j("<Button>Save</Button>")
@@ -296,5 +384,25 @@ describe("addAttribute", () => {
296
384
  expect((testIdAttr?.value as StringLiteral)?.value).toBe("save-button")
297
385
  expect((ariaAttr?.value as StringLiteral)?.value).toBe("help-text")
298
386
  })
387
+
388
+ it("should render shorthand boolean attributes correctly in source", () => {
389
+ const source = j("<Button>Save</Button>")
390
+ const element = source.find(j.JSXElement).at(0).get().value as JSXElement
391
+
392
+ addAttribute({
393
+ booleanAsShorthand: true,
394
+ element,
395
+ j,
396
+ name: "external",
397
+ value: true,
398
+ })
399
+ addAttribute({ element, j, name: "disabled", value: true }) // Regular boolean
400
+
401
+ const result = source.toSource()
402
+
403
+ expect(result).toContain("external") // Shorthand boolean
404
+ expect(result).toContain("disabled={true}") // Regular boolean
405
+ expect(result).not.toContain("external={true}")
406
+ })
299
407
  })
300
408
  })
@@ -21,7 +21,9 @@ export function addAttribute({
21
21
  name,
22
22
  j,
23
23
  value,
24
+ booleanAsShorthand = false,
24
25
  }: {
26
+ booleanAsShorthand?: boolean
25
27
  element: JSXElement
26
28
  j: JSCodeshift
27
29
  name: string
@@ -40,6 +42,18 @@ export function addAttribute({
40
42
  return
41
43
  }
42
44
 
45
+ // Handle boolean shorthand
46
+ // booleanAsShorthand is a temporary flag to allow for boolean attributes to be added as shorthand
47
+ // This will be removed in a follow-up PR for button[disabled]
48
+ if (typeof value === "boolean" && booleanAsShorthand) {
49
+ if (value === true) {
50
+ // Add as shorthand boolean attribute (just "external")
51
+ attributes.push(j.jsxAttribute(j.jsxIdentifier(name), null))
52
+ }
53
+ // For false, don't add the attribute at all
54
+ return
55
+ }
56
+
43
57
  const formattedValue = formatValue(value, j)
44
58
  attributes.push(j.jsxAttribute(j.jsxIdentifier(name), formattedValue))
45
59
  }