@sumup-oss/eslint-plugin-circuit-ui
Version:
ESLint rules to lint Circuit UI.
429 lines (428 loc) • 15.4 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.noRenamedProps = void 0;
const utils_1 = require("@typescript-eslint/utils");
const jsx_1 = require("../utils/jsx");
const object_1 = require("../utils/object");
/* eslint-disable */
const createRule = utils_1.ESLintUtils.RuleCreator((name) => `https://github.com/sumup-oss/circuit-ui/tree/main/packages/eslint-plugin-circuit-ui/${name}`);
const configs = [
{
type: 'name',
components: ['Toggle'],
props: {
explanation: 'description',
},
},
{
type: 'values',
components: ['Badge', 'NotificationInline', 'NotificationToast'],
hook: 'setToast',
prop: 'variant',
values: {
confirm: 'success',
notify: 'warning',
alert: 'danger',
},
},
{
type: 'values',
components: ['Button', 'CloseButton', 'IconButton'],
prop: 'size',
values: {
kilo: 's',
giga: 'm',
},
},
{
type: 'values',
components: ['Hamburger'],
prop: 'size',
values: {
kilo: 's',
giga: 'm',
},
},
{
type: 'values',
components: ['Avatar'],
prop: 'size',
values: {
giga: 's',
yotta: 'm',
},
},
{
type: 'values',
components: ['ProgressBar'],
prop: 'size',
values: {
byte: 's',
kilo: 'm',
mega: 'l',
},
},
{
type: 'values',
components: ['Selector'],
prop: 'size',
values: {
kilo: 's',
mega: 'm',
},
},
{
type: 'values',
components: ['Spinner'],
prop: 'size',
values: {
byte: 's',
kilo: 'm',
giga: 'l',
},
},
{
type: 'custom',
components: ['IconButton'],
// children → icon
transform: (node, component, context) => {
const current = 'children';
const replacement = 'icon';
const iconAttribute = (0, jsx_1.findAttribute)(node, 'icon');
// Icon is passed as a prop as intended.
if (iconAttribute) {
return;
}
const children = (0, jsx_1.filterWhitespaceChildren)(node.children);
const child = children[0];
// Icon might be passed as children, however, these types of children
// can't be automatically replaced:
if (
// No or multiple children
children.length !== 1 ||
// Non-element child
child.type !== 'JSXElement' ||
// Element with children
child.children.length > 0 ||
// Element with props other than `size`
child.openingElement.attributes.filter((attr) => attr.type !== 'JSXAttribute' || attr.name.name !== 'size').length > 0) {
context.report({
node,
messageId: 'propName',
data: { component, current, replacement },
});
return;
}
const iconName = child.openingElement.name
.name;
context.report({
node: node,
messageId: 'propName',
data: { component, current, replacement },
fix(fixer) {
const range = [
node.openingElement.range[1] - 1,
node.closingElement.range[1],
];
const prop = ` ${replacement}={${iconName}}`;
return [
fixer.insertTextBeforeRange(range, prop),
fixer.replaceTextRange(range, ' />'),
];
},
});
},
},
{
type: 'custom',
components: ['IconButton'],
// label → children
transform: (node, component, context) => {
const current = 'label';
const replacement = 'children';
const labelAttribute = (0, jsx_1.findAttribute)(node, 'label');
if (!labelAttribute) {
return;
}
const labelValue = (0, jsx_1.transformAttributeValueToChildren)(context.sourceCode, labelAttribute);
if (
// Unable to transform the label value
!labelValue ||
// Shouldn't override existing children
node.children.length > 0) {
context.report({
node: labelAttribute,
messageId: 'propName',
data: { component, current, replacement },
});
return;
}
context.report({
node,
messageId: 'propName',
data: { component, current, replacement },
fix(fixer) {
const elementName = node.openingElement.name.name;
const range = [
node.openingElement.range[1] - 2,
node.openingElement.range[1],
];
return [
fixer.remove(labelAttribute),
fixer.replaceTextRange(range, `>${labelValue}</${elementName}>`),
];
},
});
},
},
{
type: 'name',
components: ['Table'],
props: {
initialSortedRow: 'initialSortedColumn',
},
},
{
type: 'values',
components: ['Title', 'Display'],
prop: 'size',
values: {
one: 'l',
two: 'm',
three: 'm',
four: 's',
},
},
{
type: 'values',
components: ['Headline'],
prop: 'size',
values: {
one: 'l',
two: 'm',
three: 's',
four: 's',
},
},
{
type: 'values',
components: ['Anchor', 'Body', 'List'],
prop: 'size',
values: {
one: 'm',
two: 's',
},
},
{
type: 'custom',
components: ['Anchor', 'Body'],
// variant → weight or color
transform: (node, component, context) => {
node.openingElement.attributes.forEach((attribute) => {
if (attribute.type !== 'JSXAttribute' ||
attribute.name.type !== 'JSXIdentifier' ||
attribute.name.name !== 'variant') {
return;
}
const current = (0, jsx_1.getAttributeValue)(attribute);
if (current === 'highlight') {
const asAttribute = (0, jsx_1.findAttribute)(node, 'as');
const weightAttribute = (0, jsx_1.findAttribute)(node, 'weight');
const replacement = asAttribute ? 'weight="semibold"' : `as="strong"`;
context.report({
node: attribute,
messageId: 'bodyVariant',
data: { component, current, replacement },
fix: weightAttribute
? undefined
: (fixer) => fixer.replaceText(attribute, replacement),
});
return;
}
if (current && ['alert', 'confirm', 'subtle'].includes(current)) {
const replacementMap = {
'alert': `color="danger"`,
'confirm': `color="success"`,
'subtle': `color="subtle"`,
};
const replacement = replacementMap[current];
const colorAttribute = (0, jsx_1.findAttribute)(node, 'color');
context.report({
node: attribute,
messageId: 'bodyVariant',
data: { component, current, replacement },
fix: colorAttribute
? undefined
: (fixer) => {
return fixer.replaceText(attribute, replacement);
},
});
}
});
},
},
];
exports.noRenamedProps = createRule({
name: 'no-renamed-props',
meta: {
type: 'suggestion',
schema: [],
fixable: 'code',
docs: {
description: 'Component props should use the latest names',
recommended: 'recommended',
},
messages: {
propName: "The {{component}}'s `{{current}}` prop has been renamed to `{{replacement}}`.",
propValue: "The {{component}}'s `{{prop}}` prop values have been renamed. Replace `{{current}}` with `{{replacement}}`.",
bodyVariant: 'The {{component}}\'s `variant` prop has been deprecated. Replace `variant="{{current}}"` with `{{replacement}}`.',
},
},
defaultOptions: [],
create(context) {
function replaceComponentPropName(node, component, config) {
const { props } = config;
node.openingElement.attributes.forEach((attribute) => {
if (attribute.type !== 'JSXAttribute' ||
attribute.name.type !== 'JSXIdentifier') {
return;
}
const current = attribute.name.name;
const replacement = props[current];
if (!replacement) {
return;
}
context.report({
node: attribute,
messageId: 'propName',
data: { component, current, replacement },
fix(fixer) {
return fixer.replaceText(attribute.name, replacement);
},
});
});
}
function replaceComponentPropValues(node, component, config) {
const { prop, values } = config;
node.openingElement.attributes.forEach((attribute) => {
if (attribute.type !== 'JSXAttribute' || attribute.name.name !== prop) {
return;
}
const current = (0, jsx_1.getAttributeValue)(attribute);
if (!current) {
return;
}
const replacement = values[current];
if (!replacement) {
return;
}
context.report({
node: attribute,
messageId: 'propValue',
data: { component, prop, current, replacement },
fix(fixer) {
return fixer.replaceText(attribute.value, `"${replacement}"`);
},
});
});
}
function replaceHookPropValues(node, config) {
const { hook, prop, values } = config;
node.arguments.forEach((argument) => {
if (argument.type !== 'ObjectExpression') {
return;
}
argument.properties.forEach((property) => {
if (property.type !== 'Property' ||
property.key.type !== 'Identifier' ||
property.key.name !== prop) {
return;
}
const current = (0, object_1.getPropertyValue)(property);
if (!current) {
return;
}
const replacement = values[current];
if (!replacement) {
return;
}
context.report({
node: property,
messageId: 'propValue',
data: { component: hook, prop, current, replacement },
fix(fixer) {
return fixer.replaceText(property.value, `'${replacement}'`);
},
});
});
});
}
const components = configs.reduce((acc, config) => {
const { components, ...rest } = config;
config.components.forEach((component) => {
acc[component] = acc[component] || [];
acc[component].push(rest);
});
return acc;
}, {});
const componentVisitors = Object.entries(components).reduce((visitors, [component, configs]) => {
// eslint-disable-next-line no-param-reassign
visitors[`JSXElement[openingElement.name.name="${component}"]`] = (node) => {
configs.forEach((config) => {
switch (config.type) {
case 'name':
replaceComponentPropName(node, component, config);
break;
case 'values':
replaceComponentPropValues(node, component, config);
break;
case 'custom':
config.transform(node, component, context);
break;
}
});
};
return visitors;
}, {});
const hooks = configs.reduce((acc, config) => {
if (!config.hook) {
return acc;
}
acc[config.hook] = acc[config.hook] || [];
acc[config.hook].push(config);
return acc;
}, {});
const hookVisitors = Object.entries(hooks).reduce((visitors, [hook, configs]) => {
// eslint-disable-next-line no-param-reassign
visitors[`CallExpression[callee.name="${hook}"]`] = (node) => {
configs.forEach((config) => {
switch (config.type) {
case 'values':
replaceHookPropValues(node, config);
break;
case 'name':
case 'custom':
// TODO: Not needed yet.
break;
}
});
};
return visitors;
}, {});
return { ...componentVisitors, ...hookVisitors };
},
});