diff --git a/bin/eds-migrate.js b/bin/eds-migrate.js new file mode 100755 index 000000000..4e46f02a1 --- /dev/null +++ b/bin/eds-migrate.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +// eslint-disable-next-line import/extensions +require('../lib/bin/eds-migrate.js') + .run() + .then(() => { + process.exit(0); + }) + .catch((error) => { + if (error) { + console.log(error); + } + process.exit(1); + }); diff --git a/package.json b/package.json index 55f41ade2..3b4b355df 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,12 @@ "bin": { "eds-apply-theme": "bin/eds-apply-theme.js", "eds-import-from-figma": "bin/eds-import-from-figma.js", - "eds-init-theme": "bin/eds-init.js" + "eds-init-theme": "bin/eds-init.js", + "eds-migrate": "bin/eds-migrate.js" }, "scripts": { - "build": "yarn build:clean && yarn build:tokens && yarn build:js && yarn copy-fonts-to-lib", + "build": "yarn build:clean && yarn build:tokens && yarn build:js && yarn build:bin && yarn copy-fonts-to-lib", + "build:bin": "tsc -p src/bin/tsconfig.json", "build:clean": "rm -rf lib/", "build:tokens": "rm -rf src/tokens-dist/ && node ./style-dictionary.config.js && yarn prettier-tokens-dist", "build:js": "rollup --config", @@ -105,6 +107,8 @@ "react-uid": "^2.3.3", "style-dictionary": "^3.9.2", "svg4everybody": "^2.1.9", + "ts-dedent": "^2.2.0", + "ts-morph": "^22.0.0", "yargs": "^17.7.2" }, "devDependencies": { diff --git a/src/bin/eds-migrate.ts b/src/bin/eds-migrate.ts new file mode 100644 index 000000000..8afa01b80 --- /dev/null +++ b/src/bin/eds-migrate.ts @@ -0,0 +1,44 @@ +import chalk from 'chalk'; +import { hideBin } from 'yargs/helpers'; // eslint-disable-line import/extensions +import yargs from 'yargs/yargs'; +import runMigration, { listMigrations } from './migrate'; + +export async function run() { + // Set up the command + const args = yargs(hideBin(process.argv)) + .command( + ['$0 [options]'], + 'Run an EDS codemod migration on your source files', + ) + .options({ + list: { + describe: 'List available migrations', + type: 'boolean', + }, + name: { + describe: 'The migration to run', + type: 'string', + }, + verbose: { + describe: 'Print additional details for debugging purposes', + type: 'boolean', + }, + }).argv; + + // @ts-expect-error Typing for args isn't as good as we'd like them to be + const { name, list, verbose: isVerbose } = args; + + if (list) { + listMigrations().forEach((migration) => { + console.log(` ${migration}`); + }); + } else if (name) { + await runMigration(name, { isVerbose }); + } else { + console.warn( + chalk.red( + 'Migrate: please use --name to specify a migration name or use --list to see the list of available migrations', + ), + ); + } +} diff --git a/src/bin/migrate/README.md b/src/bin/migrate/README.md new file mode 100644 index 000000000..8784e70d9 --- /dev/null +++ b/src/bin/migrate/README.md @@ -0,0 +1,19 @@ +# EDS Migrate + +EDS Migrate is a collection of codemods written with [ts-morph](https://ts-morph.com/). It will help you migrate breaking changes & deprecations. + +## CLI Integration + +The preferred way to run these codemods is via the CLI's `eds-migrate` command. + +``` +npx eds-migrate --help +``` + +## Additional Resources + +Below are some helpful resources when writing codemodes with ts-morph + +- [ts-morph documentation](https://ts-morph.com/) +- [TypeScript AST Viewer](https://ts-ast-viewer.com/#) +- [AST Explorer](https://astexplorer.net/) diff --git a/src/bin/migrate/helpers.ts b/src/bin/migrate/helpers.ts new file mode 100644 index 000000000..8df28154a --- /dev/null +++ b/src/bin/migrate/helpers.ts @@ -0,0 +1,27 @@ +import { + InMemoryFileSystemHost, + Project, + type ImportDeclaration, +} from 'ts-morph'; + +/** + * Checks if the import declaration is for the design system. + * @returns {boolean} - True if the import is from the design system, false otherwise. + */ +export function isDesignSystemImport(node: ImportDeclaration) { + return node.getModuleSpecifierValue() === '@chanzuckerberg/eds'; +} + +/** + * Creates an in-memory source file for testing + */ +export function createTestSourceFile(sourceFileText: string) { + const host = new InMemoryFileSystemHost(); + const project = new Project({ + compilerOptions: undefined, + fileSystem: host, + skipLoadingLibFiles: true, + }); + + return project.createSourceFile('testFile.tsx', sourceFileText); +} diff --git a/src/bin/migrate/index.ts b/src/bin/migrate/index.ts new file mode 100644 index 000000000..06f0e84bf --- /dev/null +++ b/src/bin/migrate/index.ts @@ -0,0 +1,52 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Project } from 'ts-morph'; + +const MIGRATION_DIR = `${__dirname}/migrations`; + +/** + * Lists all the available migrations. + * + * @returns {string[]} Array of migration names. + */ +export function listMigrations() { + return fs + .readdirSync(MIGRATION_DIR) + .filter((fname) => fname.endsWith('.js')) + .map((fname) => fname.slice(0, -3)); +} + +/** + * Runs the migration specified by name with given options. + * + * @param {string} name - The name of the migration. + * @param {Options} options - Options for the migration. + * @returns {Promise} A Promise that resolves when the migration is complete. + */ +export default async function runMigration( + name: string, + options: { isVerbose?: boolean }, +): Promise { + const { isVerbose } = options; + + // runMigration is called by a CLI we want the directory + // the command is ran in and not the directory of this file + const tsconfigPath = path.join(process.cwd(), './tsconfig.json'); + if (isVerbose) { + console.log(`Using the following tsconfig.json file: ${tsconfigPath}`); + } + const project = new Project({ + tsConfigFilePath: path.join(tsconfigPath), + }); + + const pathToMigration = path.join(MIGRATION_DIR, `${name}.js`); + try { + console.log(`Running the following migration: "${name}"`); + const { default: migration } = await import(pathToMigration); + migration(project); + } catch (error) { + console.error('Error importing module:', error); + } + + await project.save(); +} diff --git a/src/bin/migrate/migrations/14-to-15.ts b/src/bin/migrate/migrations/14-to-15.ts new file mode 100644 index 000000000..e9dbbf7c5 --- /dev/null +++ b/src/bin/migrate/migrations/14-to-15.ts @@ -0,0 +1,332 @@ +import type { Project } from 'ts-morph'; +import editJsxProp from '../transforms/edit-jsx-prop'; +import type { Change as EditJsxPropChange } from '../transforms/edit-jsx-prop'; + +const PropChanges: EditJsxPropChange[] = [ + { + // There were a lot of changes *phew* + componentName: 'Icon', + edits: [ + { + type: 'update_value', + propName: 'name', + oldPropValue: 'add-circle', + newPropValue: 'add-encircled', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'add-alarm', + newPropValue: 'alarm-add', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'arrow-downward', + newPropValue: 'arrow-down', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'arrow-narrow-down', + newPropValue: 'arrow-down-narrow', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'arrow-back', + newPropValue: 'arrow-left', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'arrow-narrow-left', + newPropValue: 'arrow-left-narrow', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'arrow-forward', + newPropValue: 'arrow-right', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'arrow-upward', + newPropValue: 'arrow-up', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'arrow-narrow-up', + newPropValue: 'arrow-up-narrow', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'autorenew', + newPropValue: 'arrows-circular', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'notifications', + newPropValue: 'bell', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'menu-book', + newPropValue: 'book-open', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'campaign', + newPropValue: 'bullhorn', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'business-center', + newPropValue: 'business', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'check-circle', + newPropValue: 'checkmark-encircled-filled', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'expand-more', + newPropValue: 'chevron-down', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'expand-less', + newPropValue: 'chevron-up', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'schedule', + newPropValue: 'clock', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'cancel', + newPropValue: 'close-encircled', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'cloud-done', + newPropValue: 'cloud-favorable', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'add-comment', + newPropValue: 'comment-add', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'feedback', + newPropValue: 'comment-critical', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'feedback', + newPropValue: 'conversation', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'dangerous', + newPropValue: 'critical', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'assessment', + newPropValue: 'data', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'description', + newPropValue: 'document', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'insert-drive-file', + newPropValue: 'document-blank', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'event', + newPropValue: 'calendar', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'event-busy', + newPropValue: 'calendar-busy', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'event-available', + newPropValue: 'calendar-favorable', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'sentiment-satisfied', + newPropValue: 'face-happy', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'sentiment-dissatisfied', + newPropValue: 'face-unhappy', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'sentiment-very-satisfied', + newPropValue: 'face-very-happy', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'create-new-folder', + newPropValue: 'folder-add', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'school', + newPropValue: 'grad-cap', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'timeline', + newPropValue: 'graph', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'favorite', + newPropValue: 'heart-filled', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'info', + newPropValue: 'info-encircled', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'local-library', + newPropValue: 'librarian', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'spa', + newPropValue: 'lotus', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'create', + newPropValue: 'pencil', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'record-voice-over', + newPropValue: 'person-sound', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'people', + newPropValue: 'persons', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'supervised-user-circle', + newPropValue: 'persons-encircled', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'help', + newPropValue: 'question-mark-encircled', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'share-custom', + newPropValue: 'share', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'star-outline', + newPropValue: 'star', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'swap-vert', + newPropValue: 'swap-vertical', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'delete', + newPropValue: 'trash', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'trending-up', + newPropValue: 'trend-up', + }, + { + type: 'update_value', + propName: 'name', + oldPropValue: 'ondemand-video', + newPropValue: 'video', + }, + ], + }, +]; + +/** + * Runs the migration to upgrade EDS from v14 to v15 + */ +export default function migration(project: Project) { + const files = project.getSourceFiles(); + const sourceFiles = files.filter((file) => !file.isDeclarationFile()); + + console.debug(`Running migration on ${sourceFiles.length} file(s)`); + + sourceFiles.forEach((sourceFile) => { + editJsxProp({ file: sourceFile, changes: PropChanges }); + }); +} diff --git a/src/bin/migrate/migrations/14-to-15alpha.ts b/src/bin/migrate/migrations/14-to-15alpha.ts new file mode 100644 index 000000000..33e41bc09 --- /dev/null +++ b/src/bin/migrate/migrations/14-to-15alpha.ts @@ -0,0 +1,128 @@ +import type { Project } from 'ts-morph'; +import renameJsxImport from '../transforms/rename-jsx-import'; +import type { Change as RenameJsxImportChange } from '../transforms/rename-jsx-import'; + +/** + * Import paths that changed from EDS v14 to v15 alpha + */ +const ImportChanges: RenameJsxImportChange[] = [ + { + alias: 'Accordion', + oldImportName: 'Accordion', + newImportName: 'AccordionV2', + }, + { + alias: 'Button', + oldImportName: 'Button', + newImportName: 'ButtonV2', + }, + { + alias: 'ButtonGroup', + oldImportName: 'ButtonGroup', + newImportName: 'ButtonGroupV2', + }, + { + alias: 'Card', + oldImportName: 'Card', + newImportName: 'CardV2', + }, + { + alias: 'Checkbox', + oldImportName: 'Checkbox', + newImportName: 'CheckboxV2', + }, + { + alias: 'FieldNote', + oldImportName: 'FieldNote', + newImportName: 'FieldNoteV2', + }, + { + alias: 'Icon', + oldImportName: 'Icon', + newImportName: 'IconV2', + }, + { + alias: 'InlineNotification', + oldImportName: 'InlineNotification', + newImportName: 'InlineNotificationV2', + }, + { + alias: 'InputField', + oldImportName: 'InputField', + newImportName: 'InputFieldV2', + }, + { + alias: 'Link', + oldImportName: 'Link', + newImportName: 'LinkV2', + }, + { + alias: 'Menu', + oldImportName: 'Menu', + newImportName: 'MenuV2', + }, + { + alias: 'Modal', + oldImportName: 'Modal', + newImportName: 'ModalV2', + }, + { + alias: 'NumberIcon', + oldImportName: 'NumberIcon', + newImportName: 'NumberIconV2', + }, + { + alias: 'PageNotification', + oldImportName: 'PageNotification', + newImportName: 'PageNotificationV2', + }, + { + alias: 'PopoverListItem', + oldImportName: 'PopoverListItem', + newImportName: 'PopoverListItemV2', + }, + { + alias: 'Radio', + oldImportName: 'Radio', + newImportName: 'RadioV2', + }, + { + alias: 'Select', + oldImportName: 'Select', + newImportName: 'SelectV2', + }, + { + alias: 'TabGroup', + oldImportName: 'TabGroup', + newImportName: 'TabGroupV2', + }, + { + alias: 'TextareaField', + oldImportName: 'TextareaField', + newImportName: 'TextareaFieldV2', + }, + { + alias: 'Toast', + oldImportName: 'Toast', + newImportName: 'ToastV2', + }, + { + alias: 'Tooltip', + oldImportName: 'Tooltip', + newImportName: 'TooltipV2', + }, +]; + +/** + * Runs the migration to upgrade EDS from v14 to v15-alpha + */ +export default function migration(project: Project) { + const files = project.getSourceFiles(); + const sourceFiles = files.filter((file) => !file.isDeclarationFile()); + + console.debug(`Running migration on ${sourceFiles.length} file(s)`); + + sourceFiles.forEach((sourceFile) => { + renameJsxImport({ file: sourceFile, changes: ImportChanges }); + }); +} diff --git a/src/bin/migrate/transforms/edit-jsx-prop.test.ts b/src/bin/migrate/transforms/edit-jsx-prop.test.ts new file mode 100644 index 000000000..784a8b6ee --- /dev/null +++ b/src/bin/migrate/transforms/edit-jsx-prop.test.ts @@ -0,0 +1,355 @@ +import { dedent } from 'ts-dedent'; + +import transform from './edit-jsx-prop'; +import { createTestSourceFile } from '../helpers'; + +describe('transform', () => { + it('removes a single prop', () => { + const sourceFileText = dedent` + import {Icon} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + ) + } + `; + + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + componentName: 'Icon', + edits: [ + { + type: 'remove', + propName: 'color', + }, + ], + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {Icon} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + ) + } + `); + }); + + it('adds a single prop', () => { + const sourceFileText = dedent` + import {Button} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + ) + } + `; + + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + componentName: 'Button', + edits: [ + { + type: 'add', + propName: 'size', + propValue: 'lg', + }, + ], + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {Button} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + ) + } + `); + }); + + it('updates the name of a prop', () => { + const sourceFileText = dedent` + import {Button} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + ) + } + `; + + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + componentName: 'Button', + edits: [ + { + type: 'update_name', + oldPropName: 'variant', + newPropName: 'rank', + }, + ], + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {Button} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + ) + } + `); + }); + + it('updates the value of a prop when it is a string literal', () => { + const sourceFileText = dedent` + import {Icon} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + ) + } + `; + + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + componentName: 'Icon', + edits: [ + { + type: 'update_value', + propName: 'name', + oldPropValue: 'add-circle', + newPropValue: 'add-encircled', + }, + ], + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {Icon} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + ) + } + `); + }); + + it('updates the value of a prop when it is an expression', () => { + const sourceFileText = dedent` + import {Icon} from '@chanzuckerberg/eds'; + + export default function Component({isOpen}) { + return ( + + ) + } + `; + + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + componentName: 'Icon', + edits: [ + { + type: 'update_value', + propName: 'name', + oldPropValue: 'expand-more', + newPropValue: 'collapse', + }, + ], + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {Icon} from '@chanzuckerberg/eds'; + + export default function Component({isOpen}) { + return ( + + ) + } + `); + }); + + it('edits multiple props on the same component', () => { + const sourceFileText = dedent` + import {Button, ButtonGroup} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + + + + ) + } + `; + + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + componentName: 'ButtonGroup', + edits: [ + { + type: 'update_name', + oldPropName: 'orientation', + newPropName: 'layout', + }, + ], + }, + { + componentName: 'Button', + edits: [ + { + type: 'update_name', + oldPropName: 'variant', + newPropName: 'rank', + }, + { + type: 'add', + propName: 'status', + propValue: 'default', + }, + ], + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {Button, ButtonGroup} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( + + + + + ) + } + `); + }); + + it('does not modify props from Non-EDS components', () => { + const sourceFileText = dedent` + import {Link} from '~/components/Link'; + + export default function Component() { + return ( + Click Me + ) + } + `; + + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + componentName: 'Link', + edits: [ + { + type: 'remove', + propName: 'variant', + }, + ], + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {Link} from '~/components/Link'; + + export default function Component() { + return ( + Click Me + ) + } + `); + }); +}); diff --git a/src/bin/migrate/transforms/edit-jsx-prop.ts b/src/bin/migrate/transforms/edit-jsx-prop.ts new file mode 100644 index 000000000..ede37b3d8 --- /dev/null +++ b/src/bin/migrate/transforms/edit-jsx-prop.ts @@ -0,0 +1,139 @@ +import { SyntaxKind } from 'ts-morph'; +import type { + JsxOpeningElement, + JsxSelfClosingElement, + SourceFile, +} from 'ts-morph'; +import { isDesignSystemImport } from '../helpers'; + +type Edit = + | { + type: 'add'; + propName: string; + propValue: string; + } + | { + type: 'remove'; + propName: string; + } + | { + type: 'update_name'; + oldPropName: string; + newPropName: string; + } + | { + type: 'update_value'; + propName: string; + oldPropValue: string; + newPropValue: string; + }; + +function updatePropName( + element: JsxOpeningElement | JsxSelfClosingElement, + edit: Extract, +) { + const attribute = element.getAttribute(edit.oldPropName); + if (attribute && 'getNameNode' in attribute) { + attribute.setName(edit.newPropName); + } +} + +function updatePropValue( + element: JsxOpeningElement | JsxSelfClosingElement, + edit: Extract, +) { + const attribute = element + .getAttribute(edit.propName) + ?.asKind(SyntaxKind.JsxAttribute); + + if (attribute) { + const initializer = attribute.getInitializer(); + if (initializer?.isKind(SyntaxKind.StringLiteral)) { + if (initializer.getLiteralValue() === edit.oldPropValue) { + initializer.setLiteralValue(edit.newPropValue); + } + } else if (initializer?.isKind(SyntaxKind.JsxExpression)) { + initializer + .getDescendantsOfKind(SyntaxKind.StringLiteral) + .forEach((descendant) => { + if (descendant.getLiteralValue() === edit.oldPropValue) { + descendant.setLiteralValue(edit.newPropValue); + } + }); + } + } +} + +export type Change = { + componentName: string; + edits: Edit[]; +}; + +type TransformOptions = { + file: SourceFile; + changes: Change[]; +}; + +export default function transform({ file, changes }: TransformOptions) { + // console warning two changes for same component + const importDeclarations = file + .getImportDeclarations() + .filter( + (importDeclaration) => + !importDeclaration.isTypeOnly() && + isDesignSystemImport(importDeclaration), + ); + + // Only apply changes to EDS Imported components because EDS consumers may have + // their own components with the same names as our components. + const changesToApply: Change[] = []; + importDeclarations.forEach((importDeclaration) => { + const namedImports = importDeclaration.getNamedImports(); + namedImports.forEach((namedImport) => { + const change = changes.find( + (change) => + change.componentName.toLowerCase() === + namedImport.getName().toLowerCase(), + ); + if (change) { + changesToApply.push(change); + } + }); + }); + + const jsxElements = file.getDescendantsOfKind(SyntaxKind.JsxOpeningElement); + const jsxSelfClosingElements = file.getDescendantsOfKind( + SyntaxKind.JsxSelfClosingElement, + ); + + [...jsxElements, ...jsxSelfClosingElements].forEach((element) => { + const tagName = element.getTagNameNode().getText(); + for (const change of changesToApply) { + const isChangeable = + change.componentName.toLowerCase() === tagName.toLowerCase(); + if (!isChangeable) { + continue; + } + + change.edits.forEach((edit) => { + switch (edit.type) { + case 'add': + element.addAttribute({ + name: edit.propName, + initializer: `"${edit.propValue}"`, + }); + break; + case 'remove': + element.getAttribute(edit.propName)?.remove(); + break; + case 'update_name': + updatePropName(element, edit); + break; + case 'update_value': + updatePropValue(element, edit); + break; + } + }); + } + }); +} diff --git a/src/bin/migrate/transforms/rename-jsx-import.test.ts b/src/bin/migrate/transforms/rename-jsx-import.test.ts new file mode 100644 index 000000000..3ba598a39 --- /dev/null +++ b/src/bin/migrate/transforms/rename-jsx-import.test.ts @@ -0,0 +1,143 @@ +import { dedent } from 'ts-dedent'; + +import transform from './rename-jsx-import'; +import { createTestSourceFile } from '../helpers'; + +describe('transform', () => { + it('does not modify import statements that are from other libraries', () => { + const sourceFileText = dedent` + import {Button} from '@headlessui/react'; + `; + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + oldImportName: 'Button', + newImportName: 'ButtonV2', + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {Button} from '@headlessui/react'; + `); + }); + + it('renames import statements', () => { + const sourceFileText = dedent` + import {Button, Icon} from '@chanzuckerberg/eds'; + import type {ButtonProps} from '@chanzuckerberg/eds/lib/components/Button'; + import clsx from 'clsx'; + `; + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + oldImportName: 'Button', + newImportName: 'ButtonV2', + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {ButtonV2, Icon} from '@chanzuckerberg/eds'; + import type {ButtonProps} from '@chanzuckerberg/eds/lib/components/Button'; + import clsx from 'clsx'; + `); + }); + + it('adds aliases to import statements', () => { + const sourceFileText = dedent` + import {Button} from '@chanzuckerberg/eds'; + `; + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + alias: 'Button', + oldImportName: 'Button', + newImportName: 'ButtonV2', + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {ButtonV2 as Button} from '@chanzuckerberg/eds'; + `); + }); + + it('remove aliases from import statements', () => { + const sourceFileText = dedent` + import {ButtonV2 as Button} from '@chanzuckerberg/eds'; + `; + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + removeAlias: true, + oldImportName: 'ButtonV2', + newImportName: 'Button', + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {Button} from '@chanzuckerberg/eds'; + `); + }); + + it('renames JsxElements when imports are changed', () => { + const sourceFileText = dedent` + import {Button, Icon} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( +
+ + Something here + + +
+ ) + } + `; + const sourceFile = createTestSourceFile(sourceFileText); + + transform({ + file: sourceFile, + changes: [ + { + oldImportName: 'Button', + newImportName: 'ButtonV2', + }, + { + oldImportName: 'Icon', + newImportName: 'IconV2', + }, + ], + }); + + expect(sourceFile.getText()).toEqual(dedent` + import {ButtonV2, IconV2} from '@chanzuckerberg/eds'; + + export default function Component() { + return ( +
+ + Something here + Something there + Something over there +
+ ) + } + `); + }); +}); diff --git a/src/bin/migrate/transforms/rename-jsx-import.ts b/src/bin/migrate/transforms/rename-jsx-import.ts new file mode 100644 index 000000000..1d265178d --- /dev/null +++ b/src/bin/migrate/transforms/rename-jsx-import.ts @@ -0,0 +1,82 @@ +import { SyntaxKind, type SourceFile } from 'ts-morph'; +import { isDesignSystemImport } from '../helpers'; + +export type Change = { + alias?: string; + oldImportName: string; + newImportName: string; + removeAlias?: boolean; +}; + +type TransformOptions = { + file: SourceFile; + changes: Change[]; +}; + +/** + * Transforms import declarations in a source file based on the provided conversions. + * @param {TransformOptions} options - The transformation options. + */ +export default function transform({ file, changes }: TransformOptions) { + const importDeclarations = file.getImportDeclarations(); + const importsToTransform = importDeclarations.filter( + (importDeclaration) => + !importDeclaration.isTypeOnly() && + isDesignSystemImport(importDeclaration), + ); + + /** + * Used to keep track of which JSXElements need to be renamed + */ + const appliedChanges: Change[] = []; + importsToTransform.forEach((importDeclaration) => { + const namedImports = importDeclaration.getNamedImports(); + + namedImports.forEach((namedImport) => { + const name = namedImport.getName(); + const changeToApply = changes.find( + (change) => change.oldImportName === name, + ); + + if (changeToApply) { + namedImport.getNameNode; + namedImport.setName(changeToApply.newImportName); + namedImport.getNameNode().rename(changeToApply.newImportName); + if (changeToApply.removeAlias) { + namedImport.removeAliasWithRename(); + } else if (changeToApply.alias) { + namedImport.renameAlias(changeToApply.alias); + } + appliedChanges.push(changeToApply); + } + }); + }); + + file.getDescendantsOfKind(SyntaxKind.JsxElement).forEach((element) => { + const openingElement = element.getOpeningElement(); + const closingElement = element.getClosingElement(); + const openingTagNameNode = openingElement.getTagNameNode(); + const closingTagNameNode = closingElement.getTagNameNode(); + + const appliedChange = appliedChanges.find( + (change) => change.oldImportName === openingTagNameNode.getText(), + ); + + if (appliedChange) { + openingTagNameNode.replaceWithText(appliedChange.newImportName); + closingTagNameNode.replaceWithText(appliedChange.newImportName); + } + }); + + file + .getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement) + .forEach((element) => { + const tagNameNode = element.getTagNameNode(); + const appliedChange = appliedChanges.find( + (change) => change.oldImportName === tagNameNode.getText(), + ); + if (appliedChange) { + tagNameNode.replaceWithText(appliedChange.newImportName); + } + }); +} diff --git a/src/bin/tsconfig.json b/src/bin/tsconfig.json new file mode 100644 index 000000000..241d5f650 --- /dev/null +++ b/src/bin/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig", + "include": ["**/*"], + "exclude": ["**/*.test.*"], + "compilerOptions": { + "outDir": "../../lib/bin", + "declaration": false, + "declarationDir": null, + "moduleResolution": "NodeNext", + "module": "NodeNext" + } +} diff --git a/yarn.lock b/yarn.lock index 52a66da5b..43313e995 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2850,7 +2850,9 @@ __metadata: stylelint-config-recommended: "npm:^13.0.0" svg4everybody: "npm:^2.1.9" tailwindcss: "npm:^3.4.3" + ts-dedent: "npm:^2.2.0" ts-jest: "npm:^29.1.2" + ts-morph: "npm:^22.0.0" typescript: "npm:^5.2.2" yargs: "npm:^17.7.2" peerDependencies: @@ -2860,6 +2862,7 @@ __metadata: eds-apply-theme: bin/eds-apply-theme.js eds-import-from-figma: bin/eds-import-from-figma.js eds-init-theme: bin/eds-init.js + eds-migrate: bin/eds-migrate.js languageName: unknown linkType: soft @@ -6467,6 +6470,18 @@ __metadata: languageName: node linkType: hard +"@ts-morph/common@npm:~0.23.0": + version: 0.23.0 + resolution: "@ts-morph/common@npm:0.23.0" + dependencies: + fast-glob: "npm:^3.3.2" + minimatch: "npm:^9.0.3" + mkdirp: "npm:^3.0.1" + path-browserify: "npm:^1.0.1" + checksum: 10/05eabbab5a63d71a7dac17202519d23d4d4ec30780364d4dc3096ca86291e19f0284d0592a6ee89ec257204075a985d00f4788d816a89c41d0c1e0c8d281c480 + languageName: node + linkType: hard + "@types/aria-query@npm:^5.0.1": version: 5.0.4 resolution: "@types/aria-query@npm:5.0.4" @@ -9136,6 +9151,13 @@ __metadata: languageName: node linkType: hard +"code-block-writer@npm:^13.0.1": + version: 13.0.1 + resolution: "code-block-writer@npm:13.0.1" + checksum: 10/3da803b1149d05a09b99e150df0e6d2ac5007bcf2ddd23d72e8b3e827cb6b7cb69b695472cfbc8b46a2bca4e7c11636788b9a7e7d518f3b45d0bddcac240b4af + languageName: node + linkType: hard + "codecov@npm:^3.8.3": version: 3.8.3 resolution: "codecov@npm:3.8.3" @@ -11657,7 +11679,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0, fast-glob@npm:^3.3.1": +"fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0, fast-glob@npm:^3.3.1, fast-glob@npm:^3.3.2": version: 3.3.2 resolution: "fast-glob@npm:3.3.2" dependencies: @@ -15797,6 +15819,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^9.0.3": + version: 9.0.4 + resolution: "minimatch@npm:9.0.4" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10/4cdc18d112b164084513e890d6323370db14c22249d536ad1854539577a895e690a27513dc346392f61a4a50afbbd8abc88f3f25558bfbbbb862cd56508b20f5 + languageName: node + linkType: hard + "minimist-options@npm:4.1.0": version: 4.1.0 resolution: "minimist-options@npm:4.1.0" @@ -20781,6 +20812,16 @@ __metadata: languageName: node linkType: hard +"ts-morph@npm:^22.0.0": + version: 22.0.0 + resolution: "ts-morph@npm:22.0.0" + dependencies: + "@ts-morph/common": "npm:~0.23.0" + code-block-writer: "npm:^13.0.1" + checksum: 10/e5d81d0d8d990fa9f86e285bd4052bcfa462e2f798f7eda86e11afc7d884dfdb053998dcbf79942942e8032070f8b266745e017771674a169731494fe035e192 + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.14.2": version: 3.14.2 resolution: "tsconfig-paths@npm:3.14.2"