Skip to content

Commit c8ce680

Browse files
committed
refactor(ui-codemods): add ui metapackage to icon codemod and simplify it
1 parent ab2c8c2 commit c8ce680

File tree

10 files changed

+140
-144
lines changed

10 files changed

+140
-144
lines changed

packages/ui-codemods/lib/__node_tests__/__testfixtures__/migrateToNewIcons/basic.input.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import React from 'react'
2-
import { IconA11yLine, IconAddLine, IconAdminLine } from '@instructure/ui-icons'
2+
import {
3+
IconA11yLine,
4+
IconAddLine,
5+
IconAdminLine,
6+
IconSearchLine,
7+
IconAiSolid
8+
} from '@instructure/ui-icons'
39

410
function MyComponent() {
511
return (
612
<div>
713
<IconA11yLine />
814
<IconAddLine />
915
<IconAdminLine />
16+
<IconSearchLine />
17+
<IconAiSolid />
1018
</div>
1119
)
1220
}

packages/ui-codemods/lib/__node_tests__/__testfixtures__/migrateToNewIcons/basic.output.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1+
import React from 'react'
12
import {
23
Accessibility2InstUIIcon,
34
PlusInstUIIcon,
4-
ShieldUserInstUIIcon
5+
ShieldUserInstUIIcon,
6+
SearchInstUIIcon,
7+
IgniteaiLogoInstUIIcon
58
} from '@instructure/ui-icons'
6-
import React from 'react'
79

