@atlaskit/eslint-plugin-design-system
Version:
The essential plugin for use with the Atlassian Design System.
307 lines (295 loc) • 13.9 kB
JavaScript
import { isNodeOfType } from 'eslint-codemod-utils';
import { getScope, getSourceCode } from '@atlaskit/eslint-utils/context-compat';
import { getImportSources } from '@atlaskit/eslint-utils/is-supported-import';
import { createLintRule } from '../utils/create-lint-rule';
import { errorBoundary } from '../utils/error-boundary';
import { includesHardCodedColor } from '../utils/includes-hard-coded-color';
import { isDecendantOfGlobalToken } from '../utils/is-decendant-of-global-token';
import { isDecendantOfStyleBlock } from '../utils/is-decendant-of-style-block';
import { isDecendantOfType } from '../utils/is-decendant-of-type';
import { isDecendantOfXcssBlock } from '../utils/is-decendant-of-xcss-block';
import { convertHyphenatedNameToCamelCase } from './convert-hyphenated-name-to-camel-case';
import { emToPixels } from './em-to-pixels';
import { getDomainsForProperty } from './get-domains-for-property';
import { getFontSizeValueInScope } from './get-font-size-value-in-scope';
import { getPropertyNodeFromParent } from './get-property-node-from-parent';
import { getTokenReplacement } from './get-token-replacement';
import { getValueForPropertyNode } from './get-value-for-property-node';
import { getValueFromShorthand } from './get-value-from-shorthand';
import { getValueFromTemplateLiteralRaw } from './get-value-from-template-literal-raw';
import { includesTokenString } from './includes-token-string';
import { insertTokensImport } from './insert-tokens-import';
import { isAuto } from './is-auto';
import { isCalc } from './is-calc';
import { isTokenValueString } from './is-token-value-string';
import { isValidSpacingValue } from './is-valid-spacing-value';
import { isZero } from './is-zero';
import { lintJSXIdentifierForColor } from './lint-jsx-identifier-for-color';
import { lintJSXLiteralForColor } from './lint-jsx-literal-for-color';
import { lintJSXMemberForColor } from './lint-jsx-member-for-color';
import { lintObjectForColor } from './lint-object-for-color';
import { lintTemplateIdentifierForColor } from './lint-template-identifier-for-color';
import { processCssNode } from './process-css-node';
import ruleMeta from './rule-meta';
import { lintObjectForSpacing } from './spacing';
import { splitShorthandValues } from './split-shorthand-values';
const defaultConfig = {
domains: ['color', 'spacing'],
applyImport: true,
shouldEnforceFallbacks: false,
failSilently: false
};
export const createWithConfig = initialConfig => context => {
// TODO: JFP-2823 - this type cast was added due to Jira's ESLint v9 migration
const userConfig = context.options[0];
// merge configs
const config = {
domains: (userConfig === null || userConfig === void 0 ? void 0 : userConfig.domains) || initialConfig.domains,
applyImport: (userConfig === null || userConfig === void 0 ? void 0 : userConfig.applyImport) !== undefined ? userConfig.applyImport : initialConfig.applyImport,
shouldEnforceFallbacks: (userConfig === null || userConfig === void 0 ? void 0 : userConfig.shouldEnforceFallbacks) !== undefined ? userConfig.shouldEnforceFallbacks : initialConfig.shouldEnforceFallbacks,
exceptions: (userConfig === null || userConfig === void 0 ? void 0 : userConfig.exceptions) || [],
failSilently: (userConfig === null || userConfig === void 0 ? void 0 : userConfig.failSilently) || defaultConfig.failSilently
};
let tokenNode = null;
return errorBoundary({
ImportDeclaration: node => {
if (node.source.value === '@atlaskit/tokens' && config.applyImport) {
tokenNode = node;
}
},
// For expressions within template literals (e.g. `color: ${red}`) - color only
'TemplateLiteral > Identifier': node => {
if (config.domains.includes('color')) {
return lintTemplateIdentifierForColor(node, context, config);
}
return;
},
// const styles = css({ color: 'red', margin: '4px' }), styled.div({ color: 'red', margin: '4px' })
ObjectExpression: parentNode => {
const {
references
} = getScope(context, parentNode);
/**
* NOTE: This rule doesn't have an `importSources` config option,
* so this will just be equal to DEFAULT_IMPORT_SOURCES (which is fine)
*/
const importSources = getImportSources(context);
// To force the correct node type
if (!isNodeOfType(parentNode, 'ObjectExpression')) {
return;
}
// Return for nested objects - these get handled automatically so without returning we'd be doubling up
if (parentNode.parent.type === 'Property') {
return;
}
if (isDecendantOfXcssBlock(parentNode, references, importSources)) {
return;
}
if (!isDecendantOfStyleBlock(parentNode) && !isDecendantOfType(parentNode, 'JSXExpressionContainer')) {
return;
}
function findObjectStyles(node) {
if (!isNodeOfType(node, 'Property')) {
return;
}
if (isNodeOfType(node.value, 'ObjectExpression')) {
return node.value.properties.forEach(findObjectStyles);
}
if (!isNodeOfType(node.key, 'Identifier') && !isNodeOfType(node.key, 'Literal')) {
return;
}
const propertyName = isNodeOfType(node.key, 'Identifier') ? node.key.name : String(node.key.value);
// Returns which domains to lint against based on rule's config and current property
const domains = getDomainsForProperty(propertyName, config.domains);
if (domains.length === 0 || isDecendantOfGlobalToken(node.value)) {
return;
}
if (isNodeOfType(node.value, 'TemplateLiteral')) {
const value = getValueFromTemplateLiteralRaw(node.value, context);
if (Array.isArray(value) && value.some(isCalc)) {
return context.report({
node,
messageId: 'noCalcUsage',
data: {
payload: `${propertyName}`
}
});
}
if (node.value.expressions.some(isDecendantOfGlobalToken)) {
return;
}
}
if (domains.includes('color')) {
return lintObjectForColor(node, context, config);
}
if (domains.includes('spacing') || domains.includes('shape')) {
/**
* We do this in case the fontSize for a style object is declared alongside the `em` or `lineHeight` declaration.
*/
const fontSizeNode = getPropertyNodeFromParent('fontSize', parentNode);
const fontSize = fontSizeNode && getValueForPropertyNode(fontSizeNode, context);
return lintObjectForSpacing(node, context, config, fontSize, tokenNode);
}
}
parentNode.properties.forEach(findObjectStyles);
},
// CSSTemplateLiteral and StyledTemplateLiteral
// const cssTemplateLiteral = css`color: red; padding: 12px`;
// const styledTemplateLiteral = styled.p`color: red; padding: 8px`;
'TaggedTemplateExpression[tag.name="css"],TaggedTemplateExpression[tag.object.name="styled"],TaggedTemplateExpression[tag.callee.name="styled"]': node => {
// To force the correct node type
if (!isNodeOfType(node, 'TaggedTemplateExpression')) {
return;
}
const processedCssLines = processCssNode(node, context);
if (!processedCssLines) {
// if we can't get a processed css we bail
return;
}
const globalFontSize = getFontSizeValueInScope(processedCssLines);
const textForSource = getSourceCode(context).getText(node.quasi);
const allReplacedValues = [];
const completeSource = processedCssLines.reduce((currentSource, [resolvedCssLine, originalCssLine]) => {
const [originalProperty, resolvedCssValues] = resolvedCssLine.split(':');
const [_, originalCssValues] = originalCssLine.split(':');
const propertyName = convertHyphenatedNameToCamelCase(originalProperty);
const isFontFamily = /fontFamily/.test(propertyName);
const replacedValuesPerProperty = [originalProperty];
const domains = getDomainsForProperty(propertyName, config.domains);
if (domains.length === 0 || !resolvedCssValues) {
// in both of these cases no changes should be made to the current property
return currentSource;
}
if (domains.includes('color')) {
if (includesTokenString(resolvedCssValues.trim())) {
return currentSource;
}
if (includesHardCodedColor(resolvedCssValues)) {
context.report({
messageId: 'hardCodedColor',
node
});
return currentSource;
}
}
if (domains.includes('spacing') || domains.includes('shape')) {
if (!isValidSpacingValue(resolvedCssValues, globalFontSize)) {
// no changes should be made to the current property
return currentSource;
}
// gets the values from the associated property, numeric values or NaN
const processedNumericValues = getValueFromShorthand(resolvedCssValues);
const processedValues = splitShorthandValues(resolvedCssValues);
// only splits shorthand values but it does not transform NaNs so tokens are preserved
const originalValues = splitShorthandValues(originalCssValues);
// reconstructing the string
// should replace what it can and preserve the raw value for everything else
const replacementValue = processedNumericValues
// put together resolved value and original value on a tuple
.map((value, index) => [
// if emToPX conversion fails we'll default to original value
emToPixels(value, globalFontSize) || value, processedValues[index], originalValues[index]]).map(([numericOrNanValue, pxValue, originalValue]) => {
if (!originalValue) {
return originalValue;
}
if (isCalc(originalValue)) {
context.report({
node,
messageId: 'noCalcUsage',
data: {
payload: `${propertyName}`
}
});
return originalValue;
}
if (isTokenValueString(originalValue)) {
// if the value is already valid, nothing to report or replace
return originalValue;
}
// do not replace 0 or auto with tokens
if (isZero(pxValue) || isAuto(pxValue)) {
return originalValue;
}
if (isNaN(numericOrNanValue) && !isFontFamily) {
// do not report if we have nothing to replace with
return originalValue;
}
// value is numeric or fontFamily, and needs replacing we'll report first
context.report({
node,
messageId: 'noRawSpacingValues',
data: {
payload: `${propertyName}:${numericOrNanValue}`
}
});
// from here on we know value is numeric or a font family, so it might or might not have a token equivalent
const replacementNode = getTokenReplacement(propertyName, numericOrNanValue);
if (!replacementNode) {
return originalValue;
}
const replacementToken = '${' + replacementNode.toString() + '}';
replacedValuesPerProperty.push(isFontFamily ? numericOrNanValue.trim() : pxValue);
return replacementToken;
}).join(' ');
if (replacedValuesPerProperty.length > 1) {
// first value is the property name, so it will always have at least 1
allReplacedValues.push(replacedValuesPerProperty);
}
// replace property:val with new property:val
const replacedCssLine = currentSource.replace(originalCssLine,
// padding: ${gridSize()}px;
`${originalProperty}: ${replacementValue}`);
if (!replacedCssLine) {
return currentSource;
}
return replacedCssLine;
}
return currentSource;
}, textForSource);
if (completeSource !== textForSource) {
// means we found some replacement values, we'll give the option to fix them
context.report({
node,
messageId: 'autofixesPossible',
fix: fixer => {
return (!tokenNode && config.applyImport ? [insertTokensImport(fixer)] : []).concat([fixer.replaceText(node.quasi, completeSource)]);
}
});
}
},
// For inline JSX styles - literals (e.g. <Test color="red"/>) - color only
'JSXAttribute > Literal': node => {
if (config.domains.includes('color')) {
return lintJSXLiteralForColor(node, context, config);
}
return;
},
// Add handling for JSXExpressionContainer with string literals
'JSXAttribute > JSXExpressionContainer > Literal': node => {
if (config.domains.includes('color')) {
return lintJSXLiteralForColor(node, context, config);
}
return;
},
// For inline JSX styles - members (e.g. <Test color={color.red}/>) - color only
'JSXExpressionContainer > MemberExpression': node => {
if (config.domains.includes('color')) {
return lintJSXMemberForColor(node, context, config);
}
return;
},
// For inline JSX styles - identifiers (e.g. <Test color={red}/>) - color only
'JSXExpressionContainer > Identifier': node => {
if (config.domains.includes('color')) {
return lintJSXIdentifierForColor(node, context, config);
}
return;
}
}, config);
};
const rule = createLintRule({
meta: ruleMeta,
create: createWithConfig(defaultConfig)
});
// eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
export default rule;