Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/mui-to-bui-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@backstage/migrate-mui-bootstrap-to-bui': minor
'@backstage/migrate-mui-icons-to-remix-icons': minor
'@backstage/migrate-mui-styles-to-bui-css-modules': minor
'@backstage/migrate-mui-layout-to-bui-layout': minor
'@backstage/remove-mui-dependencies': minor
---

Add foundation codemods for the MUI 4 to BUI migration: bootstrap app dependencies and root CSS, replace MUI icons with Remix icons, migrate makeStyles to CSS modules, convert layout primitives to BUI equivalents, and remove unused @material-ui/\* dependencies from package.json after migration.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-bootstrap-to-bui](./codemods/misc/migrate-mui-bootstrap-to-bui) | MUI 4 to BUI: Bootstrap app dependencies and root CSS |
| [migrate-mui-icons-to-remix-icons](./codemods/misc/migrate-mui-icons-to-remix-icons) | MUI 4 to BUI: Replace MUI icons with Remix icons |
| [migrate-mui-layout-to-bui-layout](./codemods/misc/migrate-mui-layout-to-bui-layout) | MUI 4 to BUI: Convert common MUI layout primitives to BUI layout |
| [migrate-mui-styles-to-bui-css-modules](./codemods/misc/migrate-mui-styles-to-bui-css-modules) | MUI 4 to BUI: Migrate makeStyles usage to BUI CSS modules |
| [remove-mui-dependencies](./codemods/misc/remove-mui-dependencies) | MUI 4 to BUI: Remove unused @material-ui/\* dependencies from package.json |

<!-- CODEMODS_END -->

## Usage
Expand Down
7 changes: 7 additions & 0 deletions codemods/misc/migrate-mui-bootstrap-to-bui/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @backstage/migrate-mui-bootstrap-to-bui

## 0.1.0

### Minor Changes

- Initial release: add `@backstage/ui` and `@remixicon/react` dependencies to `package.json` and insert the global BUI stylesheet in app/plugin entry files during the MUI 4 to BUI migration.
21 changes: 21 additions & 0 deletions codemods/misc/migrate-mui-bootstrap-to-bui/codemod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
schema_version: '1.0'

name: '@backstage/migrate-mui-bootstrap-to-bui'
version: '0.1.0'
description: 'MUI 4 to BUI: Bootstrap app dependencies and root CSS'
author: 'Paul Schultz<pschultz@redhat.com>'
license: 'Apache-2.0'
repository: 'https://github.com/backstage/codemods'
workflow: 'workflow.yaml'

targets:
languages: ['tsx', 'ts', 'json']

keywords: ['backstage', 'migration', 'mui', 'bui', 'bootstrap', 'bui']

registry:
access: 'public'
visibility: 'public'

capabilities:
- fetch
13 changes: 13 additions & 0 deletions codemods/misc/migrate-mui-bootstrap-to-bui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@backstage/migrate-mui-bootstrap-to-bui",
"version": "0.1.0",
"description": "MUI 4 to BUI: Bootstrap app dependencies and root CSS",
"type": "module",
"scripts": {
"test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod jssg test -l json ./scripts/package-json-codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml"
},
"devDependencies": {
"@codemod.com/jssg-types": "1.6.2",
"codemod": "1.12.3"
}
}
111 changes: 111 additions & 0 deletions codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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-bootstrap-to-bui')

const BUI_CSS_IMPORT = '@backstage/ui/css/styles.css'

function findImportStatementsMatching(rootNode: SgNode<TSX>, pattern: string): SgNode<TSX>[] {
return rootNode.findAll({
rule: {
kind: 'import_statement',
has: {
kind: 'string',
has: {
kind: 'string_fragment',
regex: pattern,
},
},
},
})
}

function normalizeFilePath(filename: string): string {
return filename.replace(/^\\\\\?\\/, '').replaceAll('\\', '/')
}

