UNPKG

@atlaskit/eslint-plugin-design-system

Version:

The essential plugin for use with the Atlassian Design System.

307 lines (295 loc) 13.9 kB
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;