Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| Name                       | Description | 💼 | ⚠️ | 🔧 | 💡 |
| :--------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | :-- | :-- | :-- | :-- |
| [classnames-order](docs/rules/classnames-order.md) | Enforces a consistent order for the Tailwind CSS classnames, based on the compiler. | | ✅ | 🔧 | |
| [enforces-shorthand](docs/rules/enforces-shorthand.md) | Avoid using multiple Tailwind CSS classnames when not required. | | ✅ | 🔧 | |
| [no-contradicting-classname](docs/rules/no-contradicting-classname.md) | Avoid contradicting Tailwind CSS classnames. | ✅ | | | 💡 |
| [no-custom-classname](docs/rules/no-custom-classname.md) | Detects classnames which do not belong to Tailwind CSS. | | ✅ | | 💡 |

Expand Down
12 changes: 12 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# eslint-plugin-tailwindcss roadmap

// Infos:
//
https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/HEAD/docs/rules/enforces-shorthand.md

https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/HEAD/docs/rules/enforces-negative-arbitrary-values.md

https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/HEAD/docs/rules/no-arbitrary-value.md

À confirmer si c'est possible:

https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/HEAD/docs/rules/no-unnecessary-arbitrary-value.md

## April 2026

- `no-contradicting-classname`
Expand Down
13 changes: 13 additions & 0 deletions docs/rules/enforces-shorthand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Avoid using multiple Tailwind CSS classnames when not required

⚠️ This rule _warns_ in the ✅ `recommended` config.

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

## Options

<!-- begin auto-generated rule options list -->

<!-- end auto-generated rule options list -->
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
classnamesOrder,
RULE_NAME as CLASSNAMES_ORDER,
} from "./rules/classnames-order";
import {
enforcesShorthand,
RULE_NAME as ENFORCES_SHORTHAND,
} from "./rules/enforces-shorthand";
import {
noContradictingClassname,
RULE_NAME as NO_CONTRADICTING_CLASSNAME,
Expand Down Expand Up @@ -37,13 +41,15 @@ const plugin = {
},
rules: {
[CLASSNAMES_ORDER]: classnamesOrder,
[ENFORCES_SHORTHAND]: enforcesShorthand,
[NO_CUSTOM_CLASSNAME]: noCustomClassname,
[NO_CONTRADICTING_CLASSNAME]: noContradictingClassname,
},
} satisfies FlatConfig.Plugin;

const recommended = {
[CLASSNAMES_ORDER]: "warn",
[ENFORCES_SHORTHAND]: "warn",
[NO_CUSTOM_CLASSNAME]: "warn",
[NO_CONTRADICTING_CLASSNAME]: "error",
} as const;
Expand Down
31 changes: 4 additions & 27 deletions src/rules/classnames-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { RuleCreator } from "@typescript-eslint/utils/eslint-utils";
import { RuleContext as TSESLintRuleContext } from "@typescript-eslint/utils/ts-eslint";

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

const joinSortedClassnames = (
classNames: Array<string>,
whitespaces: Array<string>,
headSpace: boolean,
tailSpace: boolean,
) => {
// Make a copy of whitespaces because we don't want to mutate the original array
// (Remember that ESLint runs several times and we don't want to mess up the whitespaces for the next runs)
const spaces = [...whitespaces];

const head = headSpace ? spaces.shift() : "";
const tail = tailSpace ? spaces.pop() : "";

const validatedClasses: Array<string> = [];
for (const [index, className] of classNames.entries()) {
const spacer =
validatedClasses.length === 0 ? "" : (spaces[index - 1] ?? " ");
validatedClasses.push(spacer + className);
}

if (validatedClasses.length === 0) return "";
return head + validatedClasses.join("") + tail;
};

