eslint-plugin-tailwindcss
Version:
Rules enforcing best practices while using Tailwind CSS
330 lines (311 loc) • 12.6 kB
JavaScript
/**
* @fileoverview Detect obsolete classnames when upgrading to Tailwind CSS v3
* @author François Massart
*/
;
const docsUrl = require('../util/docsUrl');
const customConfig = require('../util/customConfig');
const astUtil = require('../util/ast');
const groupUtil = require('../util/groupMethods');
const getOption = require('../util/settings');
const parserUtil = require('../util/parser');
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
// Predefine message for use in context.report conditional.
// messageId will still be usable in tests.
const CLASSNAME_NOT_NEEDED_MSG = `Classname '{{classnames}}' is not needed in Tailwind CSS v3!`;
const CLASSNAMES_NOT_NEEDED_MSG = `Classnames '{{classnames}}' are not needed in Tailwind CSS v3!`;
const CLASSNAME_CHANGED_MSG = `Classname '{{deprecated}}' should be updated to '{{updated}}' in Tailwind CSS v3!`;
const OPACITY_CLASS_DEPRECATED_MSG = `Classname '{{classname}}' should be replaced by an opacity suffix (eg. '/{{value}}')`;
module.exports = {
meta: {
docs: {
description: 'Detect obsolete classnames when upgrading to Tailwind CSS v3',
category: 'Possible Errors',
recommended: true,
url: docsUrl('migration-from-tailwind-2'),
},
messages: {
classnameNotNeeded: CLASSNAME_NOT_NEEDED_MSG,
classnamesNotNeeded: CLASSNAMES_NOT_NEEDED_MSG,
classnameChanged: CLASSNAME_CHANGED_MSG,
classnameOpacityDeprecated: OPACITY_CLASS_DEPRECATED_MSG,
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
callees: {
type: 'array',
items: { type: 'string', minLength: 0 },
uniqueItems: true,
},
ignoredKeys: {
type: 'array',
items: { type: 'string', minLength: 0 },
uniqueItems: true,
},
config: {
// returned from `loadConfig()` utility
type: ['string', 'object'],
},
tags: {
type: 'array',
items: { type: 'string', minLength: 0 },
uniqueItems: true,
},
},
},
],
},
create: function (context) {
const callees = getOption(context, 'callees');
const skipClassAttribute = getOption(context, 'skipClassAttribute');
const tags = getOption(context, 'tags');
const twConfig = getOption(context, 'config');
const classRegex = getOption(context, 'classRegex');
const mergedConfig = customConfig.resolve(twConfig);
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
/**
* Recursive function crawling into child nodes
* @param {ASTNode} node The root node of the current parsing
* @param {ASTNode} arg The child node of node
* @returns {void}
*/
const parseForObsoleteClassNames = (node, arg = null) => {
let originalClassNamesValue = null;
let start = null;
let end = null;
let prefix = '';
let suffix = '';
if (arg === null) {
originalClassNamesValue = astUtil.extractValueFromNode(node);
const range = astUtil.extractRangeFromNode(node);
if (node.type === 'TextAttribute') {
start = range[0];
end = range[1];
} else {
start = range[0] + 1;
end = range[1] - 1;
}
} else {
switch (arg.type) {
case 'Identifier':
return;
case 'TemplateLiteral':
arg.expressions.forEach((exp) => {
parseForObsoleteClassNames(node, exp);
});
arg.quasis.forEach((quasis) => {
parseForObsoleteClassNames(node, quasis);
});
return;
case 'ConditionalExpression':
parseForObsoleteClassNames(node, arg.consequent);
parseForObsoleteClassNames(node, arg.alternate);
return;
case 'LogicalExpression':
parseForObsoleteClassNames(node, arg.right);
return;
case 'ArrayExpression':
arg.elements.forEach((el) => {
parseForObsoleteClassNames(node, el);
});
return;
case 'ObjectExpression':
arg.properties.forEach((prop) => {
parseForObsoleteClassNames(node, prop.key);
});
return;
case 'Property':
parseForObsoleteClassNames(node, arg.key);
return;
case 'Literal':
originalClassNamesValue = arg.value;
start = arg.range[0] + 1;
end = arg.range[1] - 1;
break;
case 'TemplateElement':
originalClassNamesValue = arg.value.raw;
if (originalClassNamesValue === '') {
return;
}
start = arg.range[0];
end = arg.range[1];
// https://github.com/eslint/eslint/issues/13360
// The problem is that range computation includes the backticks (`test`)
// but value.raw does not include them, so there is a mismatch.
// start/end does not include the backticks, therefore it matches value.raw.
const txt = context.getSourceCode().getText(arg);
prefix = astUtil.getTemplateElementPrefix(txt, originalClassNamesValue);
suffix = astUtil.getTemplateElementSuffix(txt, originalClassNamesValue);
originalClassNamesValue = astUtil.getTemplateElementBody(txt, prefix, suffix);
break;
}
}
let { classNames, whitespaces, headSpace, tailSpace } =
astUtil.extractClassnamesFromValue(originalClassNamesValue);
const notNeeded = [];
const outdated = [];
const deprecatedBgOpacity = [];
const filtered = classNames.filter((cls) => {
const suffix = groupUtil.getSuffix(cls, mergedConfig.separator);
if (/^((backdrop\-)?(filter|transform))$/i.test(suffix)) {
notNeeded.push(cls);
return false;
}
let overflowRes = /^overflow\-(?<value>clip|ellipsis)$/i.exec(suffix);
if (overflowRes && overflowRes.groups && overflowRes.groups.value) {
outdated.push([cls, cls.replace(/overflow\-(clip|ellipsis)$/i, `text-${overflowRes.groups.value}`)]);
}
let growShrinkRes = /flex\-(?<prop>grow|shrink)(\-(?<value>${flexVal}))?/i.exec(suffix);
if (growShrinkRes && growShrinkRes.groups && growShrinkRes.groups.prop) {
const prop = growShrinkRes.groups.prop;
const flexVal = growShrinkRes.groups.flexVal;
const optionalVal = flexVal ? `\-${flexVal}` : '';
const fixRegex = new RegExp(`flex\-${prop}${optionalVal}`);
outdated.push([cls, cls.replace(fixRegex, `${prop}${flexVal ? '-' + flexVal : ''}`)]);
}
let boxRes = /^decoration\-(?<value>clone|slice)$/i.exec(suffix);
if (boxRes && boxRes.groups && boxRes.groups.value) {
const boxVal = boxRes.groups.value;
const fixRegex = new RegExp(`decoration\-${boxVal}`);
outdated.push([cls, cls.replace(fixRegex, `box-decoration\-${boxVal}`)]);
}
let bgOpacityRes = /^(bg|border|ring)\-opacity\-(?<value>\d{1,})$/i.exec(suffix);
if (bgOpacityRes && bgOpacityRes.groups && bgOpacityRes.groups.value) {
const opacityVal = bgOpacityRes.groups.value;
deprecatedBgOpacity.push([cls, opacityVal]);
}
let placeholderRes = /^placeholder\-(?<value>.{1,})$/i.exec(suffix);
if (placeholderRes && placeholderRes.groups && placeholderRes.groups.value) {
const placeholderVal = placeholderRes.groups.value;
const fixPlaceholderRegex = new RegExp(`placeholder\-${placeholderVal}$`);
outdated.push([cls, cls.replace(fixPlaceholderRegex, `placeholder:text\-${placeholderVal}`)]);
}
return true;
});
if (notNeeded.length) {
let validatedClassNamesValue = '';
for (let i = 0; i < filtered.length; i++) {
const isLast = i === filtered.length - 1;
const w = whitespaces[i] ?? '';
const cls = filtered[i];
validatedClassNamesValue += headSpace ? `${w}${cls}` : isLast ? `${cls}` : `${cls}${w}`;
if (headSpace && tailSpace && isLast) {
validatedClassNamesValue += whitespaces[whitespaces.length - 1] ?? '';
}
}
validatedClassNamesValue = prefix + validatedClassNamesValue + suffix;
context.report({
node,
messageId: notNeeded.length === 1 ? 'classnameNotNeeded' : 'classnamesNotNeeded',
data: {
classnames: notNeeded.join(', '),
},
fix: function (fixer) {
return fixer.replaceTextRange([start, end], validatedClassNamesValue);
},
});
}
outdated.forEach((outdatedClass) => {
let validatedClassNamesValue = '';
for (let i = 0; i < filtered.length; i++) {
const w = whitespaces[i] ?? '';
const cls = filtered[i];
validatedClassNamesValue += headSpace ? `${w}${cls}` : `${cls}${w}`;
if (headSpace && tailSpace && i === filtered.length - 1) {
validatedClassNamesValue += whitespaces[whitespaces.length - 1] ?? '';
}
}
validatedClassNamesValue =
prefix + validatedClassNamesValue.replace(outdatedClass[0], outdatedClass[1]) + suffix;
context.report({
node,
messageId: 'classnameChanged',
data: {
deprecated: outdatedClass[0],
updated: outdatedClass[1],
},
fix: function (fixer) {
return fixer.replaceTextRange([start, end], validatedClassNamesValue);
},
});
});
deprecatedBgOpacity.forEach((bgClass) => {
context.report({
node,
messageId: 'classnameOpacityDeprecated',
data: {
classname: bgClass[0],
value: bgClass[1],
},
});
});
};
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
const attributeVisitor = function (node) {
if (!astUtil.isClassAttribute(node, classRegex) || skipClassAttribute) {
return;
}
if (astUtil.isLiteralAttributeValue(node)) {
parseForObsoleteClassNames(node);
} else if (node.value && node.value.type === 'JSXExpressionContainer') {
parseForObsoleteClassNames(node, node.value.expression);
}
};
const callExpressionVisitor = function (node) {
const calleeStr = astUtil.calleeToString(node.callee);
if (callees.findIndex((name) => calleeStr === name) === -1) {
return;
}
node.arguments.forEach((arg) => {
parseForObsoleteClassNames(node, arg);
});
};
const scriptVisitor = {
JSXAttribute: attributeVisitor,
TextAttribute: attributeVisitor,
CallExpression: callExpressionVisitor,
TaggedTemplateExpression: function (node) {
if (!tags.includes(node.tag.name ?? node.tag.object?.name ?? node.tag.callee?.name)) {
return;
}
parseForObsoleteClassNames(node, node.quasi);
},
};
const templateVisitor = {
CallExpression: callExpressionVisitor,
/*
Tagged templates inside data bindings
https://github.com/vuejs/vue/issues/9721
*/
VAttribute: function (node) {
switch (true) {
case !astUtil.isValidVueAttribute(node, classRegex):
return;
case astUtil.isVLiteralValue(node):
parseForObsoleteClassNames(node);
break;
case astUtil.isArrayExpression(node):
node.value.expression.elements.forEach((arg) => {
parseForObsoleteClassNames(node, arg);
});
break;
case astUtil.isObjectExpression(node):
node.value.expression.properties.forEach((prop) => {
parseForObsoleteClassNames(node, prop);
});
break;
}
},
};
return parserUtil.defineTemplateBodyVisitor(context, templateVisitor, scriptVisitor);
},
};