UNPKG

@atlaskit/eslint-plugin-design-system

Version:

The essential plugin for use with the Atlassian Design System.

629 lines (609 loc) 26.6 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray")); var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck")); var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass")); var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray")); var _eslintCodemodUtils = require("eslint-codemod-utils"); var _estraverse = _interopRequireDefault(require("estraverse")); var _assign = _interopRequireDefault(require("lodash/assign")); var _contextCompat = require("@atlaskit/eslint-utils/context-compat"); var _findVariable = require("@atlaskit/eslint-utils/find-variable"); var _isSupportedImport = require("@atlaskit/eslint-utils/is-supported-import"); var _import = require("../../ast-nodes/import"); var _createLintRule = require("../utils/create-lint-rule"); var _getFirstSupportedImport = require("../utils/get-first-supported-import"); var _getModuleOfIdentifier = require("../utils/get-module-of-identifier"); var isDOMElementName = function isDOMElementName(elementName) { return elementName.charAt(0) !== elementName.charAt(0).toUpperCase() && elementName.charAt(0) === elementName.charAt(0).toLowerCase(); }; function isCssCallExpression(node, cssFunctions, context) { cssFunctions = [].concat((0, _toConsumableArray2.default)(cssFunctions), ['cssMap']); if (!(0, _eslintCodemodUtils.isNodeOfType)(node, 'CallExpression') || !(0, _eslintCodemodUtils.isNodeOfType)(node.callee, 'Identifier')) { return false; } var module = (0, _getModuleOfIdentifier.getModuleOfIdentifier)((0, _contextCompat.getSourceCode)(context), node.callee.name); if (!module) { return false; } if (!cssFunctions.includes(module.importName)) { return false; } return node.arguments.length > 0 && node.arguments[0].type === 'ObjectExpression'; } function findSpreadProperties(node) { return node.properties.filter(function (property) { return property.type === 'SpreadElement' || // @ts-expect-error property.type === 'ExperimentalSpreadProperty'; }); } var getProgramNode = function getProgramNode(expression) { while (expression.parent.type !== 'Program') { expression = expression.parent; } return expression.parent; }; var isDeclaredInsideComponent = function isDeclaredInsideComponent(expression) { // These nodes imply that there is a distinct own scope (function scope / block scope), // and so the presence of them means that expression was not defined in the module scope. var NOT_MODULE_SCOPE = ['ArrowFunctionExpression', 'BlockStatement', 'ClassDeclaration', 'FunctionExpression']; while (expression.type !== 'Program') { if (NOT_MODULE_SCOPE.includes(expression.type)) { return true; } expression = expression.parent; } return false; }; var JSXExpressionLinter = /*#__PURE__*/function () { // File-level tracking of styles hoisted from the cssAtTopOfModule/cssAtBottomOfModule fixers. /** * Traverses and lints a expression found in a JSX css or xcss prop, e.g. * <div css={expressionToLint} /> * * @param context The context of the rule. Used to find the current scope and the source code of the file. * @param cssAttributeName Used to encapsulate ObjectExpressions when cssAtTopOfModule/cssAtBottomOfModule violations are triggered. * @param configuration What css-related functions to account for (eg. css, xcss, cssMap), and whether to detect bottom vs top expressions. * @param expression The expression to traverse and lint. */ function JSXExpressionLinter(context, cssAttributeName, configuration, expression) { (0, _classCallCheck2.default)(this, JSXExpressionLinter); this.context = context; this.cssAttributeName = cssAttributeName; this.configuration = configuration; this.expression = expression; this.hoistedCss = []; } /** * Generates the declarator string when fixing the cssAtTopOfModule/cssAtBottomOfModule cases. * When `styles` already exists, `styles_1, styles_2, ..., styles_X` are incrementally created for each unhoisted style. * * The generated `styles` variable declaration names must be manually modified to be more informative at the discretion of owning teams. */ return (0, _createClass2.default)(JSXExpressionLinter, [{ key: "getDeclaratorString", value: function getDeclaratorString(node) { var scope = (0, _contextCompat.getScope)(this.context, node); // Get to ModuleScope while (scope && scope.upper && scope.upper.type !== 'global') { var _scope; scope = (_scope = scope) === null || _scope === void 0 ? void 0 : _scope.upper; } var variables = scope.variables.map(function (variable) { return variable.name; }).concat(this.hoistedCss); var count = 2; var declaratorName = 'styles'; // Base case if (!variables.includes(declaratorName)) { return declaratorName; } else { // If styles already exists, increment the number while (variables.includes("".concat(declaratorName).concat(count))) { count++; } } // Keep track of it by adding it to the hoistedCss global array this.hoistedCss = [].concat((0, _toConsumableArray2.default)(this.hoistedCss), ["".concat(declaratorName).concat(count)]); return "".concat(declaratorName).concat(count); } }, { key: "analyzeIdentifier", value: function analyzeIdentifier(sourceIdentifier) { var _getIdentifierInParen, _getIdentifierInParen2, _this = this; var scope = (0, _contextCompat.getScope)(this.context, sourceIdentifier); var _ref = (_getIdentifierInParen = (_getIdentifierInParen2 = (0, _eslintCodemodUtils.getIdentifierInParentScope)(scope, sourceIdentifier.name)) === null || _getIdentifierInParen2 === void 0 ? void 0 : _getIdentifierInParen2.identifiers) !== null && _getIdentifierInParen !== void 0 ? _getIdentifierInParen : [], _ref2 = (0, _slicedToArray2.default)(_ref, 1), identifier = _ref2[0]; if (!identifier || !identifier.parent) { // Identifier isn't in the module, skip! return; } // Specifically for the `xcss` prop we allow `xcss` values that come from a function parameter. if (this.cssAttributeName === 'xcss' && ( // Allowing `xcss` and `${string}Xcss` values identifier.name === 'xcss' || identifier.name.endsWith('Xcss'))) { var sourceCode = (0, _contextCompat.getSourceCode)(this.context); var variable = (0, _findVariable.findVariable)({ identifier: identifier, sourceCode: sourceCode }); // Only allowing values where the parameter definition is the only definition if ((variable === null || variable === void 0 ? void 0 : variable.defs.length) === 1 && (variable === null || variable === void 0 ? void 0 : variable.defs[0].type) === 'Parameter') { return; } } if (identifier.parent.type !== 'VariableDeclarator') { // When variable is not in the file or coming from import this.context.report({ node: sourceIdentifier, messageId: 'cssInModule' }); return; } if (isDeclaredInsideComponent(identifier)) { // When variable is declared inside the component this.context.report({ node: sourceIdentifier, messageId: this.configuration.stylesPlacement === 'bottom' ? 'cssAtBottomOfModule' : 'cssAtTopOfModule', fix: function fix(fixer) { if (!_this.configuration.autoFix) { return []; } return _this.fixCssNotInModuleScope(fixer, identifier, false); } }); return; } if (identifier.parent && identifier.parent.init && !isCssCallExpression(identifier.parent.init, this.configuration.cssFunctions, this.context)) { // When variable value is not of type css({}) var value = identifier.parent.init; if (!value) { return; } var valueExpression = // @ts-expect-error remove once eslint types are switched to @typescript-eslint value.type === 'TSAsExpression' ? value.expression : value; if (['ObjectExpression', 'TemplateLiteral'].includes(valueExpression.type)) { this.context.report({ node: identifier, messageId: 'cssObjectTypeOnly', fix: function fix(fixer) { if (!_this.configuration.autoFix) { return []; } return _this.addCssFunctionCall(fixer, identifier.parent); } }); } else { this.context.report({ node: identifier, messageId: 'cssObjectTypeOnly' }); } return; } var spreadProperties = (0, _eslintCodemodUtils.isNodeOfType)(identifier.parent.init, 'CallExpression') && findSpreadProperties(identifier.parent.init.arguments[0]); if (spreadProperties) { // TODO: Recursively handle spread items in children properties. spreadProperties.forEach(function (prop) { _this.context.report({ node: prop, messageId: 'cssArrayStylesOnly' }); }); } } /** * Returns a fixer that adds `import { css } from 'import-source'` or * `import { xcss } from 'import-source'` to the start of the file, depending * on the value of cssAttributeName and importSource. */ }, { key: "addImportSource", value: function addImportSource(fixer) { var importSource = this.cssAttributeName === 'xcss' ? this.configuration.xcssImportSource : this.configuration.cssImportSource; // Add the `import { css } from 'my-css-in-js-library';` statement var packageImport = (0, _getFirstSupportedImport.getFirstSupportedImport)(this.context, [importSource]); if (packageImport) { var addCssImport = _import.Import.insertNamedSpecifiers(packageImport, [this.cssAttributeName], fixer); if (addCssImport) { return addCssImport; } } else { return (0, _eslintCodemodUtils.insertAtStartOfFile)(fixer, "".concat((0, _eslintCodemodUtils.insertImportDeclaration)(importSource, [this.cssAttributeName]), ";\n")); } } /** * Returns a list of fixes that: * - add the `css` or `xcss` function call around the current node. * - add an import statement for the package from which `css` is imported */ }, { key: "addCssFunctionCall", value: function addCssFunctionCall(fixer, node) { var fixes = []; var sourceCode = (0, _contextCompat.getSourceCode)(this.context); if (node.type !== 'VariableDeclarator' || !node.init || !this.cssAttributeName) { return []; } var compiledImportFix = this.addImportSource(fixer); if (compiledImportFix) { fixes.push(compiledImportFix); } var init = node.init; var initString = sourceCode.getText(init); if (node.init.type === 'TemplateLiteral') { fixes.push(fixer.replaceText(init, "".concat(this.cssAttributeName).concat(initString))); } else { fixes.push(fixer.replaceText(init, "".concat(this.cssAttributeName, "(").concat(initString, ")"))); } return fixes; } /** * Check if the expression has or potentially has a local variable * (as opposed to an imported one), erring on the side ot "yes" * when an expression is too complicated to analyse. * * This is useful because expressions containing local variables * cannot be easily hoisted, whereas this is not a problem with imported * variables. * * @param context Context of the rule. * @param node Any node that is potentially hoistable. * @returns Whether the node potentially has a local variable (and thus is not safe to hoist). */ }, { key: "potentiallyHasLocalVariable", value: function potentiallyHasLocalVariable(node) { var _this2 = this; /** * If we've passed an `Identifier` then it is an identifier that's been * passed to the `css` prop. * * We need to check its initializer to see if it is safe to auto-fix. */ if (node.type === 'Identifier') { var variable = (0, _findVariable.findVariable)({ identifier: node, sourceCode: (0, _contextCompat.getSourceCode)(this.context) }); if (!variable) { // If we cannot resolve the variable then we cannot check it, so assume worst-case. return true; } return variable.defs.some(function (def) { // If the binding isn't from a variable declaration we assume worst-case. if (def.type !== 'Variable') { return true; } var init = def.node.init; if (!init) { // If there is no initializer something weird is happening so assume worst-case. return true; } return _this2.potentiallyHasLocalVariable(init); }); } var hasPotentiallyLocalVariable = false; var isImportedVariable = function isImportedVariable(identifier) { return !!(0, _getModuleOfIdentifier.getModuleOfIdentifier)((0, _contextCompat.getSourceCode)(_this2.context), identifier); }; _estraverse.default.traverse(node, { fallback: 'iteration', enter: function enter(node, _parent) { if ((0, _eslintCodemodUtils.isNodeOfType)(node, 'SpreadElement') || // @ts-expect-error remove once we can be sure that no parser interprets // the spread operator as ExperimentalSpreadProperty anymore (0, _eslintCodemodUtils.isNodeOfType)(node, 'ExperimentalSpreadProperty')) { // Spread elements could contain anything... so we don't bother. // // e.g. <div css={css({ ...(!height && { visibility: 'hidden' })} /> hasPotentiallyLocalVariable = true; this.break(); } if (!(0, _eslintCodemodUtils.isNodeOfType)(node, 'Property')) { return; } switch (node.value.type) { case 'Literal': break; case 'Identifier': // e.g. css({ margin: myVariable }) if (!isImportedVariable(node.value.name)) { hasPotentiallyLocalVariable = true; } this.break(); break; case 'MemberExpression': // e.g. css({ margin: props.color }) // css({ margin: props.media.color }) if (node.value.object.type === 'Identifier' && isImportedVariable(node.value.object.name)) { // We found an imported variable, don't do anything. } else { // e.g. css({ margin: [some complicated expression].media.color }) // This can potentially get too complex, so we assume there's a local // variable in there somewhere. hasPotentiallyLocalVariable = true; } this.break(); break; case 'TemplateLiteral': if (!!node.value.expressions.length) { // Too many edge cases here, don't bother... // e.g. css({ animation: `${expandStyles(right, rightExpanded, isExpanded)} 0.2s ease-in-out` }); hasPotentiallyLocalVariable = true; this.break(); } break; default: // Catch-all for values such as "A && B", "A ? B : C" hasPotentiallyLocalVariable = true; this.break(); break; } } }); return hasPotentiallyLocalVariable; } /** * Fixer for the cssAtTopOfModule/cssAtBottomOfModule violation cases. * * This deals with Identifiers and Expressions passed from the traverseExpressionWithConfig() function. * * @param fixer The ESLint RuleFixer object * @param context The context of the rule * @param configuration The configuration of the rule, determining whether the fix is implmeneted at the top or bottom of the module * @param node Any potentially hoistable node, or an identifier. * @param cssAttributeName An optional parameter only added when we fix an ObjectExpression */ }, { key: "fixCssNotInModuleScope", value: function fixCssNotInModuleScope(fixer, node, isObjectExpression) { var sourceCode = (0, _contextCompat.getSourceCode)(this.context); // Get the program node in order to properly position the hoisted styles var programNode = getProgramNode(node); var fixerNodePlacement = programNode; if (this.configuration.stylesPlacement === 'bottom') { // The last value is the bottom of the file fixerNodePlacement = programNode.body[programNode.body.length - 1]; } else { var _ref3; // Place after the last ImportDeclaration fixerNodePlacement = (_ref3 = programNode.body.length === 1 ? programNode.body[0] : programNode.body.find(function (node) { return node.type !== 'ImportDeclaration'; })) !== null && _ref3 !== void 0 ? _ref3 : fixerNodePlacement; } var moduleString; var fixes = []; if (this.potentiallyHasLocalVariable(node)) { return []; } if (node.type === 'Identifier') { var identifier = node; var declarator = identifier.parent.parent; moduleString = sourceCode.getText(declarator); fixes.push(fixer.remove(declarator)); } else { var _declarator = this.getDeclaratorString(node); var text = sourceCode.getText(node); // If this has been passed, then we know it's an ObjectExpression if (isObjectExpression) { moduleString = "const ".concat(_declarator, " = ").concat(this.cssAttributeName, "(").concat(text, ");"); var compiledImportFix = this.addImportSource(fixer); if (compiledImportFix) { fixes.push(compiledImportFix); } } else { moduleString = "const ".concat(_declarator, " = ").concat(text, ";"); } fixes.push(fixer.replaceText(node, _declarator)); } return [].concat(fixes, [ // Insert the node either before or after, depending on the rule configuration this.configuration.stylesPlacement === 'bottom' ? fixer.insertTextAfter(fixerNodePlacement, '\n' + moduleString) : fixer.insertTextBefore(fixerNodePlacement, moduleString + '\n')]); } /** * Handle different cases based on what's been passed in the css-related JSXAttribute. * * @param expression the expression of the JSXAttribute value. */ }, { key: "traverseExpression", value: function traverseExpression(expression) { var _this3 = this; switch (expression.type) { case 'Identifier': // {styles} // We've found an identifier - time to analyze it! this.analyzeIdentifier(expression); break; case 'ArrayExpression': // {[styles, moreStyles]} // We've found an array expression - let's traverse again over each element individually. expression.elements.forEach(function (element) { return _this3.traverseExpression(element); }); break; case 'LogicalExpression': // {isEnabled && styles} // We've found a logical expression - we're only interested in the right expression so // let's traverse that and see what it is! this.traverseExpression(expression.right); break; case 'ConditionalExpression': // {isEnabled ? styles : null} // We've found a conditional expression - we're only interested in the consequent and // alternate (styles : null) this.traverseExpression(expression.consequent); this.traverseExpression(expression.alternate); break; case 'ObjectExpression': case 'CallExpression': case 'TaggedTemplateExpression': case 'TemplateLiteral': if (expression.type === 'CallExpression' && expression.callee.type === 'Identifier' && expression.callee.name === 'cx') { expression.arguments.forEach(function (exp) { return exp && _this3.traverseExpression(exp); }); return; } // We've found elements that shouldn't be here! Report an error. this.context.report({ node: expression, messageId: this.configuration.stylesPlacement === 'bottom' ? 'cssAtBottomOfModule' : 'cssAtTopOfModule', fix: function fix(fixer) { if (!_this3.configuration.autoFix) { return []; } // Don't fix CallExpressions unless they're from cssFunctions or cssMap if (expression.type === 'CallExpression' && !isCssCallExpression(expression, _this3.configuration.cssFunctions, _this3.context)) { return []; } if (expression.type === 'ObjectExpression') { return _this3.fixCssNotInModuleScope(fixer, expression, true); } return _this3.fixCssNotInModuleScope(fixer, expression, false); } }); break; // @ts-expect-error - our ESLint-related types assume vanilla JS, when in fact // it is running @typescript-eslint // // Switching to the more accurate @typescript-eslint types would break // eslint-codemod-utils and all ESLint rules in packages/design-system, // so we just leave this as-is. case 'TSAsExpression': // @ts-expect-error this.traverseExpression(expression.expression); break; default: // Do nothing! break; } } }, { key: "run", value: function run() { return this.traverseExpression(this.expression); } }]); }(); var defaultConfig = { cssFunctions: ['css', 'xcss'], stylesPlacement: 'top', cssImportSource: _isSupportedImport.CSS_IN_JS_IMPORTS.compiled, xcssImportSource: _isSupportedImport.CSS_IN_JS_IMPORTS.atlaskitPrimitives, excludeReactComponents: false, autoFix: true, shouldAlwaysCheckXcss: false }; var rule = (0, _createLintRule.createLintRule)({ meta: { type: 'problem', name: 'consistent-css-prop-usage', docs: { description: 'Ensures consistency with `css` and `xcss` prop usages', url: 'https://hello.atlassian.net/wiki/spaces/AF/pages/2630143294/Styling+Components', recommended: true, severity: 'error' }, fixable: 'code', messages: { cssAtTopOfModule: "Create styles at the top of the module scope using the respective css function.", cssAtBottomOfModule: "Create styles at the bottom of the module scope using the respective css function.", cssObjectTypeOnly: "Create styles using objects passed to a css function call, e.g. `css({ textAlign: 'center'; })`.", cssInModule: "Imported styles should not be used; instead define in the module, import a component, or use a design token.", cssArrayStylesOnly: "Compose styles with an array on the css prop instead of using object spread.", noMemberExpressions: "Styles should be a regular variable (e.g. 'buttonStyles'), not a member of an object (e.g. 'myObject.styles')." }, schema: [{ type: 'object', properties: { cssFunctions: { type: 'array', items: [{ type: 'string' }] }, stylesPlacement: { type: 'string', enum: ['top', 'bottom'] }, cssImportSource: { type: 'string' }, xcssImportSource: { type: 'string' }, excludeReactComponents: { type: 'boolean' }, shouldAlwaysCheckXcss: { type: 'boolean' }, autoFix: { type: 'boolean' } }, additionalProperties: false }] }, create: function create(context) { var mergedConfig = (0, _assign.default)({}, defaultConfig, context.options[0]); return { JSXAttribute: function JSXAttribute(nodeOriginal) { var node = nodeOriginal; var name = node.name, value = node.value; /** * We skip linting `xcss` attributes if: * * - excludeReactComponents === true * - shouldAlwaysCheckXcss === false * * In the future we may want to remove `shouldAlwaysCheckXcss` * and just always lint `xcss`, regardless of `excludeReactComponents` */ if (mergedConfig.excludeReactComponents && name.name === 'xcss' && !mergedConfig.shouldAlwaysCheckXcss) { return; } if (mergedConfig.excludeReactComponents && node.parent.type === 'JSXOpeningElement' && name.name === 'css') { // e.g. <item.before /> if (node.parent.name.type === 'JSXMemberExpression') { return; } // e.g. <div />, <MenuItem /> if (node.parent.name.type === 'JSXIdentifier' && !isDOMElementName(node.parent.name.name)) { return; } } if (name.type === 'JSXIdentifier' && mergedConfig.cssFunctions.includes(name.name)) { // When not a jsx expression. For eg. css="" if ((value === null || value === void 0 ? void 0 : value.type) !== 'JSXExpressionContainer') { context.report({ node: node, messageId: mergedConfig.stylesPlacement === 'bottom' ? 'cssAtBottomOfModule' : 'cssAtTopOfModule' }); return; } if (value.expression.type === 'JSXEmptyExpression') { // e.g. the comment in // <div css={/* Hello there */} /> return; } var linter = new JSXExpressionLinter(context, name.name, mergedConfig, value.expression); linter.run(); } } }; } }); var _default = exports.default = rule;