Last active
November 27, 2021 12:28
-
-
Save ykiu/ba6a85ff2ed00c1d9c2d84b06ed56003 to your computer and use it in GitHub Desktop.
A jscodeshift codemod for migrating from makeStyles() API of Material UI v4 to emotion's css props. Assumes the input is in JavaScript (not TypeScript).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function lookupByName(scope, name) { | |
const path = scope.getBindings()[name]?.[0]; | |
if (path) return path; | |
if (scope.parent == null) return null; | |
return lookupByName(scope.parent, name); | |
} | |
function getClosestFunctionBody(path) { | |
if (path.value.type === 'FunctionDeclaration') { | |
return path.get('body'); | |
} | |
if (path.value.type === 'ArrowFunctionExpression') { | |
const body = path.get('body'); | |
if (body.value.type === 'BlockStatement') { | |
return body; | |
} | |
} | |
return getClosestFunctionBody(path.parentPath); | |
} | |
function getJssStyleDeclarationObj(jssPropertyAccessExpression) { | |
// --> `classes.avatar` | |
const jssPropertyName = jssPropertyAccessExpression.get('property').value.name; | |
// --> "avatar" | |
const classesDeclaration = lookupByName(jssPropertyAccessExpression.scope, 'classes')?.parentPath; | |
// --> `const classes = useStyles();` | |
if (!classesDeclaration) throw new Error(`Failed to resolve classes for "${jssPropertyName}"`); | |
const useStylesCallee = classesDeclaration.get('init').get('callee').get('name'); | |
// --> `useStyles` | |
const useStylesName = useStylesCallee.value; | |
// --> "useStyles" | |
const jssPropertyDefinition = lookupByName(useStylesCallee.scope, useStylesName) | |
.parentPath.get('init') | |
.get('arguments') | |
.get(0) | |
.get('body') | |
.get('properties') | |
.filter((p) => p.get('key').value.name === jssPropertyName)[0]; | |
// --> `avatar: { margin: theme.spacing(1) }` | |
const cssStyleDeclarationObj = jssPropertyDefinition?.get('value'); | |
// --> { margin: theme.spacing(1) } | |
if (!cssStyleDeclarationObj) { | |
// This happens when classes.avatar is accessed but the style not defined in makeStyles(...) | |
return null; | |
} | |
if (cssStyleDeclarationObj.value.type !== 'ObjectExpression') | |
throw new Error( | |
`Non-object style definition is not supported yet. Got ${cssStyleDeclarationObj.value.type}.`, | |
); | |
const styleObjNode = cssStyleDeclarationObj.value; | |
return styleObjNode; | |
} | |
module.exports = (fileInfo, api) => { | |
const j = api.jscodeshift; | |
const wrapper = j(fileInfo.source); | |
let emotionCssIsUsed = false; | |
const functionBodiesWithTheme = new Set(); | |
let themeImported = false; | |
function replaceJssClassNameWithEmotionCss(jssPropertyAccessExpression) { | |
const emotionCss = | |
getJssStyleDeclarationObj(jssPropertyAccessExpression) ?? j.objectExpression([]); | |
jssPropertyAccessExpression.replace(emotionCss); | |
} | |
wrapper.findJSXElements().forEach((path) => { | |
const jsxClassNameAttribute = path | |
.get('openingElement') | |
.get('attributes') | |
.filter((a) => a.get('name').get('name').value === 'className')[0]; | |
// --> `className={classes.avatar}` | |
if (!jsxClassNameAttribute) return; | |
const jsxClassNameExpression = jsxClassNameAttribute.get('value').get('expression'); | |
switch (jsxClassNameExpression.value.type) { | |
case 'MemberExpression': { | |
// property access like `classes.avatar` | |
replaceJssClassNameWithEmotionCss(jsxClassNameExpression); | |
jsxClassNameAttribute.get('name').get('name').replace('css'); | |
emotionCssIsUsed = true; | |
break; | |
} | |
case 'CallExpression': { | |
// function call like `classNames(...)` | |
const calleeName = jsxClassNameExpression.get('callee').value.name; | |
if (calleeName !== 'classNames') | |
throw new Error(`Only classNames() is supported. got ${calleeName}`); | |
// Classify classNames() arguments into | |
// - non-jss classes (like class names passed from outside the component) | |
// - jss classes (those from useStyles) | |
const nonJssClasses = []; | |
const jssClasses = []; | |
jsxClassNameExpression.get('arguments').each((expression) => { | |
if (expression.value.type === 'MemberExpression') { | |
jssClasses.push(expression); | |
return; | |
} | |
const hasJssClass = j(expression).find('MemberExpression').size() > 0; | |
if (hasJssClass) { | |
jssClasses.push(expression); | |
return; | |
} | |
nonJssClasses.push(expression); | |
}); | |
// Mutate jss classes so they become valid emotion css expressions | |
// like `{ margin: theme.spacing(1) }`. | |
jssClasses.forEach((jssClass) => { | |
if (jssClass.value.type === 'MemberExpression') { | |
replaceJssClassNameWithEmotionCss(jssClass); | |
return; | |
} | |
j(jssClass).find('MemberExpression').forEach(replaceJssClassNameWithEmotionCss); | |
}); | |
const emotionCssExpressions = jssClasses.map((e) => e.value); | |
// If there is only one emotion style then go with | |
// `css={{ margin: theme.spacing(1) }}` | |
// If there are more, | |
// `css={[{ margin: theme.spacing(1) }, someCondition && { margin: theme.spacing(2) }]}` | |
const emotionCssExpression = | |
emotionCssExpressions.length === 1 | |
? emotionCssExpressions[0] | |
: j.arrayExpression(emotionCssExpressions); | |
const emotionCssAttribute = j.jsxAttribute( | |
j.jsxIdentifier('css'), | |
j.jsxExpressionContainer(emotionCssExpression), | |
); | |
// --> css={{ margin: theme.spacing(1) }} | |
jsxClassNameAttribute.insertAfter(emotionCssAttribute); | |
// Remove the jss classes from the classNames() arguments. | |
jssClasses.forEach((jssClass) => jssClass.prune()); | |
if (nonJssClasses.length === 1) { | |
// There is only one class name in the classNames() call. | |
// Replace `classNames(foo)` with just `foo`. | |
jsxClassNameExpression.replace(nonJssClasses[0].value); | |
} | |
emotionCssIsUsed = true; | |
break; | |
} | |
default: | |
} | |
const body = getClosestFunctionBody(jsxClassNameExpression); | |
const themeUsed = j(body).find('Identifier', { name: 'theme' }).size() > 0; | |
if (themeUsed && !functionBodiesWithTheme.has(body)) { | |
// Add `const theme = useTheme();` to the function body. | |
functionBodiesWithTheme.add(body); | |
body | |
.get('body') | |
.insertAt( | |
0, | |
j.variableDeclaration('const', [ | |
j.variableDeclarator( | |
j.identifier('theme'), | |
j.callExpression(j.identifier('useTheme'), []), | |
), | |
]), | |
); | |
if (!themeImported) { | |
// Add `import { useTheme } from '@mui/material`; | |
themeImported = true; | |
wrapper | |
.get('program') | |
.get('body') | |
.insertAt( | |
0, | |
j.importDeclaration( | |
[j.importSpecifier(j.identifier('useTheme'), j.identifier('useTheme'))], | |
j.stringLiteral('@mui/material'), | |
), | |
); | |
} | |
} | |
}); | |
// Remove classes: null from defaultProps | |
wrapper | |
.find('AssignmentExpression', { left: { property: { name: 'defaultProps' } } }) | |
.get('right') | |
.get('properties') | |
.each((property) => { | |
if (property.get('key').value.name === 'classes') { | |
property.prune(); | |
} | |
}); | |
// Remove classes: PropTypes.objectOf(PropTypes.string.isRequired) from propTypes | |
wrapper | |
.find('AssignmentExpression', { left: { property: { name: 'propTypes' } } }) | |
.get('right') | |
.get('properties') | |
.each((property) => { | |
if (property.get('key').value.name === 'classes') { | |
property.prune(); | |
} | |
}); | |
const maybeJsxPragma = emotionCssIsUsed ? '/** @jsxImportSource @emotion/react */\n\n' : ''; | |
return `${maybeJsxPragma}${wrapper.toSource()}`; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment