@planningcenter/tapestry-migration-cli 2.3.0-rc.9 → 2.4.0-rc.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 (28) hide show
  1. package/dist/tapestry-react-shim.cjs +1 -1
  2. package/package.json +2 -2
  3. package/src/components/button/index.ts +2 -0
  4. package/src/components/button/transforms/convertStyleProps.test.ts +4 -5
  5. package/src/components/button/transforms/removeTypeButton.test.ts +197 -0
  6. package/src/components/button/transforms/removeTypeButton.ts +15 -0
  7. package/src/components/link/index.ts +32 -0
  8. package/src/components/link/transforms/inlineToKind.test.ts +308 -0
  9. package/src/components/link/transforms/inlineToKind.ts +51 -0
  10. package/src/components/link/transforms/targetBlankToExternal.test.ts +191 -0
  11. package/src/components/link/transforms/targetBlankToExternal.ts +30 -0
  12. package/src/components/link/transforms/toToHref.test.ts +245 -0
  13. package/src/components/link/transforms/toToHref.ts +14 -0
  14. package/src/components/shared/actions/addAttribute.test.ts +108 -0
  15. package/src/components/shared/actions/addAttribute.ts +14 -0
  16. package/src/components/shared/actions/removeAttribute.ts +9 -2
  17. package/src/components/shared/actions/transformElementName.ts +23 -9
  18. package/src/components/shared/transformFactories/attributeTransformFactory.test.ts +83 -0
  19. package/src/components/shared/transformFactories/attributeTransformFactory.ts +21 -14
  20. package/src/components/shared/transformFactories/componentTransformFactory.test.ts +85 -2
  21. package/src/components/shared/transformFactories/componentTransformFactory.ts +41 -22
  22. package/src/components/shared/transformFactories/helpers/findJSXElements.ts +37 -0
  23. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +2 -27
  24. package/src/components/shared/types.ts +19 -1
  25. package/src/index.ts +7 -2
  26. package/src/jscodeshiftRunner.ts +7 -0
  27. package/src/reportGenerator.ts +450 -0
  28. package/src/shared/types.ts +1 -0
@@ -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
@@ -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