@planningcenter/tapestry-migration-cli 3.2.3-rc.8 → 3.3.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.
package/package.json CHANGED
@@ -1,12 +1,10 @@
1
1
  {
2
2
  "name": "@planningcenter/tapestry-migration-cli",
3
- "version": "3.2.3-rc.8",
3
+ "version": "3.3.0-rc.0",
4
4
  "description": "CLI tool for Tapestry migrations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
- "bin": {
8
- "tapestry-migration-cli": "./src/index.ts"
9
- },
7
+ "bin": "./src/index.ts",
10
8
  "scripts": {
11
9
  "build": "vite build",
12
10
  "dev": "tsc --watch",
@@ -32,7 +30,7 @@
32
30
  },
33
31
  "devDependencies": {
34
32
  "@emotion/react": "^11.14.0",
35
- "@planningcenter/tapestry": "^3.2.3-rc.8",
33
+ "@planningcenter/tapestry": "^3.3.0-rc.0",
36
34
  "@planningcenter/tapestry-react": "^4.11.5",
37
35
  "@types/jscodeshift": "^17.3.0",
38
36
  "@types/node": "^20.0.0",
@@ -52,5 +50,5 @@
52
50
  "publishConfig": {
53
51
  "access": "public"
54
52
  },
55
- "gitHead": "370843598ae9aaeb9e975b4659173743cb7e519e"
53
+ "gitHead": "7fd3d76244befe3a32ba0a79ff512f7c31db40b2"
56
54
  }
@@ -0,0 +1,90 @@
1
+ import { Transform } from "jscodeshift"
2
+
3
+ import { createAsPropCommentTransform } from "./transforms/commentOnAsProp"
4
+ import { createStylePropsCleanupTransform } from "./transforms/convertStyleProps"
5
+
6
+ const TAPESTRY_REACT_PACKAGE = "@planningcenter/tapestry-react"
7
+
8
+ /**
9
+ * Collect every top-level named import from @planningcenter/tapestry-react
10
+ * (e.g. `import { Stack, Box as MyBox } from "@planningcenter/tapestry-react"`
11
+ * yields ["Stack", "Box"]). We target the *imported* name, not the local
12
+ * alias, because `attributeTransformFactory` already resolves aliases via
13
+ * `getImportName`.
14
+ *
15
+ * Member-expression targets like `<Link.Inline>` are intentionally out of
16
+ * scope — they're handled by the dedicated `run link` migration.
17
+ */
18
+ function collectTapestryReactImports(
19
+ fileInfo: Parameters<Transform>[0],
20
+ api: Parameters<Transform>[1]
21
+ ): string[] {
22
+ const j = api.jscodeshift
23
+ const source = j(fileInfo.source)
24
+ const names = new Set<string>()
25
+
26
+ source
27
+ .find(j.ImportDeclaration, {
28
+ source: { value: TAPESTRY_REACT_PACKAGE },
29
+ })
30
+ .forEach((path) => {
31
+ path.node.specifiers?.forEach((spec) => {
32
+ if (
33
+ spec.type === "ImportSpecifier" &&
34
+ spec.imported.type === "Identifier"
35
+ ) {
36
+ names.add(spec.imported.name)
37
+ }
38
+ })
39
+ })
40
+
41
+ return [...names]
42
+ }
43
+
44
+ /**
45
+ * The `preflight` orchestrator: a cross-component assessment pass for
46
+ * tapestry-react. It applies each registered transform to every imported
47
+ * tapestry-react component in each file so the resulting report can scope
48
+ * the migration. Current transforms:
49
+ * - shorthand style-prop conversion (`createStylePropsCleanupTransform`)
50
+ * - `as={...}` TODO annotation (`createAsPropCommentTransform`) — flags call
51
+ * sites because `@planningcenter/tapestry` does not support the `as` prop
52
+ *
53
+ * Note: applying this pass produces inline `style={{}}` objects, which is
54
+ * rarely the desired end state and can negatively impact rendering
55
+ * performance. Run with `--dry-run` for assessment and use the per-component
56
+ * migrations (e.g. `run button`) for the real conversion.
57
+ */
58
+ const transform: Transform = (fileInfo, api, options) => {
59
+ const componentNames = collectTapestryReactImports(fileInfo, api)
60
+ if (componentNames.length === 0) {
61
+ return null
62
+ }
63
+
64
+ let currentSource = fileInfo.source
65
+ let hasAnyChanges = false
66
+
67
+ const transformBuilders = [
68
+ createStylePropsCleanupTransform,
69
+ createAsPropCommentTransform,
70
+ ]
71
+
72
+ for (const componentName of componentNames) {
73
+ for (const build of transformBuilders) {
74
+ const individualTransform = build(componentName)
75
+ const result = individualTransform(
76
+ { ...fileInfo, source: currentSource },
77
+ api,
78
+ options
79
+ )
80
+ if (result && result !== currentSource) {
81
+ currentSource = result as string
82
+ hasAnyChanges = true
83
+ }
84
+ }
85
+ }
86
+
87
+ return hasAnyChanges ? currentSource : null
88
+ }
89
+
90
+ export default transform
@@ -0,0 +1,38 @@
1
+ import { Transform } from "jscodeshift"
2
+
3
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
4
+ import { getAttribute } from "../../shared/actions/getAttribute"
5
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
6
+
7
+ const COMMENT =
8
+ "The `as` prop is not supported in @planningcenter/tapestry. Review this usage and migrate to a supported API before upgrading."
9
+
10
+ const ANNOTATION_MARKER = "TODO: tapestry-migration (as):"
11
+
12
+ /**
13
+ * Builds a transform that flags `as={...}` usage on a single tapestry-react
14
+ * component with a TODO comment. `@planningcenter/tapestry` does not support
15
+ * the `as` prop, so these call sites need manual review before upgrading.
16
+ *
17
+ * The transform is idempotent: re-running it on an already-annotated file
18
+ * does not stack duplicate comments.
19
+ */
20
+ export function createAsPropCommentTransform(componentName: string): Transform {
21
+ return attributeTransformFactory({
22
+ condition: (element) => Boolean(getAttribute({ element, name: "as" })),
23
+ targetComponent: componentName,
24
+ targetPackage: "@planningcenter/tapestry-react",
25
+ transform: (element, { j }) => {
26
+ const attribute = getAttribute({ element, name: "as" })
27
+ if (!attribute) return false
28
+
29
+ const alreadyAnnotated = attribute.comments?.some((comment) =>
30
+ comment.value.includes(ANNOTATION_MARKER)
31
+ )
32
+ if (alreadyAnnotated) return false
33
+
34
+ addCommentToAttribute({ attribute, j, text: COMMENT })
35
+ return true
36
+ },
37
+ })
38
+ }
@@ -0,0 +1,333 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import preflight from "../index"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyPreflight(source: string, options = {}) {
9
+ const fileInfo = { path: "test.tsx", source }
10
+ const result = preflight(
11
+ fileInfo,
12
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
13
+ options
14
+ ) as string | null
15
+ return result || source
16
+ }
17
+
18
+ describe("preflight: convertStyleProps across all tapestry-react components", () => {
19
+ describe("discovery", () => {
20
+ it("no-ops when the file has no @planningcenter/tapestry-react imports", () => {
21
+ const source = `
22
+ import { Something } from "some-other-package"
23
+
24
+ export function TestComponent() {
25
+ return <Something padding={16} />
26
+ }
27
+ `
28
+
29
+ const result = applyPreflight(source)
30
+
31
+ // Style props on non-tapestry-react components are left alone.
32
+ expect(result).toContain("padding={16}")
33
+ expect(result).not.toContain("style={{")
34
+ })
35
+
36
+ it("leaves a same-named import from a different package untouched", () => {
37
+ const source = `
38
+ import { Stack } from "some-other-ui-lib"
39
+
40
+ export function TestComponent() {
41
+ return <Stack padding={16} margin={8} />
42
+ }
43
+ `
44
+
45
+ const result = applyPreflight(source)
46
+
47
+ expect(result).toContain("padding={16}")
48
+ expect(result).toContain("margin={8}")
49
+ expect(result).not.toContain("style={{")
50
+ })
51
+ })
52
+
53
+ describe("unmigrated components (no dedicated per-component migration)", () => {
54
+ it("merges shorthand style props into style={{}} on Stack", () => {
55
+ const source = `
56
+ import { Stack } from "@planningcenter/tapestry-react"
57
+
58
+ export function TestComponent() {
59
+ return <Stack padding={16} margin={8} />
60
+ }
61
+ `
62
+
63
+ const result = applyPreflight(source)
64
+
65
+ expect(result).toMatchInlineSnapshot(`
66
+ "
67
+ import { Stack } from "@planningcenter/tapestry-react"
68
+
69
+ export function TestComponent() {
70
+ return (
71
+ <Stack
72
+ style={{
73
+ paddingTop: "128px",
74
+ paddingRight: "128px",
75
+ paddingBottom: "128px",
76
+ paddingLeft: "128px",
77
+ marginTop: "64px",
78
+ marginRight: "64px",
79
+ marginBottom: "64px",
80
+ marginLeft: "64px"
81
+ }} />
82
+ );
83
+ }
84
+ "
85
+ `)
86
+ })
87
+
88
+ it("preserves the visible prop by routing it through style", () => {
89
+ const source = `
90
+ import { Box } from "@planningcenter/tapestry-react"
91
+
92
+ export function TestComponent() {
93
+ return <Box visible={false} padding={8} />
94
+ }
95
+ `
96
+
97
+ const result = applyPreflight(source)
98
+
99
+ expect(result).toContain("style={{")
100
+ expect(result).toContain('display: "none"')
101
+ expect(result).not.toContain("visible={false}")
102
+ expect(result).not.toContain("padding={8}")
103
+ })
104
+
105
+ it("leaves non-style props (like children and id) alone", () => {
106
+ const source = `
107
+ import { Stack } from "@planningcenter/tapestry-react"
108
+
109
+ export function TestComponent() {
110
+ return <Stack id="main" padding={16}>hello</Stack>
111
+ }
112
+ `
113
+
114
+ const result = applyPreflight(source)
115
+
116
+ expect(result).toContain('id="main"')
117
+ expect(result).toContain(">hello</Stack>")
118
+ expect(result).not.toContain("padding={16}")
119
+ })
120
+ })
121
+
122
+ describe("multiple components in one file", () => {
123
+ it("applies style-prop cleanup independently to each imported component", () => {
124
+ const source = `
125
+ import { Stack, Box } from "@planningcenter/tapestry-react"
126
+
127
+ export function TestComponent() {
128
+ return (
129
+ <Stack padding={16}>
130
+ <Box margin={8} />
131
+ </Stack>
132
+ )
133
+ }
134
+ `
135
+
136
+ const result = applyPreflight(source)
137
+
138
+ expect(result).not.toContain("padding={16}")
139
+ expect(result).not.toContain("margin={8}")
140
+ expect(result.match(/style=\{\{/g)?.length ?? 0).toBeGreaterThanOrEqual(2)
141
+ })
142
+ })
143
+
144
+ describe("aliased imports", () => {
145
+ it("handles local aliases via the factory's getImportName resolution", () => {
146
+ const source = `
147
+ import { Stack as MyStack } from "@planningcenter/tapestry-react"
148
+
149
+ export function TestComponent() {
150
+ return <MyStack padding={16} />
151
+ }
152
+ `
153
+
154
+ const result = applyPreflight(source)
155
+
156
+ expect(result).not.toContain("padding={16}")
157
+ expect(result).toContain("style={{")
158
+ })
159
+ })
160
+
161
+ describe("components that already have dedicated migrations", () => {
162
+ it("still applies the generic cleanup to Checkbox (user chose to include all components)", () => {
163
+ const source = `
164
+ import { Checkbox } from "@planningcenter/tapestry-react"
165
+
166
+ export function TestComponent() {
167
+ return <Checkbox visible={false} marginTop={16} label="Test" />
168
+ }
169
+ `
170
+
171
+ const result = applyPreflight(source)
172
+
173
+ expect(result).toContain("style={{")
174
+ expect(result).toContain('display: "none"')
175
+ expect(result).not.toContain("visible={false}")
176
+ expect(result).not.toContain("marginTop={16}")
177
+ })
178
+ })
179
+
180
+ describe("import preservation", () => {
181
+ it("does not modify the import declaration", () => {
182
+ const source = `
183
+ import { Stack } from "@planningcenter/tapestry-react"
184
+
185
+ export function TestComponent() {
186
+ return <Stack padding={16} />
187
+ }
188
+ `
189
+
190
+ const result = applyPreflight(source)
191
+
192
+ expect(result).toContain(
193
+ 'import { Stack } from "@planningcenter/tapestry-react"'
194
+ )
195
+ })
196
+ })
197
+
198
+ describe("as prop TODO annotation", () => {
199
+ it("adds a TODO comment when a tapestry-react component uses as={string}", () => {
200
+ const source = `
201
+ import { Box } from "@planningcenter/tapestry-react"
202
+
203
+ export function TestComponent() {
204
+ return <Box as="section" />
205
+ }
206
+ `
207
+
208
+ const result = applyPreflight(source)
209
+
210
+ expect(result).toMatch(/TODO: tapestry-migration \(as\):/)
211
+ expect(result).toContain("not supported in @planningcenter/tapestry")
212
+ // The `as` prop itself must be preserved — only a comment is added.
213
+ expect(result).toContain('as="section"')
214
+ })
215
+
216
+ it("adds a TODO comment when as={} references a component", () => {
217
+ const source = `
218
+ import { Box } from "@planningcenter/tapestry-react"
219
+ import { MyWrapper } from "./MyWrapper"
220
+
221
+ export function TestComponent() {
222
+ return <Box as={MyWrapper} />
223
+ }
224
+ `
225
+
226
+ const result = applyPreflight(source)
227
+
228
+ expect(result).toMatch(/TODO: tapestry-migration \(as\):/)
229
+ expect(result).toContain("as={MyWrapper}")
230
+ })
231
+
232
+ it("leaves components without an as prop untouched by the as-prop transform", () => {
233
+ const source = `
234
+ import { Box } from "@planningcenter/tapestry-react"
235
+
236
+ export function TestComponent() {
237
+ return <Box id="plain" />
238
+ }
239
+ `
240
+
241
+ const result = applyPreflight(source)
242
+
243
+ expect(result).not.toMatch(/TODO: tapestry-migration \(as\):/)
244
+ })
245
+
246
+ it("does not flag as={} on non-tapestry-react components", () => {
247
+ const source = `
248
+ import { Box } from "some-other-ui-lib"
249
+
250
+ export function TestComponent() {
251
+ return <Box as="section" />
252
+ }
253
+ `
254
+
255
+ const result = applyPreflight(source)
256
+
257
+ expect(result).not.toMatch(/TODO: tapestry-migration \(as\):/)
258
+ })
259
+
260
+ it("flags as={} across multiple different tapestry-react components", () => {
261
+ const source = `
262
+ import { Stack, Box } from "@planningcenter/tapestry-react"
263
+
264
+ export function TestComponent() {
265
+ return (
266
+ <Stack as="main">
267
+ <Box as="aside" />
268
+ </Stack>
269
+ )
270
+ }
271
+ `
272
+
273
+ const result = applyPreflight(source)
274
+
275
+ const matches = result.match(/TODO: tapestry-migration \(as\):/g) ?? []
276
+ expect(matches.length).toBe(2)
277
+ })
278
+
279
+ it("is idempotent — a second run does not stack duplicate TODO comments", () => {
280
+ const source = `
281
+ import { Box } from "@planningcenter/tapestry-react"
282
+
283
+ export function TestComponent() {
284
+ return <Box as="section" />
285
+ }
286
+ `
287
+
288
+ const firstPass = applyPreflight(source)
289
+ const secondPass = applyPreflight(firstPass)
290
+
291
+ const firstCount =
292
+ firstPass.match(/TODO: tapestry-migration \(as\):/g)?.length ?? 0
293
+ const secondCount =
294
+ secondPass.match(/TODO: tapestry-migration \(as\):/g)?.length ?? 0
295
+
296
+ expect(firstCount).toBe(1)
297
+ expect(secondCount).toBe(1)
298
+ })
299
+
300
+ it("handles aliased imports", () => {
301
+ const source = `
302
+ import { Box as MyBox } from "@planningcenter/tapestry-react"
303
+
304
+ export function TestComponent() {
305
+ return <MyBox as="section" />
306
+ }
307
+ `
308
+
309
+ const result = applyPreflight(source)
310
+
311
+ expect(result).toMatch(/TODO: tapestry-migration \(as\):/)
312
+ })
313
+
314
+ it("still applies style-prop cleanup on the same element that has an as prop", () => {
315
+ const source = `
316
+ import { Box } from "@planningcenter/tapestry-react"
317
+
318
+ export function TestComponent() {
319
+ return <Box as="section" padding={16} />
320
+ }
321
+ `
322
+
323
+ const result = applyPreflight(source)
324
+
325
+ // as prop flagged with TODO
326
+ expect(result).toMatch(/TODO: tapestry-migration \(as\):/)
327
+ expect(result).toContain('as="section"')
328
+ // shorthand style prop still migrated
329
+ expect(result).toContain("style={{")
330
+ expect(result).not.toContain("padding={16}")
331
+ })
332
+ })
333
+ })
@@ -0,0 +1,29 @@
1
+ import { Transform } from "jscodeshift"
2
+
3
+ import { stackViewPlugin } from "../../../stubs/stackViewPlugin"
4
+ import { stylePropTransformFactory } from "../../shared/transformFactories/stylePropTransformFactory"
5
+
6
+ /**
7
+ * Builds a conservative, cross-component style-prop cleanup transform for a
8
+ * single tapestry-react component. The orchestrator calls this once per
9
+ * discovered named import from `@planningcenter/tapestry-react`.
10
+ *
11
+ * Deliberately minimal compared to per-component migrations:
12
+ * - Keeps only `visible` (universally kept by all existing migrations).
13
+ * - Uses `stackViewPlugin` — broadly correct for 7 of 8 existing components.
14
+ * Link's text-plugin props stay with `run link`.
15
+ * - No `stylesToRemove` or `stylePropMapping` — those are genuinely
16
+ * component-specific and belong in dedicated migrations.
17
+ */
18
+ export function createStylePropsCleanupTransform(
19
+ componentName: string
20
+ ): Transform {
21
+ return stylePropTransformFactory({
22
+ plugin: stackViewPlugin,
23
+ stylePropMapping: {},
24
+ stylesToKeep: ["visible"],
25
+ stylesToRemove: [],
26
+ targetComponent: componentName,
27
+ targetPackage: "@planningcenter/tapestry-react",
28
+ })
29
+ }
package/src/index.ts CHANGED
@@ -70,6 +70,40 @@ program
70
70
  }
71
71
  })
