@planningcenter/tapestry-migration-cli 2.3.0-rc.15 → 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 +2 -2
- package/src/components/link/index.ts +6 -1
- package/src/components/link/transforms/targetBlankToExternal.test.ts +191 -0
- package/src/components/link/transforms/targetBlankToExternal.ts +30 -0
- package/src/components/shared/actions/addAttribute.test.ts +108 -0
- package/src/components/shared/actions/addAttribute.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planningcenter/tapestry-migration-cli",
|
|
3
|
-
"version": "2.3.0-rc.
|
|
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": "
|
|
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[] = [
|
|
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
|
}
|