@planningcenter/tapestry-migration-cli 2.3.0-rc.8 → 2.3.0

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.
Files changed (25) hide show
  1. package/dist/tapestry-react-shim.cjs +2 -1
  2. package/package.json +2 -2
  3. package/src/components/button/transforms/convertStyleProps.test.ts +4 -5
  4. package/src/components/link/index.ts +32 -0
  5. package/src/components/link/transforms/inlineToKind.test.ts +308 -0
  6. package/src/components/link/transforms/inlineToKind.ts +51 -0
  7. package/src/components/link/transforms/targetBlankToExternal.test.ts +191 -0
  8. package/src/components/link/transforms/targetBlankToExternal.ts +30 -0
  9. package/src/components/link/transforms/toToHref.test.ts +245 -0
  10. package/src/components/link/transforms/toToHref.ts +14 -0
  11. package/src/components/shared/actions/addAttribute.test.ts +108 -0
  12. package/src/components/shared/actions/addAttribute.ts +14 -0
  13. package/src/components/shared/actions/removeAttribute.ts +9 -2
  14. package/src/components/shared/actions/transformElementName.ts +23 -9
  15. package/src/components/shared/transformFactories/attributeTransformFactory.test.ts +83 -0
  16. package/src/components/shared/transformFactories/attributeTransformFactory.ts +21 -14
  17. package/src/components/shared/transformFactories/componentTransformFactory.test.ts +85 -2
  18. package/src/components/shared/transformFactories/componentTransformFactory.ts +41 -22
  19. package/src/components/shared/transformFactories/helpers/findJSXElements.ts +37 -0
  20. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +2 -27
  21. package/src/components/shared/types.ts +19 -1
  22. package/src/index.ts +7 -2
  23. package/src/jscodeshiftRunner.ts +7 -0
  24. package/src/reportGenerator.ts +450 -0
  25. package/src/shared/types.ts +1 -0
