@planningcenter/tapestry-migration-cli 3.6.1-qa-1035.0 → 3.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/tapestry-migration-cli",
3
- "version": "3.6.1-qa-1035.0",
3
+ "version": "3.7.0",
4
4
  "description": "CLI tool for Tapestry migrations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@emotion/react": "^11.14.0",
33
- "@planningcenter/tapestry": "^3.6.1-qa-1035.0",
33
+ "@planningcenter/tapestry": "^3.7.0",
34
34
  "@planningcenter/tapestry-react": "^4.11.5",
35
35
  "@types/jscodeshift": "^17.3.0",
36
36
  "@types/node": "^20.0.0",
@@ -50,5 +50,5 @@
50
50
  "publishConfig": {
51
51
  "access": "public"
52
52
  },
53
- "gitHead": "2b87acce23b1df6872d7bbeadd648b117c9962c4"
53
+ "gitHead": "d959b05c0d33fcee1f86953c994ffdff835e3b49"
54
54
  }
@@ -145,6 +145,21 @@ export default function Test() {
145
145
  expect(result).not.toContain("flex=")
146
146
  })
147
147
 
148
+ it("applies flexPropToExpand through the chain — unmappable flex stays as JSX prop with TODO, not in style={{}}", () => {
149
+ const input = `
150
+ import { StackView } from "@planningcenter/tapestry-react"
151
+
152
+ export default function Test({ dynamicValue }) {
153
+ return <StackView flex={dynamicValue}>Test</StackView>
154
+ }
155
+ `.trim()
156
+
157
+ const result = applyTransform(input)
158
+ expect(result).toContain("flex={dynamicValue}")
159
+ expect(result).not.toContain("style={{")
160
+ expect(result?.match(/TODO/g)).toHaveLength(1)
161
+ })
162
+
148
163
  it("applies mediaQueriesToResponsive through the chain", () => {
149
164
  const input = `
150
165
  import { StackView } from "@planningcenter/tapestry-react"
@@ -174,6 +189,21 @@ export default function Test() {
174
189
  expect(result?.match(/direction=/g)).toHaveLength(1)
175
190
  })
176
191
 
192
+ it("applies dedupeAttributes through the chain — axis and flexDirection both mapping to direction collapse to one prop", () => {
193
+ const input = `
194
+ import { StackView } from "@planningcenter/tapestry-react"
195
+
196
+ export default function Test() {
197
+ return <StackView axis="horizontal" flexDirection="column">Test</StackView>
198
+ }
199
+ `.trim()
200
+
201
+ const result = applyTransform(input)
202
+ expect(result?.match(/direction=/g)).toHaveLength(1)
203
+ expect(result).toContain('direction="column"')
204
+ expect(result).toContain("TODO: tapestry-migration (direction)")
205
+ })
206
+
177
207
  it("applies cssFlexAliasesToProps through the chain — unmappable justifyContent stays as JSX prop with TODO, not in style={{}}", () => {
178
208
  const input = `
179
209
  import { StackView } from "@planningcenter/tapestry-react"
@@ -5,6 +5,7 @@ import auditSpreadProps from "./transforms/auditSpreadProps"
5
5
  import axisToDirection from "./transforms/axisToDirection"
6
6
  import convertStyleProps from "./transforms/convertStyleProps"
7
7
  import cssFlexAliasesToProps from "./transforms/cssFlexAliasesToProps"
8
+ import dedupeAttributes from "./transforms/dedupeAttributes"
8
9
  import distributionToJustify from "./transforms/distributionToJustify"
9
10
  import flexPropToExpand from "./transforms/flexPropToExpand"
10
11
  import innerRefToRef from "./transforms/innerRefToRef"
@@ -33,6 +34,7 @@ const transform: Transform = (fileInfo, api, options) => {
33
34
  convertStyleProps,
34
35
  unsupportedProps,
35
36
  auditSpreadProps,
37
+ dedupeAttributes,
36
38
  moveFlexImport,
37
39
  ]
38
40
 
@@ -100,6 +100,107 @@ export default function Test() {
100
100
  expect(result).not.toContain("StackView.BASELINE")
101
101
  })
102
102
 
103
+ it('normalizes alignment="flex-start" to align="start"', () => {
104
+ const input = `
105
+ import { StackView } from "@planningcenter/tapestry-react"
106
+
107
+ export default function Test() {
108
+ return <StackView alignment="flex-start">Test</StackView>
109
+ }
110
+ `.trim()
111
+
112
+ const result = applyTransform(input)
113
+ expect(result).toContain('align="start"')
114
+ expect(result).not.toContain("alignment")
115
+ })
116
+
117
+ it('normalizes alignment="flex-end" to align="end"', () => {
118
+ const input = `
119
+ import { StackView } from "@planningcenter/tapestry-react"
120
+
121
+ export default function Test() {
122
+ return <StackView alignment="flex-end">Test</StackView>
123
+ }
124
+ `.trim()
125
+
126
+ const result = applyTransform(input)
127
+ expect(result).toContain('align="end"')
128
+ expect(result).not.toContain("alignment")
129
+ })
130
+
131
+ it("migrates a ternary with a valid string branch and an undefined pass-through branch", () => {
132
+ const input = `
133
+ import { StackView } from "@planningcenter/tapestry-react"
134
+
135
+ export default function Test({ compact }) {
136
+ return <StackView alignment={compact ? 'center' : undefined}>Test</StackView>
137
+ }
138
+ `.trim()
139
+
140
+ const result = applyTransform(input)
141
+ expect(result).toContain('align={compact ? "center" : undefined}')
142
+ expect(result).not.toContain("alignment")
143
+ })
144
+
145
+ it("migrates a ternary with two valid string branches", () => {
146
+ const input = `
147
+ import { StackView } from "@planningcenter/tapestry-react"
148
+
149
+ export default function Test({ compact }) {
150
+ return <StackView alignment={compact ? 'center' : 'end'}>Test</StackView>
151
+ }
152
+ `.trim()
153
+
154
+ const result = applyTransform(input)
155
+ expect(result).toContain('align={compact ? "center" : "end"}')
156
+ expect(result).not.toContain("alignment")
157
+ })
158
+
159
+ it("normalizes flex-start/flex-end within a ternary", () => {
160
+ const input = `
161
+ import { StackView } from "@planningcenter/tapestry-react"
162
+
163
+ export default function Test({ compact }) {
164
+ return <StackView alignment={compact ? 'flex-start' : 'flex-end'}>Test</StackView>
165
+ }
166
+ `.trim()
167
+
168
+ const result = applyTransform(input)
169
+ expect(result).toContain('align={compact ? "start" : "end"}')
170
+ expect(result).not.toContain("alignment")
171
+ })
172
+
173
+ it("migrates a ternary with a dynamic branch, trusting it at runtime", () => {
174
+ const input = `
175
+ import { StackView } from "@planningcenter/tapestry-react"
176
+
177
+ export default function Test({ compact, dynamicValue }) {
178
+ return <StackView alignment={compact ? 'center' : dynamicValue}>Test</StackView>
179
+ }
180
+ `.trim()
181
+
182
+ const result = applyTransform(input)
183
+ expect(result).toContain('align={compact ? "center" : dynamicValue}')
184
+ expect(result).not.toContain("alignment")
185
+ expect(result).not.toContain("TODO")
186
+ })
187
+
188
+ it("flags a ternary with a fill branch and leaves it in place", () => {
189
+ const input = `
190
+ import { StackView } from "@planningcenter/tapestry-react"
191
+
192
+ export default function Test({ compact }) {
193
+ return <StackView alignment={compact ? 'center' : 'fill'}>Test</StackView>
194
+ }
195
+ `.trim()
196
+
197
+ const result = applyTransform(input)
198
+ expect(result).toContain("alignment={compact ? 'center' : 'fill'}")
199
+ expect(result).toContain("TODO: tapestry-migration (alignment)")
200
+ expect(result).not.toContain('align="')
201
+ expect(result).not.toContain("align={")
202
+ })
203
+
103
204
  it("flags alignment={StackView.FILL} with a TODO and leaves it in place", () => {
104
205
  const input = `
105
206
  import { StackView } from "@planningcenter/tapestry-react"
@@ -1,10 +1,11 @@
1
- import { Transform } from "jscodeshift"
1
+ import { Expression, Transform } from "jscodeshift"
2
2
 
3
- import { addComment } from "../../shared/actions/addComment"
3
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
4
4
  import { getAttribute } from "../../shared/actions/getAttribute"
5
5
  import { resolveConstantAttributeValue } from "../../shared/actions/resolveConstantAttributeValue"
6
6
  import { hasAttribute } from "../../shared/conditions/hasAttribute"
7
7
  import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
8
+ import { FLEX_START_END_MAP } from "./constants"
8
9
 
9
10
  const VALID_ALIGN_VALUES = new Set([
10
11
  "start",
@@ -23,42 +24,95 @@ const CONSTANT_ALIGN_MAP: Record<string, string> = {
23
24
  STRETCH: "stretch",
24
25
  }
25
26
 
27
+ const TODO_TEXT =
28
+ "could not migrate alignment to align automatically; convert this value to start, center, end, stretch, or baseline manually"
29
+
30
+ function normalizeAlignValue(raw: string): string | null {
31
+ const normalized = FLEX_START_END_MAP[raw] ?? raw
32
+ return VALID_ALIGN_VALUES.has(normalized) ? normalized : null
33
+ }
34
+
35
+ type BranchResolution =
36
+ | { kind: "invalid" }
37
+ | { kind: "passthrough" }
38
+ | { kind: "valid"; value: string }
39
+
40
+ function resolveBranch(node: Expression): BranchResolution {
41
+ if (node.type === "StringLiteral") {
42
+ const normalized = normalizeAlignValue(node.value)
43
+ return normalized !== null
44
+ ? { kind: "valid", value: normalized }
45
+ : { kind: "invalid" }
46
+ }
47
+ if (node.type === "MemberExpression" && node.property.type === "Identifier") {
48
+ const mapped = CONSTANT_ALIGN_MAP[node.property.name]
49
+ if (mapped === undefined) return { kind: "passthrough" }
50
+ const normalized = normalizeAlignValue(mapped)
51
+ return normalized !== null
52
+ ? { kind: "valid", value: normalized }
53
+ : { kind: "invalid" }
54
+ }
55
+ return { kind: "passthrough" }
56
+ }
57
+
26
58
  const transform: Transform = attributeTransformFactory({
27
59
  condition: hasAttribute("alignment"),
28
60
  targetComponent: "StackView",
29
61
  targetPackage: "@planningcenter/tapestry-react",
30
- transform: (element, { j, source }) => {
62
+ transform: (element, { j }) => {
31
63
  const alignmentAttribute = getAttribute({ element, name: "alignment" })
32
64
  if (!alignmentAttribute) return false
33
65
 
66
+ const value = alignmentAttribute.value
67
+
68
+ if (
69
+ value?.type === "JSXExpressionContainer" &&
70
+ value.expression.type === "ConditionalExpression"
71
+ ) {
72
+ const conditional = value.expression
73
+ const consequent = resolveBranch(conditional.consequent as Expression)
74
+ const alternate = resolveBranch(conditional.alternate as Expression)
75
+
76
+ if (consequent.kind !== "invalid" && alternate.kind !== "invalid") {
77
+ if (consequent.kind === "valid") {
78
+ conditional.consequent = j.stringLiteral(consequent.value)
79
+ }
80
+ if (alternate.kind === "valid") {
81
+ conditional.alternate = j.stringLiteral(alternate.value)
82
+ }
83
+ alignmentAttribute.name.name = "align"
84
+ return true
85
+ }
86
+
87
+ addCommentToAttribute({
88
+ attribute: alignmentAttribute,
89
+ j,
90
+ text: TODO_TEXT,
91
+ })
92
+ return true
93
+ }
94
+
34
95
  const resolved = resolveConstantAttributeValue({
35
96
  attribute: alignmentAttribute,
36
97
  constantMap: CONSTANT_ALIGN_MAP,
37
98
  })
38
- const alignValue =
39
- resolved !== null &&
40
- (VALID_ALIGN_VALUES.has(resolved) || resolved === "fill")
41
- ? resolved
42
- : null
43
99
 
44
- if (alignValue === null) {
45
- addComment({
46
- element,
100
+ if (resolved === "fill") {
101
+ addCommentToAttribute({
102
+ attribute: alignmentAttribute,
47
103
  j,
48
- scope: "alignment",
49
- source,
50
- text: "could not migrate alignment to align automatically; convert this value to start, center, end, stretch, or baseline manually",
104
+ text: 'alignment="fill" has no Flex equivalent; remove this prop and use expand on the child element instead',
51
105
  })
52
106
  return true
53
107
  }
54
108
 
55
- if (alignValue === "fill") {
56
- addComment({
57
- element,
109
+ const alignValue = resolved !== null ? normalizeAlignValue(resolved) : null
110
+
111
+ if (alignValue === null) {
112
+ addCommentToAttribute({
113
+ attribute: alignmentAttribute,
58
114
  j,
59
- scope: "alignment",
60
- source,
61
- text: 'alignment="fill" has no Flex equivalent; remove this prop and use expand on the child element instead',
115
+ text: TODO_TEXT,
62
116
  })
63
117
  return true
64
118
  }
@@ -0,0 +1,4 @@
1
+ export const FLEX_START_END_MAP: Record<string, string> = {
2
+ "flex-end": "end",
3
+ "flex-start": "start",
4
+ }
@@ -6,11 +6,7 @@ import { transformAttributeName } from "../../shared/actions/transformAttributeN
6
6
  import { hasAttribute } from "../../shared/conditions/hasAttribute"
7
7
  import { orConditions } from "../../shared/conditions/orConditions"
8
8
  import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
9
-
10
- const FLEX_START_END_MAP: Record<string, string> = {
11
- "flex-end": "end",
12
- "flex-start": "start",
13
- }
9
+ import { FLEX_START_END_MAP } from "./constants"
14
10
 
15
11
  const VALID_JUSTIFY_VALUES = new Set([
16
12
  "start",
@@ -0,0 +1,120 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./dedupeAttributes"
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("dedupeAttributes transform", () => {
18
+ it("keeps the last of two duplicate attributes and drops the earlier one", () => {
19
+ const input = `
20
+ import { StackView } from "@planningcenter/tapestry-react"
21
+
22
+ export default function Test() {
23
+ return <StackView direction="row" direction="column">Test</StackView>
24
+ }
25
+ `.trim()
26
+
27
+ const result = applyTransform(input)
28
+ expect(result).toContain('direction="column"')
29
+ expect(result?.match(/direction=/g)).toHaveLength(1)
30
+ expect(result).toContain("TODO: tapestry-migration (direction)")
31
+ expect(result).toContain("duplicate direction prop")
32
+ })
33
+
34
+ it("keeps the last of three duplicate attributes and drops the earlier ones", () => {
35
+ const input = `
36
+ import { StackView } from "@planningcenter/tapestry-react"
37
+
38
+ export default function Test() {
39
+ return <StackView align="start" align="center" align="end">Test</StackView>
40
+ }
41
+ `.trim()
42
+
43
+ const result = applyTransform(input)
44
+ expect(result).toContain('align="end"')
45
+ expect(result?.match(/align=/g)).toHaveLength(1)
46
+ expect(result).toContain("TODO: tapestry-migration (align)")
47
+ })
48
+
49
+ it("dedupes multiple different duplicated attribute names independently", () => {
50
+ const input = `
51
+ import { StackView } from "@planningcenter/tapestry-react"
52
+
53
+ export default function Test() {
54
+ return <StackView direction="row" direction="column" align="start" align="end">Test</StackView>
55
+ }
56
+ `.trim()
57
+
58
+ const result = applyTransform(input)
59
+ expect(result?.match(/direction=/g)).toHaveLength(1)
60
+ expect(result?.match(/align=/g)).toHaveLength(1)
61
+ expect(result).toContain('direction="column"')
62
+ expect(result).toContain('align="end"')
63
+ })
64
+
65
+ it("preserves other non-duplicate attributes untouched", () => {
66
+ const input = `
67
+ import { StackView } from "@planningcenter/tapestry-react"
68
+
69
+ export default function Test() {
70
+ return <StackView direction="row" direction="column" gap={2}>Test</StackView>
71
+ }
72
+ `.trim()
73
+
74
+ const result = applyTransform(input)
75
+ expect(result).toContain("gap={2}")
76
+ expect(result?.match(/direction=/g)).toHaveLength(1)
77
+ })
78
+
79
+ it("ignores spread attributes", () => {
80
+ const input = `
81
+ import { StackView } from "@planningcenter/tapestry-react"
82
+
83
+ export default function Test() {
84
+ return <StackView {...props} direction="row" direction="column">Test</StackView>
85
+ }
86
+ `.trim()
87
+
88
+ const result = applyTransform(input)
89
+ expect(result).toContain("{...props}")
90
+ expect(result?.match(/direction=/g)).toHaveLength(1)
91
+ })
92
+
93
+ it("returns null when there are no duplicate attributes", () => {
94
+ const input = `
95
+ import { StackView } from "@planningcenter/tapestry-react"
96
+
97
+ export default function Test() {
98
+ return <StackView direction="row" gap={2}>Test</StackView>
99
+ }
100
+ `.trim()
101
+
102
+ expect(applyTransform(input)).toBe(null)
103
+ })
104
+
105
+ it("returns null when StackView is not imported from tapestry-react", () => {
106
+ const input = `
107
+ import { StackView } from "some-other-package"
108
+
109
+ export default function Test() {
110
+ return <StackView direction="row" direction="column">Test</StackView>
111
+ }
112
+ `.trim()
113
+
114
+ expect(applyTransform(input)).toBe(null)
115
+ })
116
+
117
+ it("returns null for an empty file", () => {
118
+ expect(applyTransform("")).toBe(null)
119
+ })
120
+ })
@@ -0,0 +1,49 @@
1
+ import { JSXAttribute } from "jscodeshift"
2
+
3
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
4
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
5
+
6
+ export default attributeTransformFactory({
7
+ targetComponent: "StackView",
8
+ targetPackage: "@planningcenter/tapestry-react",
9
+ transform: (element, { j }) => {
10
+ const attrs = element.openingElement.attributes ?? []
11
+ const byName = new Map<string, JSXAttribute[]>()
12
+
13
+ for (const attr of attrs) {
14
+ if (attr.type !== "JSXAttribute") continue
15
+ if (attr.name.type !== "JSXIdentifier") continue
16
+ const name = attr.name.name
17
+ const group = byName.get(name)
18
+ if (group) {
19
+ group.push(attr)
20
+ } else {
21
+ byName.set(name, [attr])
22
+ }
23
+ }
24
+
25
+ let changed = false
26
+
27
+ for (const [name, group] of byName) {
28
+ if (group.length < 2) continue
29
+
30
+ const kept = group[group.length - 1]
31
+ const dropped = group.slice(0, -1)
32
+
33
+ for (const attr of dropped) {
34
+ const idx = attrs.indexOf(attr)
35
+ attrs.splice(idx, 1)
36
+ }
37
+
38
+ addCommentToAttribute({
39
+ attribute: kept,
40
+ j,
41
+ text: `duplicate ${name} prop after migration — an earlier ${name} value was dropped in favor of this one; verify this is correct`,
42
+ })
43
+
44
+ changed = true
45
+ }
46
+
47
+ return changed
48
+ },
49
+ })
@@ -114,6 +114,65 @@ export default function Test() {
114
114
  expect(result).not.toContain("StackView.SPACE_EVENLY")
115
115
  })
116
116
 
117
+ it("migrates a ternary with a valid string branch and an undefined pass-through branch", () => {
118
+ const input = `
119
+ import { StackView } from "@planningcenter/tapestry-react"
120
+
121
+ export default function Test({ compact }) {
122
+ return <StackView distribution={compact ? 'center' : undefined}>Test</StackView>
123
+ }
124
+ `.trim()
125
+
126
+ const result = applyTransform(input)
127
+ expect(result).toContain('justify={compact ? "center" : undefined}')
128
+ expect(result).not.toContain("distribution")
129
+ })
130
+
131
+ it("migrates a ternary with two valid string branches", () => {
132
+ const input = `
133
+ import { StackView } from "@planningcenter/tapestry-react"
134
+
135
+ export default function Test({ compact }) {
136
+ return <StackView distribution={compact ? 'center' : 'start'}>Test</StackView>
137
+ }
138
+ `.trim()
139
+
140
+ const result = applyTransform(input)
141
+ expect(result).toContain('justify={compact ? "center" : "start"}')
142
+ expect(result).not.toContain("distribution")
143
+ })
144
+
145
+ it("migrates a ternary with a dynamic branch, trusting it at runtime", () => {
146
+ const input = `
147
+ import { StackView } from "@planningcenter/tapestry-react"
148
+
149
+ export default function Test({ compact, dynamicValue }) {
150
+ return <StackView distribution={compact ? 'center' : dynamicValue}>Test</StackView>
151
+ }
152
+ `.trim()
153
+
154
+ const result = applyTransform(input)
155
+ expect(result).toContain('justify={compact ? "center" : dynamicValue}')
156
+ expect(result).not.toContain("distribution")
157
+ expect(result).not.toContain("TODO")
158
+ })
159
+
160
+ it("flags a ternary with a fill branch and leaves it in place", () => {
161
+ const input = `
162
+ import { StackView } from "@planningcenter/tapestry-react"
163
+
164
+ export default function Test({ compact }) {
165
+ return <StackView distribution={compact ? 'center' : 'fill'}>Test</StackView>
166
+ }
167
+ `.trim()
168
+
169
+ const result = applyTransform(input)
170
+ expect(result).toContain("distribution={compact ? 'center' : 'fill'}")
171
+ expect(result).toContain("TODO: tapestry-migration (distribution)")
172
+ expect(result).not.toContain('justify="')
173
+ expect(result).not.toContain("justify={")
174
+ })
175
+
117
176
  it("flags distribution={StackView.FILL} with a TODO and leaves it in place", () => {
118
177
  const input = `
119
178
  import { StackView } from "@planningcenter/tapestry-react"
@@ -1,6 +1,6 @@
1
- import { Transform } from "jscodeshift"
1
+ import { Expression, Transform } from "jscodeshift"
2
2
 
3
- import { addComment } from "../../shared/actions/addComment"
3
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
4
4
  import { getAttribute } from "../../shared/actions/getAttribute"
5
5
  import { resolveConstantAttributeValue } from "../../shared/actions/resolveConstantAttributeValue"
6
6
  import { hasAttribute } from "../../shared/conditions/hasAttribute"
@@ -23,45 +23,94 @@ const CONSTANT_DISTRIBUTION_MAP: Record<string, string> = {
23
23
  START: "start",
24
24
  }
25
25
 
26
+ const TODO_TEXT =
27
+ "could not migrate distribution to justify automatically; convert this value to start, center, end, space-between, or space-evenly manually"
28
+
29
+ function resolveJustifyValue(raw: string): string | null {
30
+ return VALID_JUSTIFY_VALUES.has(raw) ? raw : null
31
+ }
32
+
33
+ type BranchResolution =
34
+ | { kind: "invalid" }
35
+ | { kind: "passthrough" }
36
+ | { kind: "valid"; value: string }
37
+
38
+ function resolveBranch(node: Expression): BranchResolution {
39
+ if (node.type === "StringLiteral") {
40
+ const value = resolveJustifyValue(node.value)
41
+ return value !== null ? { kind: "valid", value } : { kind: "invalid" }
42
+ }
43
+ if (node.type === "MemberExpression" && node.property.type === "Identifier") {
44
+ const mapped = CONSTANT_DISTRIBUTION_MAP[node.property.name]
45
+ if (mapped === undefined) return { kind: "passthrough" }
46
+ const value = resolveJustifyValue(mapped)
47
+ return value !== null ? { kind: "valid", value } : { kind: "invalid" }
48
+ }
49
+ return { kind: "passthrough" }
50
+ }
51
+
26
52
  const transform: Transform = attributeTransformFactory({
27
53
  condition: hasAttribute("distribution"),
28
54
  targetComponent: "StackView",
29
55
  targetPackage: "@planningcenter/tapestry-react",
30
- transform: (element, { j, source }) => {
56
+ transform: (element, { j }) => {
31
57
  const distributionAttribute = getAttribute({
32
58
  element,
33
59
  name: "distribution",
34
60
  })
35
61
  if (!distributionAttribute) return false
36
62
 
63
+ const value = distributionAttribute.value
64
+
65
+ if (
66
+ value?.type === "JSXExpressionContainer" &&
67
+ value.expression.type === "ConditionalExpression"
68
+ ) {
69
+ const conditional = value.expression
70
+ const consequent = resolveBranch(conditional.consequent as Expression)
71
+ const alternate = resolveBranch(conditional.alternate as Expression)
72
+
73
+ if (consequent.kind !== "invalid" && alternate.kind !== "invalid") {
74
+ if (consequent.kind === "valid") {
75
+ conditional.consequent = j.stringLiteral(consequent.value)
76
+ }
77
+ if (alternate.kind === "valid") {
78
+ conditional.alternate = j.stringLiteral(alternate.value)
79
+ }
80
+ distributionAttribute.name.name = "justify"
81
+ return true
82
+ }
83
+
84
+ addCommentToAttribute({
85
+ attribute: distributionAttribute,
86
+ j,
87
+ text: TODO_TEXT,
88
+ })
89
+ return true
90
+ }
91
+
37
92
  const resolved = resolveConstantAttributeValue({
38
93
  attribute: distributionAttribute,
39
94
  constantMap: CONSTANT_DISTRIBUTION_MAP,
40
95
  })
41
- const distributionValue =
42
- resolved !== null &&
43
- (VALID_JUSTIFY_VALUES.has(resolved) || resolved === "fill")
44
- ? resolved
45
- : null
46
96
 
47
- if (distributionValue === null) {
48
- addComment({
49
- element,
97
+ if (resolved === "fill") {
98
+ addCommentToAttribute({
99
+ attribute: distributionAttribute,
50
100
  j,
51
- scope: "distribution",
52
- source,
53
- text: "could not migrate distribution to justify automatically; convert this value to start, center, end, space-between, or space-evenly manually",
101
+ text: 'distribution="fill" has no Flex equivalent; remove this prop and use expand on the child elements instead',
54
102
  })
55
103
  return true
56
104
  }
57
105
 
58
- if (distributionValue === "fill") {
59
- addComment({
60
- element,
106
+ const distributionValue =
107
+ resolved !== null ? resolveJustifyValue(resolved) : null
108
+
109
+ if (distributionValue === null) {
110
+ addCommentToAttribute({
111
+ attribute: distributionAttribute,
61
112
  j,
62
- scope: "distribution",
63
- source,
64
- text: 'distribution="fill" has no Flex equivalent; remove this prop and use expand on the child elements instead',
113
+ text: TODO_TEXT,
65
114
  })
66
115
  return true
67
116
  }
@@ -107,6 +107,22 @@ export default function Test() {
107
107
  expect(result).not.toContain("TODO")
108
108
  })
109
109
 
110
+ it("expands flex={2} into grow={2} shrink={1} basis='0'", () => {
111
+ const input = `
112
+ import { StackView } from "@planningcenter/tapestry-react"
113
+ export default function Test() {
114
+ return <StackView flex={2}>Test</StackView>
115
+ }
116
+ `.trim()
117
+
118
+ const result = applyTransform(input)
119
+ expect(result).toContain("grow={2}")
120
+ expect(result).toContain("shrink={1}")
121
+ expect(result).toContain('basis="0"')
122
+ expect(result).not.toContain("flex=")
123
+ expect(result).not.toContain("TODO")
124
+ })
125
+
110
126
  it("adds a TODO for a dynamic flex value", () => {
111
127
  const input = `
112
128
  import { StackView } from "@planningcenter/tapestry-react"
@@ -46,19 +46,19 @@ export default attributeTransformFactory({
46
46
  return true
47
47
  }
48
48
 
49
- // flex={0} → grow={0} shrink={1} basis="0"
49
+ // flex={N} (N !== 1) → grow={N} shrink={1} basis="0"
50
50
  if (
51
51
  value?.type === "JSXExpressionContainer" &&
52
- value.expression.type === "NumericLiteral" &&
53
- (value.expression as { value: number }).value === 0
52
+ value.expression.type === "NumericLiteral"
54
53
  ) {
54
+ const grow = (value.expression as { value: number }).value
55
55
  const idx = attrs.indexOf(flexAttr)
56
56
  attrs.splice(
57
57
  idx,
58
58
  1,
59
59
  j.jsxAttribute(
60
60
  j.jsxIdentifier("grow"),
61
- j.jsxExpressionContainer(j.numericLiteral(0))
61
+ j.jsxExpressionContainer(j.numericLiteral(grow))
62
62
  ),
63
63
  j.jsxAttribute(
64
64
  j.jsxIdentifier("shrink"),
@@ -293,6 +293,22 @@ export default function Test() {
293
293
  expect(result).not.toContain("TODO")
294
294
  })
295
295
 
296
+ it('flags flex="N N basis" inside a breakpoint with a TODO instead of duplicating an existing grow key', () => {
297
+ const input = `
298
+ import { StackView } from "@planningcenter/tapestry-react"
299
+
300
+ export default function Test() {
301
+ return <StackView mediaQueries={{ md: { flex: "1 0 0", grow: 5 } }}>Test</StackView>
302
+ }
303
+ `.trim()
304
+
305
+ const result = applyTransform(input)
306
+ expect(result).toContain("responsive=")
307
+ expect(result).toContain("TODO")
308
+ expect(result).toContain("grow: 5")
309
+ expect(result?.match(/grow:/g)).toHaveLength(1)
310
+ })
311
+
296
312
  it('flags flex="inherit" inside a breakpoint with a generic TODO, not an expand-specific one', () => {
297
313
  const input = `
298
314
  import { StackView } from "@planningcenter/tapestry-react"
@@ -318,7 +334,7 @@ export default function Test() {
318
334
 
319
335
  const result = applyTransform(input)
320
336
  expect(result).toContain("TODO")
321
- expect(result).not.toContain("expand is not supported")
337
+ expect(result).toContain("expand is not supported")
322
338
  })
323
339
 
324
340
  it("flags flex={1} inside a breakpoint with a TODO (expand not supported in responsive)", () => {
@@ -353,6 +369,71 @@ export default function Test() {
353
369
  expect(result).not.toContain("TODO")
354
370
  })
355
371
 
372
+ it("flags flex={0} inside a breakpoint with a TODO instead of duplicating an existing grow key", () => {
373
+ const input = `
374
+ import { StackView } from "@planningcenter/tapestry-react"
375
+
376
+ export default function Test() {
377
+ return <StackView mediaQueries={{ md: { flex: 0, grow: 5 } }}>Test</StackView>
378
+ }
379
+ `.trim()
380
+
381
+ const result = applyTransform(input)
382
+ expect(result).toContain("responsive=")
383
+ expect(result).toContain("TODO")
384
+ expect(result).toContain("grow: 5")
385
+ expect(result?.match(/grow:/g)).toHaveLength(1)
386
+ })
387
+
388
+ it("flags axis inside a breakpoint with a TODO instead of duplicating an existing direction key", () => {
389
+ const input = `
390
+ import { StackView } from "@planningcenter/tapestry-react"
391
+
392
+ export default function Test() {
393
+ return <StackView mediaQueries={{ md: { axis: "horizontal", direction: "column" } }}>Test</StackView>
394
+ }
395
+ `.trim()
396
+
397
+ const result = applyTransform(input)
398
+ expect(result).toContain("responsive=")
399
+ expect(result).toContain("TODO")
400
+ expect(result).toContain('direction: "column"')
401
+ expect(result?.match(/direction:/g)).toHaveLength(1)
402
+ })
403
+
404
+ it("flags both axis and flexDirection with a TODO when they both target direction in the same breakpoint", () => {
405
+ const input = `
406
+ import { StackView } from "@planningcenter/tapestry-react"
407
+
408
+ export default function Test() {
409
+ return <StackView mediaQueries={{ md: { axis: "horizontal", flexDirection: "column" } }}>Test</StackView>
410
+ }
411
+ `.trim()
412
+
413
+ const result = applyTransform(input)
414
+ expect(result).toContain("responsive=")
415
+ expect(result).toContain("TODO")
416
+ expect(result).toContain('axis: "horizontal"')
417
+ expect(result).toContain('flexDirection: "column"')
418
+ expect(result?.match(/direction:/g)).toBeNull()
419
+ })
420
+
421
+ it("flags alignItems inside a breakpoint with a TODO instead of duplicating an existing align key", () => {
422
+ const input = `
423
+ import { StackView } from "@planningcenter/tapestry-react"
424
+
425
+ export default function Test() {
426
+ return <StackView mediaQueries={{ md: { alignItems: "center", align: "start" } }}>Test</StackView>
427
+ }
428
+ `.trim()
429
+
430
+ const result = applyTransform(input)
431
+ expect(result).toContain("responsive=")
432
+ expect(result).toContain("TODO")
433
+ expect(result).toContain('align: "start"')
434
+ expect(result?.match(/align:/g)).toHaveLength(1)
435
+ })
436
+
356
437
  it("flags an unsupported breakpoint (xxs) with a TODO", () => {
357
438
  const input = `
358
439
  import { StackView } from "@planningcenter/tapestry-react"
@@ -5,6 +5,7 @@ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribut
5
5
  import { getAttribute } from "../../shared/actions/getAttribute"
6
6
  import { hasAttribute } from "../../shared/conditions/hasAttribute"
7
7
  import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
8
+ import { FLEX_START_END_MAP } from "./constants"
8
9
 
9
10
  function commentOnAttr(
10
11
  attr: JSXAttribute,
@@ -88,11 +89,6 @@ const PADDING_TODO =
88
89
  `use a Tapestry spacing scale number (${PADDING_SCALE_LIST}) ` +
89
90
  "for Flex padding, or use style={{ padding: ... }} for arbitrary CSS values"
90
91
 
91
- const FLEX_START_END_MAP: Record<string, string> = {
92
- "flex-end": "end",
93
- "flex-start": "start",
94
- }
95
-
96
92
  const FLEX_RESPONSIVE_PASSTHROUGH = new Set([
97
93
  "align",
98
94
  "alignSelf",
@@ -331,7 +327,8 @@ function planInnerProp(
331
327
 
332
328
  if (propName === "flex") {
333
329
  if (node.type === "StringLiteral" && typeof node.value === "string") {
334
- const parts = (node.value as string).trim().split(/\s+/)
330
+ const raw = (node.value as string).trim()
331
+ const parts = raw.split(/\s+/)
335
332
  if (parts.length === 3) {
336
333
  const grow = Number(parts[0])
337
334
  const shrink = Number(parts[1])
@@ -346,6 +343,11 @@ function planInnerProp(
346
343
  }
347
344
  }
348
345
  }
346
+ if (raw === "1") {
347
+ return {
348
+ todo: "expand is not supported in responsive; use grow, shrink, and basis individually",
349
+ }
350
+ }
349
351
  return {
350
352
  todo: "could not migrate flex automatically; use grow, shrink, and basis individually",
351
353
  }
@@ -442,6 +444,15 @@ const transform: Transform = attributeTransformFactory({
442
444
  return true
443
445
  }
444
446
 
447
+ const pendingRenames: Array<{
448
+ apply: () => void
449
+ name: string
450
+ newKeys: string[]
451
+ }> = []
452
+ const finalKeyCounts = new Map<string, number>()
453
+ const countKey = (key: string) =>
454
+ finalKeyCounts.set(key, (finalKeyCounts.get(key) ?? 0) + 1)
455
+
445
456
  for (const innerProp of outerVal.properties) {
446
457
  if (innerProp.type !== "ObjectProperty") {
447
458
  innerTodos.push({
@@ -463,30 +474,58 @@ const transform: Transform = attributeTransformFactory({
463
474
  }
464
475
 
465
476
  const result = planInnerProp(innerKey.name, innerProp.value, j)
466
- if (result === null) continue
477
+ if (result === null) {
478
+ countKey(innerKey.name)
479
+ continue
480
+ }
467
481
  if ("todo" in result) {
482
+ countKey(innerKey.name)
468
483
  innerTodos.push({ key: innerKey.name, message: result.todo })
469
484
  continue
470
485
  }
471
486
 
472
- if ("newEntries" in result) {
473
- const entries = result.newEntries
474
- mutations.push(() => {
475
- const idx = outerVal.properties.indexOf(innerProp)
476
- outerVal.properties.splice(
477
- idx,
478
- 1,
479
- ...entries.map(({ newKey, newValue }) =>
480
- j.objectProperty(j.identifier(newKey), newValue)
487
+ const newKeys =
488
+ "newEntries" in result
489
+ ? result.newEntries.map(({ newKey }) => newKey)
490
+ : [result.newKey]
491
+ newKeys.forEach(countKey)
492
+
493
+ pendingRenames.push({
494
+ apply: () => {
495
+ if ("newEntries" in result) {
496
+ const entries = result.newEntries
497
+ const idx = outerVal.properties.indexOf(innerProp)
498
+ outerVal.properties.splice(
499
+ idx,
500
+ 1,
501
+ ...entries.map(({ newKey, newValue }) =>
502
+ j.objectProperty(j.identifier(newKey), newValue)
503
+ )
481
504
  )
482
- )
483
- })
484
- } else {
485
- mutations.push(() => {
486
- innerKey.name = result.newKey
487
- innerProp.value = result.newValue
505
+ } else {
506
+ innerKey.name = result.newKey
507
+ innerProp.value = result.newValue
508
+ }
509
+ },
510
+ name: innerKey.name,
511
+ newKeys,
512
+ })
513
+ }
514
+
515
+ for (const plan of pendingRenames) {
516
+ const collidingKeys = plan.newKeys.filter(
517
+ (key) => (finalKeyCounts.get(key) ?? 0) > 1
518
+ )
519
+
520
+ if (collidingKeys.length > 0) {
521
+ innerTodos.push({
522
+ key: plan.name,
523
+ message: `could not migrate ${plan.name} automatically — it would duplicate the ${collidingKeys.join(", ")} key already present (or produced by another prop) in this breakpoint; set the target prop(s) individually instead`,
488
524
  })
525
+ continue
489
526
  }
527
+
528
+ mutations.push(plan.apply)
490
529
  }
491
530
  }
492
531
 
@@ -136,6 +136,93 @@ export default function Test({ spacingProp }) {
136
136
  expect(result).not.toContain("gap=")
137
137
  })
138
138
 
139
+ it("renames spacing to gap for a ternary with two in-scale numeric branches", () => {
140
+ const input = `
141
+ import { StackView } from "@planningcenter/tapestry-react"
142
+
143
+ export default function Test({ compact }) {
144
+ return <StackView spacing={compact ? 1.5 : 2}>Test</StackView>
145
+ }
146
+ `.trim()
147
+
148
+ const result = applyTransform(input)
149
+ expect(result).toContain("gap={compact ? 1.5 : 2}")
150
+ expect(result).not.toContain("spacing")
151
+ })
152
+
153
+ it("renames spacing to gap for a ternary with an undefined pass-through branch", () => {
154
+ const input = `
155
+ import { StackView } from "@planningcenter/tapestry-react"
156
+
157
+ export default function Test({ compact }) {
158
+ return <StackView spacing={compact ? 2 : undefined}>Test</StackView>
159
+ }
160
+ `.trim()
161
+
162
+ const result = applyTransform(input)
163
+ expect(result).toContain("gap={compact ? 2 : undefined}")
164
+ expect(result).not.toContain("spacing")
165
+ })
166
+
167
+ it("renames spacing to gap for a ternary with a null pass-through branch", () => {
168
+ const input = `
169
+ import { StackView } from "@planningcenter/tapestry-react"
170
+
171
+ export default function Test({ compact }) {
172
+ return <StackView spacing={compact ? 2 : null}>Test</StackView>
173
+ }
174
+ `.trim()
175
+
176
+ const result = applyTransform(input)
177
+ expect(result).toContain("gap={compact ? 2 : null}")
178
+ expect(result).not.toContain("spacing")
179
+ })
180
+
181
+ it("flags a ternary with one off-scale branch with a TODO", () => {
182
+ const input = `
183
+ import { StackView } from "@planningcenter/tapestry-react"
184
+
185
+ export default function Test({ compact }) {
186
+ return <StackView spacing={compact ? 1.5 : 10}>Test</StackView>
187
+ }
188
+ `.trim()
189
+
190
+ const result = applyTransform(input)
191
+ expect(result).toContain("spacing={compact ? 1.5 : 10}")
192
+ expect(result).toContain("TODO: tapestry-migration (spacing)")
193
+ expect(result).not.toContain("gap=")
194
+ })
195
+
196
+ it("flags a ternary with a ReactNode branch with a TODO", () => {
197
+ const input = `
198
+ import { StackView } from "@planningcenter/tapestry-react"
199
+
200
+ export default function Test({ compact }) {
201
+ return <StackView spacing={compact ? 1.5 : <Divider />}>Test</StackView>
202
+ }
203
+ `.trim()
204
+
205
+ const result = applyTransform(input)
206
+ expect(result).toContain("spacing={compact ? 1.5 : <Divider />}")
207
+ expect(result).toContain("TODO: tapestry-migration (spacing)")
208
+ expect(result).not.toContain("gap=")
209
+ })
210
+
211
+ it("renames spacing to gap for a ternary with a dynamic branch, trusting it at runtime", () => {
212
+ const input = `
213
+ import { StackView } from "@planningcenter/tapestry-react"
214
+
215
+ export default function Test({ compact, dynamicValue }) {
216
+ return <StackView spacing={compact ? 1.5 : dynamicValue}>Test</StackView>
217
+ }
218
+ `.trim()
219
+
220
+ const result = applyTransform(input)
221
+ expect(result).toContain("gap={compact ? 1.5 : dynamicValue}")
222
+ expect(result).not.toContain("spacing")
223
+ expect(result).not.toContain("TODO")
224
+ })
225
+
139
226
  it("respects an aliased StackView import", () => {
140
227
  const input = `
141
228
  import { StackView as Stack } from "@planningcenter/tapestry-react"
@@ -1,12 +1,25 @@
1
1
  import { JSXAttribute, Transform } from "jscodeshift"
2
2
 
3
- import { addComment } from "../../shared/actions/addComment"
3
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
4
4
  import { getAttribute } from "../../shared/actions/getAttribute"
5
5
  import { hasAttribute } from "../../shared/conditions/hasAttribute"
6
6
  import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
7
7
 
8
8
  const VALID_GAP_VALUES = new Set([0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 7])
9
9
 
10
+ const TODO_TEXT =
11
+ "could not migrate spacing to gap automatically; use a Tapestry spacing scale number (0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 7) for gap, or for ReactNode spacers add explicit margin to the spacer element instead"
12
+
13
+ function isKnownInvalidBranch(node: unknown): boolean {
14
+ if (typeof node !== "object" || node === null) return false
15
+ const typed = node as { type: string; value?: unknown }
16
+ if (typed.type === "NumericLiteral" && typeof typed.value === "number") {
17
+ return !VALID_GAP_VALUES.has(typed.value)
18
+ }
19
+ if (typed.type === "StringLiteral") return true
20
+ return typed.type === "JSXElement" || typed.type === "JSXFragment"
21
+ }
22
+
10
23
  function resolveSpacingValue(attribute: JSXAttribute): number | null {
11
24
  const value = attribute.value
12
25
  if (
@@ -19,24 +32,29 @@ function resolveSpacingValue(attribute: JSXAttribute): number | null {
19
32
  return null
20
33
  }
21
34
 
35
+ function isAlreadyValidTernary(attribute: JSXAttribute): boolean {
36
+ const value = attribute.value
37
+ if (value?.type !== "JSXExpressionContainer") return false
38
+ const expr = value.expression
39
+ if (expr.type !== "ConditionalExpression") return false
40
+ return (
41
+ !isKnownInvalidBranch(expr.consequent) &&
42
+ !isKnownInvalidBranch(expr.alternate)
43
+ )
44
+ }
45
+
22
46
  const transform: Transform = attributeTransformFactory({
23
47
  condition: hasAttribute("spacing"),
24
48
  targetComponent: "StackView",
25
49
  targetPackage: "@planningcenter/tapestry-react",
26
- transform: (element, { j, source }) => {
50
+ transform: (element, { j }) => {
27
51
  const spacingAttribute = getAttribute({ element, name: "spacing" })
28
52
  if (!spacingAttribute) return false
29
53
 
30
54
  const spacingValue = resolveSpacingValue(spacingAttribute)
31
55
 
32
- if (spacingValue === null) {
33
- addComment({
34
- element,
35
- j,
36
- scope: "spacing",
37
- source,
38
- text: "could not migrate spacing to gap automatically; use a Tapestry spacing scale number (0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 7) for gap, or for ReactNode spacers add explicit margin to the spacer element instead",
39
- })
56
+ if (spacingValue === null && !isAlreadyValidTernary(spacingAttribute)) {
57
+ addCommentToAttribute({ attribute: spacingAttribute, j, text: TODO_TEXT })
40
58
  return true
41
59
  }
42
60