@atlaskit/eslint-plugin-design-system
Version:
The essential plugin for use with the Atlassian Design System.
584 lines (565 loc) • 23.3 kB
JavaScript
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';
const isDOMElementName = elementName => elementName.charAt(0) !== elementName.charAt(0).toUpperCase() && elementName.charAt(0) === elementName.charAt(0).toLowerCase();
function isCssCallExpression(node, cssFunctions, context) {
cssFunctions = [...cssFunctions, 'cssMap'];
if (!isNodeOfType(node, 'CallExpression') || !isNodeOfType(node.callee, 'Identifier')) {
return false;
}
const 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(property => property.type === 'SpreadElement' ||
// @ts-expect-error
property.type === 'ExperimentalSpreadProperty');
}
const getProgramNode = expression => {
while (expression.parent.type !== 'Program') {
expression = expression.parent;
}
return expression.parent;
};
const 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.
const 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;
};
class JSXExpressionLinter {
// 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.
*/
constructor(context, cssAttributeName, configuration, expression) {
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.
*/
getDeclaratorString(node) {
let 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;
}
const variables = scope.variables.map(variable => variable.name).concat(this.hoistedCss);
let count = 2;
let declaratorName = 'styles';
// Base case
if (!variables.includes(declaratorName)) {
return declaratorName;
} else {
// If styles already exists, increment the number
while (variables.includes(`${declaratorName}${count}`)) {
count++;
}
}
// Keep track of it by adding it to the hoistedCss global array
this.hoistedCss = [...this.hoistedCss, `${declaratorName}${count}`];
return `${declaratorName}${count}`;
}
analyzeIdentifier(sourceIdentifier) {
var _getIdentifierInParen, _getIdentifierInParen2;
const scope = getScope(this.context, sourceIdentifier);
const [identifier] = (_getIdentifierInParen = (_getIdentifierInParen2 = getIdentifierInParentScope(scope, sourceIdentifier.name)) === null || _getIdentifierInParen2 === void 0 ? void 0 : _getIdentifierInParen2.identifiers) !== null && _getIdentifierInParen !== void 0 ? _getIdentifierInParen : [];
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'))) {
const sourceCode = getSourceCode(this.context);
const variable = findVariable({
identifier,
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: 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({})
const value = identifier.parent.init;
if (!value) {
return;
}
const 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: fixer => {
if (!this.configuration.autoFix) {
return [];
}
return this.addCssFunctionCall(fixer, identifier.parent);
}
});
} else {
this.context.report({
node: identifier,
messageId: 'cssObjectTypeOnly'
});
}
return;
}
const spreadProperties = isNodeOfType(identifier.parent.init, 'CallExpression') && findSpreadProperties(identifier.parent.init.arguments[0]);
if (spreadProperties) {
// TODO: Recursively handle spread items in children properties.
spreadProperties.forEach(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.
*/
addImportSource(fixer) {
const importSource = this.cssAttributeName === 'xcss' ? this.configuration.xcssImportSource : this.configuration.cssImportSource;
// Add the `import { css } from 'my-css-in-js-library';` statement
const packageImport = getFirstSupportedImport(this.context, [importSource]);
if (packageImport) {
const addCssImport = Import.insertNamedSpecifiers(packageImport, [this.cssAttributeName], fixer);
if (addCssImport) {
return addCssImport;
}
} else {
return insertAtStartOfFile(fixer, `${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
*/
addCssFunctionCall(fixer, node) {
const fixes = [];
const sourceCode = getSourceCode(this.context);
if (node.type !== 'VariableDeclarator' || !node.init || !this.cssAttributeName) {
return [];
}
const compiledImportFix = this.addImportSource(fixer);
if (compiledImportFix) {
fixes.push(compiledImportFix);
}
const init = node.init;
const initString = sourceCode.getText(init);
if (node.init.type === 'TemplateLiteral') {
fixes.push(fixer.replaceText(init, `${this.cssAttributeName}${initString}`));
} else {
fixes.push(fixer.replaceText(init, `${this.cssAttributeName}(${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).
*/
potentiallyHasLocalVariable(node) {
/**
* 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') {
const 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(def => {
// If the binding isn't from a variable declaration we assume worst-case.
if (def.type !== 'Variable') {
return true;
}
const {
init
} = def.node;
if (!init) {
// If there is no initializer something weird is happening so assume worst-case.
return true;
}
return this.potentiallyHasLocalVariable(init);
});
}
let hasPotentiallyLocalVariable = false;
const isImportedVariable = identifier => !!getModuleOfIdentifier(getSourceCode(this.context), identifier);
estraverse.traverse(node, {
fallback: 'iteration',
enter: function (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
*/
fixCssNotInModuleScope(fixer, node, isObjectExpression) {
const sourceCode = getSourceCode(this.context);
// Get the program node in order to properly position the hoisted styles
const programNode = getProgramNode(node);
let 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 _ref;
// Place after the last ImportDeclaration
fixerNodePlacement = (_ref = programNode.body.length === 1 ? programNode.body[0] : programNode.body.find(node => node.type !== 'ImportDeclaration')) !== null && _ref !== void 0 ? _ref : fixerNodePlacement;
}
let moduleString;
let fixes = [];
if (this.potentiallyHasLocalVariable(node)) {
return [];
}
if (node.type === 'Identifier') {
const identifier = node;
const declarator = identifier.parent.parent;
moduleString = sourceCode.getText(declarator);
fixes.push(fixer.remove(declarator));
} else {
const declarator = this.getDeclaratorString(node);
const text = sourceCode.getText(node);
// If this has been passed, then we know it's an ObjectExpression
if (isObjectExpression) {
moduleString = `const ${declarator} = ${this.cssAttributeName}(${text});`;
const compiledImportFix = this.addImportSource(fixer);
if (compiledImportFix) {
fixes.push(compiledImportFix);
}
} else {
moduleString = `const ${declarator} = ${text};`;
}
fixes.push(fixer.replaceText(node, declarator));
}
return [...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.
*/
traverseExpression(expression) {
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(element => this.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(exp => exp && this.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: fixer => {
if (!this.configuration.autoFix) {
return [];
}
// Don't fix CallExpressions unless they're from cssFunctions or cssMap
if (expression.type === 'CallExpression' && !isCssCallExpression(expression, this.configuration.cssFunctions, this.context)) {
return [];
}
if (expression.type === 'ObjectExpression') {
return this.fixCssNotInModuleScope(fixer, expression, true);
}
return this.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;
}
}
run() {
return this.traverseExpression(this.expression);
}
}
const defaultConfig = {
cssFunctions: ['css', 'xcss'],
stylesPlacement: 'top',
cssImportSource: CSS_IN_JS_IMPORTS.compiled,
xcssImportSource: CSS_IN_JS_IMPORTS.atlaskitPrimitives,
excludeReactComponents: false,
autoFix: true,
shouldAlwaysCheckXcss: false
};
const 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(context) {
const mergedConfig = assign({}, defaultConfig, context.options[0]);
return {
JSXAttribute(nodeOriginal) {
const node = nodeOriginal;
const {
name,
value
} = node;
/**
* 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,
messageId: mergedConfig.stylesPlacement === 'bottom' ? 'cssAtBottomOfModule' : 'cssAtTopOfModule'
});
return;
}
if (value.expression.type === 'JSXEmptyExpression') {
// e.g. the comment in
// <div css={/* Hello there */} />
return;
}
const linter = new JSXExpressionLinter(context, name.name, mergedConfig, value.expression);
linter.run();
}
}
};
}
});
export default rule;