-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathimplicit-icons-to-explicit-imports.ts
362 lines (320 loc) · 11.1 KB
/
implicit-icons-to-explicit-imports.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
import { Transform, Core, JSCodeshift, ImportSpecifier } from "jscodeshift";
import { camel, pascal } from "change-case";
/**
* The default name of the icon component provided by Font Awesome React. It's
* possible it's aliased or wrapped in another component which can be configured
* via the `componentName` option.
*/
const DEFAULT_COMPONENT = "FontAwesomeIcon";
/**
* The default name of the icon prop that the name (or value) of the icon is
* passed to. By default, Font Awesome React exposes this as the `icon` prop
* but it's possible it's wrapped or passed to another component. This can be
* configured via the `propName` option.
*/
const DEFAULT_PROP = "icon";
/**
* The default package types to use. Font Awesome provides both free and pro
* icons. This can be configured via the `type` option.
*/
const DEFAULT_PACKAGE_KEY = "free";
// Assume the brand icons are both free and pro because there is only a single
// icon package for brand icons.
const BRAND_ICONS = "@fortawesome/free-brands-svg-icons";
const PACKAGES = {
pro: {
fab: BRAND_ICONS,
fal: "@fortawesome/pro-light-svg-icons",
far: "@fortawesome/pro-regular-svg-icons",
fas: "@fortawesome/pro-solid-svg-icons"
},
free: {
fab: BRAND_ICONS,
far: "@fortawesome/free-regular-svg-icons",
fas: "@fortawesome/free-solid-svg-icons"
}
};
type FontStyle = keyof typeof PACKAGES["pro"];
/**
* Options accepted by this transform to customize the behavior.
*/
export interface TransformOptions {
/**
* The name of the component to update it's icon prop with an explicit import.
* This can also accept a sub-component referenced with dot-notation. For
* example, `Dot.Notation` if it's used as `<Dot.Notation icon="..." />`.
*
* @default FontAwesomeIcon
*/
componentName?: string;
/**
* The name of the component's prop name to update with an explicit import.
*
* @default icon
*/
propName?: string;
/**
* The type of icons being used, either "free" or "pro".
*
* @default free
*/
type?: "free" | "pro";
}
/**
* Transform the string icon name to a valid Font Awesome icon definition.
*
* @example `user` -> `faUser`
* @param icon the string representation of the icon
*/
const transformName = (icon: string) => camel(`fa-${icon}`);
/**
* Find all uses of a given component in the root.
*
* @param componentName the name of the icon component
* @param root the parsed source
* @param j the jscodeshift library
*/
const findComponents = (
componentName: string,
root: ReturnType<Core>,
j: JSCodeshift
) => {
// Check if the component being referenced is using dot notation
// (eg: `Dot.Notation`). It needs to be handled slightly differently.
const IS_DOT_NOTATION = componentName.indexOf(".") !== -1;
if (IS_DOT_NOTATION) {
const [objectName, propertyName] = componentName.split(".");
// Find all icon components by querying for the dot-notation
return root.find(j.JSXElement, {
openingElement: {
name: { object: { name: objectName }, property: { name: propertyName } }
}
});
} else {
// Find all icon components directly finding the element
return root.findJSXElements(componentName);
}
};
/**
* Given two import specifiers, determine which should be first.
*
* @param firstImportSpecifier the first element to compare.
* @param secondImportSpecifier the second element to compare.
*/
const sortImportSpecifiers = (
{ imported: { name: firstName } }: ImportSpecifier,
{ imported: { name: secondName } }: ImportSpecifier
) => {
if (firstName < secondName) return -1;
if (firstName > secondName) return 1;
return 0;
};
/**
* The custom transform adhering to the jscodeshift API.
*/
const transform: Transform = (
file,
api,
{
componentName = DEFAULT_COMPONENT,
propName = DEFAULT_PROP,
type = DEFAULT_PACKAGE_KEY
}: TransformOptions
) => {
// Alias the jscodeshift API for ease of use.
const j = api.jscodeshift;
// Convert the entire file source into a collection of nodes paths.
const root = j(file.source);
// Find the very first import statement (assuming there must be one to be
// using a Font Awesome React component). This is used as a reference to
// insert all new imports that need to be added to explicitly import icons.
const FIRST_IMPORT = root.find(j.ImportDeclaration).at(0);
// Keep track if any updates get made. If not, no need to update.
let hasModifications = false;
/**
* Given an icon name (eg: `user`), transform that to the proper name
* (eg: `faUser`) and add an import. This also accounts for naming collisions
* if an icon by the same name is imported from multiple packages.
*
* @returns the icon definition that is imported and that should be used.
*
* @param iconName the icon name to import, eg: `user`.
* @param fontStyle the icon's font style, if blank it's assumed to be `fas`.
*/
const updateImports = (
iconName: string,
fontStyle: FontStyle = "fas"
): string => {
const pkg = PACKAGES[type][fontStyle];
const iconDefinition = transformName(iconName);
let localIconDefinition = iconDefinition;
// Find imports that already have this icon identifer that are not in the
// same package. If it's the same package we deduplicate later. However, if
// it's a different package an alias needs to be used to avoid naming
// collisions with the same icon being imported from different packages.
// Unique-ify by appending the icon font style and assuming that's
// sufficient.
const duplicateIcons = root
// All import statements...
.find(j.ImportDeclaration)
// from another package...
.filter(imported => imported.node.source.value !== pkg)
// that match this icon's definition
.find(j.ImportSpecifier, {
imported: {
type: "Identifier",
name: iconDefinition
}
});
// If the icon definition is already imported from a different package
// create a unique name specific to this icon and package's font style.
if (duplicateIcons.size() > 0) {
localIconDefinition = `${iconDefinition}${pascal(fontStyle)}`;
}
const existingImport = root
// All import statements...
.find(j.ImportDeclaration)
// from this same package...
.filter(imported => imported.node.source.value === pkg)
// that is the first (should only be one)
.at(0);
// First, check for an already existing import and insert the new import
// into this existing import
if (existingImport.size() > 0) {
const existingSpecifiers = existingImport
// All the specifiers (other imported values)...
.find(j.ImportSpecifier)
// as nodes (not the node paths)
.nodes();
// Add our new icon definition to the existing list of imported values.
const specifiers = [
j.importSpecifier(
j.identifier(iconDefinition),
j.identifier(localIconDefinition)
),
...existingSpecifiers
]
// Enforce deterministic sorting to ease testing.
.sort(sortImportSpecifiers)
// Ensure the import values are unique (in case there were already
// duplicates or the new one already exists).
.filter(
(specifier, index, self) =>
self.findIndex(
otherSpecifier =>
otherSpecifier.imported.name === specifier.imported.name
) === index
);
// Construct a new import statement to replace the existing one
const updatedImport = j.importDeclaration(
specifiers,
j.stringLiteral(pkg),
"value"
);
existingImport.replaceWith(updatedImport);
} else {
// Otherwise, insert and create a new import statement since one for this
// package didn't already exist.
FIRST_IMPORT.insertAfter(
j.importDeclaration(
[
j.importSpecifier(
j.identifier(iconDefinition),
j.identifier(localIconDefinition)
)
],
j.stringLiteral(pkg),
"value"
)
);
}
// The caller needs to use this value to update any usages.
return localIconDefinition;
};
const iconComponents = findComponents(componentName, root, j);
// Find all the JSX attributes that are an `icon` with an array value
iconComponents
.find(j.JSXAttribute, {
name: {
type: "JSXIdentifier",
name: propName
},
value: {
type: "JSXExpressionContainer",
expression: {
type: "ArrayExpression"
}
}
})
.find(j.ArrayExpression)
.filter(nodePath => {
const [iconType, iconName] = nodePath.node.elements;
// Validate that we can actually modify the array value and it has two
// string values. If both aren't strings, it can't be handled so print
// an error message.
if (
iconType.type !== "StringLiteral" ||
iconName.type !== "StringLiteral"
) {
console.error(
`expected: icon={[StringLiteral, StringLiteral]}\nreceived: icon={[${iconType.type}, ${iconName.type}]}\nManually update: ${file.path}`
);
// Remove this element from the replace action, the array is invalid
// and we don't know how to auto-magically update this variable.
return false;
} else {
// Continue with the replace action, the array is valid.
return true;
}
})
.replaceWith(nodePath => {
const [iconType, iconName] = nodePath.node.elements;
// This check has already been performed but it's necessary for TS to
// properly narrow the types. This `return` code path should never be hit.
if (
iconType.type !== "StringLiteral" ||
iconName.type !== "StringLiteral"
) {
return;
}
// Reassign incase the import had to modify the icon name to avoid a
// collision.
const iconDefinition = updateImports(
iconName.value,
iconType.value as FontStyle
);
// Replace the array with a JSX expression value with the now
// import font name.
const newNode = j.identifier(iconDefinition);
hasModifications = true;
return newNode;
});
// Find all the JSX attributes that are an `icon` with a string value
iconComponents
.find(j.JSXAttribute, {
name: {
type: "JSXIdentifier",
name: propName
},
value: {
type: "StringLiteral"
}
})
// Find the `icon` props with a string value and replace with a JSX
// expression, eg: "fa-twitter" -> {faTwitter}
.find(j.StringLiteral)
.replaceWith(nodePath => {
const { node } = nodePath;
// Reassign incase the import had to modify the icon name to avoid a
// collision.
// Update the imports to allow use
const iconDefinition = updateImports(node.value);
// Replace the string value with a JSX expression value with the now
// import font name.
const newNode = j.jsxExpressionContainer(j.identifier(iconDefinition));
hasModifications = true;
return newNode;
});
return hasModifications ? root.toSource() : null;
};
export default transform;