810
function MyComponent() {
911
return (
1012
<div>
1113
<Accessibility2InstUIIcon />
1214
<PlusInstUIIcon />
1315
<ShieldUserInstUIIcon />
16+
<SearchInstUIIcon />
17+
<IgniteaiLogoInstUIIcon />
1418
</div>
1519
)
1620
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from 'react'
2+
import {
3+
Button,
4+
IconSearchLine,
5+
IconAddLine,
6+
IconInfoLine,
7+
Text
8+
} from '@instructure/ui'
9+
10+
function MyComponent() {
11+
return (
12+
<div>
13+
<Button renderIcon={<IconSearchLine />}>Click me</Button>
14+
<Button renderIcon={IconInfoLine}>Info</Button>
15+
<IconAddLine />
16+
<Text>Hello</Text>
17+
</div>
18+
)
19+
}
20+
21+
export default MyComponent
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from 'react'
2+
import {
3+
Button,
4+
SearchInstUIIcon,
5+
PlusInstUIIcon,
6+
InfoInstUIIcon,
7+
Text
8+
} from '@instructure/ui'
9+
10+
function MyComponent() {
11+
return (
12+
<div>
13+
<Button renderIcon={<SearchInstUIIcon />}>Click me</Button>
14+
<Button renderIcon={InfoInstUIIcon}>Info</Button>
15+
<PlusInstUIIcon />
16+
<Text>Hello</Text>
17+
</div>
18+
)
19+
}
20+
21+
export default MyComponent

packages/ui-codemods/lib/__node_tests__/__testfixtures__/migrateToNewIcons/solidIcons.input.tsx

Lines changed: 0 additions & 13 deletions
This file was deleted.

packages/ui-codemods/lib/__node_tests__/__testfixtures__/migrateToNewIcons/solidIcons.output.tsx

Lines changed: 0 additions & 13 deletions
This file was deleted.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[
2-
"No mapping found for icon: IconFakeIconThatDoesNotExistLine. Please migrate manually."
2+
"No mapping found for \"IconFakeIconThatDoesNotExistLine\". Please migrate manually."
33
]

packages/ui-codemods/lib/__node_tests__/__testfixtures__/migrateToNewIcons/withAliases.output.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { Accessibility2InstUIIcon } from '@instructure/ui-icons'
21
import React from 'react'
2+
import { Accessibility2InstUIIcon as A11yIcon } from '@instructure/ui-icons'
33

44
function MyComponent() {
55
return (
66
<div>
7-
<Accessibility2InstUIIcon />
7+
<A11yIcon />
88
</div>
99
)
1010
}

packages/ui-codemods/lib/migrateToNewIcons.ts

Lines changed: 64 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -22,144 +22,108 @@
2222
* SOFTWARE.
2323
*/
2424

25-
import type { Transform } from 'jscodeshift'
25+
import type { Collection, JSCodeshift, Transform } from 'jscodeshift'
2626
import type { InstUICodemod } from './utils/instUICodemodExecutor'
2727
import instUICodemodExecutor from './utils/instUICodemodExecutor'
28-
import { printWarning } from './utils/codemodHelpers'
29-
import { isImportSpecifier } from './utils/codemodTypeCheckers'
28+
import {
29+
findEveryImport,
30+
printWarning,
31+
renameImportAndUsages
32+
} from './utils/codemodHelpers'
3033
import fs from 'fs'
3134
import path from 'path'
3235
import { fileURLToPath } from 'url'
3336

3437
const __filename = fileURLToPath(import.meta.url)
3538
const __dirname = path.dirname(__filename)
3639

40+
const IMPORT_PATHS = [
41+
'@instructure/ui-icons',
42+
'@instructure/ui-icons/es/svg',
43+
'@instructure/ui'
44+
]
45+
3746
const mappingData = JSON.parse(
3847
fs.readFileSync(
3948
path.join(__dirname, '../../ui-icons/src/lucide/mapping.json'),
4049
'utf-8'
4150
)
4251
)
43-
const iconMapping = mappingData.mappingOverrides as Record<string, string>
4452

45-
const ICON_IMPORT_PATHS = [
46-
'@instructure/ui-icons',
47-
'@instructure/ui-icons/es/svg'
48-
]
49-
const NEW_ICON_IMPORT_PATH = '@instructure/ui-icons'
53+
const iconMappings = mappingData.mappingOverrides as Record<string, string>
5054

51-
/**
52-
* Convert kebab-case to PascalCase.
53-
* Example: 'accessibility-2' -> 'Accessibility2'
54-
*/
5555
function kebabToPascal(str: string): string {
5656
return str
5757
.split('-')
5858
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
5959
.join('')
6060
}
6161

62+
const LEGACY_ICON_PATTERN = /^Icon(.+?)(Line|Solid)$/
63+
6264
/**
63-
* Returns the new icon export name for the given legacy icon name,
64-
* or null if no mapping exists.
65-
* Example: 'IconA11yLine' -> 'Accessibility2InstUIIcon'
65+
* Warns about legacy icons that has no mappings and have to be migrated manually
6666
*/
67-
function getNewIconName(oldName: string): string | null {
68-
const match = oldName.match(/^Icon(.+?)(Line|Solid)$/)
69-
if (!match) return null
70-
const mappedName = iconMapping[match[1]]
71-
if (!mappedName) return null
72-
return kebabToPascal(mappedName) + 'InstUIIcon'
67+
function warnUnmappedIcons(
68+
j: JSCodeshift,
69+
root: Collection,
70+
filePath: string
71+
): void {
72+
// findEveryImport only accepts a single path, so flatMap collects results across all paths
73+
IMPORT_PATHS.flatMap((p) => findEveryImport(j, root, p)).forEach(
74+
(localName) => {
75+
const match = localName.match(LEGACY_ICON_PATTERN)
76+
if (!match || iconMappings[match[1]]) return
77+
78+
printWarning(
79+
filePath,
80+
undefined,
81+
`No mapping found for "${localName}". Please migrate manually.`
82+
)
83+
}
84+
)
7385
}
7486

7587
/**
76-
* Migrates from legacy InstUI icons to new lucide and custom icons
88+
* Iterates every known legacy icon name (derived from mapping.json) and migrates it:
89+
* 1. Renames the import specifier in-place
90+
* 2. Renames JSX elements
91+
* 3. Renames references (`renderIcon={OldIcon}`).
7792
*/
78-
const migrateToNewIcons: Transform = (
79-
file,
80-
api,
81-
options?: { fileName?: string; usePrettier?: boolean }
82-
) => {
83-
return instUICodemodExecutor(migrateToNewIconsCodemod, file, api, options)
84-
}
93+
function migrateIcons(j: JSCodeshift, root: Collection): boolean {
94+
let didChange = false
8595

86-
const migrateToNewIconsCodemod: InstUICodemod = (j, root, filePath) => {
87-
let hasModifications = false
88-
const newIconNames = new Set<string>()
89-
// localIconName -> newIconName (handles aliased imports like `import { A as B }`)
90-
const replacements = new Map<string, string>()
91-
92-
root.find(j.ImportDeclaration).forEach((path) => {
93-
if (!ICON_IMPORT_PATHS.includes(path.node.source.value as string)) return
96+
for (const [iconSuffix, newIconKey] of Object.entries(iconMappings)) {
97+
const newName = kebabToPascal(newIconKey) + 'InstUIIcon'
9498

95-
const remainingSpecifiers: typeof path.node.specifiers = []
96-
97-
path.node.specifiers?.forEach((spec) => {
98-
if (!isImportSpecifier(spec)) {
99-
remainingSpecifiers.push(spec)
100-
return
101-
}
99+
for (const variant of ['Line', 'Solid']) {
100+
const legacyName = `Icon${iconSuffix}${variant}`
102101

103-
const oldIconName = spec.imported.name as string
104-
const localIconName = (spec.local?.name as string) ?? oldIconName
105-
const newIconName = getNewIconName(oldIconName)
106-
107-
if (!newIconName) {
108-
remainingSpecifiers.push(spec)
109-
printWarning(
110-
filePath,
111-
spec.loc?.start.line,
112-
`No mapping found for icon: ${oldIconName}. Please migrate manually.`
113-
)
114-
return
102+
if (renameImportAndUsages(j, root, legacyName, newName, IMPORT_PATHS)) {
103+
didChange = true
115104
}
116-
117-
replacements.set(localIconName, newIconName)
118-
newIconNames.add(newIconName)
119-
hasModifications = true
120-
})
121-
122-
if (remainingSpecifiers.length === 0) {
123-
j(path).remove()
124-
} else {
125-
// eslint-disable-next-line no-param-reassign
126-
path.node.specifiers = remainingSpecifiers
127105
}
128-
})
129-
130-
if (!hasModifications) return false
131-
132-
// Rename JSX usages
133-
root.find(j.JSXElement).forEach((path) => {
134-
const opening = path.node.openingElement
135-
const closing = path.node.closingElement
136-
if (opening.name.type !== 'JSXIdentifier') return
106+
}
137107

138-
const newName = replacements.get(opening.name.name)
139-
if (!newName) return
108+
return didChange
109+
}
140110

141-
opening.name.name = newName
142-
if (closing?.name.type === 'JSXIdentifier') {
143-
closing.name.name = newName
144-
}
145-
})
146-
147-
// Add new import at the top, sorted alphabetically
148-
const specifiers = Array.from(newIconNames)
149-
.sort()
150-
.map((name) => j.importSpecifier(j.identifier(name), j.identifier(name)))
151-
const newImport = j.importDeclaration(
152-
specifiers,
153-
j.literal(NEW_ICON_IMPORT_PATH)
154-
)
155-
const firstImport = root.find(j.ImportDeclaration).at(0)
156-
if (firstImport.length > 0) {
157-
firstImport.insertBefore(newImport)
158-
} else {
159-
root.get().node.program.body.unshift(newImport)
160-
}
111+
/**
112+
* Migrates legacy InstUI icons (`IconXxxLine` / `IconXxxSolid`) to new
113+
* `XxxInstUIIcon` components.
114+
*
115+
* Renames specifiers in-place for imports from:
116+
* - `@instructure/ui-icons`
117+
* - `@instructure/ui-icons/es/svg`
118+
* - `@instructure/ui`
119+
*/
120+
const migrateToNewIconsCodemod: InstUICodemod = (j, root, filePath) => {
121+
warnUnmappedIcons(j, root, filePath)
122+
return migrateIcons(j, root)
123+
}
161124

162-
return true
125+
const migrateToNewIcons: Transform = (file, api, options) => {
126+
return instUICodemodExecutor(migrateToNewIconsCodemod, file, api, options)
163127
}
164128

165129
export default migrateToNewIcons

packages/ui-codemods/lib/utils/codemodHelpers.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -516,13 +516,17 @@ function addImportIfNeeded(
516516
* import { oldName as localName } from "@import-path"
517517
* oldName()
518518
* localName()
519+
* <oldName />
520+
* <localName />
519521
* ```
520522
* with
521523
* ```
522524
* import { newName } from "@import-path"
523525
* import { newName as localName } from "@import-path"
524526
* newName()
525527
* localName()
528+
* <newName />
529+
* <localName />
526530
* ```
527531
*
528532
* @param j - jscodeshift API
@@ -553,19 +557,19 @@ function renameImportAndUsages(
553557
// eslint-disable-next-line no-param-reassign
554558
specifier.imported.name = newName
555559

556-
// Rename usages if no alias (no 'as' syntax)
557-
if (!specifier.local || specifier.local.name === oldName) {
558-
const localName = specifier.local ? specifier.local.name : oldName
560+
// Rename usages if no alias (no 'as' syntax)
561+
if (!specifier.local || specifier.local.name === oldName) {
562+
const localName = specifier.local ? specifier.local.name : oldName
559563

560-
root
561-
.find(j.Identifier, { name: localName as string})
562-
.forEach((idPath) => {
563-
// Make sure this identifier is not inside import declaration
564-
if (idPath.parentPath.node.type !== 'ImportSpecifier') {
565-
// eslint-disable-next-line no-param-reassign
566-
idPath.node.name = newName
567-
}
568-
})
564+
root
565+
.find(j.Identifier, { name: localName as string })
566+
.forEach((idPath) => {
567+
// Make sure this identifier is not inside import declaration
568+
if (idPath.parentPath.node.type !== 'ImportSpecifier') {
569+
// eslint-disable-next-line no-param-reassign
570+
idPath.node.name = newName
571+
}
572+
})
569573
}
570574

571575
didRename = true

0 commit comments

Comments
 (0)