@atlaskit/eslint-plugin-design-system
Version:
The essential plugin for use with the Atlassian Design System.
622 lines (603 loc) • 25.6 kB
JavaScript
import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
import _classCallCheck from "@babel/runtime/helpers/classCallCheck";
import _createClass from "@babel/runtime/helpers/createClass";
import _toConsumableArray from "@babel/runtime/helpers/toConsumableArray";
import { getIdentifierInParentScope, insertAtStartOfFile, insertImportDeclaration, isNodeOfType } from 'eslint-codemod-utils';
import estraverse from 'estraverse';
import assign from 'lodash/assign';
import { getScope, getSourceCode } from '@atlaskit/eslint-utils/context-compat';
import { findVariable } from '@atlaskit/eslint-utils/find-variable';
import { CSS_IN_JS_IMPORTS } from '@atlaskit/eslint-utils/is-supported-import';
import { Import } from '../../ast-nodes/import';
import { createLintRule } from '../utils/create-lint-rule';
import { getFirstSupportedImport } from '../utils/get-first-supported-import';
import { getModuleOfIdentifier } from '../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(_toConsumableArray(cssFunctions), ['cssMap']);
if (!isNodeOfType(node, 'CallExpression') || !isNodeOfType(node.callee, 'Identifier')) {
return false;
}
var module = getModuleOfIdentifier(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) {
_classCallCheck(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 _createClass(JSXExpressionLinter, [{
key: "getDeclaratorString",
value: function getDeclaratorString(node) {
var scope = 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(_toConsumableArray(this.hoistedCss), ["".concat(declaratorName).concat(count)]);
return "".concat(declaratorName).concat(count);
}
}, {
key: "analyzeIdentifier",
value: function analyzeIdentifier(sourceIdentifier) {
var _getIdentifierInParen,
_getIdentifierInParen2,
_this = this;
var scope = getScope(this.context, sourceIdentifier);
var _ref = (_getIdentifierInParen = (_getIdentifierInParen2 = getIdentifierInParentScope(scope, sourceIdentifier.name)) === null || _getIdentifierInParen2 === void 0 ? void 0 : _getIdentifierInParen2.identifiers) !== null && _getIdentifierInParen !== void 0 ? _getIdentifierInParen : [],
_ref2 = _slicedToArray(_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 = getSourceCode(this.context);
var variable = 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 = 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 = getFirstSupportedImport(this.context, [importSource]);
if (packageImport) {
var addCssImport = Import.insertNamedSpecifiers(packageImport, [this.cssAttributeName], fixer);
if (addCssImport) {
return addCssImport;
}
} else {
return insertAtStartOfFile(fixer, "".concat(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 = 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 = findVariable({
identifier: node,
sourceCode: 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 !!getModuleOfIdentifier(getSourceCode(_this2.context), identifier);
};
estraverse.traverse(node, {
fallback: 'iteration',
enter: function enter(node, _parent) {
if (isNodeOfType(node, 'SpreadElement') ||
// @ts-expect-error remove once we can be sure that no parser interprets
// the spread operator as ExperimentalSpreadProperty anymore
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 (!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 = 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: CSS_IN_JS_IMPORTS.compiled,
xcssImportSource: CSS_IN_JS_IMPORTS.atlaskitPrimitives,
excludeReactComponents: false,
autoFix: true,
shouldAlwaysCheckXcss: false
};
var rule = 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 = assign({}, 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();
}
}
};
}
});
export default rule;