UNPKG

@atlaskit/eslint-plugin-ui-styling-standard

Version:

The eslint plugin to enforce and educate on Atlassian's UI Styling Standard

317 lines (310 loc) • 11.7 kB
import { isNodeOfType } from 'eslint-codemod-utils'; import { analyze } from 'eslint-scope'; import ESTraverse from 'estraverse'; import { getScope, getSourceCode } from '@atlaskit/eslint-utils/context-compat'; import { isEmotion, isStyledComponents } from '@atlaskit/eslint-utils/is-supported-import'; import { createLintRule } from '../utils/create-rule'; /** * Represents some information about single line that contains some props, e.g. * the arrow function in * * color: (props) => `mix(${props.myColor}, #fff)` * * or about a destructuring assignment, e.g. the arrow function in * * color: ({ myColor, otherColor }) => someFunction(myColor, otherColor) * * or something that we don't recognise, or that we don't think is valid * * color: (props, someOtherVariable) => someOtherVariable */ const standardizePropExpression = (sourceCode, expression) => { const propsObjectName = 'props'; if (expression.type === 'Identifier') { return `${propsObjectName}.${expression.name}`; } if (expression.type === 'MemberExpression' && expression.property.type === 'Identifier') { const propertyName = sourceCode.getText(expression.property); return `${propsObjectName}.${propertyName}`; } // Cannot safely process the expression, bail out! return undefined; }; const parseArrowFunctionExpression = (expression, sourceCode) => { if (expression.params.length !== 1) { return { valid: false }; } const scopeManager = analyze(expression, {}); const currentScope = scopeManager.acquire(expression); if (!currentScope) { return { valid: false }; } const parameter = expression.params[0]; const props = []; // Don't bother handling type annotations that appear in the parameters of arrow functions. // // This only applies if the ESLint rule is running under @typescript-eslint (not regular eslint), hence the roundabout way we use to check whether parameter.typeAnnotation exists or not. if ('typeAnnotation' in parameter && parameter.typeAnnotation) { return { valid: false }; } if (parameter.type === 'ObjectPattern') { if (parameter.properties.some(value => value.type === 'RestElement')) { return { valid: false }; } const properties = parameter.properties; for (const property of properties) { const propertyValue = property.value; // Don't bother supporting other patterns like rest elements, default values... if (propertyValue.type !== 'Identifier') { return { valid: false }; } const variable = currentScope.variables.find(varb => varb.name === propertyValue.name); if (!variable || !variable.references || !variable.references.length) { // Something went wrong, best to just bail out. return { valid: false }; } const propName = standardizePropExpression(sourceCode, propertyValue); if (!propName) { return { valid: false }; } props.push({ propName, usages: variable.references.map(reference => reference.identifier) }); } } else if (parameter.type === 'Identifier') { const variable = currentScope.variables.find(varb => varb.name === parameter.name); if (!variable || !variable.references || !variable.references.length) { // Something went wrong, best to just bail out. return { valid: false }; } for (const reference of variable.references) { const memberExpression = reference.identifier.parent; if (memberExpression.type !== 'MemberExpression') { // If reference.identifier.parent is not a member expression, that indicates // that we have a usage of the props argument that does NOT look like // // (props) => props.color // // e.g. we might have (props) => props['...'] or (props) => `${props}px` perhaps... // // We have no choice but to bail out because the syntax is not what we expect. return { valid: false }; } const propName = standardizePropExpression(sourceCode, memberExpression); if (!propName) { return { valid: false }; } props.push({ propName, usages: [memberExpression] }); } } else { // Bail out for any syntax we don't recognise. return { valid: false }; } return { arrowFunctionExpression: expression, props, valid: true }; }; const lintArguments = (context, args) => { for (const argument of args) { if (argument.type === 'ArrowFunctionExpression') { // styled.div((props) => ({ ... })) // styled.div(({ color, height }) => ({ ... })) // // Already in the form we want. continue; } else if (argument.type === 'ObjectExpression') { const allProps = []; // vvvvvvv // styled.div({ ... }) for (const property of argument.properties) { if (property.type === 'SpreadElement') { // e.g. // styled.div({ color: 'blue', ...someStyles }) // // If we have a spread element, we can no longer safely // transform this styled/css call without a lot more effort. // // Best to give up... return; } else { ESTraverse.traverse(property.value, { enter: function (node, _parent) { if (node.type === 'ArrowFunctionExpression') { const propNames = parseArrowFunctionExpression(node, getSourceCode(context)); if (!propNames) { // We've encountered a CSS value we don't recognise, skip. return; } allProps.push(propNames); } }, /** * This is needed to handle unknown node types. Otherwise an error is thrown. */ fallback: 'iteration' }); } } if (!allProps.length) { continue; } if (allProps.some(propUsage => !propUsage.valid)) { context.report({ node: argument, messageId: 'unsupported-prop-syntax-no-autofixer' }); return; } context.report({ node: argument, messageId: 'unsupported-prop-syntax', fix(fixer) { const sourceCode = getSourceCode(context); return fixPropsObjectUsages(fixer, sourceCode, argument, // The // allProps.some((propUsage) => !propUsage.valid) // condition above should have made this type assertion unnecessary... allProps); } }); } } }; const convertPropUsage = (propUsage, sourceCode) => { const arrowFunctionOffset = propUsage.arrowFunctionExpression.body.loc ? { startIndex: sourceCode.getIndexFromLoc(propUsage.arrowFunctionExpression.body.loc.start), endIndex: sourceCode.getIndexFromLoc(propUsage.arrowFunctionExpression.body.loc.end) } : undefined; if (!arrowFunctionOffset) { return; } const sectionsToUpdate = []; const newCode = []; for (const prop of propUsage.props) { for (const usage of prop.usages) { if (!usage.loc) { return; } sectionsToUpdate.push({ identifier: usage, newCode: prop.propName, startIndex: sourceCode.getIndexFromLoc(usage.loc.start), endIndex: sourceCode.getIndexFromLoc(usage.loc.end) }); } } sectionsToUpdate.sort((a, b) => { if (!a.identifier.loc || !b.identifier.loc) { return 0; } return a.identifier.loc.start.line - b.identifier.loc.start.line || a.identifier.loc.start.column - b.identifier.loc.start.column; }); let prevEndIndex = arrowFunctionOffset.startIndex; for (const section of sectionsToUpdate) { if (prevEndIndex > section.startIndex || prevEndIndex >= section.endIndex) { // Props overlap! This is unexpected, bail out. return; } newCode.push(sourceCode.getText().slice(prevEndIndex, section.startIndex)); newCode.push(section.newCode); prevEndIndex = section.endIndex; } newCode.push(sourceCode.getText().slice(prevEndIndex, arrowFunctionOffset.endIndex)); return newCode.join(''); }; const fixPropsObjectUsages = (fixer, sourceCode, expression, allProps) => { const fixers = [ // Wrap the object (containing the CSS properties and values) in an arrow function, // so it becomes something like // // styled.div( // (props) => ({ // color: ... // }) // ); fixer.insertTextBefore(expression, '(props) => ('), fixer.insertTextAfter(expression, ')')]; for (const propUsage of allProps) { const arrowFunctionExpression = propUsage.arrowFunctionExpression; const newCode = convertPropUsage(propUsage, sourceCode); if (newCode) { fixers.push(fixer.replaceText(arrowFunctionExpression, newCode)); } } return fixers; }; const rule = createLintRule({ meta: { name: 'convert-props-syntax', docs: { description: 'Convert props syntax that is unsupported by styled-components@<4 or @emotion/styled to props syntax that is supported. This is useful when used in conjunction with `no-styled-tagged-template-expression`, as output from the latter may use props syntax unsupported by those libraries.', recommended: true, severity: 'error' }, fixable: 'code', messages: { 'unsupported-prop-syntax': 'This syntax is not supported by styled-components@<4 or @emotion/styled and will fail at runtime!\n\nTo fix this, you can either migrate this to `@compiled/react`, or use the autofixer to switch the syntax from the `styled.div({ property: (props) => props.value })` syntax to the `styled.div(props => ({ property: props.value }))` syntax.', 'unsupported-prop-syntax-no-autofixer': 'This syntax is not supported by styled-components@<4 or @emotion/styled and will fail at runtime!\n\nTo fix this, you can either migrate this to `@compiled/react`, or manually migrate the syntax from the `styled.div({ property: (props) => props.value })` syntax to the `styled.div(props => ({ property: props.value }))` syntax.\n\nCheck out the rule documentation for examples.' }, type: 'problem' }, create(context) { return { CallExpression: node => { var _node$parent, _node$parent2; const { references } = getScope(context, node); // Rule should ignore .attrs() calls if (node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier' && node.callee.property.name === 'attrs') { return; } const callee = node.callee.type === 'MemberExpression' ? node.callee.object : node.callee; if (!isStyledComponents(callee, references) && !isEmotion(callee, references)) { return; } /** * We only want to lint the second set of arguments in the following cases: * * 1. styled(BaseComponent)({ ... }) * 2. styled(BaseComponent)`` */ if ( // Using this type assertion to cast into a more useful type // For some reason the estree types are missing useful things like `parent` isNodeOfType(node, 'CallExpression') && (((_node$parent = node.parent) === null || _node$parent === void 0 ? void 0 : _node$parent.type) === 'CallExpression' || ((_node$parent2 = node.parent) === null || _node$parent2 === void 0 ? void 0 : _node$parent2.type) === 'TaggedTemplateExpression')) { return; } lintArguments(context, node.arguments); } }; } }); export default rule;