eslint-plugin-tailwindcss
Version:
Rules enforcing best practices while using Tailwind CSS
568 lines (526 loc) • 23.6 kB
JavaScript
/**
* @fileoverview Avoid using multiple Tailwind CSS classnames when not required (e.g. "mx-3 my-3" could be replaced by "m-3")
* @author François Massart
*/
;
const docsUrl = require('../util/docsUrl');
const defaultGroups = require('../config/groups').groups;
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 SHORTHAND_CANDIDATE_CLASSNAMES_DETECTED_MSG = `Classnames '{{classnames}}' could be replaced by the '{{shorthand}}' shorthand!`;
module.exports = {
meta: {
docs: {
description: 'Enforces the usage of shorthand Tailwind CSS classnames',
category: 'Best Practices',
recommended: true,
url: docsUrl('enforces-shorthand'),
},
messages: {
shorthandCandidateDetected: SHORTHAND_CANDIDATE_CLASSNAMES_DETECTED_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
//----------------------------------------------------------------------
const placeContentOptions = ['center', 'start', 'end', 'between', 'around', 'evenly', 'baseline', 'stretch'];
const placeItemsOptions = ['start', 'end', 'center', 'stretch'];
const placeSelfOptions = ['auto', 'start', 'end', 'center', 'stretch'];
// These are shorthand candidates that do not share the same parent type
const complexEquivalences = [
{
needles: ['overflow-hidden', 'text-ellipsis', 'whitespace-nowrap'],
shorthand: 'truncate',
mode: 'exact',
},
{
needles: ['w-', 'h-'],
shorthand: 'size-',
mode: 'value',
},
...placeContentOptions.map((opt) => {
return {
needles: [`content-${opt}`, `justify-${opt}`],
shorthand: `place-content-${opt}`,
mode: 'exact',
};
}),
...placeItemsOptions.map((opt) => {
return {
needles: [`items-${opt}`, `justify-items-${opt}`],
shorthand: `place-items-${opt}`,
mode: 'exact',
};
}),
...placeSelfOptions.map((opt) => {
return {
needles: [`self-${opt}`, `justify-self-${opt}`],
shorthand: `place-self-${opt}`,
mode: 'exact',
};
}),
];
// Init assets
const targetProperties = {
Layout: ['Overflow', 'Overscroll Behavior', 'Top / Right / Bottom / Left'],
'Flexbox & Grid': ['Gap'],
Spacing: ['Padding', 'Margin'],
Sizing: ['Width', 'Height'],
Borders: ['Border Radius', 'Border Width', 'Border Color'],
Tables: ['Border Spacing'],
Transforms: ['Scale'],
Typography: ['Text Overflow', 'Whitespace'],
};
// We don't want to affect other rules by object reference
const cloned = JSON.parse(JSON.stringify(defaultGroups));
const targetGroups = cloned.filter((g) => Object.keys(targetProperties).includes(g.type));
targetGroups.forEach((g) => {
// Without using the clone, other rules would be affected by this `filter()`
g.members = g.members.filter((sub) => targetProperties[g.type].includes(sub.type));
});
/**
* Retrieve the main part of a classname base on its shorthand scope
* @param {Object} targetGroups A specific subset of the groups
* @param {String} parentType The name of the parent e.g. 'Border Radius'
* @param {String} shorthand The searched shorthand e.g. 'all', 'y', 't', 'tr'
* @returns
*/
const getBodyByShorthand = (targetGroups, parentType, shorthand) => {
const findByMemberType = (obj) => obj.members.find((m) => m.type === parentType);
const mainGroup = targetGroups.find(findByMemberType);
if (!mainGroup) {
return '';
}
const typeGroup = mainGroup.members.find((m) => m.type === parentType);
// const typeGroup = mainGroup.find(findByMemberType);
if (!typeGroup) {
return '';
}
const type = typeGroup.members.find((m) => m.shorthand === shorthand);
return !type ? '' : type.body;
};
/**
* Parse the classnames and report found shorthand candidates
* @param {ASTNode} node The root node of the current parsing
* @param {ASTNode} arg The child node of node
* @returns {void}
*/
const parseForShorthandCandidates = (node, arg = null) => {
let originalClassNamesValue = null;
let start = null;
let end = null;
let prefix = '';
let suffix = '';
const troubles = [];
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) => {
parseForShorthandCandidates(node, exp);
});
arg.quasis.forEach((quasis) => {
parseForShorthandCandidates(node, quasis);
});
return;
case 'ConditionalExpression':
parseForShorthandCandidates(node, arg.consequent);
parseForShorthandCandidates(node, arg.alternate);
return;
case 'LogicalExpression':
parseForShorthandCandidates(node, arg.right);
return;
case 'ArrayExpression':
arg.elements.forEach((el) => {
parseForShorthandCandidates(node, el);
});
return;
case 'ObjectExpression':
const isUsedByClassNamesPlugin = node.callee && node.callee.name === 'classnames';
const isVue = node.key && node.key.type === 'VDirectiveKey';
arg.properties.forEach((prop) => {
const propVal = isUsedByClassNamesPlugin || isVue ? prop.key : prop.value;
parseForShorthandCandidates(node, propVal);
});
return;
case 'Property':
parseForShorthandCandidates(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);
if (classNames.length <= 1) {
// Don't run sorting for a single or empty className
return;
}
const parsed = [];
classNames.forEach((className, index) => {
parsed.push(groupUtil.parseClassname(className, targetGroups, mergedConfig, index));
});
const validated = [];
// Handle sets of classnames with different parent types
let remaining = parsed;
for (const { needles: inputSet, shorthand: outputClassname, mode } of complexEquivalences) {
if (remaining.length < inputSet.length) {
continue;
}
// Matching classes
const parsedElementsInInputSet = remaining.filter((remainingClass) => {
if (mode === 'exact') {
// Test if the name contains the target class, eg. 'text-ellipsis' inside 'md:text-ellipsis'...
return inputSet.some((inputClass) => remainingClass.name.includes(inputClass));
}
// Test if the body of the class matches, eg. 'h-' inside 'h-10'
if (mode === 'value') {
const bodyMatch = inputSet.some(
(inputClassPattern) => `${mergedConfig.prefix}${inputClassPattern}` === remainingClass.body
);
if ([undefined, null].includes(mergedConfig.theme.size)) {
return false;
}
// w-screen + h-screen ≠ size-screen (Issue #307)
const sizeKeys = Object.keys(mergedConfig.theme.size);
const isSize = ['w-', 'h-'].includes(remainingClass.body);
const isValidSize = sizeKeys.includes(remainingClass.value);
const wValue = mergedConfig.theme.width[remainingClass.value];
const hValue = mergedConfig.theme.height[remainingClass.value];
const sizeValue = mergedConfig.theme.size[remainingClass.value];
const fullMatch = wValue === hValue && wValue === sizeValue;
return bodyMatch && !(isSize && !isValidSize && !fullMatch);
}
});
const variantGroups = new Map();
parsedElementsInInputSet.forEach((o) => {
const val = mode === 'value' ? o.value : '';
const v = `${o.variants}${o.important ? '!' : ''}${val}`;
if (!variantGroups.has(v)) {
variantGroups.set(
v,
parsedElementsInInputSet.filter(
(c) => c.variants === o.variants && c.important === o.important && (val === '' || c.value === val)
)
);
}
});
const validKeys = new Set();
variantGroups.forEach((classes, key) => {
let skip = false;
// Make sure all required classes for the shorthand are present
if (classes.length < inputSet.length) {
skip = true;
}
// Make sure the classes share all the single/shared/same value
if (mode === 'value' && new Set(classes.map((p) => p.value)).size !== 1) {
skip = true;
}
if (!skip) {
validKeys.add(key);
}
});
validKeys.forEach((k) => {
const candidates = variantGroups.get(k);
const index = candidates[0].index;
const variants = candidates[0].variants;
const important = candidates[0].important ? '!' : '';
const classValue = mode === 'value' ? candidates[0].value : '';
const patchedClassname = `${variants}${important}${mergedConfig.prefix}${outputClassname}${classValue}`;
troubles.push([candidates.map((c) => `${c.name}`), patchedClassname]);
const validatedClassname = groupUtil.parseClassname(patchedClassname, targetGroups, mergedConfig, index);
validated.push(validatedClassname);
remaining = remaining.filter((p) => !candidates.includes(p));
});
}
// Handle sets of classnames with the same parent type
// Each group parentType
const checkedGroups = [];
remaining.forEach((classname, idx, arr) => {
// Valid candidate
if (classname.parentType === '') {
validated.push(classname);
} else if (!checkedGroups.includes(classname.parentType)) {
checkedGroups.push(classname.parentType);
const sameType = remaining.filter((cls) => cls.parentType === classname.parentType);
// Comparing same parentType classnames
const checkedVariantsValue = [];
sameType.forEach((cls) => {
const key = cls.variants + (cls.important ? '!' : '') + cls.value;
if (!checkedVariantsValue.includes(key)) {
checkedVariantsValue.push(key);
const sameVariantAndValue = sameType.filter((v) => {
return !(v.variants !== cls.variants || v.value !== cls.value || v.important !== cls.important);
});
if (sameVariantAndValue.length === 1) {
validated.push(cls);
} else if (sameVariantAndValue.length) {
const supportCorners = ['Border Radius'].includes(classname.parentType);
const hasTL =
supportCorners && sameVariantAndValue.some((c) => ['tl', 't', 'all'].includes(c.shorthand));
const hasTR =
supportCorners && sameVariantAndValue.some((c) => ['tr', 't', 'all'].includes(c.shorthand));
const hasBR =
supportCorners && sameVariantAndValue.some((c) => ['br', 'b', 'all'].includes(c.shorthand));
const hasBL =
supportCorners && sameVariantAndValue.some((c) => ['bl', 'b', 'all'].includes(c.shorthand));
const hasT = sameVariantAndValue.some((c) => c.shorthand === 't') || (hasTL && hasTR);
const hasR = sameVariantAndValue.some((c) => c.shorthand === 'r') || (hasTR && hasBR);
const hasB = sameVariantAndValue.some((c) => c.shorthand === 'b') || (hasBL && hasBR);
const hasL = sameVariantAndValue.some((c) => c.shorthand === 'l') || (hasTL && hasBL);
const hasX = sameVariantAndValue.some((c) => c.shorthand === 'x') || (hasL && hasR);
const hasY = sameVariantAndValue.some((c) => c.shorthand === 'y') || (hasT && hasB);
const hasAllProp = sameVariantAndValue.some((c) => c.shorthand === 'all');
const hasAllPropNoCorner = hasY && hasX;
const hasAllPropWithCorners = (hasL && hasR) || (hasT && hasB);
const hasAllEquivalent = !supportCorners ? hasAllPropNoCorner : hasAllPropWithCorners;
const hasAll = hasAllProp || hasAllEquivalent;
const important = cls.important ? '!' : '';
const isNegative = ('' + cls.value).substring(0, 1) === '-';
const minus = isNegative ? '-' : '';
const absoluteVal = isNegative ? ('' + cls.value).substring(1) : cls.value;
if (hasAll) {
const all = getBodyByShorthand(targetGroups, classname.parentType, 'all');
const val = absoluteVal.length ? '-' + absoluteVal : '';
const patchedName = `${cls.variants}${important}${minus}${mergedConfig.prefix}${all}${val}`;
troubles.push([sameVariantAndValue.map((c) => c.name), patchedName]);
cls.name = patchedName;
cls.shorthand = 'all';
validated.push(cls);
} else if (hasY || hasX) {
const xOrY = hasX ? 'x' : 'y';
const xOrYType = getBodyByShorthand(targetGroups, classname.parentType, xOrY);
const patchedName = `${cls.variants}${important}${minus}${mergedConfig.prefix}${xOrYType}${
absoluteVal.length ? '-' + absoluteVal : ''
}`;
const toBeReplaced = sameVariantAndValue
.filter((c) => {
const candidates = hasX ? ['l', 'r'] : ['t', 'b'];
return candidates.includes(c.shorthand);
})
.map((c) => c.name);
const toBeKept = sameVariantAndValue.filter((c) => {
const candidates = hasY ? ['l', 'r'] : ['t', 'b'];
return candidates.includes(c.shorthand);
});
troubles.push([toBeReplaced, patchedName]);
let replaced = false;
sameVariantAndValue.forEach((ref, i) => {
if (toBeKept.find((k) => k.name === ref.name)) {
validated.push(ref);
} else if (!replaced) {
replaced = true;
const cloned = JSON.parse(JSON.stringify(ref));
cloned.name = patchedName;
cloned.shorthand = xOrY;
validated.push(cloned);
}
});
} else if (supportCorners && (hasT || hasR || hasB || hasL)) {
const side = hasT ? 't' : hasR ? 'r' : hasB ? 'b' : 'l';
const sideBody = getBodyByShorthand(targetGroups, classname.parentType, side);
const val = absoluteVal.length ? '-' + absoluteVal : '';
const patchedName = `${cls.variants}${important}${minus}${mergedConfig.prefix}${sideBody}${val}`;
const toBeReplaced = sameVariantAndValue
.filter((c) => {
const candidates = hasT ? ['tl', 'tr'] : hasR ? ['tr', 'br'] : hasB ? ['bl', 'br'] : ['tl', 'bl'];
return candidates.includes(c.shorthand);
})
.map((c) => c.name);
const toBeKept = sameVariantAndValue.filter((c) => {
const candidates = hasT ? ['bl', 'br'] : hasR ? ['tl', 'bl'] : hasB ? ['tl', 'tr'] : ['tr', 'br'];
return candidates.includes(c.shorthand);
});
troubles.push([toBeReplaced, patchedName]);
let replaced = false;
sameVariantAndValue.forEach((ref, i) => {
if (toBeKept.find((k) => k.name === ref.name)) {
validated.push(ref);
} else if (!replaced) {
replaced = true;
const cloned = JSON.parse(JSON.stringify(ref));
cloned.name = patchedName;
cloned.shorthand = side;
validated.push(cloned);
}
});
} else {
validated.push(...sameVariantAndValue);
}
}
}
});
}
});
// Try to keep the original order
validated.sort((a, b) => (a.index < b.index ? -1 : +1));
// Generates the validated attribute value
const union = validated.map((val) => val.leading + val.name + val.trailing);
let validatedClassNamesValue = '';
// Generates the validated attribute value
if (union.length === 1) {
validatedClassNamesValue += headSpace ? whitespaces[0] : '';
validatedClassNamesValue += union[0];
validatedClassNamesValue += tailSpace ? whitespaces[whitespaces.length - 1] : '';
} else {
for (let i = 0; i < union.length; i++) {
const isLast = i === union.length - 1;
const w = whitespaces[i] ?? '';
const cls = union[i];
validatedClassNamesValue += headSpace ? `${w}${cls}` : isLast ? `${cls}` : `${cls}${w}`;
if (tailSpace && isLast) {
validatedClassNamesValue += whitespaces[whitespaces.length - 1] ?? '';
}
}
}
troubles.forEach((issue) => {
if (originalClassNamesValue !== validatedClassNamesValue) {
validatedClassNamesValue = prefix + validatedClassNamesValue + suffix;
context.report({
node: node,
messageId: 'shorthandCandidateDetected',
data: {
classnames: issue[0].join(', '),
shorthand: issue[1],
},
fix: function (fixer) {
return fixer.replaceTextRange([start, end], validatedClassNamesValue);
},
});
}
});
};
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
const attributeVisitor = function (node) {
if (!astUtil.isClassAttribute(node, classRegex) || skipClassAttribute) {
return;
}
if (astUtil.isLiteralAttributeValue(node)) {
parseForShorthandCandidates(node);
} else if (node.value && node.value.type === 'JSXExpressionContainer') {
parseForShorthandCandidates(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) => {
parseForShorthandCandidates(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;
}
parseForShorthandCandidates(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):
parseForShorthandCandidates(node);
break;
case astUtil.isArrayExpression(node):
node.value.expression.elements.forEach((arg) => {
parseForShorthandCandidates(node, arg);
});
break;
case astUtil.isObjectExpression(node):
node.value.expression.properties.forEach((prop) => {
parseForShorthandCandidates(node, prop);
});
break;
}
},
};
return parserUtil.defineTemplateBodyVisitor(context, templateVisitor, scriptVisitor);
},
};