/**
* Match Backstage app/plugin entry files where the global BUI stylesheet belongs.
*/
function isAppEntryFile(filename: string, rootNode: SgNode<TSX>): boolean {
const normalized = normalizeFilePath(filename)

if (/(?:^|\/)packages\/app\/src\/App\.tsx?$/.test(normalized)) {
return true
}
if (/(?:^|\/)src\/index\.tsx?$/.test(normalized)) {
return true
}
if (/(?:^|\/)src\/plugin\.tsx?$/.test(normalized)) {
return true
}

// Typical app bootstrap entry when the filename is index.tsx content (e.g. jssg fixtures).
const createRootImports = findImportStatementsMatching(rootNode, '^react-dom/client$')
for (const imp of createRootImports) {
const createRootSpecifier = imp.find({
rule: {
kind: 'import_specifier',
has: {
kind: 'identifier',
regex: '^createRoot$',
},
},
})
if (createRootSpecifier) {
return true
}
}

return false
}

function hasMuiImports(rootNode: SgNode<TSX>): boolean {
const muiImports = findImportStatementsMatching(rootNode, '^@material-ui/')
return muiImports.length > 0
}

function hasBuiCssImport(rootNode: SgNode<TSX>): boolean {
const cssImports = findImportStatementsMatching(rootNode, '^@backstage/ui/css/styles\\.css$')
return cssImports.length > 0
}

const transform: Codemod<TSX> = (root) => {
const rootNode = root.root()
const edits: Edit[] = []

if (!isAppEntryFile(root.filename(), rootNode)) {
return Promise.resolve(null)
}

// Only process entry files that contain @material-ui imports
if (!hasMuiImports(rootNode)) {
return Promise.resolve(null)
}

// Skip if the BUI CSS import is already present
if (hasBuiCssImport(rootNode)) {
migrationMetric.increment({ action: 'already-bootstrapped' })
return Promise.resolve(null)
}

// Find the first import statement to insert before it
const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } })
if (allImports.length === 0) {
return Promise.resolve(null)
}

const [firstImport] = allImports
if (!firstImport) {
return Promise.resolve(null)
}

// Insert the BUI CSS import before the first import
edits.push(firstImport.replace(`import '${BUI_CSS_IMPORT}';\n${firstImport.text()}`))
migrationMetric.increment({ action: 'css-import-added' })

return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null)
}

export default transform
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { Codemod } from 'codemod:ast-grep'
import type JSON from 'codemod:ast-grep/langs/json'
import { useMetricAtom } from 'codemod:metrics'

import { resolveLatestCaretRange } from './resolve-latest-version.ts'

const migrationMetric = useMetricAtom('migrate-mui-bootstrap-to-bui')

const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/lab'] as const

const BUI_PACKAGE = '@backstage/ui'
const REMIX_PACKAGE = '@remixicon/react'

/** Offline fallback when registry lookup is disabled or unavailable (e.g. jssg tests). */
const FALLBACK_BUI_VERSION = '^0.16.0'
const FALLBACK_REMIX_VERSION = '^4.9.0'

interface PackageJson {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
[key: string]: unknown
}

interface PackageJsonCodemodParams {
resolveLatestVersions?: boolean
buiVersion?: string
remixVersion?: string
}

function sortObjectKeys(obj: Record<string, string>): Record<string, string> {
const sorted: Record<string, string> = {}
for (const key of Object.keys(obj).sort()) {
const value = obj[key]
if (value !== undefined) {
sorted[key] = value
}
}
return sorted
}

function normalizeSource(source: string): string {
return source.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
}

function hasMuiDependency(pkg: PackageJson): boolean {
return MUI_PACKAGES.some(
(name) => pkg.dependencies?.[name] !== undefined || pkg.devDependencies?.[name] !== undefined,
)
}

function hasMuiIcons(pkg: PackageJson): boolean {
return (
pkg.dependencies?.['@material-ui/icons'] !== undefined || pkg.devDependencies?.['@material-ui/icons'] !== undefined
)
}

