diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f1a61aee..fd7646d5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -17,7 +17,8 @@ const program = new Command() interface CodemodCliOptions extends TransformOptions { write: boolean format: boolean - transform: string + transform: string, + classname: 'classnames' | 'clsx' } const PROJECT_ROOT = path.resolve(__dirname, '../../../') @@ -26,6 +27,7 @@ program // TODO: make it `true` by default after switching to fast bulk format. .option('-f, --format [format]', 'Format Typescript source files with ESLint', false) .option('-w, --write [write]', 'Persist codemod changes to the filesystem', false) + .option('-c, --classname ', 'Select className wrapper', 'classnames') .option('-t, --transform ', 'Absolute or relative to project root path to a transform module') .argument('', 'Absolute or relative to project root file glob to change files based on') .allowUnknownOption(true) @@ -40,7 +42,8 @@ program ) .action(async (commandArgument: string, options: CodemodCliOptions) => { const { fileGlob, transformOptions } = parseOptions(commandArgument) - const { write: shouldWriteFiles, format: shouldFormat, transform } = options + const { write: shouldWriteFiles, format: shouldFormat, transform, classname: rawClassname } = options + const classname = ['classnames', 'clsx'].includes(rawClassname) ? rawClassname : 'classnames'; const projectGlob = path.isAbsolute(fileGlob) ? fileGlob : path.join(PROJECT_ROOT, fileGlob) const transformPath = path.isAbsolute(transform) ? transform : path.join(PROJECT_ROOT, transform) @@ -61,6 +64,7 @@ program shouldWriteFiles, shouldFormat, transformOptions, + classname } const results = await (codemod as Codemod)(codemodContext) diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 6952d405..a188e5cf 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -12,6 +12,7 @@ export interface CodemodContext { shouldWriteFiles?: boolean /** If `true` format Typescript source files with `prettier-eslint`. */ shouldFormat?: boolean + classname: 'classnames' | 'clsx' } export interface CodemodResultFile { diff --git a/packages/toolkit-packages/src/classNames/__tests__/classNames.test.ts b/packages/toolkit-packages/src/classNames/__tests__/classNames.test.ts index 94c8b440..60abd500 100644 --- a/packages/toolkit-packages/src/classNames/__tests__/classNames.test.ts +++ b/packages/toolkit-packages/src/classNames/__tests__/classNames.test.ts @@ -6,8 +6,11 @@ import { createSourceFile } from '@sourcegraph/codemod-toolkit-ts' import { CLASSNAMES_IDENTIFIER, CLASSNAMES_MODULE_SPECIFIER, + CLSX_IDENTIFIER, wrapIntoClassNamesUtility, addClassNamesUtilImportIfNeeded, + addClsxUtilImportIfNeeded, + wrapIntoClsxUtility, } from '..' describe('`classNames` helpers', () => { @@ -20,6 +23,15 @@ describe('`classNames` helpers', () => { }) }) + describe('wrapIntoClsxUtility', () => { + it('wraps arguments into `clsx` function call', () => { + const classNamesArguments = [factory.createStringLiteral('first'), factory.createStringLiteral('second')] + const classNamesCall = wrapIntoClsxUtility(classNamesArguments) + + expect(printNode(classNamesCall)).toEqual(`${CLSX_IDENTIFIER}("first", "second")`) + }) + }) + describe('addClassNamesUtilImportIfNeeded', () => { const classNamesImport = `import ${CLASSNAMES_IDENTIFIER} from '${CLASSNAMES_MODULE_SPECIFIER}'` @@ -52,4 +64,38 @@ describe('`classNames` helpers', () => { expect(sourceFile.getText().includes(CLASSNAMES_MODULE_SPECIFIER)).toBe(false) }) }) + + describe('addClsxUtilImportIfNeeded', () => { + const clsxImport = `import ${CLSX_IDENTIFIER} from '${CLSX_IDENTIFIER}'` + + it('adds `clsx` import if needed', () => { + const { sourceFile } = createSourceFile('
') + const hasClsxImtmport = () => { + console.log(sourceFile.getText()); + return sourceFile.getText().includes(clsxImport) + } + + expect(hasClsxImtmport()).toBe(false) + addClsxUtilImportIfNeeded(sourceFile) + expect(hasClsxImtmport()).toBe(true) + }) + + it("doesn't duplicate `clsx` import", () => { + const { sourceFile } = createSourceFile('
') + + addClsxUtilImportIfNeeded(sourceFile) + addClsxUtilImportIfNeeded(sourceFile) + + const clsxMatches = sourceFile.getText().match(new RegExp(clsxImport, 'g')) + expect(clsxMatches?.length).toEqual(1) + }) + + it("doesn't add `clsx` import if `clsx` util is not used", () => { + const { sourceFile } = createSourceFile('
') + + addClsxUtilImportIfNeeded(sourceFile) + + expect(sourceFile.getText().includes(CLSX_IDENTIFIER)).toBe(false) + }) + }) }) diff --git a/packages/toolkit-packages/src/classNames/classNames.ts b/packages/toolkit-packages/src/classNames/classNames.ts index 76d8c04b..9a7497a2 100644 --- a/packages/toolkit-packages/src/classNames/classNames.ts +++ b/packages/toolkit-packages/src/classNames/classNames.ts @@ -25,3 +25,40 @@ export function addClassNamesUtilImportIfNeeded(sourceFile: SourceFile): void { export function isClassNamesCallExpression(node?: Node): node is CallExpression { return Node.isCallExpression(node) && isImportedFromModule(node.getExpression(), CLASSNAMES_MODULE_SPECIFIER) } + +export const CLSX_IDENTIFIER = 'clsx' + +// Wraps an array of arguments in a `clsx` function call. +export function wrapIntoClsxUtility(classNames: ts.Expression[]): ts.CallExpression { + return ts.factory.createCallExpression(ts.factory.createIdentifier(CLSX_IDENTIFIER), undefined, classNames) +} + +// Adds `clsx` import to the `sourceFile` if `classNames` util is used and import doesn't exist. +export function addClsxUtilImportIfNeeded(sourceFile: SourceFile): void { + addOrUpdateImportIfIdentifierIsUsed({ + sourceFile, + importStructure: { + defaultImport: CLSX_IDENTIFIER, + moduleSpecifier: CLSX_IDENTIFIER, + }, + }) +} + +interface Utility { + wrapper: (classNames: ts.Expression[]) => ts.CallExpression, + importer: (sourceFile: SourceFile) => void, + identifier: string +} + +export const utilities: Record<'clsx' | 'classnames', Utility> = { + clsx: { + wrapper: wrapIntoClsxUtility, + importer: addClsxUtilImportIfNeeded, + identifier: CLSX_IDENTIFIER + }, + classnames: { + wrapper: wrapIntoClassNamesUtility, + importer: addClassNamesUtilImportIfNeeded, + identifier: CLASSNAMES_IDENTIFIER + } +} diff --git a/packages/transforms/src/globalCssToCssModule/__tests__/globalCssToCssModule.test.ts b/packages/transforms/src/globalCssToCssModule/__tests__/globalCssToCssModule.test.ts index 9fff8d44..f51a0625 100644 --- a/packages/transforms/src/globalCssToCssModule/__tests__/globalCssToCssModule.test.ts +++ b/packages/transforms/src/globalCssToCssModule/__tests__/globalCssToCssModule.test.ts @@ -15,7 +15,7 @@ describe('globalCssToCssModule', () => { it('transforms correctly', async () => { const project = new Project() project.addSourceFilesAtPaths(TARGET_FILE) - const [{ files }] = await globalCssToCssModule({ project, shouldFormat: true }) + const [{ files }] = await globalCssToCssModule({ project, shouldFormat: true, classname: 'classnames' }) expect(files).toBeTruthy() diff --git a/packages/transforms/src/globalCssToCssModule/globalCssToCssModule.ts b/packages/transforms/src/globalCssToCssModule/globalCssToCssModule.ts index 79cd141f..f1bef9eb 100644 --- a/packages/transforms/src/globalCssToCssModule/globalCssToCssModule.ts +++ b/packages/transforms/src/globalCssToCssModule/globalCssToCssModule.ts @@ -5,7 +5,7 @@ import signale from 'signale' import { Codemod } from '@sourcegraph/codemod-cli' import { isDefined } from '@sourcegraph/codemod-common' import { formatWithStylelint } from '@sourcegraph/codemod-toolkit-css' -import { addClassNamesUtilImportIfNeeded } from '@sourcegraph/codemod-toolkit-packages' +import { utilities } from '@sourcegraph/codemod-toolkit-packages' import { formatWithPrettierEslint } from '@sourcegraph/codemod-toolkit-ts' import { getCssModuleExportNameMap } from './postcss/getCssModuleExportNameMap' @@ -26,7 +26,7 @@ import { transformComponentFile } from './ts/transformComponentFile' * */ export const globalCssToCssModule: Codemod = context => { - const { project, shouldWriteFiles, shouldFormat } = context + const { project, shouldWriteFiles, shouldFormat, classname } = context const fs = project.getFileSystem() /** @@ -76,8 +76,10 @@ export const globalCssToCssModule: Codemod = context => { sourceFilePath: cssFilePath, }) - transformComponentFile({ tsSourceFile, exportNameMap, cssModuleFileName }) - addClassNamesUtilImportIfNeeded(tsSourceFile) + transformComponentFile({ tsSourceFile, exportNameMap, cssModuleFileName, classname }) + + utilities[classname].importer(tsSourceFile) + tsSourceFile.addImportDeclaration({ defaultImport: STYLES_IDENTIFIER, moduleSpecifier: `./${path.parse(cssModuleFileName).base}`, diff --git a/packages/transforms/src/globalCssToCssModule/ts/__tests__/getClassNameNodeReplacement.test.ts b/packages/transforms/src/globalCssToCssModule/ts/__tests__/getClassNameNodeReplacement.test.ts index d8fa90ce..e2f6dc4b 100644 --- a/packages/transforms/src/globalCssToCssModule/ts/__tests__/getClassNameNodeReplacement.test.ts +++ b/packages/transforms/src/globalCssToCssModule/ts/__tests__/getClassNameNodeReplacement.test.ts @@ -58,6 +58,7 @@ describe('getClassNameNodeReplacement', () => { parentNode, exportNameReferences, leftOverClassName, + classname: 'classnames', ...options, }) diff --git a/packages/transforms/src/globalCssToCssModule/ts/__tests__/processNodesWithClassName.test.ts b/packages/transforms/src/globalCssToCssModule/ts/__tests__/processNodesWithClassName.test.ts index ffe75534..a026b802 100644 --- a/packages/transforms/src/globalCssToCssModule/ts/__tests__/processNodesWithClassName.test.ts +++ b/packages/transforms/src/globalCssToCssModule/ts/__tests__/processNodesWithClassName.test.ts @@ -23,6 +23,7 @@ describe('processNodesWithClassName', () => { processNodesWithClassName({ usageStats: {}, nodesWithClassName: getNodesWithClassName(sourceFile), + classname: 'classnames', exportNameMap: { kek: 'kek', 'kek--wow': 'kekWow', diff --git a/packages/transforms/src/globalCssToCssModule/ts/getClassNameNodeReplacement.ts b/packages/transforms/src/globalCssToCssModule/ts/getClassNameNodeReplacement.ts index a63a4eae..675c19b7 100644 --- a/packages/transforms/src/globalCssToCssModule/ts/getClassNameNodeReplacement.ts +++ b/packages/transforms/src/globalCssToCssModule/ts/getClassNameNodeReplacement.ts @@ -1,21 +1,22 @@ import { ts, NodeParentType, SyntaxKind } from 'ts-morph' -import { wrapIntoClassNamesUtility, CLASSNAMES_IDENTIFIER } from '@sourcegraph/codemod-toolkit-packages' +import { utilities } from '@sourcegraph/codemod-toolkit-packages' export interface GetClassNameNodeReplacementOptions { parentNode: NodeParentType leftOverClassName: string - exportNameReferences: ts.PropertyAccessExpression[] + exportNameReferences: ts.PropertyAccessExpression[], + classname: 'classnames' | 'clsx' } function getClassNameNodeReplacementWithoutBraces( options: GetClassNameNodeReplacementOptions ): ts.PropertyAccessExpression | ts.CallExpression | ts.Expression[] { - const { leftOverClassName, exportNameReferences, parentNode } = options + const { leftOverClassName, exportNameReferences, parentNode, classname } = options const isInClassnamesCall = ts.isCallExpression(parentNode.compilerNode) && - parentNode.compilerNode.expression.getText() === CLASSNAMES_IDENTIFIER + parentNode.compilerNode.expression.getText() === utilities[classname].identifier // We need to use `classNames` utility for multiple `exportNames` or for a combination of the `exportName` and `StringLiteral`. // className={classNames('d-flex mr-1 kek kek--primary')} -> className={classNames('d-flex mr-1', styles.kek, styles.kekPrimary)} @@ -30,7 +31,7 @@ function getClassNameNodeReplacementWithoutBraces( return classNamesCallArguments } - return wrapIntoClassNamesUtility(classNamesCallArguments) + return utilities[classname].wrapper(classNamesCallArguments) } // Replace one class with the `exportName`. diff --git a/packages/transforms/src/globalCssToCssModule/ts/processNodesWithClassName.ts b/packages/transforms/src/globalCssToCssModule/ts/processNodesWithClassName.ts index 01ddc942..34115422 100644 --- a/packages/transforms/src/globalCssToCssModule/ts/processNodesWithClassName.ts +++ b/packages/transforms/src/globalCssToCssModule/ts/processNodesWithClassName.ts @@ -9,6 +9,7 @@ interface ProcessNodesWithClassNameOptions { nodesWithClassName: (Identifier | StringLiteral)[] exportNameMap: Record usageStats: Record + classname: 'classnames' | 'clsx' } /** @@ -41,7 +42,7 @@ interface ProcessNodesWithClassNameOptions { * @returns areAllNodesProcessed: boolean */ export function processNodesWithClassName(options: ProcessNodesWithClassNameOptions): boolean { - const { nodesWithClassName, exportNameMap, usageStats } = options + const { nodesWithClassName, exportNameMap, usageStats, classname } = options for (const nodeWithClassName of nodesWithClassName) { const classNameStringValue = @@ -68,6 +69,7 @@ export function processNodesWithClassName(options: ProcessNodesWithClassNameOpti parentNode: nodeWithClassName.getParent(), leftOverClassName: leftOverClassnames.join(' '), exportNameReferences, + classname }) if (result.isParentTransformed) { diff --git a/packages/transforms/src/globalCssToCssModule/ts/transformComponentFile.ts b/packages/transforms/src/globalCssToCssModule/ts/transformComponentFile.ts index 09e8537f..197944ea 100644 --- a/packages/transforms/src/globalCssToCssModule/ts/transformComponentFile.ts +++ b/packages/transforms/src/globalCssToCssModule/ts/transformComponentFile.ts @@ -10,10 +10,11 @@ interface TransformComponentFileOptions { tsSourceFile: SourceFile exportNameMap: Record cssModuleFileName: string + classname: 'classnames' | 'clsx' } export function transformComponentFile(options: TransformComponentFileOptions): void { - const { tsSourceFile, exportNameMap, cssModuleFileName } = options + const { tsSourceFile, exportNameMap, cssModuleFileName, classname } = options // Object to collect CSS classes usage and report unused classes after the codemod. const usageStats = Object.fromEntries( @@ -30,6 +31,7 @@ export function transformComponentFile(options: TransformComponentFileOptions): usageStats, exportNameMap, nodesWithClassName: getNodesWithClassName(tsSourceFile), + classname }) }