72
72
 
73
+ program
74
+ .command("preflight")
75
+ .description(
76
+ "Assess migration readiness across @planningcenter/tapestry-react usage. Surfaces shorthand style props and unsupported `as` prop usage so you can plan the work. Intended for assessment — running with changes applied converts shorthand style props to inline `style={{}}` objects, which is rarely the desired end state and can negatively impact rendering performance. Use the per-component migrations (e.g. `run button`) for the real conversion."
77
+ )
78
+ .requiredOption(
79
+ "-p, --path <path>",
80
+ "REQUIRED: The path to the folder/file to assess"
81
+ )
82
+ .option("-d, --dry-run", "Preview changes without writing to files")
83
+ .option("-v, --verbose", "Verbose output")
84
+ .option(
85
+ "-j, --js-theme <path>",
86
+ "Path to JavaScript theme file containing design tokens"
87
+ )
88
+ .option(
89
+ "-r, --report-path <path>",
90
+ "Path for the migration report. Creates a report when running with changes applied and does not create a report if no additional changes are required.",
91
+ "MIGRATION_REPORT.md"
92
+ )
93
+ .action((options) => {
94
+ console.log("🛫 Running tapestry-react preflight assessment...")
95
+ console.log(`📁 Target: ${options.path}`)
96
+ console.log(
97
+ `🔧 Mode: ${options.dryRun ? "Dry run (preview only)" : "Apply changes"}`
98
+ )
99
+ if (!options.dryRun) {
100
+ console.log(
101
+ "⚠️ Preflight is intended for assessment. Applying changes will convert shorthand style props to inline `style={{}}` objects, which is rarely the desired end state and can hurt rendering performance. Prefer `--dry-run` with `--report-path`."
102
+ )
103
+ }
104
+ runTransforms("preflight", options)
105
+ })
106
+
73
107
  program
74
108
  .command("help")
75
109
  .description("Show help information")
@@ -30,6 +30,8 @@ export const GLOBAL_MESSAGES = {
30
30
  "Buttons inside of Tapestry Group components are not supported and should not be migrated at this time.",
31
31
  checkbox:
32
32
  "Checkbox sizes have adjusted. Verify visual appearance as sizes may differ slightly.",
33
+ preflight:
34
+ "Preflight is an assessment pass. It surfaces shorthand style props and `as={...}` usage on tapestry-react components so you can scope the migration. Applying its changes converts shorthand style props into inline `style={{}}` objects — that is rarely the desired end state and can negatively impact rendering performance. Prefer running with `--dry-run` and `--report-path`, then use the per-component migrations (e.g. `run button`) for the real tapestry-react → tapestry conversion.",
33
35
  radio:
34
36
  "Radio sizes have adjusted. Verify visual appearance as sizes may differ slightly.",
35
37
  "toggle-switch":