function dependencySectionForMui(pkg: PackageJson): 'dependencies' | 'devDependencies' {
for (const name of MUI_PACKAGES) {
if (pkg.dependencies?.[name] !== undefined) {
return 'dependencies'
}
}
return 'devDependencies'
}

async function resolveDependencyVersion(
packageName: string,
params: PackageJsonCodemodParams,
paramOverride: string | undefined,
fallback: string,
): Promise<string> {
if (paramOverride !== undefined) {
return paramOverride
}

if (params.resolveLatestVersions !== true) {
migrationMetric.increment({ action: 'version-fallback', package: packageName, reason: 'disabled' })
return fallback
}

const latest = await resolveLatestCaretRange(packageName)
if (latest !== null) {
migrationMetric.increment({ action: 'version-resolved', package: packageName, version: latest })
return latest
}

migrationMetric.increment({ action: 'version-fallback', package: packageName, reason: 'registry-unavailable' })
return fallback
}

const transform: Codemod<JSON> = async (root, options) => {
const rootNode = root.root()
const source = normalizeSource(rootNode.text())
const params = options.params as PackageJsonCodemodParams

let pkg: PackageJson
try {
pkg = globalThis.JSON.parse(source) as PackageJson
} catch {
return Promise.resolve(null)
}

if (!hasMuiDependency(pkg)) {
return Promise.resolve(null)
}

const buiVersion = await resolveDependencyVersion(BUI_PACKAGE, params, params.buiVersion, FALLBACK_BUI_VERSION)

const section = dependencySectionForMui(pkg)
const existingDeps = pkg[section] ?? {}
let changed = false

if (existingDeps[BUI_PACKAGE] === undefined) {
existingDeps[BUI_PACKAGE] = buiVersion
changed = true
migrationMetric.increment({ action: 'bui-dependency-added' })
}

if (hasMuiIcons(pkg) && existingDeps[REMIX_PACKAGE] === undefined) {
const remixVersion = await resolveDependencyVersion(
REMIX_PACKAGE,
params,
params.remixVersion,
FALLBACK_REMIX_VERSION,
)
existingDeps[REMIX_PACKAGE] = remixVersion
changed = true
migrationMetric.increment({ action: 'remix-dependency-added' })
}

if (!changed) {
migrationMetric.increment({ action: 'already-bootstrapped-deps' })
return Promise.resolve(null)
}

pkg[section] = sortObjectKeys(existingDeps)

const indentMatch = source.match(/\n(\s+)"/)
const indent = indentMatch?.[1] ?? ' '
const result = `${globalThis.JSON.stringify(pkg, null, indent)}\n`

if (result === source) {
return Promise.resolve(null)
}

return Promise.resolve(result)
}

export default transform
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const NPM_REGISTRY = 'https://registry.npmjs.org'

const versionResolutionCache = new Map<string, string>()

interface NpmLatestResponse {
version?: string
}

export async function resolveLatestCaretRange(packageName: string): Promise<string | null> {
const cached = versionResolutionCache.get(packageName)
if (cached !== undefined) {
return cached
}

try {
const response = await fetch(`${NPM_REGISTRY}/${encodeURIComponent(packageName)}/latest`)
if (!response.ok) {
return null
}

const payload = (await response.json()) as NpmLatestResponse
if (payload.version === undefined) {
return null
}

const range = `^${payload.version}`
versionResolutionCache.set(packageName, range)
return range
} catch {
return null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import '@backstage/ui/css/styles.css';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Typography } from '@material-ui/core';
import { App } from './App';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import '@backstage/ui/css/styles.css';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Typography } from '@material-ui/core';
import { App } from './App';
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"migrate-mui-bootstrap-to-bui": [
{
"cardinality": {
"action": "already-bootstrapped"
},
"count": 1
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import '@backstage/ui/css/styles.css';
import React from 'react';
import { createRoot } from 'react-dom/client';
import Button from '@material-ui/core/Button';
import { App } from './App';
Loading