Skip to content

Instantly share code, notes, and snippets.

@ykiu
Last active November 27, 2021 12:28
Show Gist options
  • Save ykiu/ba6a85ff2ed00c1d9c2d84b06ed56003 to your computer and use it in GitHub Desktop.
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).
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