Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: autofix h-* w-* becomes size-* shorthand
  • Loading branch information
francoismassart committed Dec 29, 2023
commit d0f9f8efde235fb11ec6f4b552a5ad948849fcab
139 changes: 85 additions & 54 deletions lib/rules/enforces-shorthand.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,28 @@ module.exports = {

// These are shorthand candidates that do not share the same parent type
const complexEquivalences = [
[["overflow-hidden", "text-ellipsis", "whitespace-nowrap"], "truncate"]
]
{
needles: ['overflow-hidden', 'text-ellipsis', 'whitespace-nowrap'],
shorthand: 'truncate',
mode: 'exact',
},
{
needles: ['w-', 'h-'],
shorthand: 'size-',
mode: 'value',
},
];

// 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']
Typography: ['Text Overflow', 'Whitespace'],
};

// We don't want to affect other rules by object reference
Expand Down Expand Up @@ -219,53 +229,79 @@ module.exports = {
const validated = [];

// Handle sets of classnames with different parent types
let remaining = parsed
for (const [inputSet, outputClassname] of complexEquivalences) {
let remaining = parsed;
for (const { needles: inputSet, shorthand: outputClassname, mode } of complexEquivalences) {
if (remaining.length < inputSet.length) {
continue
}

const parsedElementsInInputSet = remaining.filter(remainingClass => inputSet.some(inputClass => remainingClass.name.includes(inputClass)))

// Make sure all required classes for the shorthand are present
if (parsedElementsInInputSet.length !== inputSet.length) {
continue
continue;
}

// Make sure the classes share all the same variants
if (new Set(parsedElementsInInputSet.map(p => p.variants)).size !== 1) {
continue
}

// Make sure the classes share all the same importance
if (new Set(parsedElementsInInputSet.map(p => p.important)).size !== 1) {
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') {
return inputSet.some((inputClassPattern) => inputClassPattern === remainingClass.body);
}
});

const index = parsedElementsInInputSet[0].index
const variants = parsedElementsInInputSet[0].variants
const important = parsedElementsInInputSet[0].important ? "!" : ""
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}`
troubles.push([parsedElementsInInputSet.map((c) => `${c.name}`), patchedClassname]);
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);
const validatedClassname = groupUtil.parseClassname(patchedClassname, targetGroups, mergedConfig, index);
validated.push(validatedClassname);

remaining = remaining.filter(p => !parsedElementsInInputSet.includes(p))
remaining = remaining.filter((p) => !candidates.includes(p));
});
}

// Handle sets of classnames with the same parent type

// Each group parentType
const checkedGroups = [];
remaining.forEach((classname) => {
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 = parsed.filter((cls) => cls.parentType === classname.parentType);
const sameType = remaining.filter((cls) => cls.parentType === classname.parentType);
// Comparing same parentType classnames
const checkedVariantsValue = [];
sameType.forEach((cls) => {
Expand Down Expand Up @@ -404,27 +440,22 @@ module.exports = {
}
}

troubles
.filter((trouble) => {
// Only valid issue if there are classes to replace
return trouble[0].length;
})
.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);
},
});
}
});
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);
},
});
}
});
};

//----------------------------------------------------------------------
Expand Down
38 changes: 27 additions & 11 deletions tests/lib/rules/enforces-shorthand.js
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,7 @@ ruleTester.run("shorthands", rule, {
output: `
<div className={ctl(\`
p-8
w-48
h-48
size-48
text-white
bg-black/50
hover:bg-black/70
Expand All @@ -406,21 +405,21 @@ ruleTester.run("shorthands", rule, {
\`)}>
Multilines
</div>`,
errors: [generateError(["py-8", "px-8"], "p-8")],
errors: [generateError(["w-48", "h-48"], "size-48"), generateError(["py-8", "px-8"], "p-8")],
},
{
code: `classnames(['py-8 px-8 w-48 h-48 text-white'])`,
output: `classnames(['p-8 w-48 h-48 text-white'])`,
errors: [generateError(["py-8", "px-8"], "p-8")],
output: `classnames(['p-8 size-48 text-white'])`,
errors: [generateError(["w-48", "h-48"], "size-48"), generateError(["py-8", "px-8"], "p-8")],
},
{
code: `classnames({'py-8 px-8 w-48 h-48 text-white': true})`,
output: `classnames({'p-8 w-48 h-48 text-white': true})`,
code: `classnames({'py-8 px-8 text-white': true})`,
output: `classnames({'p-8 text-white': true})`,
errors: [generateError(["py-8", "px-8"], "p-8")],
},
{
code: `classnames({'!py-8 !px-8 w-48 h-48 text-white': true})`,
output: `classnames({'!p-8 w-48 h-48 text-white': true})`,
code: `classnames({'!py-8 !px-8 text-white': true})`,
output: `classnames({'!p-8 text-white': true})`,
errors: [generateError(["!py-8", "!px-8"], "!p-8")],
},
{
Expand Down Expand Up @@ -652,7 +651,9 @@ ruleTester.run("shorthands", rule, {
Possible shorthand when using truncate with hover
</div>
`,
errors: [generateError(["hover:overflow-hidden", "hover:text-ellipsis", "hover:whitespace-nowrap"], "hover:truncate")],
errors: [
generateError(["hover:overflow-hidden", "hover:text-ellipsis", "hover:whitespace-nowrap"], "hover:truncate"),
],
},
{
code: `
Expand All @@ -665,7 +666,12 @@ ruleTester.run("shorthands", rule, {
Possible shorthand when using truncate with hover, breakpoint, important and prefix
</div>
`,
errors: [generateError(["hover:sm:!tw-overflow-hidden", "hover:sm:!tw-text-ellipsis", "hover:sm:!tw-whitespace-nowrap"], "hover:sm:!tw-truncate")],
errors: [
generateError(
["hover:sm:!tw-overflow-hidden", "hover:sm:!tw-text-ellipsis", "hover:sm:!tw-whitespace-nowrap"],
"hover:sm:!tw-truncate"
),
],
options: [
{
config: { prefix: "tw-" },
Expand All @@ -685,5 +691,15 @@ ruleTester.run("shorthands", rule, {
`,
errors: [generateError(["overflow-hidden", "text-ellipsis", "whitespace-nowrap"], "truncate")],
},
{
code: `<div class="h-10 w-10">New size-* utilities</div>`,
output: `<div class="size-10">New size-* utilities</div>`,
errors: [generateError(["h-10", "w-10"], "size-10")],
},
{
code: `<div class="h-10 md:h-5 md:w-5 lg:w-10">New size-* utilities</div>`,
output: `<div class="h-10 md:size-5 lg:w-10">New size-* utilities</div>`,
errors: [generateError(["md:h-5", "md:w-5"], "md:size-5")],
},
],
});
3 changes: 3 additions & 0 deletions tests/lib/rules/no-custom-classname.js
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,9 @@ ruleTester.run("no-custom-classname", rule, {
</ul>
`,
},
{
code: `<button class="size-10">New size-* utilities</button>`,
},
],

invalid: [
Expand Down