Skip to content

Commit 0290c86

Browse files
feat: enforces-shorthand (#447)
* test: auto generated vars * refactor: joiner ❤️ * test: padding config * WIP * fix(#448): false positives on tailwind-variants * add failing rounded strategies * fix: allow multiple combos for the same shorthand * fix: padding, margin & border * feat: border, scale, skew, translate, scroll m/p * docs * refactor
1 parent e0e5577 commit 0290c86

16 files changed

Lines changed: 969 additions & 99 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
| Name                       | Description | 💼 | ⚠️ | 🔧 | 💡 |
1616
| :--------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | :-- | :-- | :-- | :-- |
1717
| [classnames-order](docs/rules/classnames-order.md) | Enforces a consistent order for the Tailwind CSS classnames, based on the compiler. | || 🔧 | |
18+
| [enforces-shorthand](docs/rules/enforces-shorthand.md) | Avoid using multiple Tailwind CSS classnames when not required. | || 🔧 | |
1819
| [no-contradicting-classname](docs/rules/no-contradicting-classname.md) | Avoid contradicting Tailwind CSS classnames. || | | 💡 |
1920
| [no-custom-classname](docs/rules/no-custom-classname.md) | Detects classnames which do not belong to Tailwind CSS. | || | 💡 |
2021

ROADMAP.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# eslint-plugin-tailwindcss roadmap
22

3+
- [enforces-negative-arbitrary-values](https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/HEAD/docs/rules/enforces-negative-arbitrary-values.md)
4+
- [no-arbitrary-value](https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/HEAD/docs/rules/no-arbitrary-value.md)
5+
- [no-unnecessary-arbitrary-value](https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/HEAD/docs/rules/no-unnecessary-arbitrary-value.md)
6+
- support for ESLint v10
7+
- performances improvements
8+
9+
## May 2026
10+
11+
- `enforces-shorthand`
12+
313
## April 2026
414

515
- `no-contradicting-classname`

docs/rules/enforces-shorthand.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Avoid using multiple Tailwind CSS classnames when not required
2+
3+
⚠️ This rule _warns_ in the ✅ `recommended` config.
4+
5+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6+
7+
<!-- end auto-generated rule header -->
8+
9+
## Rule Details
10+
11+
Examples of **incorrect** code for this rule:
12+
13+
```html
14+
<section class="mt-10 mb-10 mx-10 pt-5 pb-5 pr-15 pl-15">
15+
<h1 class="overflow-hidden text-ellipsis whitespace-nowrap">
16+
This title can be very long, it could get truncated on smaller screens!
17+
</h1>
18+
</section>
19+
```
20+
21+
Examples of **correct** code for this rule:
22+
23+
```html
24+
<section class="m-10 py-5 px-15">
25+
<h1 class="truncate">
26+
This title can be very long, it could get truncated on smaller screens!
27+
</h1>
28+
</section>
29+
```
30+
31+
## Options
32+
33+
<!-- begin auto-generated rule options list -->
34+
35+
<!-- end auto-generated rule options list -->
36+
37+
There are no specific options for this rule, yet it uses the general [settings](../../README.md#settings).

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
classnamesOrder,
66
RULE_NAME as CLASSNAMES_ORDER,
77
} from "./rules/classnames-order";
8+
import {
9+
enforcesShorthand,
10+
RULE_NAME as ENFORCES_SHORTHAND,
11+
} from "./rules/enforces-shorthand";
812
import {
913
noContradictingClassname,
1014
RULE_NAME as NO_CONTRADICTING_CLASSNAME,
@@ -37,13 +41,15 @@ const plugin = {
3741
},
3842
rules: {
3943
[CLASSNAMES_ORDER]: classnamesOrder,
44+
[ENFORCES_SHORTHAND]: enforcesShorthand,
4045
[NO_CUSTOM_CLASSNAME]: noCustomClassname,
4146
[NO_CONTRADICTING_CLASSNAME]: noContradictingClassname,
4247
},
4348
} satisfies FlatConfig.Plugin;
4449

4550
const recommended = {
4651
[CLASSNAMES_ORDER]: "warn",
52+
[ENFORCES_SHORTHAND]: "warn",
4753
[NO_CUSTOM_CLASSNAME]: "warn",
4854
[NO_CONTRADICTING_CLASSNAME]: "error",
4955
} as const;

src/rules/classnames-order.ts

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { RuleCreator } from "@typescript-eslint/utils/eslint-utils";
88
import { RuleContext as TSESLintRuleContext } from "@typescript-eslint/utils/ts-eslint";
99

1010
import urlCreator from "../url-creator";
11+
import { joiner } from "../utils/joiner";
1112
import {
1213
parsePluginSettings,
1314
PluginSettings,
@@ -42,30 +43,6 @@ type RuleContext = TSESLintRuleContext<MessageIds, Options>;
4243
// The parameter passed into RuleCreator is a URL generator function.
4344
export const createRule = RuleCreator(urlCreator);
4445

45-
const joinSortedClassnames = (
46-
classNames: Array<string>,
47-
whitespaces: Array<string>,
48-
headSpace: boolean,
49-
tailSpace: boolean,
50-
) => {
51-
// Make a copy of whitespaces because we don't want to mutate the original array
52-
// (Remember that ESLint runs several times and we don't want to mess up the whitespaces for the next runs)
53-
const spaces = [...whitespaces];
54-
55-
const head = headSpace ? spaces.shift() : "";
56-
const tail = tailSpace ? spaces.pop() : "";
57-
58-
const validatedClasses: Array<string> = [];
59-
for (const [index, className] of classNames.entries()) {
60-
const spacer =
61-
validatedClasses.length === 0 ? "" : (spaces[index - 1] ?? " ");
62-
validatedClasses.push(spacer + className);
63-
}
64-
65-
if (validatedClasses.length === 0) return "";
66-
return head + validatedClasses.join("") + tail;
67-
};
68-
6946
const sortClassnames = (
7047
context: RuleContext,
7148
settings: PluginSettings,
@@ -87,12 +64,12 @@ const sortClassnames = (
8764
);
8865

8966
// Generates the validated/sorted attribute value
90-
let validatedClassNamesValue = joinSortedClassnames(
91-
orderedClassNames,
67+
let validatedClassNamesValue = joiner({
68+
classNames: orderedClassNames,
9269
whitespaces,
9370
headSpace,
9471
tailSpace,
95-
);
72+
});
9673

9774
if (originalClassNamesValue !== validatedClassNamesValue) {
9875
validatedClassNamesValue = prefix + validatedClassNamesValue + suffix;
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import * as Parser from "@typescript-eslint/parser";
2+
import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester";
3+
4+
import {
5+
generalSettings,
6+
withAngularParser,
7+
} from "../utils/parser/test-helpers";
8+
import { enforcesShorthand, MessageIds, RULE_NAME } from "./enforces-shorthand";
9+
10+
const generateError = (
11+
obsoleteClassnames: Array<string>,
12+
shorthand: string,
13+
): TestCaseError<MessageIds> => {
14+
return {
15+
messageId: "fix:use-shorthand",
16+
data: {
17+
classnames: obsoleteClassnames.map((cls) => `'${cls}'`).join(", "),
18+
shorthand: shorthand,
19+
},
20+
};
21+
};
22+
23+
const ruleTester = new RuleTester({
24+
languageOptions: {
25+
parser: Parser,
26+
parserOptions: {
27+
ecmaFeatures: {
28+
jsx: true,
29+
},
30+
},
31+
},
32+
settings: {
33+
tailwindcss: {
34+
...generalSettings,
35+
},
36+
},
37+
});
38+
39+
ruleTester.run(RULE_NAME, enforcesShorthand, {
40+
valid: [
41+
// Angular / Native HTML + static text
42+
`<h1 class="-mt- h-100 md:h-full">valid</h1>`,
43+
`<h1 class="w-full md:h-full">modifiers</h1>`,
44+
].map((testedNgCode) => ({
45+
code: testedNgCode,
46+
languageOptions: withAngularParser,
47+
})),
48+
invalid: [
49+
{
50+
code: `ctl("overflow-x-hidden overflow-y-hidden")`,
51+
errors: [
52+
generateError(
53+
["overflow-x-hidden", "overflow-y-hidden"],
54+
"overflow-hidden",
55+
),
56+
],
57+
output: `ctl("overflow-hidden")`,
58+
},
59+
{
60+
code: `ctl("overscroll-x-none overscroll-y-none")`,
61+
errors: [
62+
generateError(
63+
["overscroll-x-none", "overscroll-y-none"],
64+
"overscroll-none",
65+
),
66+
],
67+
output: `ctl("overscroll-none")`,
68+
},
69+
{
70+
code: `ctl("top-0 right-0 bottom-0")`,
71+
errors: [generateError(["top-0", "bottom-0"], "inset-y-0")],
72+
output: `ctl("right-0 inset-y-0")`,
73+
},
74+
{
75+
code: `ctl("top-0 right-0 bottom-0 left-0")`,
76+
errors: [
77+
generateError(["right-0", "left-0"], "inset-x-0"),
78+
generateError(["top-0", "bottom-0"], "inset-y-0"),
79+
],
80+
output: [
81+
`ctl("top-0 bottom-0 inset-x-0")`,
82+
`ctl("inset-x-0 inset-y-0")`,
83+
`ctl("inset-0")`,
84+
],
85+
},
86+
{
87+
code: `ctl("inset-y-0 right-0 left-0")`,
88+
errors: [generateError(["right-0", "left-0"], "inset-x-0")],
89+
output: [`ctl("inset-y-0 inset-x-0")`, `ctl("inset-0")`],
90+
},
91+
{
92+
code: `ctl("-inset-y-10 -inset-x-10")`,
93+
errors: [generateError(["-inset-x-10", "-inset-y-10"], "-inset-10")],
94+
output: [`ctl("-inset-10")`],
95+
},
96+
{
97+
code: `ctl("-my-10 -mx-10")`,
98+
errors: [generateError(["-mx-10", "-my-10"], "-m-10")],
99+
output: [`ctl("-m-10")`],
100+
},
101+
{
102+
code: `ctl("dark:-my-10 dark:-mx-10")`,
103+
errors: [generateError(["dark:-mx-10", "dark:-my-10"], "dark:-m-10")],
104+
output: [`ctl("dark:-m-10")`],
105+
},
106+
{
107+
code: `ctl("gap-x-10 gap-y-10")`,
108+
errors: [generateError(["gap-x-10", "gap-y-10"], "gap-10")],
109+
output: [`ctl("gap-10")`],
110+
},
111+
{
112+
code: `<h1 class="block md:-mx-10 md:-my-10">margin</h1>`,
113+
errors: [generateError(["md:-mx-10", "md:-my-10"], "md:-m-10")],
114+
output: `<h1 class="block md:-m-10">margin</h1>`,
115+
languageOptions: withAngularParser,
116+
},
117+
{
118+
code: `<h1 class="md:pl-10 md:pr-10">padding</h1>`,
119+
errors: [generateError(["md:pl-10", "md:pr-10"], "md:px-10")],
120+
output: `<h1 class="md:px-10">padding</h1>`,
121+
languageOptions: withAngularParser,
122+
},
123+
{
124+
code: `ctl("w-1/2 h-1/2")`,
125+
errors: [generateError(["w-1/2", "h-1/2"], "size-1/2")],
126+
output: [`ctl("size-1/2")`],
127+
},
128+
{
129+
code: `ctl("debug overflow-hidden text-ellipsis whitespace-nowrap")`,
130+
errors: [
131+
generateError(
132+
["overflow-hidden", "text-ellipsis", "whitespace-nowrap"],
133+
"truncate",
134+
),
135+
],
136+
output: [`ctl("debug truncate")`],
137+
},
138+
{
139+
code: `ctl("rounded-bl-2xl rounded-br-2xl")`,
140+
errors: [
141+
generateError(["rounded-bl-2xl", "rounded-br-2xl"], "rounded-b-2xl"),
142+
],
143+
output: [`ctl("rounded-b-2xl")`],
144+
},
145+
{
146+
code: `ctl("rounded-ee-2xl rounded-es-2xl")`,
147+
errors: [
148+
generateError(["rounded-ee-2xl", "rounded-es-2xl"], "rounded-b-2xl"),
149+
],
150+
output: [`ctl("rounded-b-2xl")`],
151+
},
152+
{
153+
code: `ctl("border-t-1 border-b-1")`,
154+
errors: [generateError(["border-t-1", "border-b-1"], "border-y-1")],
155+
output: [`ctl("border-y-1")`],
156+
},
157+
{
158+
code: `ctl("border-t border-b")`,
159+
errors: [generateError(["border-t", "border-b"], "border-y")],
160+
output: [`ctl("border-y")`],
161+
},
162+
{
163+
code: `ctl("border-t border-b border-x")`,
164+
errors: [generateError(["border-t", "border-b"], "border-y")],
165+
output: [`ctl("border-x border-y")`, `ctl("border")`],
166+
},
167+
{
168+
code: `ctl("border-spacing-x-20 border-spacing-y-20")`,
169+
errors: [
170+
generateError(
171+
["border-spacing-x-20", "border-spacing-y-20"],
172+
"border-spacing-20",
173+
),
174+
],
175+
output: [`ctl("border-spacing-20")`],
176+
},
177+
{
178+
code: `ctl("scale-x-150 scale-y-150")`,
179+
errors: [generateError(["scale-x-150", "scale-y-150"], "scale-150")],
180+
output: [`ctl("scale-150")`],
181+
},
182+
{
183+
code: `ctl("skew-x-150 dark:-skew-x-100 dark:skew-y-100 skew-y-150")`,
184+
errors: [generateError(["skew-x-150", "skew-y-150"], "skew-150")],
185+
output: [`ctl("dark:-skew-x-100 dark:skew-y-100 skew-150")`],
186+
},
187+
{
188+
code: `ctl("-translate-y-10 -translate-x-10")`,
189+
errors: [
190+
generateError(["-translate-x-10", "-translate-y-10"], "-translate-10"),
191+
],
192+
output: [`ctl("-translate-10")`],
193+
},
194+
],
195+
});

0 commit comments

Comments
 (0)