Skip to content

Commit 195459a

Browse files
gitstart-sourcegraphgitstartvalerybugakov
authored
Wildcard V2: <Icon /> Codemod (#87)
Co-authored-by: gitstart-sourcegraph <[email protected]> Co-authored-by: Valery Bugakov <[email protected]>
1 parent 59ccf3a commit 195459a

File tree

6 files changed

+227
-0
lines changed

6 files changed

+227
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Icon element to `<Icon />` Wildcard component codemod
2+
3+
yarn transform --write -t ./packages/transforms/src/iconToComponent/iconToComponent.ts '/sourcegraph/client/!(wildcard)/src/\*_/_.{ts,tsx}'
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { testCodemod } from '@sourcegraph/codemod-toolkit-ts'
2+
3+
import { iconToComponent } from '../iconToComponent'
4+
5+
testCodemod('iconToComponent', iconToComponent, [
6+
{
7+
label: 'case 1',
8+
initialSource: 'export const Test = <CloseIcon className="icon-inline hello" />',
9+
expectedSource: `
10+
import { Icon } from '@sourcegraph/wildcard'
11+
12+
export const Test = <Icon className="hello" as={CloseIcon} />
13+
`,
14+
},
15+
{
16+
label: 'case 2',
17+
initialSource: 'export const Test = <ConsoleIcon className="icon-inline-md hello" />',
18+
expectedSource: `
19+
import { Icon } from '@sourcegraph/wildcard'
20+
21+
export const Test = <Icon className="hello" as={ConsoleIcon} size="md" />
22+
`,
23+
},
24+
{
25+
label: 'case 3',
26+
initialSource: `
27+
import classNames from 'classnames'
28+
export const Test = <ConsoleIcon className={classNames('icon-inline-md hello', styles.consoleIcon)} />`,
29+
expectedSource: `
30+
import classNames from 'classnames'
31+
32+
import { Icon } from '@sourcegraph/wildcard'
33+
34+
export const Test = <Icon className={classNames('hello', styles.consoleIcon)} as={ConsoleIcon} size="md" />
35+
`,
36+
},
37+
{
38+
label: 'case 4',
39+
initialSource: `
40+
import classNames from 'classnames'
41+
export const Test = <ConsoleIcon aria-label="Console icon" className={classNames('icon-inline-md', styles.consoleIcon)} />`,
42+
expectedSource: `
43+
import { Icon } from '@sourcegraph/wildcard'
44+
45+
export const Test = <Icon aria-label="Console icon" className={styles.consoleIcon} as={ConsoleIcon} size="md" />
46+
`,
47+
},
48+
])
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ts } from 'ts-morph'
2+
3+
export const ICON_SIZES = ['sm', 'md'] as const
4+
5+
export interface ClassNameMapping {
6+
className: string
7+
props: {
8+
name: string
9+
value: ts.Node
10+
}[]
11+
}
12+
13+
const sizeClassNamesMapping: ClassNameMapping[] = ICON_SIZES.map(size => {
14+
return {
15+
className: `icon-inline-${size}`,
16+
props: [
17+
{
18+
name: 'size',
19+
value: ts.factory.createStringLiteral(size),
20+
},
21+
],
22+
}
23+
})
24+
25+
export const iconClassNamesMapping: ClassNameMapping[] = [
26+
...sizeClassNamesMapping,
27+
{
28+
className: 'icon-inline',
29+
props: [],
30+
},
31+
]
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Node, printNode, ts } from 'ts-morph'
2+
3+
import {
4+
removeClassNameAndUpdateJsxElement,
5+
addOrUpdateSourcegraphWildcardImportIfNeeded,
6+
} from '@sourcegraph/codemod-toolkit-packages'
7+
import {
8+
runTransform,
9+
getParentUntilOrThrow,
10+
isJsxTagElement,
11+
getTagName,
12+
JsxTagElement,
13+
setOnJsxTagElement,
14+
} from '@sourcegraph/codemod-toolkit-ts'
15+
16+
import { validateCodemodTarget, validateCodemodTargetOrThrow } from './validateCodemodTarget'
17+
18+
/**
19+
* Convert `<SomeIcon class="icon-inline" />` element to the `<Icon svg={<SomeIcon />} />` component.
20+
*/
21+
export const iconToComponent = runTransform(context => {
22+
const { throwManualChangeError, addManualChangeLog } = context
23+
24+
const jsxTagElementsToUpdate = new Set<JsxTagElement>()
25+
26+
return {
27+
StringLiteral(stringLiteral) {
28+
const { classNameMappings } = validateCodemodTargetOrThrow.StringLiteral(stringLiteral)
29+
const jsxAttribute = getParentUntilOrThrow(stringLiteral, Node.isJsxAttribute)
30+
31+
if (!/classname/i.test(jsxAttribute.getName())) {
32+
return
33+
}
34+
35+
const jsxTagElement = getParentUntilOrThrow(jsxAttribute, isJsxTagElement)
36+
37+
if (!validateCodemodTarget.JsxTagElement(jsxTagElement)) {
38+
throwManualChangeError({
39+
node: jsxTagElement,
40+
message: `Class '${stringLiteral.getLiteralText()}' is used on the '${getTagName(
41+
jsxTagElement
42+
)}' element. Please update it manually.`,
43+
})
44+
}
45+
46+
for (const { className, props } of classNameMappings) {
47+
const { isRemoved, manualChangeLog } = removeClassNameAndUpdateJsxElement(stringLiteral, className)
48+
49+
if (manualChangeLog) {
50+
addManualChangeLog(manualChangeLog)
51+
}
52+
53+
if (isRemoved) {
54+
jsxTagElement.addAttribute({
55+
name: 'as',
56+
initializer: printNode(
57+
ts.factory.createJsxExpression(
58+
undefined,
59+
ts.factory.createIdentifier(getTagName(jsxTagElement))
60+
)
61+
),
62+
})
63+
64+
for (const { name, value } of props) {
65+
jsxTagElement.addAttribute({
66+
name,
67+
initializer: printNode(value),
68+
})
69+
}
70+
}
71+
}
72+
73+
jsxTagElementsToUpdate.add(jsxTagElement)
74+
},
75+
SourceFileExit(sourceFile) {
76+
if (jsxTagElementsToUpdate.size === 0) {
77+
return
78+
}
79+
80+
for (const jsxTagElement of jsxTagElementsToUpdate) {
81+
if (Node.isJsxSelfClosingElement(jsxTagElement)) {
82+
setOnJsxTagElement(jsxTagElement, { name: 'Icon' })
83+
}
84+
}
85+
86+
addOrUpdateSourcegraphWildcardImportIfNeeded({
87+
sourceFile,
88+
importStructure: {
89+
namedImports: ['Icon'],
90+
},
91+
})
92+
93+
sourceFile.fixUnusedIdentifiers()
94+
},
95+
}
96+
})
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './iconToComponent'
2+
export * from './validateCodemodTarget'
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { StringLiteral } from 'ts-morph'
2+
3+
import { throwFromMethodsIfUndefinedReturn } from '@sourcegraph/codemod-common'
4+
import { JsxTagElement } from '@sourcegraph/codemod-toolkit-ts'
5+
6+
import { iconClassNamesMapping, ClassNameMapping } from './iconClassNamesMapping'
7+
8+
interface StringLiteralValidatorResult {
9+
stringLiteral: StringLiteral
10+
classNameMappings: ClassNameMapping[]
11+
}
12+
13+
interface JsxTagElementValidatorResult {
14+
jsxTagElement: JsxTagElement
15+
tagName: string
16+
}
17+
18+
export const validateCodemodTarget = {
19+
/**
20+
* Returns `JsxTagElement`.
21+
*/
22+
JsxTagElement(jsxTagElement: JsxTagElement): JsxTagElementValidatorResult | void {
23+
const tagName = jsxTagElement.getTagNameNode().getText()
24+
25+
return { jsxTagElement, tagName }
26+
},
27+
28+
/**
29+
* Returns non-void result if received `StringLiteral` has one of icon classes like `icon-inline`.
30+
*/
31+
StringLiteral(stringLiteral: StringLiteral): StringLiteralValidatorResult | void {
32+
const classNameMappings = iconClassNamesMapping.filter(({ className }) => {
33+
return stringLiteral
34+
.getLiteralValue()
35+
.split(' ')
36+
.some(word => {
37+
return word === className
38+
})
39+
})
40+
41+
if (classNameMappings.length !== 0) {
42+
return { classNameMappings, stringLiteral }
43+
}
44+
},
45+
}
46+
47+
export const validateCodemodTargetOrThrow = throwFromMethodsIfUndefinedReturn(validateCodemodTarget)

0 commit comments

Comments
 (0)