@sanity/ui-codemod 1.0.0-alpha.2 → 1.0.0-alpha.3

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": "@sanity/ui-codemod",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "license": "ISC",
@@ -1,3 +1,12 @@
1
+ import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
2
+ import {tmpdir} from 'node:os'
3
+ import {join} from 'node:path'
4
+
5
+ import {afterEach, describe, expect, it} from 'vitest'
6
+
7
+ const applyTransform = require('jscodeshift/dist/testUtils').applyTransform
8
+
9
+ import {clearModuleParseCache} from '../../../utils/parseModule'
1
10
  import {defineInlineTest} from '../../../utils/testUtils'
2
11
  import transform from './box'
3
12
 
@@ -178,6 +187,38 @@ defineInlineTest(
178
187
  'replaces Box with Grid when display is grid',
179
188
  )
180
189
 
190
+ defineInlineTest(
191
+ transform,
192
+ {},
193
+ `
194
+ <Box display={['none', undefined, null, 'flex']} />
195
+ `,
196
+ `
197
+ <Flex display={['none', undefined, null, 'flex']} />
198
+ `,
199
+ 'replaces Box with Flex when display is an array with flex',
200
+ )
201
+
202
+ defineInlineTest(
203
+ transform,
204
+ {},
205
+ `
206
+ <>
207
+ <Box display={['block', undefined, null, 'flex']} />
208
+ {/* This forces a change to avoid the transform return null */}
209
+ <Box display="flex" />
210
+ </>
211
+ `,
212
+ `
213
+ <>
214
+ <Box display={['block', undefined, null, 'flex']} />
215
+ {/* This forces a change to avoid the transform return null */}
216
+ <Flex display="flex" />
217
+ </>
218
+ `,
219
+ 'does not replace Box with Flex when display is a mixed array',
220
+ )
221
+
181
222
  defineInlineTest(
182
223
  transform,
183
224
  {},
@@ -194,11 +235,168 @@ defineInlineTest(
194
235
  import {Box} from '@sanity/ui'
195
236
 
196
237
  function Example() {
197
- // UI-CODEMOD TODO: Please double check styled(Box) migration(s) below
198
- const RootBox = styled(Box)(({theme}) => ({}));
238
+ const RootBox = styled(Box)(({theme}) => ({}))
199
239
 
200
- return <RootBox display="flex" alignItems="center" />
240
+ return (
241
+ // UI-CODEMOD TODO: Please double check styled(Box) migration below
242
+ <RootBox display="flex" alignItems="center" />
243
+ );
201
244
  }
202
245
  `,
203
- 'warns and does not transform attributes if styled Box definition should be replaced',
246
+ 'warns and does not transform attributes if styled Box should be replaced',
204
247
  )
248
+
249
+ const tempDirs: string[] = []
250
+
251
+ afterEach(() => {
252
+ clearModuleParseCache()
253
+
254
+ for (const dir of tempDirs.splice(0)) {
255
+ rmSync(dir, {recursive: true, force: true})
256
+ }
257
+ })
258
+
259
+ describe('cross-file styled aliases', () => {
260
+ it('transforms attributes on imported styled Box wrappers', () => {
261
+ const dir = mkdtempSync(join(tmpdir(), 'ui-codemod-box-crossfile-'))
262
+
263
+ tempDirs.push(dir)
264
+
265
+ writeFileSync(
266
+ join(dir, 'Component.styled.tsx'),
267
+ `
268
+ import {Box} from '@sanity/ui'
269
+
270
+ export const RootBox = styled(Box)(({theme}) => ({}))
271
+ `,
272
+ )
273
+
274
+ writeFileSync(
275
+ join(dir, 'Component.tsx'),
276
+ `
277
+ import {RootBox} from './Component.styled'
278
+
279
+ export function Component() {
280
+ return <RootBox alignItems="center" />
281
+ }
282
+ `,
283
+ )
284
+
285
+ const importerPath = join(dir, 'Component.tsx')
286
+ const source = readFileSync(importerPath, 'utf8')
287
+ const output = applyTransform(transform, {}, {source, path: importerPath}, {parser: 'tsx'})
288
+
289
+ expect(output).toContain('alignItems: "center"')
290
+ expect(output).not.toContain('<RootBox alignItems="center" />')
291
+ })
292
+
293
+ it('adds todo warning when imported styled Box wrapper should be replaced', () => {
294
+ const dir = mkdtempSync(join(tmpdir(), 'ui-codemod-box-crossfile-todo-'))
295
+
296
+ tempDirs.push(dir)
297
+
298
+ writeFileSync(
299
+ join(dir, 'Component.styled.tsx'),
300
+ `
301
+ import {Box} from '@sanity/ui'
302
+
303
+ export const RootBox = styled(Box)(({theme}) => ({}))
304
+ `,
305
+ )
306
+
307
+ writeFileSync(
308
+ join(dir, 'Component.tsx'),
309
+ `
310
+ import {RootBox} from './Component.styled'
311
+
312
+ export function Component() {
313
+ return <RootBox display="flex" alignItems="center" />
314
+ }
315
+ `,
316
+ )
317
+
318
+ const importerPath = join(dir, 'Component.tsx')
319
+ const source = readFileSync(importerPath, 'utf8')
320
+ const output = applyTransform(transform, {}, {source, path: importerPath}, {parser: 'tsx'})
321
+
322
+ expect(output).toContain('UI-CODEMOD TODO: Please double check styled(Box) migration below')
323
+ expect(output).toContain('<RootBox display="flex" alignItems="center" />')
324
+ expect(output).not.toContain('const RootBox = styled(Box)')
325
+ })
326
+
327
+ it('does not rewrite unrelated Box from another package', () => {
328
+ const dir = mkdtempSync(join(tmpdir(), 'ui-codemod-box-crossfile-unrelated-'))
329
+
330
+ tempDirs.push(dir)
331
+
332
+ writeFileSync(
333
+ join(dir, 'Component.styled.tsx'),
334
+ `
335
+ import {Box} from '@sanity/ui'
336
+
337
+ export const RootBox = styled(Box)(({theme}) => ({}))
338
+ `,
339
+ )
340
+
341
+ writeFileSync(
342
+ join(dir, 'Component.tsx'),
343
+ `
344
+ import {Box} from 'another-package'
345
+ import {RootBox} from './Component.styled'
346
+
347
+ export function Component() {
348
+ return (
349
+ <>
350
+ <RootBox alignItems="center" />
351
+ <Box display="flex" />
352
+ </>
353
+ )
354
+ }
355
+ `,
356
+ )
357
+
358
+ const importerPath = join(dir, 'Component.tsx')
359
+ const source = readFileSync(importerPath, 'utf8')
360
+ const output = applyTransform(transform, {}, {source, path: importerPath}, {parser: 'tsx'})
361
+
362
+ expect(output).toContain('alignItems: "center"')
363
+ expect(output).not.toContain('<RootBox alignItems="center" />')
364
+ expect(output).toContain('<Box display="flex" />')
365
+ expect(output).not.toContain('<Flex')
366
+ })
367
+
368
+ it('transforms styled Box wrappers imported through barrel re-exports', () => {
369
+ const dir = mkdtempSync(join(tmpdir(), 'ui-codemod-box-crossfile-barrel-'))
370
+
371
+ tempDirs.push(dir)
372
+
373
+ writeFileSync(
374
+ join(dir, 'Component.styled.tsx'),
375
+ `
376
+ import {Box} from '@sanity/ui'
377
+
378
+ export const RootBox = styled(Box)(({theme}) => ({}))
379
+ `,
380
+ )
381
+
382
+ writeFileSync(join(dir, 'index.ts'), `export {RootBox} from './Component.styled'`)
383
+
384
+ writeFileSync(
385
+ join(dir, 'Component.tsx'),
386
+ `
387
+ import {RootBox} from './index'
388
+
389
+ export function Component() {
390
+ return <RootBox alignItems="center" />
391
+ }
392
+ `,
393
+ )
394
+
395
+ const importerPath = join(dir, 'Component.tsx')
396
+ const source = readFileSync(importerPath, 'utf8')
397
+ const output = applyTransform(transform, {}, {source, path: importerPath}, {parser: 'tsx'})
398
+
399
+ expect(output).toContain('alignItems: "center"')
400
+ expect(output).not.toContain('<RootBox alignItems="center" />')
401
+ })
402
+ })
@@ -17,7 +17,7 @@ import {BOX_MODS} from './box.mods'
17
17
  const BOX_TODO_WARNING = 'Please double check the Box migration below'
18
18
  const FLEX_TODO_WARNING = 'Please double check the Flex migration below'
19
19
  const GRID_TODO_WARNING = 'Please double check the Grid migration below'
20
- const STYLED_TODO_WARNING = 'Please double check styled(Box) migration(s) below'
20
+ const STYLED_TODO_WARNING = 'Please double check styled(Box) migration below'
21
21
 
22
22
  /** @internal */
23
23
  export default function transform(
@@ -29,22 +29,48 @@ export default function transform(
29
29
 
30
30
  return transformComponent(fileInfo, api, ({j, root, markChanged}) => {
31
31
  const localNames = getComponentLocalNames(j, root, 'Box', options)
32
- const styledAliases = getStyledComponentAliases(j, root, localNames)
32
+ const styledAliases = getStyledComponentAliases(
33
+ j,
34
+ root,
35
+ 'Box',
36
+ fileInfo.path,
37
+ localNames,
38
+ options,
39
+ )
33
40
 
34
- if (!shouldTransformComponent(j, root, 'Box', localNames, options)) {
41
+ if (!shouldTransformComponent(j, root, 'Box', localNames, options, styledAliases)) {
35
42
  return
36
43
  }
37
44
 
38
- const replaceWithFlex = (attrs: (JSXAttribute | JSXSpreadAttribute)[]) => {
39
- const display = getStaticAttributeExpression(j, attrs, 'display')
40
- return display === 'flex' || display == 'inline-flex'
41
- }
45
+ const matchesDisplayValue = (
46
+ attrs: (JSXAttribute | JSXSpreadAttribute)[],
47
+ suffix: string,
48
+ exclude: string[],
49
+ ) => {
50
+ const display = getStaticAttributeExpression(j, attrs, 'display') || []
51
+ const values = Array.isArray(display) ? display : [display]
52
+ let matchesExclude
53
+
54
+ for (const excluded of exclude) {
55
+ if (values.some((value) => typeof value === 'string' && value.endsWith(excluded))) {
56
+ matchesExclude = true
57
+ break
58
+ }
59
+ }
60
+
61
+ if (matchesExclude) {
62
+ return false
63
+ }
42
64
 
43
- const replaceWithGrid = (attrs: (JSXAttribute | JSXSpreadAttribute)[]) => {
44
- const display = getStaticAttributeExpression(j, attrs, 'display')
45
- return display === 'grid' || display == 'inline-grid'
65
+ return values.some((value) => typeof value === 'string' && value.endsWith(suffix))
46
66
  }
47
67
 
68
+ const replaceWithFlex = (attrs: (JSXAttribute | JSXSpreadAttribute)[]) =>
69
+ matchesDisplayValue(attrs, 'flex', ['block', 'inline', 'grid'])
70
+
71
+ const replaceWithGrid = (attrs: (JSXAttribute | JSXSpreadAttribute)[]) =>
72
+ matchesDisplayValue(attrs, 'grid', ['block', 'inline', 'flex'])
73
+
48
74
  if (transformImport(j, root, 'Box', fromPackage, toPackage)) {
49
75
  markChanged()
50
76
  }
@@ -0,0 +1,7 @@
1
+ export function getElementMatchNames(element: string, localNames?: Iterable<string>): Set<string> {
2
+ if (localNames === undefined) {
3
+ return new Set([element])
4
+ }
5
+
6
+ return new Set(localNames)
7
+ }
@@ -0,0 +1,111 @@
1
+ import type {API, Expression, ExportSpecifier} from 'jscodeshift'
2
+
3
+ import {parseModule} from './parseModule'
4
+ import {resolveRelativeModulePath} from './resolveRelativeModulePath'
5
+
6
+ export type NamedExportInit = {
7
+ init: Expression
8
+ modulePath: string
9
+ }
10
+
11
+ function findVariableInit(
12
+ j: API['jscodeshift'],
13
+ root: ReturnType<API['jscodeshift']>,
14
+ name: string,
15
+ ): Expression | null {
16
+ let init: Expression | null = null
17
+
18
+ root.find(j.VariableDeclarator).forEach((path) => {
19
+ const {id} = path.node
20
+
21
+ if (id.type === 'Identifier' && id.name === name && path.node.init) {
22
+ init = path.node.init
23
+ }
24
+ })
25
+
26
+ return init
27
+ }
28
+
29
+ function getExportSpecifierName(exported: ExportSpecifier['exported']): string | null {
30
+ if (exported.type === 'Identifier' || exported.type === 'JSXIdentifier') {
31
+ return exported.name
32
+ }
33
+
34
+ return null
35
+ }
36
+
37
+ export function getNamedExportInit(
38
+ j: API['jscodeshift'],
39
+ exportName: string,
40
+ filePath: string,
41
+ visited: Set<string> = new Set(),
42
+ ): NamedExportInit | null {
43
+ if (visited.has(filePath)) {
44
+ return null
45
+ }
46
+
47
+ visited.add(filePath)
48
+
49
+ const root = parseModule(j, filePath)
50
+
51
+ if (!root) {
52
+ return null
53
+ }
54
+
55
+ for (const path of root.find(j.ExportNamedDeclaration).paths()) {
56
+ const {declaration, specifiers, source} = path.node
57
+ const reexportSource = typeof source?.value === 'string' ? source.value : null
58
+
59
+ if (declaration?.type === 'VariableDeclaration') {
60
+ for (const declarator of declaration.declarations) {
61
+ if (declarator.type !== 'VariableDeclarator') {
62
+ continue
63
+ }
64
+
65
+ if (
66
+ declarator.id.type === 'Identifier' &&
67
+ declarator.id.name === exportName &&
68
+ declarator.init
69
+ ) {
70
+ return {init: declarator.init, modulePath: filePath}
71
+ }
72
+ }
73
+ }
74
+
75
+ for (const spec of specifiers ?? []) {
76
+ if (spec.type !== 'ExportSpecifier') {
77
+ continue
78
+ }
79
+
80
+ const exported = getExportSpecifierName(spec.exported)
81
+
82
+ if (!exported || exported !== exportName) {
83
+ continue
84
+ }
85
+
86
+ const local = spec.local?.type === 'Identifier' ? spec.local.name : exported
87
+
88
+ if (reexportSource) {
89
+ const resolvedPath = resolveRelativeModulePath(filePath, reexportSource)
90
+
91
+ if (resolvedPath) {
92
+ const followed = getNamedExportInit(j, local, resolvedPath, visited)
93
+
94
+ if (followed) {
95
+ return followed
96
+ }
97
+ }
98
+
99
+ continue
100
+ }
101
+
102
+ const init = findVariableInit(j, root, local)
103
+
104
+ if (init) {
105
+ return {init, modulePath: filePath}
106
+ }
107
+ }
108
+ }
109
+
110
+ return null
111
+ }
@@ -1,15 +1,35 @@
1
1
  import type {API, JSXAttribute, JSXSpreadAttribute} from 'jscodeshift'
2
2
 
3
+ import type {AnyExpression} from '../types/AnyExpression'
3
4
  import {getAttribute} from './getAttribute'
4
5
  import {getAttributeExpression} from './getAttributeExpression'
5
6
 
6
7
  export type CompositePrimitive = string | number | boolean
7
8
 
9
+ export type StaticAttributeValue = CompositePrimitive | (CompositePrimitive | null | undefined)[]
10
+
11
+ function getStaticPrimitive(expr: AnyExpression): CompositePrimitive | undefined {
12
+ if (
13
+ expr.type === 'StringLiteral' ||
14
+ expr.type === 'NumericLiteral' ||
15
+ expr.type === 'BooleanLiteral' ||
16
+ expr.type === 'Literal'
17
+ ) {
18
+ const {value} = expr
19
+
20
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
21
+ return value
22
+ }
23
+ }
24
+
25
+ return undefined
26
+ }
27
+
8
28
  export function getStaticAttributeExpression(
9
29
  j: API['jscodeshift'],
10
30
  attrs: (JSXAttribute | JSXSpreadAttribute)[],
11
31
  name: string,
12
- ): CompositePrimitive | null {
32
+ ): StaticAttributeValue | null {
13
33
  const attr = getAttribute(attrs, name)
14
34
 
15
35
  if (!attr) {
@@ -22,14 +42,47 @@ export function getStaticAttributeExpression(
22
42
  return null
23
43
  }
24
44
 
25
- if (
26
- expr.type === 'StringLiteral' ||
27
- expr.type === 'NumericLiteral' ||
28
- expr.type === 'BooleanLiteral' ||
29
- expr.type === 'Literal'
30
- ) {
31
- return expr.value as string | number | boolean
45
+ const primitive = getStaticPrimitive(expr as AnyExpression)
46
+
47
+ if (primitive !== undefined) {
48
+ return primitive
49
+ }
50
+
51
+ if (expr.type !== 'ArrayExpression') {
52
+ return null
53
+ }
54
+
55
+ const elements = expr.elements as ((AnyExpression & {name?: string}) | null)[]
56
+ const values: (CompositePrimitive | null | undefined)[] = []
57
+
58
+ for (const element of elements) {
59
+ if (element === null || element.type === 'NullLiteral') {
60
+ values.push(null)
61
+ continue
62
+ }
63
+
64
+ if (element.type === 'SpreadElement') {
65
+ return null
66
+ }
67
+
68
+ if (element.type === 'Literal' && element.value === null) {
69
+ values.push(null)
70
+ continue
71
+ }
72
+
73
+ if (element.type === 'Identifier' && element.name === 'undefined') {
74
+ values.push(undefined)
75
+ continue
76
+ }
77
+
78
+ const elementValue = getStaticPrimitive(element)
79
+
80
+ if (elementValue === undefined) {
81
+ return null
82
+ }
83
+
84
+ values.push(elementValue)
32
85
  }
33
86
 
34
- return null
87
+ return values
35
88
  }
@@ -1,8 +1,13 @@
1
1
  import type {API, Collection} from 'jscodeshift'
2
2
 
3
+ import type {BaseOptions} from '../types/BaseOptions'
4
+ import {getComponentLocalNames} from './getComponentLocalNames'
5
+ import {getNamedExportInit} from './getNamedExportInit'
3
6
  import {getStyledComponentName} from './getStyledComponentName'
7
+ import {parseModule} from './parseModule'
8
+ import {resolveRelativeModulePath} from './resolveRelativeModulePath'
4
9
 
5
- export function getStyledComponentAliases(
10
+ export function getSameFileStyledComponentAliases(
6
11
  j: API['jscodeshift'],
7
12
  root: Collection,
8
13
  localNames: Iterable<string>,
@@ -26,3 +31,82 @@ export function getStyledComponentAliases(
26
31
 
27
32
  return aliases
28
33
  }
34
+
35
+ export function getImportedStyledComponentAliases(
36
+ j: API['jscodeshift'],
37
+ root: Collection,
38
+ componentName: string,
39
+ filePath: string | undefined,
40
+ options?: BaseOptions,
41
+ ): Set<string> {
42
+ const aliases = new Set<string>()
43
+
44
+ if (!filePath) {
45
+ return aliases
46
+ }
47
+
48
+ root.find(j.ImportDeclaration).forEach((path) => {
49
+ const source = path.node.source.value
50
+
51
+ if (typeof source !== 'string') {
52
+ return
53
+ }
54
+
55
+ const resolvedPath = resolveRelativeModulePath(filePath, source)
56
+
57
+ if (!resolvedPath) {
58
+ return
59
+ }
60
+
61
+ for (const spec of path.node.specifiers ?? []) {
62
+ if (spec.type !== 'ImportSpecifier') {
63
+ continue
64
+ }
65
+
66
+ if ('importKind' in spec && spec.importKind === 'type') {
67
+ continue
68
+ }
69
+
70
+ if (spec.imported.type !== 'Identifier') {
71
+ continue
72
+ }
73
+
74
+ const exportName = spec.imported.name
75
+ const localName = spec.local?.type === 'Identifier' ? spec.local.name : exportName
76
+ const namedExport = getNamedExportInit(j, exportName, resolvedPath)
77
+
78
+ if (!namedExport) {
79
+ continue
80
+ }
81
+
82
+ const sourceRoot = parseModule(j, namedExport.modulePath)
83
+
84
+ if (!sourceRoot) {
85
+ continue
86
+ }
87
+
88
+ const exportLocalNames = getComponentLocalNames(j, sourceRoot, componentName, options)
89
+ const baseComponent = getStyledComponentName(namedExport.init)
90
+
91
+ if (baseComponent && exportLocalNames.has(baseComponent)) {
92
+ aliases.add(localName)
93
+ }
94
+ }
95
+ })
96
+
97
+ return aliases
98
+ }
99
+
100
+ export function getStyledComponentAliases(
101
+ j: API['jscodeshift'],
102
+ root: Collection,
103
+ componentName: string,
104
+ filePath: string | undefined,
105
+ localNames: Set<string>,
106
+ options?: BaseOptions,
107
+ ): Set<string> {
108
+ const sameFile = getSameFileStyledComponentAliases(j, root, localNames)
109
+ const imported = getImportedStyledComponentAliases(j, root, componentName, filePath, options)
110
+
111
+ return new Set([...sameFile, ...imported])
112
+ }
@@ -1,10 +1,4 @@
1
- import {
2
- type API,
3
- type ASTPath,
4
- type ImportDeclaration,
5
- type JSXOpeningElement,
6
- type VariableDeclarator,
7
- } from 'jscodeshift'
1
+ import {type API, type ASTPath, type ImportDeclaration, type JSXOpeningElement} from 'jscodeshift'
8
2
 
9
3
  type CommentableNode = {
10
4
  comments?: {type: string; value: string; leading?: boolean}[] | null
@@ -16,19 +10,13 @@ type CommentableNode = {
16
10
  */
17
11
  export function insertTodoWarning(
18
12
  j: API['jscodeshift'],
19
- path: ASTPath<JSXOpeningElement | ImportDeclaration | VariableDeclarator>,
13
+ path: ASTPath<JSXOpeningElement | ImportDeclaration>,
20
14
  warning: string,
21
15
  ): boolean {
22
16
  let target: CommentableNode | null = null
23
17
 
24
18
  if (path.node.type === 'ImportDeclaration') {
25
19
  target = path.node
26
- } else if (path.node.type === 'VariableDeclarator') {
27
- const parent = path.parent
28
-
29
- if (parent?.node.type === 'VariableDeclaration') {
30
- target = parent.node
31
- }
32
20
  } else if (path.parent?.node.type === 'JSXElement') {
33
21
  target = path.parent.node
34
22
  }
@@ -0,0 +1,29 @@
1
+ import {readFileSync} from 'node:fs'
2
+
3
+ import type {API, Collection} from 'jscodeshift'
4
+
5
+ const parseCache = new Map<string, Collection>()
6
+
7
+ export function parseModule(j: API['jscodeshift'], filePath: string): Collection | null {
8
+ const cached = parseCache.get(filePath)
9
+
10
+ if (cached) {
11
+ return cached
12
+ }
13
+
14
+ try {
15
+ const source = readFileSync(filePath, 'utf8')
16
+ const root = j(source)
17
+
18
+ parseCache.set(filePath, root)
19
+
20
+ return root
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ /** @internal */
27
+ export function clearModuleParseCache(): void {
28
+ parseCache.clear()
29
+ }