const sortClassnames = (
context: RuleContext,
settings: PluginSettings,
Expand All @@ -87,12 +64,12 @@ const sortClassnames = (
);

// Generates the validated/sorted attribute value
let validatedClassNamesValue = joinSortedClassnames(
orderedClassNames,
let validatedClassNamesValue = joiner({
classNames: orderedClassNames,
whitespaces,
headSpace,
tailSpace,
);
});

if (originalClassNamesValue !== validatedClassNamesValue) {
validatedClassNamesValue = prefix + validatedClassNamesValue + suffix;
Expand Down
129 changes: 129 additions & 0 deletions src/rules/enforces-shorthand.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as Parser from "@typescript-eslint/parser";
import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester";

import {
generalSettings,
withAngularParser,
} from "../utils/parser/test-helpers";
import { enforcesShorthand, MessageIds, RULE_NAME } from "./enforces-shorthand";

const generateError = (
obsoleteClassnames: Array<string>,
shorthand: string,
): TestCaseError<MessageIds> => {
return {
messageId: "fix:use-shorthand",
data: {
classnames: obsoleteClassnames.map((cls) => `'${cls}'`).join(", "),
shorthand: shorthand,
},
};
};

const ruleTester = new RuleTester({
languageOptions: {
parser: Parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
tailwindcss: {
...generalSettings,
},
},
});

ruleTester.run(RULE_NAME, enforcesShorthand, {
valid: [
// Angular / Native HTML + static text
`<h1 class="-mt- h-100 md:h-full">valid</h1>`,
`<h1 class="w-full md:h-full">modifiers</h1>`,
].map((testedNgCode) => ({
code: testedNgCode,
languageOptions: withAngularParser,
})),
invalid: [
{
code: `ctl("overflow-x-hidden overflow-y-hidden")`,
errors: [
generateError(
["overflow-x-hidden", "overflow-y-hidden"],
"overflow-hidden",
),
],
output: `ctl("overflow-hidden")`,
},
{
code: `ctl("overscroll-x-none overscroll-y-none")`,
errors: [
generateError(
["overscroll-x-none", "overscroll-y-none"],
"overscroll-none",
),
],
output: `ctl("overscroll-none")`,
},
{
code: `ctl("top-0 right-0 bottom-0")`,
errors: [generateError(["top-0", "bottom-0"], "inset-y-0")],
output: `ctl("right-0 inset-y-0")`,
},
{
code: `ctl("top-0 right-0 bottom-0 left-0")`,
errors: [
generateError(["right-0", "left-0"], "inset-x-0"),
generateError(["top-0", "bottom-0"], "inset-y-0"),
],
output: [
`ctl("top-0 bottom-0 inset-x-0")`,
`ctl("inset-x-0 inset-y-0")`,
`ctl("inset-0")`,
],
},
{
code: `ctl("inset-y-0 right-0 left-0")`,
errors: [generateError(["right-0", "left-0"], "inset-x-0")],
output: [`ctl("inset-y-0 inset-x-0")`, `ctl("inset-0")`],
},
{
code: `ctl("-inset-y-10 -inset-x-10")`,
errors: [generateError(["-inset-x-10", "-inset-y-10"], "-inset-10")],
output: [`ctl("-inset-10")`],
},
{
code: `ctl("gap-x-10 gap-y-10")`,
errors: [generateError(["gap-x-10", "gap-y-10"], "gap-10")],
output: [`ctl("gap-10")`],
},
{
code: `<h1 class="block md:-mx-10 md:-my-10">margin</h1>`,
errors: [generateError(["md:-mx-10", "md:-my-10"], "md:-m-10")],
output: `<h1 class="block md:-m-10">margin</h1>`,
languageOptions: withAngularParser,
},
{
code: `<h1 class="md:pl-10 md:pr-10">padding</h1>`,
errors: [generateError(["md:pl-10", "md:pr-10"], "md:px-10")],
output: `<h1 class="md:px-10">padding</h1>`,
languageOptions: withAngularParser,
},
{
code: `ctl("w-1/2 h-1/2")`,
errors: [generateError(["w-1/2", "h-1/2"], "size-1/2")],
output: [`ctl("size-1/2")`],
},
{
code: `ctl("debug overflow-hidden text-ellipsis whitespace-nowrap")`,
errors: [
generateError(
["overflow-hidden", "text-ellipsis", "whitespace-nowrap"],
"truncate",
),
],
output: [`ctl("debug truncate")`],
},
],
});
Loading
Loading