diff --git a/.changeset/mui-to-bui-core-components.md b/.changeset/mui-to-bui-core-components.md new file mode 100644 index 0000000..496f550 --- /dev/null +++ b/.changeset/mui-to-bui-core-components.md @@ -0,0 +1,9 @@ +--- +'@backstage/migrate-mui-typography-to-text': minor +'@backstage/migrate-mui-alert-to-bui-alert': minor +'@backstage/migrate-mui-button-to-bui-button': minor +'@backstage/migrate-mui-icon-button-to-button-icon': minor +'@backstage/migrate-mui-tooltip-to-bui-tooltip': minor +--- + +Add core component codemods for the MUI 4 to BUI migration: Typography to Text, Alert, Button, IconButton to ButtonIcon, and Tooltip to TooltipTrigger. diff --git a/README.md b/README.md index b200fef..12268c4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,16 @@ Run the [`migration-recipe`](./codemods/v1.51.0/migration-recipe) to apply every Older versions are available in the [`codemods/`](./codemods) directory. +### misc + +| Codemod | Description | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| [migrate-mui-alert-to-bui-alert](./codemods/misc/migrate-mui-alert-to-bui-alert) | MUI 4 to BUI: Replace MUI Alert with BUI Alert | +| [migrate-mui-button-to-bui-button](./codemods/misc/migrate-mui-button-to-bui-button) | MUI 4 to BUI: Replace MUI Button with BUI Button | +| [migrate-mui-icon-button-to-button-icon](./codemods/misc/migrate-mui-icon-button-to-button-icon) | MUI 4 to BUI: Replace IconButton with ButtonIcon | +| [migrate-mui-tooltip-to-bui-tooltip](./codemods/misc/migrate-mui-tooltip-to-bui-tooltip) | MUI 4 to BUI: Replace Tooltip with TooltipTrigger | +| [migrate-mui-typography-to-text](./codemods/misc/migrate-mui-typography-to-text) | MUI 4 to BUI: Replace Typography with Text | + ## Usage diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md b/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md new file mode 100644 index 0000000..f814c62 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-alert-to-bui-alert diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml b/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml new file mode 100644 index 0000000..8c43006 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-alert-to-bui-alert' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace MUI Alert with BUI Alert' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'alert', 'bui', 'alert'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/package.json b/codemods/misc/migrate-mui-alert-to-bui-alert/package.json new file mode 100644 index 0000000..eea18bc --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-alert-to-bui-alert", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace MUI Alert with BUI Alert", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts new file mode 100644 index 0000000..b7f3f8c --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts @@ -0,0 +1,497 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-alert-to-bui-alert') + +const SEVERITY_TO_STATUS: Record = { + error: 'danger', + warning: 'warning', + info: 'info', + success: 'success', +} + +const BUI_SOURCE = '@backstage/ui' + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +function collectAlertImports(rootNode: SgNode): { + alertLocalName: string | null + alertTitleLocalName: string | null + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, { source: string; names: string[] }> +} { + let alertLocalName: string | null = null + let alertTitleLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab/Alert')) { + alertLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Alert')) { + alertLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab/AlertTitle')) { + alertTitleLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const alertName = getNamedImportLocalName(imp, 'Alert') + if (alertName) { + alertLocalName = alertName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, { source: '@material-ui/core', names: ['Alert'] }) + } + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab')) { + const alertName = getNamedImportLocalName(imp, 'Alert') + const alertTitleName = getNamedImportLocalName(imp, 'AlertTitle') + + if (alertName) { + alertLocalName = alertName + } + if (alertTitleName) { + alertTitleLocalName = alertTitleName + } + + const namesToRemove: string[] = [] + if (alertName) { + namesToRemove.push('Alert') + } + if (alertTitleName) { + namesToRemove.push('AlertTitle') + } + + if (namesToRemove.length > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (namesToRemove.length >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, { source: '@material-ui/lab', names: namesToRemove }) + } + } + } + + return { alertLocalName, alertTitleLocalName, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '${source}';`)) + } + migrationMetric.increment({ action: 'import-removed' }) +} + +function buildBuiImportEdit(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + let hasAlert = false + for (const spec of specifiers) { + const idents = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + if (idents[0]?.text() === 'Alert') { + hasAlert = true + } + } + if (!hasAlert) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + names.push('Alert') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\nimport { Alert } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + } + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Alert } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { Alert } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { Alert } from '${BUI_SOURCE}';`)) + } + } + + migrationMetric.increment({ action: 'import-added' }) + return false +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` +} + +function getSeverityValue(opening: SgNode): { value: string | null; isDynamic: boolean } { + const severityAttr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: '^severity$', + }, + }, + }) + + if (!severityAttr) { + return { value: null, isDynamic: false } + } + + const stringNode = severityAttr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return { value: frag?.text() ?? null, isDynamic: false } + } + + const exprNode = severityAttr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + return { value: exprNode.text(), isDynamic: true } + } + + return { value: null, isDynamic: false } +} + +function hasProp(opening: SgNode, propName: string): boolean { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + return attr !== null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return '' +} + +function shouldAddDefaultIcon(opening: SgNode): boolean { + if (hasProp(opening, 'iconMapping')) { + return false + } + if (!hasProp(opening, 'icon')) { + return true + } + const iconValue = getPropRawValue(opening, 'icon') + return iconValue === '' || iconValue === '{true}' || iconValue === 'true' +} + +function extractChildContent( + element: SgNode, + alertTitleLocalName: string | null, +): { title: string | null; description: string | null; hasComplexContent: boolean } { + if (!element.is('jsx_element')) { + return { title: null, description: null, hasComplexContent: false } + } + + let title: string | null = null + let hasComplexContent = false + const descriptionParts: string[] = [] + + for (const child of element.children()) { + const kind = child.kind() + + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + + if (kind === 'jsx_text') { + const trimmed = child.text().trim() + if (trimmed.length > 0) { + descriptionParts.push(trimmed) + } + continue + } + + if (kind === 'jsx_element' && alertTitleLocalName) { + const opening = child.child(0) + const nameNode = opening?.child(1) + if (nameNode?.text() === alertTitleLocalName) { + const titleParts: string[] = [] + for (const titleChild of child.children()) { + if (titleChild.kind() === 'jsx_opening_element' || titleChild.kind() === 'jsx_closing_element') { + continue + } + if (titleChild.kind() === 'jsx_text') { + const t = titleChild.text().trim() + if (t.length > 0) { + titleParts.push(t) + } + } else { + hasComplexContent = true + } + } + if (titleParts.length > 0) { + title = titleParts.join(' ') + } + continue + } + } + + if (kind === 'jsx_element' || kind === 'jsx_self_closing_element' || kind === 'jsx_expression') { + hasComplexContent = true + continue + } + } + + const description = descriptionParts.length > 0 ? descriptionParts.join(' ') : null + return { title, description, hasComplexContent } +} + +function transformAlertElements( + rootNode: SgNode, + alertLocalName: string, + alertTitleLocalName: string | null, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const nameNode = opening.child(1) + if (!nameNode || nameNode.text() !== alertLocalName) { + continue + } + + const insertTodo = (reason: string) => { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + '{/* TODO(backstage-codemod): migrate Alert actions or complex children manually */}', + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason }) + } + + if (hasProp(opening, 'action') || hasProp(opening, 'onClose')) { + insertTodo('action-or-onClose') + continue + } + + const { value: severityValue, isDynamic } = getSeverityValue(opening) + + if (isDynamic) { + insertTodo('dynamic-severity') + continue + } + + const status = severityValue ? (SEVERITY_TO_STATUS[severityValue] ?? severityValue) : null + + if (isSelfClosing) { + const props: string[] = [] + if (status) { + props.push(`status="${status}"`) + } + if (shouldAddDefaultIcon(opening)) { + props.push('icon') + } + edits.push(el.replace(``)) + migrated = true + migrationMetric.increment({ action: 'alert-migrated', variant: 'self-closing' }) + continue + } + + const { title, description, hasComplexContent } = extractChildContent(el, alertTitleLocalName) + + if (hasComplexContent) { + insertTodo('complex-children') + continue + } + + const props: string[] = [] + if (status) { + props.push(`status="${status}"`) + } + if (shouldAddDefaultIcon(opening)) { + props.push('icon') + } + if (title) { + props.push(`title="${title}"`) + } + if (description) { + props.push(`description="${description}"`) + } + edits.push(el.replace(``)) + migrated = true + migrationMetric.increment({ action: 'alert-migrated', variant: title ? 'with-title' : 'simple' }) + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { alertLocalName, alertTitleLocalName, importNodesToRemove, importSpecifiersToRemove } = + collectAlertImports(rootNode) + + if (!alertLocalName) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformAlertElements(rootNode, alertLocalName, alertTitleLocalName, edits) + + let replacedImport = false + if (migrated && !preserveImport && importNodesToRemove.length > 1) { + for (const imp of importNodesToRemove.slice(1)) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + if (migrated) { + replacedImport = buildBuiImportEdit(rootNode, importNodesToRemove, edits) + } + + if (!preserveImport) { + const [firstImport] = importNodesToRemove + if (firstImport) { + if (replacedImport) { + migrationMetric.increment({ action: 'import-removed' }) + } else { + edits.push(firstImport.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + for (const [imp, { source, names }] of importSpecifiersToRemove) { + pruneBarrelImportSpecifiers(imp, source, names, edits) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx new file mode 100644 index 0000000..793345b --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/input.tsx new file mode 100644 index 0000000..605c01a --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/input.tsx @@ -0,0 +1,5 @@ +import Alert from '@material-ui/core/Alert'; + +const MyComponent = () => ( + Note +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/metrics.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/metrics.json new file mode 100644 index 0000000..bb49a48 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-alert-to-bui-alert": [ + { + "cardinality": { + "action": "alert-migrated", + "variant": "simple" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..c4e04d1 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Alert, Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..651450f --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Alert from '@material-ui/lab/Alert'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Saved + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..6f8e059 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-alert-to-bui-alert": [ + { + "cardinality": { + "action": "alert-migrated", + "variant": "simple" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..465c4b2 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..465c4b2 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/expected.tsx new file mode 100644 index 0000000..465c4b2 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/expected.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/input.tsx new file mode 100644 index 0000000..465c4b2 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/input.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx new file mode 100644 index 0000000..5c7e52e --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/input.tsx new file mode 100644 index 0000000..1e8dbe6 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/input.tsx @@ -0,0 +1,5 @@ +import Alert from '@material-ui/lab/Alert'; + +const MyComponent = () => ( + Heads up +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/metrics.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/metrics.json new file mode 100644 index 0000000..bb49a48 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-alert-to-bui-alert": [ + { + "cardinality": { + "action": "alert-migrated", + "variant": "simple" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx new file mode 100644 index 0000000..36ce2a6 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx @@ -0,0 +1,6 @@ +import { Alert } from '@backstage/ui'; + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/input.tsx new file mode 100644 index 0000000..d8e24ef --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/input.tsx @@ -0,0 +1,9 @@ +import Alert from '@material-ui/lab/Alert'; +import AlertTitle from '@material-ui/lab/AlertTitle'; + +const MyComponent = () => ( + + Error + Something failed. + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/metrics.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/metrics.json new file mode 100644 index 0000000..d6951da --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-alert-to-bui-alert": [ + { + "cardinality": { + "action": "alert-migrated", + "variant": "with-title" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tsconfig.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/workflow.yaml b/codemods/misc/migrate-mui-alert-to-bui-alert/workflow.yaml new file mode 100644 index 0000000..bd04f9c --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace MUI Alert with BUI Alert' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-button-to-bui-button/CHANGELOG.md b/codemods/misc/migrate-mui-button-to-bui-button/CHANGELOG.md new file mode 100644 index 0000000..e312afe --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-button-to-bui-button diff --git a/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml b/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml new file mode 100644 index 0000000..e7f0331 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-button-to-bui-button' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace MUI Button with BUI Button' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'button'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-button-to-bui-button/package.json b/codemods/misc/migrate-mui-button-to-bui-button/package.json new file mode 100644 index 0000000..1aff35b --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-button-to-bui-button", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace MUI Button with BUI Button", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts new file mode 100644 index 0000000..3b51f1c --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts @@ -0,0 +1,433 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-button-to-bui-button') + +const VARIANT_MAP: Record = { + contained: 'primary', + outlined: 'secondary', + text: 'tertiary', +} + +const BUI_SOURCE = '@backstage/ui' + +/** Props that need TODO markers because their semantics don't map mechanically. */ +const TODO_PROPS = new Set([ + 'startIcon', + 'endIcon', + 'href', + 'component', + 'fullWidth', + 'disableElevation', + 'disableRipple', + 'disableFocusRipple', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +function collectButtonImports(rootNode: SgNode): { + buttonLocalName: string | null + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, string[]> +} { + let buttonLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, string[]>() + + // Default import: import Button from '@material-ui/core/Button' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Button')) { + buttonLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + // Named import from barrel: import { Button } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'Button') + if (localName) { + buttonLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, ['Button']) + } + } + } + + return { buttonLocalName, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], edits: Edit[]): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '@material-ui/core';`)) + } + migrationMetric.increment({ action: 'import-removed' }) +} + +function addButtonToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + let hasButton = false + for (const spec of specifiers) { + const idents = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + if (idents[0]?.text() === 'Button') { + hasButton = true + } + } + if (!hasButton) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + names.push('Button') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\nimport { Button } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + } + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Button } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { Button } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { Button } from '${BUI_SOURCE}';`)) + } + } + + migrationMetric.increment({ action: 'import-added' }) + return false +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function hasProp(opening: SgNode, propName: string): boolean { + return ( + opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) !== null + ) +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function getPropStringValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + return null +} + +function isPropDynamic(opening: SgNode, propName: string): boolean { + const attr = getPropAttr(opening, propName) + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'jsx_expression' } }) !== null +} + +function transformButtonElements( + rootNode: SgNode, + buttonLocalName: string, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (name !== buttonLocalName) { + continue + } + + // Check for TODO-triggering props + let needsTodo = false + const todoReasons: string[] = [] + + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + // Dynamic variant — cannot map deterministically + if (isPropDynamic(opening, 'variant')) { + needsTodo = true + todoReasons.push('dynamic-variant') + } + + // Non-default color implies semantic intent + const colorValue = getPropStringValue(opening, 'color') + if (colorValue && colorValue !== 'primary' && colorValue !== 'default') { + needsTodo = true + todoReasons.push(`color-${colorValue}`) + } + if (isPropDynamic(opening, 'color')) { + needsTodo = true + todoReasons.push('dynamic-color') + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): verify Button intent manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + // Build new props + const newProps: string[] = [] + + // Map variant + const variantValue = getPropStringValue(opening, 'variant') + if (variantValue) { + const buiVariant = VARIANT_MAP[variantValue] + if (buiVariant) { + newProps.push(`variant="${buiVariant}"`) + } else { + // Unknown static variant — keep as-is with TODO + preserveImport = true + edits.push( + el.replace( + withTodoComment( + '{/* TODO(backstage-codemod): verify Button intent manually (unknown-variant) */}', + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'unknown-variant' }) + continue + } + } + + // Map disabled → isDisabled + const disabledAttr = getPropAttr(opening, 'disabled') + if (disabledAttr) { + const exprNode = disabledAttr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + // disabled={expr} → isDisabled={expr} + newProps.push(`isDisabled=${exprNode.text()}`) + } else { + // Boolean shorthand: disabled → isDisabled + newProps.push('isDisabled') + } + } + + // Preserve all other safe props as-is (onClick, className, etc.) + const handledProps = new Set(['variant', 'disabled', 'color']) + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (handledProps.has(propName)) { + continue + } + newProps.push(attr.text()) + } + + // Preserve spread attributes + const spreadAttrs = opening.findAll({ rule: { kind: 'jsx_expression' } }) + for (const spread of spreadAttrs) { + if (spread.text().startsWith('{...')) { + newProps.push(spread.text()) + } + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + // Preserve children via AST traversal + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + edits.push(el.replace(`${children}`)) + } + + migrated = true + migrationMetric.increment({ action: 'button-migrated', variant: variantValue ?? 'default' }) + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { buttonLocalName, importNodesToRemove, importSpecifiersToRemove } = collectButtonImports(rootNode) + + if (!buttonLocalName) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformButtonElements(rootNode, buttonLocalName, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addButtonToBuiImport(rootNode, importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + for (const [imp, namesToRemove] of importSpecifiersToRemove) { + pruneBarrelImportSpecifiers(imp, namesToRemove, edits) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx new file mode 100644 index 0000000..69d5b80 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/input.tsx new file mode 100644 index 0000000..b749435 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json new file mode 100644 index 0000000..29e0200 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx new file mode 100644 index 0000000..c27b952 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx @@ -0,0 +1,8 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): verify Button intent manually (color-secondary) */} + + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/input.tsx new file mode 100644 index 0000000..3da3ab8 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json new file mode 100644 index 0000000..c2578da --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "color-secondary" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx new file mode 100644 index 0000000..314bb3a --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx @@ -0,0 +1,7 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/input.tsx new file mode 100644 index 0000000..2beb102 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/input.tsx @@ -0,0 +1,7 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json new file mode 100644 index 0000000..29e0200 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx new file mode 100644 index 0000000..ceea1b4 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/input.tsx new file mode 100644 index 0000000..16bc1d1 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json new file mode 100644 index 0000000..29e0200 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx new file mode 100644 index 0000000..70c0676 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx @@ -0,0 +1,8 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = ({ variant }: { variant: string }) => ( + <> + {/* TODO(backstage-codemod): verify Button intent manually (dynamic-variant) */} + + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/input.tsx new file mode 100644 index 0000000..1036aea --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = ({ variant }: { variant: string }) => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json new file mode 100644 index 0000000..0db75b6 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "dynamic-variant" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..2e1df73 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Alert, Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..79f6fac --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Button from '@material-ui/core/Button'; +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..2c31eb8 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..2decf0d --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..a03f340 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/input.tsx @@ -0,0 +1,5 @@ +import { Button } from '@material-ui/core'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..5dcb702 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "outlined" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx new file mode 100644 index 0000000..cf010ef --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/input.tsx new file mode 100644 index 0000000..4cf3363 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json new file mode 100644 index 0000000..7e7e452 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "default" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..7eb642c --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..7eb642c --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx new file mode 100644 index 0000000..16c64c0 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/input.tsx new file mode 100644 index 0000000..181787b --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json new file mode 100644 index 0000000..5dcb702 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "outlined" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx new file mode 100644 index 0000000..209b8f4 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/input.tsx new file mode 100644 index 0000000..c29040e --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = (props: any) => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json new file mode 100644 index 0000000..aedf738 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "text" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx new file mode 100644 index 0000000..bb97928 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx @@ -0,0 +1,8 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): verify Button intent manually (startIcon) */} + + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/input.tsx new file mode 100644 index 0000000..a8f6ea3 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json new file mode 100644 index 0000000..00f029c --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "startIcon" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx new file mode 100644 index 0000000..aa9ddc3 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/input.tsx new file mode 100644 index 0000000..2e5959c --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json new file mode 100644 index 0000000..aedf738 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "text" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tsconfig.json b/codemods/misc/migrate-mui-button-to-bui-button/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-button-to-bui-button/workflow.yaml b/codemods/misc/migrate-mui-button-to-bui-button/workflow.yaml new file mode 100644 index 0000000..4d76527 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace MUI Button with BUI Button' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/CHANGELOG.md b/codemods/misc/migrate-mui-icon-button-to-button-icon/CHANGELOG.md new file mode 100644 index 0000000..37354d3 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-icon-button-to-button-icon diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml b/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml new file mode 100644 index 0000000..9ab9b40 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-icon-button-to-button-icon' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace IconButton with ButtonIcon' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'icon-button', 'button-icon'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/package.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/package.json new file mode 100644 index 0000000..bdb4f0a --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-icon-button-to-button-icon", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace IconButton with ButtonIcon", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts new file mode 100644 index 0000000..0837528 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts @@ -0,0 +1,382 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-icon-button-to-button-icon') + +const BUI_SOURCE = '@backstage/ui' + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +function collectIconButtonImports(rootNode: SgNode): { + iconButtonLocalName: string | null + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, string[]> +} { + let iconButtonLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, string[]>() + + // Default import: import IconButton from '@material-ui/core/IconButton' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/IconButton')) { + iconButtonLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + // Named import from barrel: import { IconButton } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'IconButton') + if (localName) { + iconButtonLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, ['IconButton']) + } + } + } + + return { iconButtonLocalName, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], edits: Edit[]): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '@material-ui/core';`)) + } + migrationMetric.increment({ action: 'import-removed' }) +} + +function addButtonIconToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + let hasButtonIcon = false + for (const spec of specifiers) { + const idents = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + if (idents[0]?.text() === 'ButtonIcon') { + hasButtonIcon = true + } + } + if (!hasButtonIcon) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + names.push('ButtonIcon') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + } + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ButtonIcon } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) + } + } + + migrationMetric.increment({ action: 'import-added' }) + return false +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function hasProp(opening: SgNode, propName: string): boolean { + return getPropAttr(opening, propName) !== null +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text') { + if (child.text().trim().length === 0) { + continue + } + } + children.push(child) + } + return children +} + +function formatIconProp(iconChild: SgNode): string { + const iconText = iconChild.text() + if (iconChild.kind() === 'jsx_expression') { + return `icon={${iconText.slice(1, -1)}}` + } + return `icon={${iconText}}` +} + +function isSingleIconChild(child: SgNode): boolean { + const kind = child.kind() + return kind === 'jsx_self_closing_element' || kind === 'jsx_element' || kind === 'jsx_expression' +} + +/** Props that are dropped silently (MUI-specific, no BUI equivalent). */ +const DROPPED_PROPS = new Set(['size', 'edge', 'color', 'disableRipple', 'disableFocusRipple']) + +function transformIconButtonElements( + rootNode: SgNode, + iconButtonLocalName: string, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (name !== iconButtonLocalName) { + continue + } + + const insertTodo = (reason: string) => { + preserveImport = true + edits.push( + el.replace( + withTodoComment('{/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */}', el.text()), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason }) + } + + if (isSelfClosing) { + insertTodo('no-children') + continue + } + + const children = getJsxChildren(el) + const [iconChild] = children + if (children.length !== 1 || !iconChild || !isSingleIconChild(iconChild)) { + insertTodo('complex-children') + continue + } + + const hasAriaLabel = hasProp(opening, 'aria-label') + if (!hasAriaLabel) { + insertTodo('missing-aria-label') + continue + } + + // Build new props + const newProps: string[] = [] + + // icon prop from child + newProps.push(formatIconProp(iconChild)) + + // Map props from opening element + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + + if (propName === 'disabled') { + // Map disabled → isDisabled + const exprNode = attr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + newProps.push(`isDisabled=${exprNode.text()}`) + } else { + newProps.push('isDisabled') + } + continue + } + + if (propName === 'onClick') { + // Map onClick → onPress + const exprNode = attr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + newProps.push(`onPress=${exprNode.text()}`) + } + continue + } + + if (DROPPED_PROPS.has(propName)) { + migrationMetric.increment({ action: 'prop-dropped', prop: propName }) + continue + } + + // Preserve all other props (aria-label, className, data-*, etc.) + newProps.push(attr.text()) + } + + // Preserve spread attributes + const spreadAttrs = opening.findAll({ rule: { kind: 'jsx_expression' } }) + for (const spread of spreadAttrs) { + if (spread.text().startsWith('{...')) { + newProps.push(spread.text()) + } + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + edits.push(el.replace(``)) + migrated = true + migrationMetric.increment({ action: 'icon-button-migrated' }) + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { iconButtonLocalName, importNodesToRemove, importSpecifiersToRemove } = collectIconButtonImports(rootNode) + + if (!iconButtonLocalName) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformIconButtonElements(rootNode, iconButtonLocalName, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addButtonIconToBuiImport(rootNode, importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + for (const [imp, namesToRemove] of importSpecifiersToRemove) { + pruneBarrelImportSpecifiers(imp, namesToRemove, edits) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx new file mode 100644 index 0000000..553f7ad --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } aria-label="delete" isDisabled={!canDelete} onPress={handleDelete} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/input.tsx new file mode 100644 index 0000000..0f099bb --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx new file mode 100644 index 0000000..f631327 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } aria-label="copy" className="custom-btn" data-testid="copy-btn" onPress={handleCopy} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/input.tsx new file mode 100644 index 0000000..d1c2a3e --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx new file mode 100644 index 0000000..605e0ca --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx @@ -0,0 +1,11 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */} + + + extra + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/input.tsx new file mode 100644 index 0000000..c82139c --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/input.tsx @@ -0,0 +1,8 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + extra + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json new file mode 100644 index 0000000..52188ca --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-children" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx new file mode 100644 index 0000000..95cb4bf --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } aria-label="close" isDisabled onPress={onClose} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/input.tsx new file mode 100644 index 0000000..c64057e --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx new file mode 100644 index 0000000..3f19a0f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } aria-label="menu" onPress={handleMenu} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/input.tsx new file mode 100644 index 0000000..eb387ea --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json new file mode 100644 index 0000000..f38e617 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json @@ -0,0 +1,43 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "color" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "edge" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "size" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..9ed4995 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + } aria-label="close" onPress={handleClose} /> + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..3f21bf4 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/input.tsx @@ -0,0 +1,11 @@ +import IconButton from '@material-ui/core/IconButton'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..43d525f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx new file mode 100644 index 0000000..f662bbf --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx @@ -0,0 +1,10 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */} + + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/input.tsx new file mode 100644 index 0000000..b500ecf --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json new file mode 100644 index 0000000..0e30158 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "missing-aria-label" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..afa4621 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } aria-label="edit" onPress={handleEdit} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..4e371e1 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/input.tsx @@ -0,0 +1,7 @@ +import { IconButton } from '@material-ui/core'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..d27199f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } aria-label="delete" onPress={handleDelete} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..d27199f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } aria-label="delete" onPress={handleDelete} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx new file mode 100644 index 0000000..7519265 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = (props: any) => ( + } aria-label="action" {...props} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/input.tsx new file mode 100644 index 0000000..aee1ce8 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = (props: any) => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tsconfig.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/workflow.yaml b/codemods/misc/migrate-mui-icon-button-to-button-icon/workflow.yaml new file mode 100644 index 0000000..ac5b58b --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace IconButton with ButtonIcon' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md new file mode 100644 index 0000000..c167960 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-tooltip-to-bui-tooltip diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml new file mode 100644 index 0000000..91ad750 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-tooltip-to-bui-tooltip' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Tooltip with TooltipTrigger' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'tooltip'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/package.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/package.json new file mode 100644 index 0000000..12cad93 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-tooltip-to-bui-tooltip", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Tooltip with TooltipTrigger", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts new file mode 100644 index 0000000..2480fac --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts @@ -0,0 +1,456 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-tooltip-to-bui-tooltip') + +const BUI_SOURCE = '@backstage/ui' + +const TODO_PROPS = new Set([ + 'leaveDelay', + 'enterDelay', + 'enterTouchDelay', + 'leaveTouchDelay', + 'interactive', + 'TransitionComponent', + 'TransitionProps', + 'PopperProps', + 'classes', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +function collectTooltipImports(rootNode: SgNode): { + tooltipLocalName: string | null + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, string[]> +} { + let tooltipLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, string[]>() + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Tooltip')) { + tooltipLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'Tooltip') + if (localName) { + tooltipLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, ['Tooltip']) + } + } + } + + return { tooltipLocalName, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], edits: Edit[]): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '@material-ui/core';`)) + } + migrationMetric.increment({ action: 'import-removed' }) +} + +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + const sortedNames = [...names].sort() + edits.push( + existingImport.replace(`${existingImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + migrationMetric.increment({ action: 'import-added' }) + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + const sortedNames = [...names].sort() + const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(buiImport)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\n${buiImport}`)) + } + } + + migrationMetric.increment({ action: 'import-added' }) + return false +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function hasProp(opening: SgNode, propName: string): boolean { + return ( + opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) !== null + ) +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function getPropStringValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + return null +} + +function isPropDynamic(opening: SgNode, propName: string): boolean { + const attr = getPropAttr(opening, propName) + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'jsx_expression' } }) !== null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function getSimpleHandlerFromProp(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + const children: SgNode[] = [] + for (const child of expr.children()) { + if (child.kind() !== '{' && child.kind() !== '}') { + children.push(child) + } + } + const [onlyChild] = children + if (children.length === 1 && onlyChild?.is('identifier')) { + return onlyChild.text() + } + return null +} + +function buildControlledTooltipProps(opening: SgNode): string[] { + const props: string[] = [] + + const openValue = getPropRawValue(opening, 'open') + if (openValue) { + props.push(`isOpen=${openValue}`) + } + + const closeHandler = getSimpleHandlerFromProp(opening, 'onClose') + const openHandler = getSimpleHandlerFromProp(opening, 'onOpen') + + if (closeHandler) { + props.push(`onOpenChange={open => !open && ${closeHandler}()}`) + migrationMetric.increment({ action: 'onClose-rewritten' }) + } else if (openHandler) { + props.push(`onOpenChange={open => open && ${openHandler}()}`) + migrationMetric.increment({ action: 'onOpen-rewritten' }) + } else if (hasProp(opening, 'onClose') || hasProp(opening, 'onOpen')) { + return [] + } + + return props +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text') { + if (child.text().trim().length === 0) { + continue + } + } + children.push(child) + } + return children +} + +function transformTooltipElements(rootNode: SgNode, tooltipLocalName: string, edits: Edit[]): boolean { + let migrated = false + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (name !== tooltipLocalName) { + continue + } + + let needsTodo = false + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + break + } + } + + const controlledProps = buildControlledTooltipProps(opening) + if ( + (hasProp(opening, 'onClose') || hasProp(opening, 'onOpen')) && + controlledProps.length === 0 && + !getPropRawValue(opening, 'open') + ) { + needsTodo = true + } else if ( + (hasProp(opening, 'onClose') || hasProp(opening, 'onOpen')) && + controlledProps.length === 0 && + getPropRawValue(opening, 'open') + ) { + needsTodo = true + } + + if (needsTodo) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) + continue + } + + const titleStr = getPropStringValue(opening, 'title') + const titleDynamic = isPropDynamic(opening, 'title') + const titleRaw = getPropRawValue(opening, 'title') + + if (!titleStr && !titleDynamic) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'no-title' }) + continue + } + + if (isSelfClosing) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'no-children' }) + continue + } + + const children = getJsxChildren(el) + if (children.length !== 1) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'multiple-children' }) + continue + } + + const [child] = children + if (!child) { + continue + } + const childText = child.text() + + let tooltipContent: string + if (titleStr !== null) { + tooltipContent = titleStr + } else if (titleRaw !== null) { + tooltipContent = titleRaw + } else { + tooltipContent = '' + } + + const placementValue = getPropStringValue(opening, 'placement') + const placementDynamic = isPropDynamic(opening, 'placement') + let placementTodo = '' + if (placementValue || placementDynamic) { + placementTodo = '{/* TODO(backstage-codemod): verify Tooltip placement mapping manually */}\n' + migrationMetric.increment({ action: 'placement-dropped', value: placementValue ?? 'dynamic' }) + } + + const tooltipEl = `${tooltipContent}` + const triggerProps = controlledProps.length > 0 ? ` ${controlledProps.join(' ')}` : '' + + edits.push( + el.replace(`${placementTodo}\n ${childText}\n ${tooltipEl}\n`), + ) + migrationMetric.increment({ action: 'tooltip-migrated' }) + migrated = true + } + + return migrated +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { tooltipLocalName, importNodesToRemove, importSpecifiersToRemove } = collectTooltipImports(rootNode) + + if (!tooltipLocalName) { + return Promise.resolve(null) + } + + const migrated = transformTooltipElements(rootNode, tooltipLocalName, edits) + + if (!migrated) { + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + } + + const buiNames = ['Tooltip', 'TooltipTrigger'] + const replacedImport = addBuiImport(rootNode, buiNames, importNodesToRemove, edits) + + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + for (const [imp, namesToRemove] of importSpecifiersToRemove) { + pruneBarrelImportSpecifiers(imp, namesToRemove, edits) + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/expected.tsx new file mode 100644 index 0000000..c602041 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/expected.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + !open && handleClose()}> + Hover me + Info + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/input.tsx new file mode 100644 index 0000000..ce5035e --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/input.tsx @@ -0,0 +1,7 @@ +import Tooltip from '@material-ui/core/Tooltip'; + +const MyComponent = () => ( + + Hover me + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/metrics.json new file mode 100644 index 0000000..f5c0ac6 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/expected.tsx new file mode 100644 index 0000000..b18f1ae --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/expected.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = ({ label }: { label: string }) => ( + + {label} + {label} + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/input.tsx new file mode 100644 index 0000000..ab072ed --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/input.tsx @@ -0,0 +1,7 @@ +import Tooltip from '@material-ui/core/Tooltip'; + +const MyComponent = ({ label }: { label: string }) => ( + + {label} + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/metrics.json new file mode 100644 index 0000000..d22f925 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..3d1ddb9 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + + Save changes + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..013dfd2 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/input.tsx @@ -0,0 +1,8 @@ +import Tooltip from '@material-ui/core/Tooltip'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..9fd7f1b --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..0642311 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/expected.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + + Help + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..01ce08c --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/input.tsx @@ -0,0 +1,7 @@ +import { Tooltip } from '@material-ui/core'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..d22f925 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..f78bc63 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/expected.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + Hover + Info + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..f78bc63 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/input.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + Hover + Info + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/expected.tsx new file mode 100644 index 0000000..5ad3080 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/expected.tsx @@ -0,0 +1,9 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify Tooltip placement mapping manually */} + + Hover + Info + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/input.tsx new file mode 100644 index 0000000..c4155c2 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/input.tsx @@ -0,0 +1,7 @@ +import Tooltip from '@material-ui/core/Tooltip'; + +const MyComponent = () => ( + + Hover + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/metrics.json new file mode 100644 index 0000000..ea9ce79 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/metrics.json @@ -0,0 +1,29 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "placement-dropped", + "value": "top" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/expected.tsx new file mode 100644 index 0000000..07f66b1 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/expected.tsx @@ -0,0 +1,10 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + + + + More actions + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/input.tsx new file mode 100644 index 0000000..b2f6fc3 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/input.tsx @@ -0,0 +1,9 @@ +import Tooltip from '@material-ui/core/Tooltip'; + +const MyComponent = () => ( + + + + + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/metrics.json new file mode 100644 index 0000000..d22f925 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tsconfig.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/workflow.yaml b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/workflow.yaml new file mode 100644 index 0000000..2c6f782 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Tooltip with TooltipTrigger' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-typography-to-text/CHANGELOG.md b/codemods/misc/migrate-mui-typography-to-text/CHANGELOG.md new file mode 100644 index 0000000..ed60226 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-typography-to-text diff --git a/codemods/misc/migrate-mui-typography-to-text/codemod.yaml b/codemods/misc/migrate-mui-typography-to-text/codemod.yaml new file mode 100644 index 0000000..80104b5 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-typography-to-text' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Typography with Text' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'typography', 'text'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-typography-to-text/package.json b/codemods/misc/migrate-mui-typography-to-text/package.json new file mode 100644 index 0000000..e5940aa --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-typography-to-text", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Typography with Text", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts new file mode 100644 index 0000000..bdf6759 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -0,0 +1,506 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-typography-to-text') + +// MUI Typography variant → BUI Text variant +const VARIANT_MAP: Record = { + h1: 'title-large', + h2: 'title-small', + h3: 'subtitle', + h4: 'body-small', + h5: 'caption', + h6: 'caption', + subtitle1: 'subtitle', + subtitle2: 'subtitle', + body1: 'body', + body2: 'body-small', + caption: 'caption', + overline: 'caption', + button: 'body', +} + +// MUI color prop → BUI Text color prop +const COLOR_MAP: Record = { + textPrimary: 'primary', + textSecondary: 'secondary', + primary: 'primary', + secondary: 'secondary', + error: 'danger', + inherit: 'inherit', +} + +const BUI_SOURCE = '@backstage/ui' + +// Component names we target +const TARGET_COMPONENTS = new Set(['Typography', 'DialogContentText']) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +interface ImportCollectionResult { + localNames: Map + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, string[]> +} + +function collectTypographyImports(rootNode: SgNode): ImportCollectionResult { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, string[]>() + + // Default import: import Typography from '@material-ui/core/Typography' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Typography')) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, 'Typography') + } + importNodesToRemove.push(imp) + } + + // Default import: import DialogContentText from '@material-ui/core/DialogContentText' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/DialogContentText')) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, 'DialogContentText') + } + importNodesToRemove.push(imp) + } + + // Named imports from barrel: import { Typography, DialogContentText } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + let foundCount = 0 + + for (const componentName of TARGET_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + const toRemove: string[] = [] + for (const componentName of TARGET_COMPONENTS) { + if (getNamedImportLocalName(imp, componentName)) { + toRemove.push(componentName) + } + } + importSpecifiersToRemove.set(imp, toRemove) + } + } + } + + return { localNames, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers( + imp: SgNode, + namesToRemove: string[], + edits: Edit[], + appendTextImport = false, +): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + let replacement = `import { ${specTexts} } from '@material-ui/core';` + if (appendTextImport) { + replacement += `\nimport { Text } from '${BUI_SOURCE}';` + migrationMetric.increment({ action: 'import-added' }) + } + edits.push(imp.replace(replacement)) + } + migrationMetric.increment({ action: 'import-removed' }) +} + +function addTextToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + let hasText = false + for (const spec of specifiers) { + const idents = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + if (idents[0]?.text() === 'Text') { + hasText = true + } + } + if (!hasText) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + names.push('Text') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\nimport { Text } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + } + } + return + } + + const removeIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removeIds.has(imp.id())) ?? null + + if (importNodesToRemove.length === 1 && !anchorImport) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { Text } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-removed' }) + } + } else { + const [firstImport] = allImports + if (firstImport) { + edits.push(firstImport.replace(`import { Text } from '${BUI_SOURCE}';\n${firstImport.text()}`)) + } else if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Text } from '${BUI_SOURCE}';`)) + } + } + + migrationMetric.increment({ action: 'import-added' }) +} + +function getAttrStringValue( + opening: SgNode, + propName: string, +): { value: string | null; isDynamic: boolean; attrNode: SgNode | null } { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + + if (!attr) { + return { value: null, isDynamic: false, attrNode: null } + } + + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return { value: frag?.text() ?? null, isDynamic: false, attrNode: attr } + } + + const exprNode = attr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + return { value: exprNode.text(), isDynamic: true, attrNode: attr } + } + + // Boolean attribute (e.g. gutterBottom without value) + return { value: '', isDynamic: false, attrNode: attr } +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` +} + +function getOpeningElement(el: SgNode): SgNode | null { + if (el.is('jsx_self_closing_element')) { + return el + } + return el.child(0) +} + +function getElementName(opening: SgNode): string | null { + // The tag name is the first named child that is an identifier + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function buildPartialTextProps( + opening: SgNode, + buiVariant: string | null, + buiColor: string | null, + componentValue: string | null, + componentDynamic: boolean, +): string[] { + const newProps: string[] = [] + if (buiVariant) { + newProps.push(`variant="${buiVariant}"`) + } + if (buiColor) { + newProps.push(`color="${buiColor}"`) + } + if (componentValue && !componentDynamic) { + newProps.push(`as="${componentValue}"`) + } else if (componentDynamic && componentValue) { + newProps.push(`as={${componentValue.slice(1, -1)}}`) + } + + const handledProps = new Set(['variant', 'color', 'component', 'gutterBottom']) + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (handledProps.has(propName)) { + continue + } + newProps.push(attr.text()) + } + + const spreadAttrs = opening.findAll({ rule: { kind: 'jsx_expression' } }) + for (const spread of spreadAttrs) { + if (spread.text().startsWith('{...')) { + newProps.push(spread.text()) + } + } + + return newProps +} + +function buildTextElement(el: SgNode, isSelfClosing: boolean, newProps: string[]): string { + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + if (isSelfClosing) { + return `` + } + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + return `${children}` +} + +function transformTypographyElements(rootNode: SgNode, localNames: Map, edits: Edit[]): boolean { + let migrated = false + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = getOpeningElement(el) + if (!opening) { + continue + } + + const componentLocalName = getElementName(opening) + if (!componentLocalName || !localNames.has(componentLocalName)) { + continue + } + + // Collect props + const { value: variantValue, isDynamic: variantDynamic } = getAttrStringValue(opening, 'variant') + const { value: colorValue, isDynamic: colorDynamic } = getAttrStringValue(opening, 'color') + const { value: componentValue, isDynamic: componentDynamic } = getAttrStringValue(opening, 'component') + const { attrNode: gutterBottomAttr } = getAttrStringValue(opening, 'gutterBottom') + + if (variantDynamic || colorDynamic) { + const partialProps = buildPartialTextProps(opening, null, null, componentValue, componentDynamic) + const textElement = buildTextElement(el, isSelfClosing, partialProps) + edits.push( + el.replace(withTodoComment('{/* TODO(backstage-codemod): verify Text variant manually */}', textElement)), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-props' }) + migrationMetric.increment({ + action: 'typography-migrated', + component: localNames.get(componentLocalName) ?? componentLocalName, + }) + migrated = true + continue + } + + // Map variant + let buiVariant: string | null = null + let needsTodo = false + if (variantValue) { + buiVariant = VARIANT_MAP[variantValue] ?? null + if (!buiVariant) { + needsTodo = true + } + } + + // Map color + let buiColor: string | null = null + if (colorValue) { + buiColor = COLOR_MAP[colorValue] ?? null + if (!buiColor) { + needsTodo = true + } + } + + if (needsTodo) { + const partialProps = buildPartialTextProps(opening, buiVariant, buiColor, componentValue, componentDynamic) + const textElement = buildTextElement(el, isSelfClosing, partialProps) + edits.push( + el.replace(withTodoComment('{/* TODO(backstage-codemod): verify Text variant manually */}', textElement)), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'unmapped-variant-or-color' }) + migrationMetric.increment({ + action: 'typography-migrated', + component: localNames.get(componentLocalName) ?? componentLocalName, + }) + migrated = true + continue + } + + // Build new props + const newProps = buildPartialTextProps(opening, buiVariant, buiColor, componentValue, componentDynamic) + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + const gutterBottomTodo = gutterBottomAttr + ? '{/* TODO(backstage-codemod): verify Text variant manually (gutterBottom) */}' + : null + + const wrapWithGutterBottomTodo = (content: string): string => { + if (!gutterBottomTodo) { + return content + } + return withTodoComment(gutterBottomTodo, content) + } + + if (isSelfClosing) { + edits.push(el.replace(wrapWithGutterBottomTodo(``))) + } else { + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + + edits.push(el.replace(wrapWithGutterBottomTodo(`${children}`))) + } + + if (gutterBottomAttr) { + migrationMetric.increment({ action: 'todo-inserted', reason: 'gutterBottom' }) + migrationMetric.increment({ action: 'gutterBottom-dropped' }) + } + migrated = true + migrationMetric.increment({ + action: 'typography-migrated', + component: localNames.get(componentLocalName) ?? componentLocalName, + }) + } + + return migrated +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove, importSpecifiersToRemove } = collectTypographyImports(rootNode) + + if (localNames.size === 0) { + return Promise.resolve(null) + } + + const migrated = transformTypographyElements(rootNode, localNames, edits) + + for (const imp of importNodesToRemove) { + if ( + migrated && + importNodesToRemove.length === 1 && + findImportStatementsFrom(rootNode, BUI_SOURCE).length === 0 && + imp.id() === importNodesToRemove[0]?.id() + ) { + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + let addedTextViaBarrelPrune = false + + for (const [imp, namesToRemove] of importSpecifiersToRemove) { + const appendTextImport = migrated && findImportStatementsFrom(rootNode, BUI_SOURCE).length === 0 + if (appendTextImport) { + addedTextViaBarrelPrune = true + } + pruneBarrelImportSpecifiers(imp, namesToRemove, edits, appendTextImport) + } + + if (migrated && !addedTextViaBarrelPrune) { + addTextToBuiImport(rootNode, importNodesToRemove, edits) + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx new file mode 100644 index 0000000..5070b94 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Details +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/input.tsx new file mode 100644 index 0000000..b3fc5af --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Details +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx new file mode 100644 index 0000000..8f07c9d --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Inline text +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/input.tsx new file mode 100644 index 0000000..7ab325f --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Inline text +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx new file mode 100644 index 0000000..ec88b26 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyDialog = () => ( + Overview +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/input.tsx new file mode 100644 index 0000000..3088e48 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/input.tsx @@ -0,0 +1,5 @@ +import DialogContentText from '@material-ui/core/DialogContentText'; + +const MyDialog = () => ( + Overview +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json new file mode 100644 index 0000000..37cd4fb --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "DialogContentText" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx new file mode 100644 index 0000000..6fe8eb6 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Label +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/input.tsx new file mode 100644 index 0000000..e456817 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Label +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx new file mode 100644 index 0000000..4dc7973 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx @@ -0,0 +1,8 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): verify Text variant manually (gutterBottom) */} + Section Title + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/input.tsx new file mode 100644 index 0000000..1ddb8e6 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Section Title +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json new file mode 100644 index 0000000..00e25a8 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json @@ -0,0 +1,36 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "gutterBottom-dropped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "gutterBottom" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx new file mode 100644 index 0000000..e3ccbb6 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const PageTitle = () => ( + Welcome +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/input.tsx new file mode 100644 index 0000000..42bf382 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const PageTitle = () => ( + Welcome +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..d37b4fe --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Text } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Small text + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..2ff0cd2 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Typography from '@material-ui/core/Typography'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Small text + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..83d6a25 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..78bc5c1 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx @@ -0,0 +1,9 @@ +import { Button } from '@material-ui/core'; +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + <> + Hello + + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..fff07d1 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { Typography, Button } from '@material-ui/core'; + +const MyComponent = () => ( + <> + Hello + + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..ffcabf4 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..ffcabf4 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/expected.tsx new file mode 100644 index 0000000..ffcabf4 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/input.tsx new file mode 100644 index 0000000..ffcabf4 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/input.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx new file mode 100644 index 0000000..4d16d58 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx @@ -0,0 +1,7 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + + Click here for details. + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/input.tsx new file mode 100644 index 0000000..59d9719 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/input.tsx @@ -0,0 +1,7 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + + Click here for details. + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx new file mode 100644 index 0000000..f42c68f --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx @@ -0,0 +1,8 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): verify Text variant manually */} + Hidden text + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/input.tsx new file mode 100644 index 0000000..57b1642 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Hidden text +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json new file mode 100644 index 0000000..065f853 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json @@ -0,0 +1,30 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "unmapped-variant-or-color" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tsconfig.json b/codemods/misc/migrate-mui-typography-to-text/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-typography-to-text/workflow.yaml b/codemods/misc/migrate-mui-typography-to-text/workflow.yaml new file mode 100644 index 0000000..ee784f3 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Typography with Text' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/yarn.lock b/yarn.lock index afce4d8..c8b4823 100644 --- a/yarn.lock +++ b/yarn.lock @@ -172,6 +172,51 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-alert-to-bui-alert@workspace:codemods/misc/migrate-mui-alert-to-bui-alert": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-alert-to-bui-alert@workspace:codemods/misc/migrate-mui-alert-to-bui-alert" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-button-to-bui-button@workspace:codemods/misc/migrate-mui-button-to-bui-button": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-button-to-bui-button@workspace:codemods/misc/migrate-mui-button-to-bui-button" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-icon-button-to-button-icon@workspace:codemods/misc/migrate-mui-icon-button-to-button-icon": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-icon-button-to-button-icon@workspace:codemods/misc/migrate-mui-icon-button-to-button-icon" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-tooltip-to-bui-tooltip@workspace:codemods/misc/migrate-mui-tooltip-to-bui-tooltip": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-tooltip-to-bui-tooltip@workspace:codemods/misc/migrate-mui-tooltip-to-bui-tooltip" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-typography-to-text@workspace:codemods/misc/migrate-mui-typography-to-text": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-typography-to-text@workspace:codemods/misc/migrate-mui-typography-to-text" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page": version: 0.0.0-use.local resolution: "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page"