|
22 | 22 | * SOFTWARE. |
23 | 23 | */ |
24 | 24 |
|
25 | | -import type { Transform } from 'jscodeshift' |
| 25 | +import type { Collection, JSCodeshift, Transform } from 'jscodeshift' |
26 | 26 | import type { InstUICodemod } from './utils/instUICodemodExecutor' |
27 | 27 | 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' |
30 | 33 | import fs from 'fs' |
31 | 34 | import path from 'path' |
32 | 35 | import { fileURLToPath } from 'url' |
33 | 36 |
|
34 | 37 | const __filename = fileURLToPath(import.meta.url) |
35 | 38 | const __dirname = path.dirname(__filename) |
36 | 39 |
|
| 40 | +const IMPORT_PATHS = [ |
| 41 | + '@instructure/ui-icons', |
| 42 | + '@instructure/ui-icons/es/svg', |
| 43 | + '@instructure/ui' |
| 44 | +] |
| 45 | + |
37 | 46 | const mappingData = JSON.parse( |
38 | 47 | fs.readFileSync( |
39 | 48 | path.join(__dirname, '../../ui-icons/src/lucide/mapping.json'), |
40 | 49 | 'utf-8' |
41 | 50 | ) |
42 | 51 | ) |
43 | | -const iconMapping = mappingData.mappingOverrides as Record<string, string> |
44 | 52 |
|
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> |
50 | 54 |
|
51 | | -/** |
52 | | - * Convert kebab-case to PascalCase. |
53 | | - * Example: 'accessibility-2' -> 'Accessibility2' |
54 | | - */ |
55 | 55 | function kebabToPascal(str: string): string { |
56 | 56 | return str |
57 | 57 | .split('-') |
58 | 58 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) |
59 | 59 | .join('') |
60 | 60 | } |
61 | 61 |
|
| 62 | +const LEGACY_ICON_PATTERN = /^Icon(.+?)(Line|Solid)$/ |
| 63 | + |
62 | 64 | /** |
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 |
66 | 66 | */ |
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 | + ) |
73 | 85 | } |
74 | 86 |
|
75 | 87 | /** |
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}`). |
77 | 92 | */ |
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 |
85 | 95 |
|
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' |
94 | 98 |
|
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}` |
102 | 101 |
|
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 |
115 | 104 | } |
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 |
127 | 105 | } |
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 | + } |
137 | 107 |
|
138 | | - const newName = replacements.get(opening.name.name) |
139 | | - if (!newName) return |
| 108 | + return didChange |
| 109 | +} |
140 | 110 |
|
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 | +} |
161 | 124 |
|
162 | | - return true |
| 125 | +const migrateToNewIcons: Transform = (file, api, options) => { |
| 126 | + return instUICodemodExecutor(migrateToNewIconsCodemod, file, api, options) |
163 | 127 | } |
164 | 128 |
|
165 | 129 | export default migrateToNewIcons |
|
0 commit comments