@@ -0,0 +1,245 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./toToHref"
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("toToHref transform", () => {
18
+ describe("basic transformations", () => {
19
+ it("should transform Link 'to' prop to 'href' prop", () => {
20
+ const input = `
21
+ import { Link } from "@planningcenter/tapestry-react"
22
+
23
+ export default function Test() {
24
+ return <Link to="/dashboard">Dashboard</Link>
25
+ }
26
+ `.trim()
27
+
28
+ const expected = `
29
+ import { Link } from "@planningcenter/tapestry-react"
30
+
31
+ export default function Test() {
32
+ return <Link href="/dashboard">Dashboard</Link>;
33
+ }
34
+ `.trim()
35
+
36
+ const result = applyTransform(input)
37
+ expect(result?.trim()).toBe(expected)
38
+ })
39
+
40
+ it("should handle multiple Link components with 'to' props", () => {
41
+ const input = `
42
+ import { Link } from "@planningcenter/tapestry-react"
43
+
44
+ export default function Test() {
45
+ return (
46
+ <div>
47
+ <Link to="/home">Home</Link>
48
+ <Link to="/about">About</Link>
49
+ </div>
50
+ )
51
+ }
52
+ `.trim()
53
+
54
+ const result = applyTransform(input)
55
+ expect(result).toContain('href="/home"')
56
+ expect(result).toContain('href="/about"')
57
+ expect(result).not.toContain('to="/home"')
58
+ expect(result).not.toContain('to="/about"')
59
+ })
60
+
61
+ it("should preserve other props while transforming 'to' to 'href'", () => {
62
+ const input = `
63
+ import { Link } from "@planningcenter/tapestry-react"
64
+
65
+ export default function Test() {
66
+ return (
67
+ <Link
68
+ to="/dashboard"
69
+ className="nav-link"
70
+ target="_blank"
71
+ rel="noopener"
72
+ >
73
+ Dashboard
74
+ </Link>
75
+ )
76
+ }
77
+ `.trim()
78
+
79
+ const result = applyTransform(input)
80
+ expect(result).toContain('href="/dashboard"')
81
+ expect(result).toContain('className="nav-link"')
82
+ expect(result).toContain('target="_blank"')
83
+ expect(result).toContain('rel="noopener"')
84
+ expect(result).not.toContain('to="/dashboard"')
85
+ })
86
+
87
+ it("should handle dynamic 'to' prop values", () => {
88
+ const input = `
89
+ import { Link } from "@planningcenter/tapestry-react"
90
+
91
+ export default function Test({ userId }) {
92
+ return <Link to={\`/user/\${userId}\`}>User Profile</Link>
93
+ }
94
+ `.trim()
95
+
96
+ const result = applyTransform(input)
97
+ expect(result).toContain("href={`/user/${userId}`}")
98
+ expect(result).not.toContain("to={`/user/${userId}`}")
99
+ })
100
+ })
101
+
102
+ describe("edge cases", () => {
103
+ it("should not transform Links without 'to' prop", () => {
104
+ const input = `
105
+ import { Link } from "@planningcenter/tapestry-react"
106
+
107
+ export default function Test() {
108
+ return <Link href="/already-href">Already has href</Link>
109
+ }
110
+ `.trim()
111
+
112
+ const result = applyTransform(input)
113
+ expect(result).toBe(null) // No changes needed
114
+ })
115
+
116
+ it("should not transform if Link is not imported from tapestry-react", () => {
117
+ const input = `
118
+ import { Link } from "react-router-dom"
119
+
120
+ export default function Test() {
121
+ return <Link to="/dashboard">Dashboard</Link>
122
+ }
123
+ `.trim()
124
+
125
+ const result = applyTransform(input)
126
+ expect(result).toBe(null) // No changes needed
127
+ })
128
+
129
+ it("should handle aliased Link import", () => {
130
+ const input = `
131
+ import { Link as TapestryLink } from "@planningcenter/tapestry-react"
132
+
133
+ export default function Test() {
134
+ return <TapestryLink to="/dashboard">Dashboard</TapestryLink>
135
+ }
136
+ `.trim()
137
+
138
+ const result = applyTransform(input)
139
+ expect(result).toContain('href="/dashboard"')
140
+ expect(result).not.toContain('to="/dashboard"')
141
+ })
142
+
143
+ it("should handle self-closing Link tags", () => {
144
+ const input = `
145
+ import { Link } from "@planningcenter/tapestry-react"
146
+
147
+ export default function Test() {
148
+ return <Link to="/dashboard" />
149
+ }
150
+ `.trim()
151
+
152
+ const expected = `
153
+ import { Link } from "@planningcenter/tapestry-react"
154
+
155
+ export default function Test() {
156
+ return <Link href="/dashboard" />;
157
+ }
158
+ `.trim()
159
+
160
+ const result = applyTransform(input)
161
+ expect(result?.trim()).toBe(expected)
162
+ })
163
+ })
164
+
165
+ describe("mixed scenarios", () => {
166
+ it("should handle mixed Link elements with various props", () => {
167
+ const input = `
168
+ import { Link } from "@planningcenter/tapestry-react"
169
+
170
+ export default function Test() {
171
+ return (
172
+ <nav>
173
+ <Link to="/home" className="home-link">Home</Link>
174
+ <Link to="/profile" target="_blank">Profile</Link>
175
+ <Link href="/already-href">Already Good</Link>
176
+ </nav>
177
+ )
178
+ }
179
+ `.trim()
180
+
181
+ const result = applyTransform(input)
182
+ expect(result).toContain('href="/home"')
183
+ expect(result).toContain('href="/profile"')
184
+ expect(result).toContain('href="/already-href"')
185
+ expect(result).not.toContain('to="/home"')
186
+ expect(result).not.toContain('to="/profile"')
187
+ })
188
+
189
+ it("should handle complex JSX expressions", () => {
190
+ const input = `
191
+ import { Link } from "@planningcenter/tapestry-react"
192
+
193
+ export default function Test({ items }) {
194
+ return (
195
+ <div>
196
+ {items.map(item => (
197
+ <Link key={item.id} to={item.url}>{item.name}</Link>
198
+ ))}
199
+ {showConditional && <Link to="/conditional">Conditional</Link>}
200
+ </div>
201
+ )
202
+ }
203
+ `.trim()
204
+
205
+ const result = applyTransform(input)
206
+ expect(result).toContain("href={item.url}")
207
+ expect(result).toContain('href="/conditional"')
208
+ expect(result).not.toContain("to={item.url}")
209
+ expect(result).not.toContain('to="/conditional"')
210
+ })
211
+ })
212
+
213
+ describe("no changes scenarios", () => {
214
+ it("should return null when no tapestry-react Links exist", () => {
215
+ const input = `
216
+ import React from "react"
217
+
218
+ export default function Test() {
219
+ return <div>No Links here</div>
220
+ }
221
+ `.trim()
222
+
223
+ const result = applyTransform(input)
224
+ expect(result).toBe(null)
225
+ })
226
+
227
+ it("should return null when no Links have 'to' prop", () => {
228
+ const input = `
229
+ import { Link } from "@planningcenter/tapestry-react"
230
+
231
+ export default function Test() {
232
+ return <Link href="/already-good">Already Good</Link>
233
+ }
234
+ `.trim()
235
+
236
+ const result = applyTransform(input)
237
+ expect(result).toBe(null)
238
+ })
239
+
240
+ it("should return null for empty file", () => {
241
+ const result = applyTransform("")
242
+ expect(result).toBe(null)
243
+ })
244
+ })
245
+ })
@@ -0,0 +1,14 @@
1
+ import { transformAttributeName } from "../../shared/actions/transformAttributeName"
2
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
3
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
4
+
5
+ const transform = attributeTransformFactory({
6
+ condition: hasAttribute("to"),
7
+ targetComponent: "Link",
8
+ targetPackage: "@planningcenter/tapestry-react",
9
+ transform: (element) => {
10
+ return transformAttributeName("to", "href", { element })
11
+ },
12
+ })
13
+
14
+ 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
  }
