@sumup-oss/eslint-plugin-circuit-ui
Version:
ESLint rules to lint Circuit UI.
142 lines (141 loc) • 6.1 kB
JavaScript
/**
* Copyright 2023, SumUp Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
import { schema } from '@sumup-oss/design-tokens';
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/sumup-oss/circuit-ui/tree/main/packages/eslint-plugin-circuit-ui/${name}`);
export const preferCustomProperties = createRule({
name: 'prefer-custom-properties',
meta: {
type: 'suggestion',
schema: [],
fixable: 'code',
docs: {
description: 'Custom properties prefixed with `--cui-` should be valid Circuit UI design tokens.',
recommended: 'warn',
},
messages: {
replace: 'Use CSS custom properties instead of the Emotion.js theme. Replace `{{jsToken}}` with `{{cssVariable}}`.',
refactor: 'Use CSS custom properties instead of the Emotion.js theme.',
},
},
defaultOptions: [],
create(context) {
function transformCamelToKebabCase(string) {
return string.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
}
function createCSSCustomProperty(segments) {
const name = segments.map(transformCamelToKebabCase).join('-');
return `--cui-${name}`;
}
function isValidCustomProperty(customProperty) {
return schema.findIndex((token) => token.name === customProperty) !== -1;
}
function reportColorToken(node, identifiers) {
if (identifiers[1] === 'colors' && identifiers.length === 3) {
context.report({
node,
messageId: 'refactor',
});
}
}
function flattenMemberExpression(expression, identifiers = [], computed = false) {
if (expression.type === TSESTree.AST_NODE_TYPES.MemberExpression) {
return flattenMemberExpression(expression.object, expression.property.type === TSESTree.AST_NODE_TYPES.Identifier
? [expression.property.name, ...identifiers]
: identifiers, computed || expression.computed);
}
if (expression.type !== TSESTree.AST_NODE_TYPES.Identifier) {
return { identifiers, computed: true };
}
return { identifiers: [expression.name, ...identifiers], computed };
}
function checkTaggedTemplateExpression(node) {
const { quasi } = node;
quasi.expressions.forEach((expression) => {
const { identifiers, computed } = flattenMemberExpression(expression);
const [theme, ...rest] = identifiers;
// Ignore template literals that don't reference the `theme`
if (theme !== 'theme') {
return;
}
// Computed expressions cannot be auto-fixed.
if (computed) {
context.report({
node: expression,
messageId: 'refactor',
});
return;
}
const customProperty = createCSSCustomProperty(rest);
if (!isValidCustomProperty(customProperty)) {
reportColorToken(expression, identifiers);
return;
}
const jsToken = `\${${identifiers.join('.')}}`;
const cssVariable = `var(${customProperty})`;
const text = context.sourceCode
.getText(node)
.replace(jsToken, cssVariable);
context.report({
node: expression,
messageId: 'replace',
data: { jsToken, cssVariable },
fix(fixer) {
return fixer.replaceText(node, text);
},
});
});
}
function checkMemberExpression(node) {
if (node.parent?.type === TSESTree.AST_NODE_TYPES.TemplateLiteral) {
return;
}
const { identifiers, computed } = flattenMemberExpression(node);
const [theme, ...rest] = identifiers;
// Ignore template literals that don't reference the `theme`
if (theme !== 'theme') {
return;
}
// Computed expressions cannot be auto-fixed.
if (computed ||
node.parent?.type === TSESTree.AST_NODE_TYPES.BinaryExpression) {
context.report({
node,
messageId: 'refactor',
});
return;
}
const customProperty = createCSSCustomProperty(rest);
if (!isValidCustomProperty(customProperty)) {
reportColorToken(node, identifiers);
return;
}
const jsToken = `\${${identifiers.join('.')}}`;
const cssVariable = `var(${customProperty})`;
context.report({
node,
messageId: 'replace',
data: { jsToken, cssVariable },
fix(fixer) {
return fixer.replaceText(node, `'${cssVariable}'`);
},
});
}
return {
'TaggedTemplateExpression[tag.name="css"]': checkTaggedTemplateExpression,
'MemberExpression:has(Identifier[name="theme"])': checkMemberExpression,
};
},
});