@planningcenter/tapestry-migration-cli 2.2.0-rc.2 → 2.2.0-rc.4
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/button/index.ts +7 -1
- package/src/components/button/transforms/removeTo.test.ts +370 -0
- package/src/components/button/transforms/removeTo.ts +25 -0
- package/src/components/shared/actions/addComment.test.ts +243 -0
- package/src/components/shared/actions/addComment.ts +69 -0
- package/src/components/shared/actions/getPrintableAttributeValue.test.ts +202 -0
- package/src/components/shared/actions/getPrintableAttributeValue.ts +23 -0
- package/src/components/shared/actions/removeAttribute.test.ts +343 -0
- package/src/components/shared/actions/removeAttribute.ts +37 -0
- package/src/components/shared/transformFactories/attributeTransformFactory.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planningcenter/tapestry-migration-cli",
|
|
3
|
-
"version": "2.2.0-rc.
|
|
3
|
+
"version": "2.2.0-rc.4",
|
|
4
4
|
"description": "CLI tool for Tapestry migrations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -47,5 +47,5 @@
|
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
|
49
49
|
},
|
|
50
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "71a24313f6632525d4608b8b2d107eb6ddcb67f6"
|
|
51
51
|
}
|
|
@@ -2,13 +2,19 @@ import { Transform } from "jscodeshift"
|
|
|
2
2
|
|
|
3
3
|
import linkToButton from "./transforms/linkToButton"
|
|
4
4
|
import moveButtonImport from "./transforms/moveButtonImport"
|
|
5
|
+
import removeToTransform from "./transforms/removeTo"
|
|
5
6
|
import titleToLabel from "./transforms/titleToLabel"
|
|
6
7
|
|
|
7
8
|
const transform: Transform = (fileInfo, api, options) => {
|
|
8
9
|
let currentSource = fileInfo.source
|
|
9
10
|
let hasAnyChanges = false
|
|
10
11
|
|
|
11
|
-
const transforms: Transform[] = [
|
|
12
|
+
const transforms: Transform[] = [
|
|
13
|
+
linkToButton,
|
|
14
|
+
titleToLabel,
|
|
15
|
+
removeToTransform,
|
|
16
|
+
moveButtonImport,
|
|
17
|
+
]
|
|
12
18
|
|
|
13
19
|
for (const individualTransform of transforms) {
|
|
14
20
|
const result = individualTransform(
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import jscodeshift from "jscodeshift"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
|
|
4
|
+
import transform from "./removeTo"
|
|
5
|
+
|
|
6
|
+
const j = jscodeshift.withParser("tsx")
|
|
7
|
+
|
|
8
|
+
function applyTransform(source: string): string | null {
|
|
9
|
+
const fileInfo = { path: "test.tsx", source }
|
|
10
|
+
return transform(
|
|
11
|
+
fileInfo,
|
|
12
|
+
{ j, jscodeshift: j, report: () => {}, stats: () => {} },
|
|
13
|
+
{}
|
|
14
|
+
) as string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("removeTo transform", () => {
|
|
18
|
+
describe("basic transformations", () => {
|
|
19
|
+
it("should remove 'to' attribute with empty string value", () => {
|
|
20
|
+
const input = `
|
|
21
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
22
|
+
|
|
23
|
+
export default function Test() {
|
|
24
|
+
return <Button to="">Save</Button>
|
|
25
|
+
}
|
|
26
|
+
`.trim()
|
|
27
|
+
|
|
28
|
+
const expected = `
|
|
29
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
30
|
+
|
|
31
|
+
export default function Test() {
|
|
32
|
+
return <Button>Save</Button>;
|
|
33
|
+
}
|
|
34
|
+
`.trim()
|
|
35
|
+
|
|
36
|
+
const result = applyTransform(input)
|
|
37
|
+
expect(result?.trim()).toBe(expected)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("should remove 'to' attribute with hash value", () => {
|
|
41
|
+
const input = `
|
|
42
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
43
|
+
|
|
44
|
+
export default function Test() {
|
|
45
|
+
return <Button to="#">Save</Button>
|
|
46
|
+
}
|
|
47
|
+
`.trim()
|
|
48
|
+
|
|
49
|
+
const result = applyTransform(input)
|
|
50
|
+
expect(result).toContain("<Button>Save</Button>")
|
|
51
|
+
expect(result).not.toContain('to="#"')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("should remove 'to' attribute with expression containing empty string", () => {
|
|
55
|
+
const input = `
|
|
56
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
57
|
+
|
|
58
|
+
export default function Test() {
|
|
59
|
+
return <Button to={""}>Save</Button>
|
|
60
|
+
}
|
|
61
|
+
`.trim()
|
|
62
|
+
|
|
63
|
+
const result = applyTransform(input)
|
|
64
|
+
expect(result).toContain("<Button>Save</Button>")
|
|
65
|
+
expect(result).not.toContain('to={""}')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("should remove 'to' attribute with expression containing hash", () => {
|
|
69
|
+
const input = `
|
|
70
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
71
|
+
|
|
72
|
+
export default function Test() {
|
|
73
|
+
return <Button to={"#"}>Save</Button>
|
|
74
|
+
}
|
|
75
|
+
`.trim()
|
|
76
|
+
|
|
77
|
+
const result = applyTransform(input)
|
|
78
|
+
expect(result).toContain("<Button>Save</Button>")
|
|
79
|
+
expect(result).not.toContain('to={"#"}')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe("comment generation for non-empty values", () => {
|
|
84
|
+
it("should remove 'to' attribute with string value and add TODO comment", () => {
|
|
85
|
+
const input = `
|
|
86
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
87
|
+
|
|
88
|
+
export default function Test() {
|
|
89
|
+
return <Button to="/users">Save</Button>
|
|
90
|
+
}
|
|
91
|
+
`.trim()
|
|
92
|
+
|
|
93
|
+
const result = applyTransform(input)
|
|
94
|
+
expect(result).toContain("<Button>Save</Button>")
|
|
95
|
+
expect(result).not.toContain('Button to="/users"')
|
|
96
|
+
expect(result).toContain("TODO: tapestry-migration")
|
|
97
|
+
expect(result).toContain('removed: to="/users"')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("should remove 'to' attribute with expression value and add TODO comment", () => {
|
|
101
|
+
const input = `
|
|
102
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
103
|
+
|
|
104
|
+
export default function Test() {
|
|
105
|
+
return <Button to={"/profile"}>Save</Button>
|
|
106
|
+
}
|
|
107
|
+
`.trim()
|
|
108
|
+
|
|
109
|
+
const result = applyTransform(input)
|
|
110
|
+
expect(result).toContain("<Button>Save</Button>")
|
|
111
|
+
expect(result).not.toContain('to={"/profile"}')
|
|
112
|
+
expect(result).toContain("TODO: tapestry-migration")
|
|
113
|
+
expect(result).toContain('removed: to="/profile"')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("should handle complex string values in comments", () => {
|
|
117
|
+
const input = `
|
|
118
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
119
|
+
|
|
120
|
+
export default function Test() {
|
|
121
|
+
return <Button to="/users?tab=active&sort=name">View Users</Button>
|
|
122
|
+
}
|
|
123
|
+
`.trim()
|
|
124
|
+
|
|
125
|
+
const result = applyTransform(input)
|
|
126
|
+
expect(result).toContain("<Button>View Users</Button>")
|
|
127
|
+
expect(result).toContain("TODO: tapestry-migration")
|
|
128
|
+
expect(result).toContain('removed: to="/users?tab=active&sort=name"')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe("multiple Button components", () => {
|
|
133
|
+
it("should handle multiple Button components with different 'to' values", () => {
|
|
134
|
+
const input = `
|
|
135
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
136
|
+
|
|
137
|
+
export default function Test() {
|
|
138
|
+
return (
|
|
139
|
+
<div>
|
|
140
|
+
<Button to="">Empty</Button>
|
|
141
|
+
<Button to="/users">Users</Button>
|
|
142
|
+
<Button to="#">Hash</Button>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
`.trim()
|
|
147
|
+
|
|
148
|
+
const result = applyTransform(input)
|
|
149
|
+
expect(result).toContain("<Button>Empty</Button>")
|
|
150
|
+
expect(result).toContain("<Button>Hash</Button>")
|
|
151
|
+
expect(result).toContain("<Button>Users</Button>")
|
|
152
|
+
expect(result).not.toContain('Button to=""')
|
|
153
|
+
expect(result).not.toContain('Button to="#"')
|
|
154
|
+
expect(result).not.toContain('Button to="/users"')
|
|
155
|
+
expect(result).toContain("TODO: tapestry-migration")
|
|
156
|
+
expect(result).toContain('removed: to="/users"')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("should preserve other attributes", () => {
|
|
160
|
+
const input = `
|
|
161
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
162
|
+
|
|
163
|
+
export default function Test() {
|
|
164
|
+
return <Button to="/test" variant="primary" onClick={handleClick} disabled>Save</Button>
|
|
165
|
+
}
|
|
166
|
+
`.trim()
|
|
167
|
+
|
|
168
|
+
const result = applyTransform(input)
|
|
169
|
+
expect(result).toContain('variant="primary"')
|
|
170
|
+
expect(result).toContain("onClick={handleClick}")
|
|
171
|
+
expect(result).toContain("disabled")
|
|
172
|
+
expect(result).not.toContain('Button to="/test"')
|
|
173
|
+
expect(result).toContain("TODO: tapestry-migration")
|
|
174
|
+
expect(result).toContain('removed: to="/test"')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe("edge cases", () => {
|
|
179
|
+
it("should not transform Button without 'to' attribute", () => {
|
|
180
|
+
const input = `
|
|
181
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
182
|
+
|
|
183
|
+
export default function Test() {
|
|
184
|
+
return <Button variant="primary">Save</Button>
|
|
185
|
+
}
|
|
186
|
+
`.trim()
|
|
187
|
+
|
|
188
|
+
const result = applyTransform(input)
|
|
189
|
+
expect(result).toBe(null)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("should not transform if Button is not imported from tapestry-react", () => {
|
|
193
|
+
const input = `
|
|
194
|
+
import { Button } from "@planningcenter/tapestry"
|
|
195
|
+
|
|
196
|
+
export default function Test() {
|
|
197
|
+
return <Button to="/test">Save</Button>
|
|
198
|
+
}
|
|
199
|
+
`.trim()
|
|
200
|
+
|
|
201
|
+
const result = applyTransform(input)
|
|
202
|
+
expect(result).toBe(null)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("should handle Button with aliased import", () => {
|
|
206
|
+
const input = `
|
|
207
|
+
import { Button as TapestryButton } from "@planningcenter/tapestry-react"
|
|
208
|
+
|
|
209
|
+
export default function Test() {
|
|
210
|
+
return <TapestryButton to="/users">Save</TapestryButton>
|
|
211
|
+
}
|
|
212
|
+
`.trim()
|
|
213
|
+
|
|
214
|
+
const result = applyTransform(input)
|
|
215
|
+
expect(result).toContain("<TapestryButton>Save</TapestryButton>")
|
|
216
|
+
expect(result).not.toContain('TapestryButton to="/users"')
|
|
217
|
+
expect(result).toContain("TODO: tapestry-migration")
|
|
218
|
+
expect(result).toContain('removed: to="/users"')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("should handle self-closing Button components", () => {
|
|
222
|
+
const input = `
|
|
223
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
224
|
+
|
|
225
|
+
export default function Test() {
|
|
226
|
+
return <Button to="/test" />
|
|
227
|
+
}
|
|
228
|
+
`.trim()
|
|
229
|
+
|
|
230
|
+
const result = applyTransform(input)
|
|
231
|
+
expect(result).toContain("<Button />")
|
|
232
|
+
expect(result).not.toContain('Button to="/test"')
|
|
233
|
+
expect(result).toContain("TODO: tapestry-migration")
|
|
234
|
+
expect(result).toContain('removed: to="/test"')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("should handle Button components with complex children", () => {
|
|
238
|
+
const input = `
|
|
239
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
240
|
+
|
|
241
|
+
export default function Test() {
|
|
242
|
+
return (
|
|
243
|
+
<Button to="/dashboard">
|
|
244
|
+
<span>Go to</span>
|
|
245
|
+
<strong>Dashboard</strong>
|
|
246
|
+
</Button>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
`.trim()
|
|
250
|
+
|
|
251
|
+
const result = applyTransform(input)
|
|
252
|
+
expect(result).toContain("<Button>")
|
|
253
|
+
expect(result).toContain("<span>Go to</span>")
|
|
254
|
+
expect(result).toContain("<strong>Dashboard</strong>")
|
|
255
|
+
expect(result).toContain("</Button>")
|
|
256
|
+
expect(result).not.toContain('Button to="/dashboard"')
|
|
257
|
+
expect(result).toContain("TODO: tapestry-migration")
|
|
258
|
+
expect(result).toContain('removed: to="/dashboard"')
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe("no changes scenarios", () => {
|
|
263
|
+
it("should return null when no Button import exists", () => {
|
|
264
|
+
const input = `
|
|
265
|
+
import React from "react"
|
|
266
|
+
|
|
267
|
+
export default function Test() {
|
|
268
|
+
return <div>Hello</div>
|
|
269
|
+
}
|
|
270
|
+
`.trim()
|
|
271
|
+
|
|
272
|
+
const result = applyTransform(input)
|
|
273
|
+
expect(result).toBe(null)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it("should return null when no Button components with 'to' exist", () => {
|
|
277
|
+
const input = `
|
|
278
|
+
import { Button, Link } from "@planningcenter/tapestry-react"
|
|
279
|
+
|
|
280
|
+
export default function Test() {
|
|
281
|
+
return (
|
|
282
|
+
<div>
|
|
283
|
+
<Button>Save</Button>
|
|
284
|
+
<Link to="/users">Users</Link>
|
|
285
|
+
</div>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
`.trim()
|
|
289
|
+
|
|
290
|
+
const result = applyTransform(input)
|
|
291
|
+
expect(result).toBe(null)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it("should return null for empty file", () => {
|
|
295
|
+
const result = applyTransform("")
|
|
296
|
+
expect(result).toBe(null)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe("complex scenarios", () => {
|
|
301
|
+
it("should handle mixed imports and multiple components", () => {
|
|
302
|
+
const input = `
|
|
303
|
+
import React from "react"
|
|
304
|
+
import { Button, Link } from "@planningcenter/tapestry-react"
|
|
305
|
+
import { Input } from "@planningcenter/tapestry"
|
|
306
|
+
|
|
307
|
+
export default function Test() {
|
|
308
|
+
return (
|
|
309
|
+
<form>
|
|
310
|
+
<Input name="email" />
|
|
311
|
+
<Button to="/submit" type="submit">Submit</Button>
|
|
312
|
+
<Link to="/cancel">Cancel</Link>
|
|
313
|
+
<Button to="">Reset</Button>
|
|
314
|
+
</form>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
`.trim()
|
|
318
|
+
|
|
319
|
+
const result = applyTransform(input)
|
|
320
|
+
expect(result).toContain('<Button type="submit">Submit</Button>')
|
|
321
|
+
expect(result).toContain("<Button>Reset</Button>")
|
|
322
|
+
expect(result).toContain('<Link to="/cancel">Cancel</Link>') // Link should be unchanged
|
|
323
|
+
expect(result).not.toContain('Button to="/submit"')
|
|
324
|
+
expect(result).not.toContain('Button to=""')
|
|
325
|
+
expect(result).toContain(
|
|
326
|
+
'/* TODO: tapestry-migration (to): prop is not supported. If needed, use Link instead - removed: to="/submit" */'
|
|
327
|
+
)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it("should handle Button usage in JSX expressions", () => {
|
|
331
|
+
const input = `
|
|
332
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
333
|
+
|
|
334
|
+
export default function Test({ showButton, items }) {
|
|
335
|
+
return (
|
|
336
|
+
<div>
|
|
337
|
+
{showButton && <Button to="/conditional">Conditional</Button>}
|
|
338
|
+
{items.map(item => <Button key={item.id} to={item.url}>{item.name}</Button>)}
|
|
339
|
+
</div>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
`.trim()
|
|
343
|
+
|
|
344
|
+
const result = applyTransform(input)
|
|
345
|
+
expect(result).toContain("<Button>Conditional</Button>")
|
|
346
|
+
expect(result).toContain("<Button key={item.id}>{item.name}</Button>")
|
|
347
|
+
expect(result).not.toContain('Button to="/conditional"')
|
|
348
|
+
expect(result).not.toContain("} to={item.url}")
|
|
349
|
+
expect(result).toContain("TODO: tapestry-migration")
|
|
350
|
+
expect(result).toContain('removed: to="/conditional"')
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
describe("comment placement and format", () => {
|
|
355
|
+
it("should add comments with correct format", () => {
|
|
356
|
+
const input = `
|
|
357
|
+
import { Button } from "@planningcenter/tapestry-react"
|
|
358
|
+
|
|
359
|
+
export default function Test() {
|
|
360
|
+
return <Button href="/test">Save</Button>
|
|
361
|
+
}
|
|
362
|
+
`.trim()
|
|
363
|
+
|
|
364
|
+
const result = applyTransform(input)
|
|
365
|
+
expect(result).toContain(
|
|
366
|
+
'/* TODO: tapestry-migration (href): prop is not supported. If needed, use Link instead - removed: href="/test" */'
|
|
367
|
+
)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Transform } from "jscodeshift"
|
|
2
|
+
|
|
3
|
+
import { removeAttribute } from "../../shared/actions/removeAttribute"
|
|
4
|
+
import { hasAttribute } from "../../shared/conditions/hasAttribute"
|
|
5
|
+
import { orConditions } from "../../shared/conditions/orConditions"
|
|
6
|
+
import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
|
|
7
|
+
|
|
8
|
+
function buildComment(propName: string, formattedValue: string) {
|
|
9
|
+
if (formattedValue === '""' || formattedValue === '"#"') return
|
|
10
|
+
return `prop is not supported. If needed, use Link instead - removed: ${propName}=${formattedValue}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const transform: Transform = attributeTransformFactory({
|
|
14
|
+
condition: orConditions(hasAttribute("to"), hasAttribute("href")),
|
|
15
|
+
targetComponent: "Button",
|
|
16
|
+
targetPackage: "@planningcenter/tapestry-react",
|
|
17
|
+
transform: (element, { j, source }) => {
|
|
18
|
+
const options = { buildComment, element, j, source }
|
|
19
|
+
const toRemoved = removeAttribute("to", options)
|
|
20
|
+
const hrefRemoved = removeAttribute("href", options)
|
|
21
|
+
return toRemoved || hrefRemoved
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export default transform
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import jscodeshift, { JSXElement, JSXIdentifier } from "jscodeshift"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
|
|
4
|
+
import { addComment } from "./addComment"
|
|
5
|
+
|
|
6
|
+
const j = jscodeshift.withParser("tsx")
|
|
7
|
+
|
|
8
|
+
function createElementFromCode(code: string): JSXElement {
|
|
9
|
+
const source = j(`<div>${code}</div>`)
|
|
10
|
+
return source.find(j.JSXElement).at(0).get().value.children?.[0] as JSXElement
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createTopLevelElementFromCode(code: string): JSXElement {
|
|
14
|
+
const source = j(code)
|
|
15
|
+
return source.find(j.JSXElement).at(0).get().value as JSXElement
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("addComment", () => {
|
|
19
|
+
it("should add comment to element", () => {
|
|
20
|
+
const element = createElementFromCode("<Button>Save</Button>")
|
|
21
|
+
const commentText = "TODO: This needs to be updated"
|
|
22
|
+
|
|
23
|
+
addComment({ element, j, scope: "test", source: j(""), text: commentText })
|
|
24
|
+
|
|
25
|
+
expect(element.comments).toHaveLength(1)
|
|
26
|
+
expect(element.comments?.[0].type).toBe("CommentBlock")
|
|
27
|
+
expect(element.comments?.[0].value).toBe(
|
|
28
|
+
` TODO: tapestry-migration (test): ${commentText} `
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("should add multiple comments to element", () => {
|
|
33
|
+
const element = createElementFromCode("<Button>Save</Button>")
|
|
34
|
+
const firstComment = "TODO: First comment"
|
|
35
|
+
const secondComment = "TODO: Second comment"
|
|
36
|
+
|
|
37
|
+
addComment({ element, j, scope: "test", source: j(""), text: firstComment })
|
|
38
|
+
addComment({
|
|
39
|
+
element,
|
|
40
|
+
j,
|
|
41
|
+
scope: "test",
|
|
42
|
+
source: j(""),
|
|
43
|
+
text: secondComment,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(element.comments).toHaveLength(2)
|
|
47
|
+
expect(element.comments?.[0].value).toBe(
|
|
48
|
+
` TODO: tapestry-migration (test): ${firstComment} `
|
|
49
|
+
)
|
|
50
|
+
expect(element.comments?.[1].value).toBe(
|
|
51
|
+
` TODO: tapestry-migration (test): ${secondComment} `
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("should render comment above top-level element", () => {
|
|
56
|
+
const element = createTopLevelElementFromCode("<Button>Save</Button>")
|
|
57
|
+
const commentText = "TODO: Update this button"
|
|
58
|
+
|
|
59
|
+
addComment({ element, j, scope: "test", source: j(""), text: commentText })
|
|
60
|
+
|
|
61
|
+
const source = j.withParser("tsx")("").find(j.Program).get()
|
|
62
|
+
source.value.body = [j.expressionStatement(element)]
|
|
63
|
+
const result = j(source).toSource()
|
|
64
|
+
|
|
65
|
+
expect(result).toContain(
|
|
66
|
+
`/* TODO: tapestry-migration (test): ${commentText} */`
|
|
67
|
+
)
|
|
68
|
+
expect(result).toContain("<Button>Save</Button>")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("should render comment above nested element (current JSCodeshift limitation)", () => {
|
|
72
|
+
const outerCode = `
|
|
73
|
+
<div>
|
|
74
|
+
<Button>Save</Button>
|
|
75
|
+
</div>
|
|
76
|
+
`
|
|
77
|
+
const source = j(outerCode)
|
|
78
|
+
const buttonElement = source
|
|
79
|
+
.find(j.JSXElement)
|
|
80
|
+
.filter(
|
|
81
|
+
(path) =>
|
|
82
|
+
(path.value.openingElement.name as JSXIdentifier)?.name === "Button"
|
|
83
|
+
)
|
|
84
|
+
.at(0)
|
|
85
|
+
.get().value as JSXElement
|
|
86
|
+
|
|
87
|
+
const commentText = "TODO: Update this nested button"
|
|
88
|
+
addComment({
|
|
89
|
+
element: buttonElement,
|
|
90
|
+
j,
|
|
91
|
+
scope: "test",
|
|
92
|
+
source,
|
|
93
|
+
text: commentText,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const result = source.toSource()
|
|
97
|
+
|
|
98
|
+
// Current JSCodeshift limitation: comments are rendered as regular block comments
|
|
99
|
+
// This is a known issue - comments in JSX context should ideally be {/* */}
|
|
100
|
+
expect(result).toContain(
|
|
101
|
+
`/* TODO: tapestry-migration (test): ${commentText} */`
|
|
102
|
+
)
|
|
103
|
+
expect(result).toContain("<Button>Save</Button>")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("should handle empty comment text", () => {
|
|
107
|
+
const element = createElementFromCode("<Button>Save</Button>")
|
|
108
|
+
const commentText = ""
|
|
109
|
+
|
|
110
|
+
addComment({ element, j, scope: "test", source: j(""), text: commentText })
|
|
111
|
+
|
|
112
|
+
expect(element.comments).toHaveLength(1)
|
|
113
|
+
expect(element.comments?.[0].value).toBe(
|
|
114
|
+
` TODO: tapestry-migration (test): ${commentText} `
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("should handle comment text with special characters", () => {
|
|
119
|
+
const element = createElementFromCode("<Button>Save</Button>")
|
|
120
|
+
const commentText = "TODO: Handle 'quotes' and \"double quotes\" and <tags>"
|
|
121
|
+
|
|
122
|
+
addComment({ element, j, scope: "test", source: j(""), text: commentText })
|
|
123
|
+
|
|
124
|
+
expect(element.comments).toHaveLength(1)
|
|
125
|
+
expect(element.comments?.[0].value).toBe(
|
|
126
|
+
` TODO: tapestry-migration (test): ${commentText} `
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("should handle multiline comment text", () => {
|
|
131
|
+
const element = createElementFromCode("<Button>Save</Button>")
|
|
132
|
+
const commentText =
|
|
133
|
+
"TODO: This is a long comment\nthat spans multiple lines"
|
|
134
|
+
|
|
135
|
+
addComment({ element, j, scope: "test", source: j(""), text: commentText })
|
|
136
|
+
|
|
137
|
+
expect(element.comments).toHaveLength(1)
|
|
138
|
+
expect(element.comments?.[0].value).toBe(
|
|
139
|
+
` TODO: tapestry-migration (test): ${commentText} `
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe("JSX context formatting (current limitations)", () => {
|
|
144
|
+
it("should format comments in JSX children context (JSCodeshift limitation)", () => {
|
|
145
|
+
const outerCode = `
|
|
146
|
+
<div>
|
|
147
|
+
<span>Before</span>
|
|
148
|
+
<Button>Save</Button>
|
|
149
|
+
<span>After</span>
|
|
150
|
+
</div>
|
|
151
|
+
`
|
|
152
|
+
const source = j(outerCode)
|
|
153
|
+
const buttonElement = source
|
|
154
|
+
.find(j.JSXElement)
|
|
155
|
+
.filter(
|
|
156
|
+
(path) =>
|
|
157
|
+
(path.value.openingElement.name as JSXIdentifier)?.name === "Button"
|
|
158
|
+
)
|
|
159
|
+
.at(0)
|
|
160
|
+
.get().value as JSXElement
|
|
161
|
+
|
|
162
|
+
addComment({
|
|
163
|
+
element: buttonElement,
|
|
164
|
+
j,
|
|
165
|
+
scope: "test",
|
|
166
|
+
source,
|
|
167
|
+
text: "Fix this button",
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const result = source.toSource()
|
|
171
|
+
|
|
172
|
+
expect(result).toContain(
|
|
173
|
+
"/* TODO: tapestry-migration (test): Fix this button */"
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("should format comments in JSX attribute context (JSCodeshift limitation)", () => {
|
|
178
|
+
const outerCode = `
|
|
179
|
+
<div>
|
|
180
|
+
<Button iconLeft={<Icon name="star" />}>Save</Button>
|
|
181
|
+
</div>
|
|
182
|
+
`
|
|
183
|
+
const source = j(outerCode)
|
|
184
|
+
const iconElement = source
|
|
185
|
+
.find(j.JSXElement)
|
|
186
|
+
.filter(
|
|
187
|
+
(path) =>
|
|
188
|
+
(path.value.openingElement.name as JSXIdentifier)?.name === "Icon"
|
|
189
|
+
)
|
|
190
|
+
.at(0)
|
|
191
|
+
.get().value as JSXElement
|
|
192
|
+
|
|
193
|
+
addComment({
|
|
194
|
+
element: iconElement,
|
|
195
|
+
j,
|
|
196
|
+
scope: "test",
|
|
197
|
+
source,
|
|
198
|
+
text: "Update icon",
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const result = source.toSource()
|
|
202
|
+
|
|
203
|
+
expect(result).toContain(
|
|
204
|
+
"/* TODO: tapestry-migration (test): Update icon */"
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("should format comments for deeply nested elements with JSX expression", () => {
|
|
209
|
+
const outerCode = `
|
|
210
|
+
<div>
|
|
211
|
+
<section>
|
|
212
|
+
<article>
|
|
213
|
+
<Button>Deep Button</Button>
|
|
214
|
+
</article>
|
|
215
|
+
</section>
|
|
216
|
+
</div>
|
|
217
|
+
`
|
|
218
|
+
const source = j(outerCode)
|
|
219
|
+
const buttonElement = source
|
|
220
|
+
.find(j.JSXElement)
|
|
221
|
+
.filter(
|
|
222
|
+
(path) =>
|
|
223
|
+
(path.value.openingElement.name as JSXIdentifier)?.name === "Button"
|
|
224
|
+
)
|
|
225
|
+
.at(0)
|
|
226
|
+
.get().value as JSXElement
|
|
227
|
+
|
|
228
|
+
addComment({
|
|
229
|
+
element: buttonElement,
|
|
230
|
+
j,
|
|
231
|
+
scope: "test",
|
|
232
|
+
source,
|
|
233
|
+
text: "Deeply nested comment",
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const result = source.toSource()
|
|
237
|
+
|
|
238
|
+
expect(result.replace(/\n/gm, "")).toMatch(
|
|
239
|
+
/\{\/\*\sTODO:\stapestry-migration\s\(test\):\sDeeply\snested\scomment\s\*\/\s*}/
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ASTPath, Collection, JSCodeshift, JSXElement } from "jscodeshift"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adds a comment to a JSX element.
|
|
5
|
+
*
|
|
6
|
+
* For JSX contexts, creates a proper JSX expression comment by embedding
|
|
7
|
+
* the comment in a JSX expression container that renders properly.
|
|
8
|
+
*/
|
|
9
|
+
export function addComment({
|
|
10
|
+
text,
|
|
11
|
+
element,
|
|
12
|
+
j,
|
|
13
|
+
source,
|
|
14
|
+
scope,
|
|
15
|
+
}: {
|
|
16
|
+
element: JSXElement
|
|
17
|
+
j: JSCodeshift
|
|
18
|
+
scope: string
|
|
19
|
+
source: Collection
|
|
20
|
+
text: string
|
|
21
|
+
}) {
|
|
22
|
+
const commentText = formatComment(text, scope)
|
|
23
|
+
if (tryInsertJSXComment(source, element, commentText, j)) {
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const comment = j.commentBlock(commentText, true, false)
|
|
28
|
+
|
|
29
|
+
if (element.comments) {
|
|
30
|
+
element.comments.push(comment)
|
|
31
|
+
} else {
|
|
32
|
+
element.comments = [comment]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createJSXExpressionComment(text: string, j: JSCodeshift) {
|
|
37
|
+
const comment = j.commentBlock(text, true, false)
|
|
38
|
+
const emptyExpr = j.jsxEmptyExpression()
|
|
39
|
+
emptyExpr.comments = [comment]
|
|
40
|
+
return j.jsxExpressionContainer(emptyExpr)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function tryInsertJSXComment(
|
|
44
|
+
source: Collection,
|
|
45
|
+
element: JSXElement,
|
|
46
|
+
text: string,
|
|
47
|
+
j: JSCodeshift
|
|
48
|
+
): boolean {
|
|
49
|
+
let found = false
|
|
50
|
+
|
|
51
|
+
source.find(j.JSXElement).forEach((path: ASTPath<JSXElement>) => {
|
|
52
|
+
if (path.value === element && path.parent && path.parent.value.children) {
|
|
53
|
+
const parent = path.parent.value
|
|
54
|
+
const childIndex = parent.children.indexOf(element)
|
|
55
|
+
|
|
56
|
+
if (childIndex >= 0) {
|
|
57
|
+
const jsxComment = createJSXExpressionComment(text, j)
|
|
58
|
+
parent.children.splice(childIndex, 0, jsxComment)
|
|
59
|
+
found = true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return found
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatComment(text: string, scope: string): string {
|
|
68
|
+
return ` TODO: tapestry-migration (${scope}): ${text} `
|
|
69
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import jscodeshift from "jscodeshift"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
|
|
4
|
+
import { getPrintableAttributeValue } from "./getPrintableAttributeValue"
|
|
5
|
+
|
|
6
|
+
const j = jscodeshift.withParser("tsx")
|
|
7
|
+
|
|
8
|
+
// Helper to create JSX attributes for testing
|
|
9
|
+
function createAttribute(code: string) {
|
|
10
|
+
const source = j(`<div ${code} />`)
|
|
11
|
+
const element = source.find(j.JSXElement).at(0)
|
|
12
|
+
const attributes = element.get().value.openingElement.attributes
|
|
13
|
+
return attributes?.[0] || null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("getPrintableAttributeValue", () => {
|
|
17
|
+
describe("null/undefined handling", () => {
|
|
18
|
+
it("should return null for null attribute", () => {
|
|
19
|
+
const result = getPrintableAttributeValue(null, j)
|
|
20
|
+
expect(result).toBe(null)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("should return null for undefined attribute", () => {
|
|
24
|
+
const result = getPrintableAttributeValue(undefined, j)
|
|
25
|
+
expect(result).toBe(null)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("should return null for attribute with no value", () => {
|
|
29
|
+
const attribute = createAttribute("disabled")
|
|
30
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
31
|
+
expect(result).toBe(null)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe("string literal values", () => {
|
|
36
|
+
it("should format empty string literal", () => {
|
|
37
|
+
const attribute = createAttribute('to=""')
|
|
38
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
39
|
+
expect(result).toBe('""')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("should format simple string literal", () => {
|
|
43
|
+
const attribute = createAttribute('to="/users"')
|
|
44
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
45
|
+
expect(result).toBe('"/users"')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("should format hash string literal", () => {
|
|
49
|
+
const attribute = createAttribute('to="#"')
|
|
50
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
51
|
+
expect(result).toBe('"#"')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("should handle string with special characters", () => {
|
|
55
|
+
const attribute = createAttribute('to="/users?tab=active&sort=name"')
|
|
56
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
57
|
+
expect(result).toBe('"/users?tab=active&sort=name"')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("should handle string with quotes inside", () => {
|
|
61
|
+
const attribute = createAttribute(
|
|
62
|
+
"aria-label='Click \"here\" to continue'"
|
|
63
|
+
)
|
|
64
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
65
|
+
expect(result).toBe('"Click "here" to continue"')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("JSX expression containers with string literals", () => {
|
|
70
|
+
it("should normalize empty string expression to string literal format", () => {
|
|
71
|
+
const attribute = createAttribute('to={""}')
|
|
72
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
73
|
+
expect(result).toBe('""')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("should normalize simple string expression to string literal format", () => {
|
|
77
|
+
const attribute = createAttribute('to={"/profile"}')
|
|
78
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
79
|
+
expect(result).toBe('"/profile"')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("should normalize hash string expression to string literal format", () => {
|
|
83
|
+
const attribute = createAttribute('to={"#"}')
|
|
84
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
85
|
+
expect(result).toBe('"#"')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("should handle complex string in expression", () => {
|
|
89
|
+
const attribute = createAttribute(
|
|
90
|
+
'to={"/dashboard?user=123&tab=settings"}'
|
|
91
|
+
)
|
|
92
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
93
|
+
expect(result).toBe('"/dashboard?user=123&tab=settings"')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe("JSX expression containers with complex expressions", () => {
|
|
98
|
+
it("should return full source for variable expression", () => {
|
|
99
|
+
const attribute = createAttribute("to={url}")
|
|
100
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
101
|
+
expect(result).toBe("{url}")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("should return full source for template literal expression", () => {
|
|
105
|
+
const attribute = createAttribute("to={`/users/${userId}`}")
|
|
106
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
107
|
+
expect(result).toBe("{`/users/${userId}`}")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("should return full source for function call expression", () => {
|
|
111
|
+
const attribute = createAttribute("to={getUrl()}")
|
|
112
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
113
|
+
expect(result).toBe("{getUrl()}")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("should return full source for conditional expression", () => {
|
|
117
|
+
const attribute = createAttribute(
|
|
118
|
+
'to={isActive ? "/dashboard" : "/login"}'
|
|
119
|
+
)
|
|
120
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
121
|
+
expect(result).toBe('{isActive ? "/dashboard" : "/login"}')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("should return full source for object property access", () => {
|
|
125
|
+
const attribute = createAttribute("to={config.baseUrl}")
|
|
126
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
127
|
+
expect(result).toBe("{config.baseUrl}")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("should return full source for array access", () => {
|
|
131
|
+
const attribute = createAttribute("to={routes[0]}")
|
|
132
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
133
|
+
expect(result).toBe("{routes[0]}")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("should return full source for complex nested expression", () => {
|
|
137
|
+
const attribute = createAttribute(
|
|
138
|
+
"onClick={() => navigate(getUrl(userId))}"
|
|
139
|
+
)
|
|
140
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
141
|
+
expect(result).toBe("{() => navigate(getUrl(userId))}")
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("edge cases", () => {
|
|
146
|
+
it("should handle boolean attribute (no value)", () => {
|
|
147
|
+
const attribute = createAttribute("disabled")
|
|
148
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
149
|
+
expect(result).toBe(null)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("should handle numeric literal in expression", () => {
|
|
153
|
+
const attribute = createAttribute("tabIndex={0}")
|
|
154
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
155
|
+
expect(result).toBe("{0}")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("should handle boolean literal in expression", () => {
|
|
159
|
+
const attribute = createAttribute("disabled={true}")
|
|
160
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
161
|
+
expect(result).toBe("{true}")
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("should handle null literal in expression", () => {
|
|
165
|
+
const attribute = createAttribute("value={null}")
|
|
166
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
167
|
+
expect(result).toBe("{null}")
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("should handle undefined literal in expression", () => {
|
|
171
|
+
const attribute = createAttribute("value={undefined}")
|
|
172
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
173
|
+
expect(result).toBe("{undefined}")
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe("string escaping and special characters", () => {
|
|
178
|
+
it("should handle strings with newlines", () => {
|
|
179
|
+
const attribute = createAttribute('to={"line1\\nline2"}')
|
|
180
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
181
|
+
expect(result).toBe('"line1\nline2"')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("should handle strings with tabs", () => {
|
|
185
|
+
const attribute = createAttribute('to={"col1\\tcol2"}')
|
|
186
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
187
|
+
expect(result).toBe('"col1\tcol2"')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("should handle strings with backslashes", () => {
|
|
191
|
+
const attribute = createAttribute('to={"C:\\\\Users\\\\file.txt"}')
|
|
192
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
193
|
+
expect(result).toBe('"C:\\Users\\file.txt"')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("should handle unicode characters", () => {
|
|
197
|
+
const attribute = createAttribute('to={"🚀 rocket"}')
|
|
198
|
+
const result = getPrintableAttributeValue(attribute, j)
|
|
199
|
+
expect(result).toBe('"🚀 rocket"')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { JSCodeshift, JSXAttribute } from "jscodeshift"
|
|
2
|
+
|
|
3
|
+
export function getPrintableAttributeValue(
|
|
4
|
+
attribute: JSXAttribute | null | undefined,
|
|
5
|
+
j: JSCodeshift
|
|
6
|
+
): string | null {
|
|
7
|
+
if (!attribute) return null
|
|
8
|
+
|
|
9
|
+
const value = attribute.value
|
|
10
|
+
|
|
11
|
+
if (!value) return null
|
|
12
|
+
if (value.type === "StringLiteral") return `"${value.value}"`
|
|
13
|
+
if (value.type === "JSXExpressionContainer") {
|
|
14
|
+
// Check if the expression is a simple string literal
|
|
15
|
+
if (value.expression.type === "StringLiteral") {
|
|
16
|
+
return `"${value.expression.value}"`
|
|
17
|
+
}
|
|
18
|
+
// For complex expressions, return the full source
|
|
19
|
+
return j(value).toSource()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import jscodeshift, { JSXAttribute } from "jscodeshift"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
|
|
4
|
+
import { removeAttribute } from "./removeAttribute"
|
|
5
|
+
|
|
6
|
+
const j = jscodeshift.withParser("tsx")
|
|
7
|
+
|
|
8
|
+
// Helper to create JSX elements for testing
|
|
9
|
+
function createElement(code: string) {
|
|
10
|
+
const source = j(`<div>${code}</div>`)
|
|
11
|
+
const element = source.find(j.JSXElement).at(0).get().value.children?.[0]
|
|
12
|
+
return element
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("removeAttribute", () => {
|
|
16
|
+
describe("basic attribute removal", () => {
|
|
17
|
+
it("should remove existing string attribute", () => {
|
|
18
|
+
const element = createElement('<Button to="/users">Save</Button>')
|
|
19
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
20
|
+
|
|
21
|
+
expect(result).toBe(true)
|
|
22
|
+
expect(element.openingElement.attributes).toHaveLength(0)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("should remove existing expression attribute", () => {
|
|
26
|
+
const element = createElement("<Button to={url}>Save</Button>")
|
|
27
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
28
|
+
|
|
29
|
+
expect(result).toBe(true)
|
|
30
|
+
expect(element.openingElement.attributes).toHaveLength(0)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("should remove boolean attribute", () => {
|
|
34
|
+
const element = createElement("<Button disabled>Save</Button>")
|
|
35
|
+
const result = removeAttribute("disabled", { element, j, source: j("") })
|
|
36
|
+
|
|
37
|
+
expect(result).toBe(true)
|
|
38
|
+
expect(element.openingElement.attributes).toHaveLength(0)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("should return false when attribute does not exist", () => {
|
|
42
|
+
const element = createElement('<Button variant="primary">Save</Button>')
|
|
43
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
44
|
+
|
|
45
|
+
expect(result).toBe(false)
|
|
46
|
+
expect(element.openingElement.attributes).toHaveLength(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("should return false when element has no attributes", () => {
|
|
50
|
+
const element = createElement("<Button>Save</Button>")
|
|
51
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
52
|
+
|
|
53
|
+
expect(result).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("partial attribute removal", () => {
|
|
58
|
+
it("should remove target attribute while preserving others", () => {
|
|
59
|
+
const element = createElement(
|
|
60
|
+
'<Button to="/users" variant="primary" disabled>Save</Button>'
|
|
61
|
+
)
|
|
62
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
63
|
+
|
|
64
|
+
expect(result).toBe(true)
|
|
65
|
+
expect(element.openingElement.attributes).toHaveLength(2)
|
|
66
|
+
|
|
67
|
+
// Check that other attributes remain
|
|
68
|
+
const remainingAttrs = element.openingElement.attributes || []
|
|
69
|
+
const variantAttr = remainingAttrs.find(
|
|
70
|
+
(attr: JSXAttribute) =>
|
|
71
|
+
attr.type === "JSXAttribute" && attr.name?.name === "variant"
|
|
72
|
+
)
|
|
73
|
+
const disabledAttr = remainingAttrs.find(
|
|
74
|
+
(attr: JSXAttribute) =>
|
|
75
|
+
attr.type === "JSXAttribute" && attr.name?.name === "disabled"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
expect(variantAttr).toBeTruthy()
|
|
79
|
+
expect(disabledAttr).toBeTruthy()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("should handle removal of first attribute", () => {
|
|
83
|
+
const element = createElement(
|
|
84
|
+
'<Button to="/users" variant="primary">Save</Button>'
|
|
85
|
+
)
|
|
86
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
87
|
+
|
|
88
|
+
expect(result).toBe(true)
|
|
89
|
+
expect(element.openingElement.attributes).toHaveLength(1)
|
|
90
|
+
expect(
|
|
91
|
+
(element.openingElement.attributes?.[0] as JSXAttribute)?.name?.name
|
|
92
|
+
).toBe("variant")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("should handle removal of last attribute", () => {
|
|
96
|
+
const element = createElement(
|
|
97
|
+
'<Button variant="primary" to="/users">Save</Button>'
|
|
98
|
+
)
|
|
99
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
100
|
+
|
|
101
|
+
expect(result).toBe(true)
|
|
102
|
+
expect(element.openingElement.attributes).toHaveLength(1)
|
|
103
|
+
expect(
|
|
104
|
+
(element.openingElement.attributes?.[0] as JSXAttribute)?.name?.name
|
|
105
|
+
).toBe("variant")
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("should handle removal of middle attribute", () => {
|
|
109
|
+
const element = createElement(
|
|
110
|
+
'<Button variant="primary" to="/users" disabled>Save</Button>'
|
|
111
|
+
)
|
|
112
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
113
|
+
|
|
114
|
+
expect(result).toBe(true)
|
|
115
|
+
expect(element.openingElement.attributes).toHaveLength(2)
|
|
116
|
+
|
|
117
|
+
const names = element.openingElement.attributes?.map(
|
|
118
|
+
(attr: JSXAttribute) =>
|
|
119
|
+
attr.type === "JSXAttribute" ? attr.name?.name : null
|
|
120
|
+
)
|
|
121
|
+
expect(names).toEqual(["variant", "disabled"])
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe("comment generation", () => {
|
|
126
|
+
it("should add comment when buildComment function is provided", () => {
|
|
127
|
+
const element = createElement('<Button to="/users">Save</Button>')
|
|
128
|
+
const buildComment = (propName: string, formattedValue: string) =>
|
|
129
|
+
`Removed ${propName}=${formattedValue}`
|
|
130
|
+
|
|
131
|
+
const result = removeAttribute("to", {
|
|
132
|
+
buildComment,
|
|
133
|
+
element,
|
|
134
|
+
j,
|
|
135
|
+
source: j(""),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
expect(result).toBe(true)
|
|
139
|
+
expect(element.comments).toBeDefined()
|
|
140
|
+
expect(element.comments).toHaveLength(1)
|
|
141
|
+
expect(element.comments?.[0].value).toContain('Removed to="/users"')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("should add multiple comments when called multiple times", () => {
|
|
145
|
+
const element = createElement(
|
|
146
|
+
'<Button to="/users" href="/users">Save</Button>'
|
|
147
|
+
)
|
|
148
|
+
const buildComment = (propName: string, formattedValue: string) =>
|
|
149
|
+
`Removed ${propName}=${formattedValue}`
|
|
150
|
+
|
|
151
|
+
removeAttribute("to", { buildComment, element, j, source: j("") })
|
|
152
|
+
removeAttribute("href", { buildComment, element, j, source: j("") })
|
|
153
|
+
|
|
154
|
+
expect(element.comments).toHaveLength(2)
|
|
155
|
+
expect(element.comments?.[0].value).toContain('Removed to="/users"')
|
|
156
|
+
expect(element.comments?.[1].value).toContain('Removed href="/users"')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("should not add comment when buildComment returns undefined", () => {
|
|
160
|
+
const element = createElement('<Button to="">Save</Button>')
|
|
161
|
+
const buildComment = (propName: string, formattedValue: string) => {
|
|
162
|
+
if (formattedValue === '""') return undefined
|
|
163
|
+
return `Removed ${propName}=${formattedValue}`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const result = removeAttribute("to", {
|
|
167
|
+
buildComment,
|
|
168
|
+
element,
|
|
169
|
+
j,
|
|
170
|
+
source: j(""),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect(result).toBe(true)
|
|
174
|
+
expect(element.comments).toBeUndefined()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("should not add comment when buildComment returns empty string", () => {
|
|
178
|
+
const element = createElement('<Button to="">Save</Button>')
|
|
179
|
+
const buildComment = () => ""
|
|
180
|
+
|
|
181
|
+
const result = removeAttribute("to", {
|
|
182
|
+
buildComment,
|
|
183
|
+
element,
|
|
184
|
+
j,
|
|
185
|
+
source: j(""),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(result).toBe(true)
|
|
189
|
+
expect(element.comments).toBeUndefined()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("should handle comment generation with expression attributes", () => {
|
|
193
|
+
const element = createElement("<Button to={url}>Save</Button>")
|
|
194
|
+
const buildComment = (propName: string, formattedValue: string) =>
|
|
195
|
+
`Removed ${propName}=${formattedValue}`
|
|
196
|
+
|
|
197
|
+
const result = removeAttribute("to", {
|
|
198
|
+
buildComment,
|
|
199
|
+
element,
|
|
200
|
+
j,
|
|
201
|
+
source: j(""),
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(result).toBe(true)
|
|
205
|
+
expect(element.comments?.[0].value).toContain("Removed to={url}")
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("should handle comment generation with boolean attributes", () => {
|
|
209
|
+
const element = createElement("<Button disabled>Save</Button>")
|
|
210
|
+
const buildComment = (propName: string, formattedValue: string) =>
|
|
211
|
+
`Removed ${propName}=${formattedValue || "true"}`
|
|
212
|
+
|
|
213
|
+
const result = removeAttribute("disabled", {
|
|
214
|
+
buildComment,
|
|
215
|
+
element,
|
|
216
|
+
j,
|
|
217
|
+
source: j(""),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(result).toBe(true)
|
|
221
|
+
expect(element.comments?.[0].value).toContain("Removed disabled=true")
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe("edge cases", () => {
|
|
226
|
+
it("should handle element with null attributes", () => {
|
|
227
|
+
const element = createElement("<Button>Save</Button>")
|
|
228
|
+
element.openingElement.attributes = null
|
|
229
|
+
|
|
230
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
231
|
+
|
|
232
|
+
expect(result).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it("should handle element with undefined attributes", () => {
|
|
236
|
+
const element = createElement("<Button>Save</Button>")
|
|
237
|
+
element.openingElement.attributes = undefined
|
|
238
|
+
|
|
239
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
240
|
+
|
|
241
|
+
expect(result).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("should handle JSX spread attributes", () => {
|
|
245
|
+
const element = createElement(
|
|
246
|
+
'<Button {...props} to="/users">Save</Button>'
|
|
247
|
+
)
|
|
248
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
249
|
+
|
|
250
|
+
expect(result).toBe(true)
|
|
251
|
+
expect(element.openingElement.attributes).toHaveLength(1)
|
|
252
|
+
expect(element.openingElement.attributes?.[0].type).toBe(
|
|
253
|
+
"JSXSpreadAttribute"
|
|
254
|
+
)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it("should not remove JSX spread attributes when looking for named attribute", () => {
|
|
258
|
+
const element = createElement("<Button {...props}>Save</Button>")
|
|
259
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
260
|
+
|
|
261
|
+
expect(result).toBe(false)
|
|
262
|
+
expect(element.openingElement.attributes).toHaveLength(1)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("should handle self-closing elements", () => {
|
|
266
|
+
const element = createElement('<Button to="/users" />')
|
|
267
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
268
|
+
|
|
269
|
+
expect(result).toBe(true)
|
|
270
|
+
expect(element.openingElement.attributes).toHaveLength(0)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it("should work with different JSX element types", () => {
|
|
274
|
+
const element = createElement('<Link to="/users">Go</Link>')
|
|
275
|
+
const result = removeAttribute("to", { element, j, source: j("") })
|
|
276
|
+
|
|
277
|
+
expect(result).toBe(true)
|
|
278
|
+
expect(element.openingElement.attributes).toHaveLength(0)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe("complex attribute values", () => {
|
|
283
|
+
it("should handle complex object expression", () => {
|
|
284
|
+
const element = createElement(
|
|
285
|
+
'<Button style={{ color: "red", fontSize: 14 }}>Save</Button>'
|
|
286
|
+
)
|
|
287
|
+
const buildComment = (propName: string, formattedValue: string) =>
|
|
288
|
+
`Removed ${propName}=${formattedValue}`
|
|
289
|
+
|
|
290
|
+
const result = removeAttribute("style", {
|
|
291
|
+
buildComment,
|
|
292
|
+
element,
|
|
293
|
+
j,
|
|
294
|
+
source: j(""),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
expect(result).toBe(true)
|
|
298
|
+
expect(element.comments?.[0].value).toContain("Removed style={{")
|
|
299
|
+
expect(element.comments?.[0].value).toContain('color: "red"')
|
|
300
|
+
expect(element.comments?.[0].value).toContain("fontSize: 14")
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it("should handle arrow function expression", () => {
|
|
304
|
+
const element = createElement(
|
|
305
|
+
"<Button onClick={() => doSomething()}>Save</Button>"
|
|
306
|
+
)
|
|
307
|
+
const buildComment = (propName: string, formattedValue: string) =>
|
|
308
|
+
`Removed ${propName}=${formattedValue}`
|
|
309
|
+
|
|
310
|
+
const result = removeAttribute("onClick", {
|
|
311
|
+
buildComment,
|
|
312
|
+
element,
|
|
313
|
+
j,
|
|
314
|
+
source: j(""),
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
expect(result).toBe(true)
|
|
318
|
+
expect(element.comments?.[0].value).toContain(
|
|
319
|
+
"Removed onClick={() => doSomething()}"
|
|
320
|
+
)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it("should handle template literal expression", () => {
|
|
324
|
+
const element = createElement(
|
|
325
|
+
"<Button to={`/users/${userId}`}>Save</Button>"
|
|
326
|
+
)
|
|
327
|
+
const buildComment = (propName: string, formattedValue: string) =>
|
|
328
|
+
`Removed ${propName}=${formattedValue}`
|
|
329
|
+
|
|
330
|
+
const result = removeAttribute("to", {
|
|
331
|
+
buildComment,
|
|
332
|
+
element,
|
|
333
|
+
j,
|
|
334
|
+
source: j(""),
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
expect(result).toBe(true)
|
|
338
|
+
expect(element.comments?.[0].value).toContain(
|
|
339
|
+
"Removed to={`/users/${userId}`}"
|
|
340
|
+
)
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Collection, JSCodeshift, JSXElement } from "jscodeshift"
|
|
2
|
+
|
|
3
|
+
import { findAttribute } from "../findAttribute"
|
|
4
|
+
import { addComment } from "./addComment"
|
|
5
|
+
import { getPrintableAttributeValue } from "./getPrintableAttributeValue"
|
|
6
|
+
|
|
7
|
+
type RemoveAttributeConfig = {
|
|
8
|
+
/** Function to build comment text given the prop name and formatted value */
|
|
9
|
+
buildComment?: (propName: string, formattedValue: string) => string | void
|
|
10
|
+
element: JSXElement
|
|
11
|
+
j: JSCodeshift
|
|
12
|
+
source: Collection
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function removeAttribute(
|
|
16
|
+
name: string,
|
|
17
|
+
{ element, buildComment, j, source }: RemoveAttributeConfig
|
|
18
|
+
): boolean {
|
|
19
|
+
const attributes = element.openingElement.attributes || []
|
|
20
|
+
const attribute = findAttribute(attributes, name)
|
|
21
|
+
|
|
22
|
+
if (!attribute) return false
|
|
23
|
+
|
|
24
|
+
const index = attributes?.indexOf(attribute)
|
|
25
|
+
|
|
26
|
+
if (buildComment) {
|
|
27
|
+
const printableValue = getPrintableAttributeValue(attribute, j)
|
|
28
|
+
const commentText = buildComment(name, printableValue || "")
|
|
29
|
+
|
|
30
|
+
if (commentText) {
|
|
31
|
+
addComment({ element, j, scope: name, source, text: commentText })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
attributes.splice(index, 1)
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JSCodeshift, JSXElement, Transform } from "jscodeshift"
|
|
1
|
+
import { Collection, JSCodeshift, JSXElement, Transform } from "jscodeshift"
|
|
2
2
|
|
|
3
3
|
import { TransformCondition } from "../types"
|
|
4
4
|
import { getImportName } from "./helpers/manageImports"
|
|
@@ -14,7 +14,10 @@ export function attributeTransformFactory(config: {
|
|
|
14
14
|
/** Package the target component is imported from */
|
|
15
15
|
targetPackage: string
|
|
16
16
|
/** Function that performs the actual attribute transformation */
|
|
17
|
-
transform: (
|
|
17
|
+
transform: (
|
|
18
|
+
element: JSXElement,
|
|
19
|
+
resources: { j: JSCodeshift; source: Collection }
|
|
20
|
+
) => boolean
|
|
18
21
|
}): Transform {
|
|
19
22
|
return (fileInfo, api) => {
|
|
20
23
|
const j = api.jscodeshift
|
|
@@ -40,7 +43,7 @@ export function attributeTransformFactory(config: {
|
|
|
40
43
|
const element = path.parent.value
|
|
41
44
|
|
|
42
45
|
if (config.condition(element)) {
|
|
43
|
-
if (config.transform(element, j)) {
|
|
46
|
+
if (config.transform(element, { j, source })) {
|
|
44
47
|
hasChanges = true
|
|
45
48
|
}
|
|
46
49
|
}
|