@@ -7,6 +7,7 @@ import { getPrintableAttributeValue } from "./getPrintableAttributeValue"
7
7
  type RemoveAttributeConfig = {
8
8
  /** Function to build comment text given the prop name and formatted value */
9
9
  buildComment?: (propName: string, formattedValue: string) => string | void
10
+ commentScope?: string
10
11
  element: JSXElement
11
12
  j: JSCodeshift
12
13
  source: Collection
@@ -14,7 +15,7 @@ type RemoveAttributeConfig = {
14
15
 
15
16
  export function removeAttribute(
16
17
  name: string,
17
- { element, buildComment, j, source }: RemoveAttributeConfig
18
+ { element, buildComment, j, source, commentScope }: RemoveAttributeConfig
18
19
  ): boolean {
19
20
  const attributes = element.openingElement.attributes || []
20
21
  const attribute = findAttribute(attributes, name)
@@ -28,7 +29,13 @@ export function removeAttribute(
28
29
  const commentText = buildComment(name, printableValue || "")
29
30
 
30
31
  if (commentText) {
31
- addComment({ element, j, scope: name, source, text: commentText })
32
+ addComment({
33
+ element,
34
+ j,
35
+ scope: commentScope || name,
36
+ source,
37
+ text: commentText,
38
+ })
32
39
  }
33
40
  }
34
41
 
@@ -1,7 +1,8 @@
1
- import { JSXElement } from "jscodeshift"
1
+ import { JSXElement, jsxIdentifier } from "jscodeshift"
2
2
 
3
3
  /**
4
4
  * Transforms JSX element names (both opening and closing tags)
5
+ * Supports both JSXIdentifier and JSXMemberExpression
5
6
  */
6
7
  export function transformElementName({
7
8
  element,
@@ -10,15 +11,28 @@ export function transformElementName({
10
11
  element: JSXElement
11
12
  name: string
12
13
  }): boolean {
13
- if (element?.openingElement?.name?.type === "JSXIdentifier") {
14
- element.openingElement.name.name = name
14
+ const openingName = element?.openingElement?.name
15
+ if (!openingName) return false
15
16
 
16
- if (element.closingElement) {
17
- if (element.closingElement.name.type === "JSXIdentifier") {
18
- element.closingElement.name.name = name
19
- }
17
+ // Transform opening element
18
+ if (openingName.type === "JSXIdentifier") {
19
+ openingName.name = name
20
+ } else if (openingName.type === "JSXMemberExpression") {
21
+ // Transform JSXMemberExpression (e.g., Link.Inline) to JSXIdentifier (e.g., Link)
22
+ element.openingElement.name = jsxIdentifier(name)
23
+ } else {
24
+ return false
25
+ }
26
+
27
+ // Transform closing element if it exists
28
+ if (element.closingElement) {
29
+ const closingName = element.closingElement.name
30
+ if (closingName.type === "JSXIdentifier") {
31
+ closingName.name = name
32
+ } else if (closingName.type === "JSXMemberExpression") {
33
+ element.closingElement.name = jsxIdentifier(name)
20
34
  }
21
- return true
22
35
  }
23
- return false
36
+
37
+ return true
24
38
  }
@@ -85,4 +85,87 @@ describe("attributeTransformFactory", () => {
85
85
  const result = transform(fileInfo, api, {})
86
86
  expect(result).toBe(null)
87
87
  })
88
+
89
+ describe("member expression support", () => {
90
+ it("should transform attributes on Link.Inline elements", () => {
91
+ const transform = attributeTransformFactory({
92
+ condition: () => true,
93
+ targetComponent: "Link.Inline",
94
+ targetPackage: "@planningcenter/tapestry-react",
95
+ transform: (element, { j }) => {
96
+ const attributes = element.openingElement.attributes || []
97
+ const kindAttr = j.jsxAttribute(
98
+ j.jsxIdentifier("kind"),
99
+ j.stringLiteral("inline-text")
100
+ )
101
+ attributes.push(kindAttr)
102
+ return true
103
+ },
104
+ })
105
+ const fileInfo = {
106
+ path: "test.tsx",
107
+ source: `import { Link } from "@planningcenter/tapestry-react";<Link.Inline>Test</Link.Inline>`,
108
+ }
109
+ const api = { j, jscodeshift: j, report: () => {}, stats: () => {} }
110
+
111
+ const result = transform(fileInfo, api, {})
112
+ expect(result).toContain('kind="inline-text"')
113
+ expect(result).toContain("<Link.Inline")
114
+ })
115
+
116
+ it("should not transform other member expressions", () => {
117
+ const transform = attributeTransformFactory({
118
+ condition: () => true,
119
+ targetComponent: "Link.Inline",
120
+ targetPackage: "@planningcenter/tapestry-react",
121
+ transform: (element, { j }) => {
122
+ const attributes = element.openingElement.attributes || []
123
+ const kindAttr = j.jsxAttribute(
124
+ j.jsxIdentifier("kind"),
125
+ j.stringLiteral("inline-text")
126
+ )
127
+ attributes.push(kindAttr)
128
+ return true
129
+ },
130
+ })
131
+ const fileInfo = {
132
+ path: "test.tsx",
133
+ source: `import { Link } from "@planningcenter/tapestry-react";<div><Link.Inline>Inline</Link.Inline><Link.Primary>Primary</Link.Primary></div>`,
134
+ }
135
+ const api = { j, jscodeshift: j, report: () => {}, stats: () => {} }
136
+
137
+ const result = transform(fileInfo, api, {})
138
+ expect(result).toContain(
139
+ '<Link.Inline kind="inline-text">Inline</Link.Inline>'
140
+ )
141
+ expect(result).toContain("<Link.Primary>Primary</Link.Primary>")
142
+ expect(result).not.toContain('<Link.Primary kind="inline-text">')
143
+ })
144
+
145
+ it("should handle aliased imports with member expressions", () => {
146
+ const transform = attributeTransformFactory({
147
+ condition: () => true,
148
+ targetComponent: "Link.Inline",
149
+ targetPackage: "@planningcenter/tapestry-react",
150
+ transform: (element, { j }) => {
151
+ const attributes = element.openingElement.attributes || []
152
+ const kindAttr = j.jsxAttribute(
153
+ j.jsxIdentifier("kind"),
154
+ j.stringLiteral("inline-text")
155
+ )
156
+ attributes.push(kindAttr)
157
+ return true
158
+ },
159
+ })
160
+ const fileInfo = {
161
+ path: "test.tsx",
162
+ source: `import { Link as TapestryLink } from "@planningcenter/tapestry-react";<TapestryLink.Inline>Test</TapestryLink.Inline>`,
163
+ }
164
+ const api = { j, jscodeshift: j, report: () => {}, stats: () => {} }
165
+
166
+ const result = transform(fileInfo, api, {})
167
+ expect(result).toContain('kind="inline-text"')
168
+ expect(result).toContain("<TapestryLink.Inline")
169
+ })
170
+ })
88
171
  })
@@ -7,6 +7,7 @@ import {
7
7
  } from "jscodeshift"
8
8
 
9
9
  import { TransformCondition } from "../types"
10
+ import { findJSXElements } from "./helpers/findJSXElements"
10
11
  import { getImportName } from "./helpers/manageImports"
11
12
 
12
13
  /**
@@ -15,7 +16,7 @@ import { getImportName } from "./helpers/manageImports"
15
16
  export function attributeTransformFactory(config: {
16
17
  /** Condition that must be met for the transform to occur */
17
18
  condition?: TransformCondition
18
- /** Component to target for attribute transformation */
19
+ /** Component to target for attribute transformation (supports member expressions like "Link.Inline") */
19
20
  targetComponent: string
20
21
  /** Package the target component is imported from */
21
22
  targetPackage: string
@@ -30,32 +31,38 @@ export function attributeTransformFactory(config: {
30
31
  const source = j(fileInfo.source)
31
32
  let hasChanges = false
32
33
 
34
+ // Parse the targetComponent to handle member expressions
35
+ const [targetComponentName] = config.targetComponent.split(".")
36
+
33
37
  // Get the local name of the target component
34
- const targetComponentName = getImportName(
35
- config.targetComponent,
38
+ const targetComponentLocalName = getImportName(
39
+ targetComponentName,
36
40
  config.targetPackage,
37
41
  { j, source }
38
42
  )
39
43
 
40
44
  // Only proceed if target component is imported
41
- if (!targetComponentName) {
45
+ if (!targetComponentLocalName) {
42
46
  return null
43
47
  }
44
48
 
45
49
  const { condition = () => true, transform } = config
46
50
 
47
51
  // Find and transform matching JSX elements
48
- source
49
- .find(j.JSXOpeningElement, { name: { name: targetComponentName } })
50
- .forEach((path) => {
51
- const element = path.parent.value
52
-
53
- if (condition(element)) {
54
- if (transform(element, { j, options, source })) {
55
- hasChanges = true
56
- }
52
+ findJSXElements(
53
+ source,
54
+ j,
55
+ config.targetComponent,
56
+ targetComponentLocalName
57
+ ).forEach((path) => {
58
+ const element = path.parent.value
59
+
60
+ if (condition(element)) {
61
+ if (transform(element, { j, options, source })) {
62
+ hasChanges = true
57
63
  }
58
- })
64
+ }
65
+ })
59
66
 
60
67
  return hasChanges ? source.toSource() : null
61
68
  }