From f274f0df5c003078f769ac6216869ced8f2c18d1 Mon Sep 17 00:00:00 2001 From: francoismassart Date: Wed, 22 Apr 2026 15:51:08 +0200 Subject: [PATCH 1/4] test: auto generated vars --- .../tailwindcss-api/worker/load-theme.spec.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/utils/tailwindcss-api/worker/load-theme.spec.ts b/src/utils/tailwindcss-api/worker/load-theme.spec.ts index 364f08b..62d5d1a 100644 --- a/src/utils/tailwindcss-api/worker/load-theme.spec.ts +++ b/src/utils/tailwindcss-api/worker/load-theme.spec.ts @@ -12,6 +12,45 @@ test(`load theme from "tiny-prefixed.css"`, () => { expect(theme.values.size).toBe(3); }); +test(`load theme from "normal.css"`, () => { + const path = require.resolve("../../../../tests/stubs/css/normal.css"); + const theme = loadThemeWorker(path); + // We can read CSS vars + expect(theme.values.get("--color-primary")?.value).toBe("#123456"); + // But no way to determine predefined widths + // the Tailwind CSS v4 engine will build from discovered classnames usage + let counter = 0; + for (const [key, value] of theme.values) { + if (key.startsWith("--font-")) continue; + if (key.startsWith("--color-")) continue; + if (key.startsWith("--spacing-")) continue; + if (key.startsWith("--breakpoint-")) continue; + if (key.startsWith("--container-")) continue; + if (key.startsWith("--text-")) continue; + if (key.startsWith("--spacing")) continue; + if (key.startsWith("--tracking-")) continue; + if (key.startsWith("--animate-")) continue; + if (key.startsWith("--leading-")) continue; + if (key.startsWith("--radius-")) continue; + if (key.startsWith("--shadow-")) continue; + if (key.startsWith("--inset-")) continue; + if (key.startsWith("--drop-shadow-")) continue; + if (key.startsWith("--ease-")) continue; + if (key.startsWith("--blur-")) continue; + if (key.startsWith("--perspective-")) continue; + if (key.startsWith("--aspect-")) continue; + if (key.startsWith("--default-")) continue; + if (key.startsWith("--blur")) continue; + if (key.startsWith("--shadow")) continue; + if (key.startsWith("--drop-shadow")) continue; + if (key.startsWith("--radius")) continue; + if (key.startsWith("--max-width-prose")) continue; + console.log(key, value); + counter++; + } + expect(counter).toBe(0); +}); + // At this moment, no possibility to read the custom dark variant from the config /*/ test(`load theme from "custom-dark.css"`, () => { From 69dcd7b32749c8681df7909fa70f1c7e43c35fa5 Mon Sep 17 00:00:00 2001 From: francoismassart Date: Wed, 22 Apr 2026 16:37:40 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20joiner=20=E2=9D=A4=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rules/classnames-order.ts | 31 ++-------------- src/rules/no-contradicting-classname.ts | 34 ++--------------- src/rules/no-custom-classname.ts | 48 ++++++------------------ src/utils/joiner.spec.ts | 49 +++++++++++++++++++++++++ src/utils/joiner.ts | 35 ++++++++++++++++++ 5 files changed, 103 insertions(+), 94 deletions(-) create mode 100644 src/utils/joiner.spec.ts create mode 100644 src/utils/joiner.ts diff --git a/src/rules/classnames-order.ts b/src/rules/classnames-order.ts index d6ee1dc..83a4663 100644 --- a/src/rules/classnames-order.ts +++ b/src/rules/classnames-order.ts @@ -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, @@ -42,30 +43,6 @@ type RuleContext = TSESLintRuleContext; // The parameter passed into RuleCreator is a URL generator function. export const createRule = RuleCreator(urlCreator); -const joinSortedClassnames = ( - classNames: Array, - whitespaces: Array, - 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 = []; - 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, @@ -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; diff --git a/src/rules/no-contradicting-classname.ts b/src/rules/no-contradicting-classname.ts index 99d5b9c..4afad06 100644 --- a/src/rules/no-contradicting-classname.ts +++ b/src/rules/no-contradicting-classname.ts @@ -9,6 +9,7 @@ import { RuleContext as TSESLintRuleContext } from "@typescript-eslint/utils/ts- import urlCreator from "../url-creator"; import { getPropertiesFromCssRule } from "../utils/get-properties-from-css-rule"; +import { joiner } from "../utils/joiner"; import { mapGetKeyFromSetValues } from "../utils/map"; import { parsePluginSettings, @@ -83,32 +84,6 @@ const getCommonProperties = (groupMembers: Map>) => { return commonProperties; }; -const removeContradictions = ( - targets: Array, - classNames: Array, - whitespaces: Array, - 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 = []; - for (const [index, className] of classNames.entries()) { - if (!targets.includes(className)) { - const spacer = - validatedClasses.length === 0 ? "" : (spaces[index - 1] ?? " "); - validatedClasses.push(spacer + className); - } - } - if (validatedClasses.length === 0) return ""; - return head + validatedClasses.join("") + tail; -}; - const getContradictions = ( context: RuleContext, settings: PluginSettings, @@ -164,13 +139,13 @@ const getContradictions = ( const otherClassnamesFormatted = otherClassnames .map((cn) => `'${cn}'`) .join(", "); - let patchedValue = removeContradictions( - otherClassnames, + let patchedValue = joiner({ classNames, whitespaces, headSpace, tailSpace, - ); + validator: (cls) => !otherClassnames.includes(cls), + }); patchedValue = prefix + patchedValue + suffix; const patchedLoc = generateLocForClassname( node, @@ -221,7 +196,6 @@ export const noContradictingClassname = createRule({ "issue:contradiction": `'{{classname}}' conflicts with {{otherClassnames}}.`, "fix:contradiction:keep": `Keep '{{keepClassname}}' (remove {{removeClassnames}}).`, }, - // fixable: "code", // Schema is also parsed by `eslint-doc-generator` schema: [ { diff --git a/src/rules/no-custom-classname.ts b/src/rules/no-custom-classname.ts index 22768fd..599a007 100644 --- a/src/rules/no-custom-classname.ts +++ b/src/rules/no-custom-classname.ts @@ -7,6 +7,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, @@ -52,33 +53,6 @@ const isWhitelisted = (className: string, whitelist: Set): boolean => { return [...whitelist].some((pattern) => passRegexTest(pattern, className)); }; -const removeClassname = ( - invalidClassName: string, - classNames: Array, - whitespaces: Array, - 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 = []; - for (const [index, className] of classNames.entries()) { - if (className !== invalidClassName) { - const spacer = - validatedClasses.length === 0 ? "" : (spaces[index - 1] ?? " "); - validatedClasses.push(spacer + className); - } - } - - if (validatedClasses.length === 0) return ""; - return head + validatedClasses.join("") + tail; -}; - const detectCustomClassnames = ( context: RuleContext, settings: PluginSettings, @@ -99,25 +73,25 @@ const detectCustomClassnames = ( // Process the extracted classnames and report const { classNames, whitespaces, headSpace, tailSpace } = getClassnamesFromValue(originalClassNamesValue); - for (const cls of classNames) { - if (isWhitelisted(cls, mergedWhitelist)) continue; - if (isValidClassNameWorker(settings.cssConfigPath, cls)) continue; + for (const customClass of classNames) { + if (isWhitelisted(customClass, mergedWhitelist)) continue; + if (isValidClassNameWorker(settings.cssConfigPath, customClass)) continue; // Generates the "cleaned" attribute value - let patchedValue = removeClassname( - cls, + let patchedValue = joiner({ classNames, whitespaces, headSpace, tailSpace, - ); + validator: (candidate) => candidate !== customClass, + }); const patchedLoc = generateLocForClassname( node, - cls, + customClass, originalClassNamesValue, genericContext, ); - const range = getRange(node, cls, originalClassNamesValue); + const range = getRange(node, customClass, originalClassNamesValue); patchedValue = prefix + patchedValue + suffix; if (originalClassNamesValue === patchedValue) { @@ -127,7 +101,7 @@ const detectCustomClassnames = ( loc: patchedLoc, messageId: "issue:unknown-classname", data: { - classname: cls, + classname: customClass, }, suggest: range[0] && range[1] @@ -135,7 +109,7 @@ const detectCustomClassnames = ( { messageId: "fix:unknown-classname:remove", data: { - classname: cls, + classname: customClass, }, fix: (fixer) => fixer.replaceTextRange([start, end], patchedValue), diff --git a/src/utils/joiner.spec.ts b/src/utils/joiner.spec.ts new file mode 100644 index 0000000..57a8834 --- /dev/null +++ b/src/utils/joiner.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "vitest"; + +import { joiner } from "./joiner"; + +test(`joiner() with head and tail spaces`, () => { + const classNames = ["a", "x", "b"]; + const whitespaces = ["\n ", " ", " ", "\n "]; + expect( + joiner({ + classNames, + whitespaces, + headSpace: true, + tailSpace: true, + }), + ).toBe("\n a x b\n "); + + expect( + joiner({ + classNames, + whitespaces, + headSpace: true, + tailSpace: true, + validator: (cls) => cls !== "x", + }), + ).toBe("\n a b\n "); +}); + +test(`joiner() without head nor tail spaces`, () => { + const classNames = ["a", "x", "b"]; + const whitespaces = [" ", " "]; + expect( + joiner({ + classNames, + whitespaces, + headSpace: false, + tailSpace: false, + }), + ).toBe("a x b"); + + expect( + joiner({ + classNames, + whitespaces, + headSpace: false, + tailSpace: false, + validator: (cls) => cls !== "x", + }), + ).toBe("a b"); +}); diff --git a/src/utils/joiner.ts b/src/utils/joiner.ts new file mode 100644 index 0000000..4b0a217 --- /dev/null +++ b/src/utils/joiner.ts @@ -0,0 +1,35 @@ +type JoinerOptions = { + classNames: Array; + whitespaces: Array; + headSpace: boolean; + tailSpace: boolean; + validator?: (candidate: string) => boolean; +}; +/** + * Helper function to join classnames with whitespaces, and preserve head/tail spaces if needed. + */ +export const joiner = ({ + classNames, + whitespaces, + headSpace, + tailSpace, + validator = () => true, +}: JoinerOptions) => { + // 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 = []; + for (const [index, className] of classNames.entries()) { + if (!validator(className)) continue; + const spacer = + validatedClasses.length === 0 ? "" : (spaces[index - 1] ?? " "); + validatedClasses.push(spacer + className); + } + + if (validatedClasses.length === 0) return ""; + return head + validatedClasses.join("") + tail; +}; From e8474deb6600e61e168128e4b8b1b350111a193a Mon Sep 17 00:00:00 2001 From: francoismassart Date: Thu, 23 Apr 2026 14:55:49 +0200 Subject: [PATCH 3/4] test: padding config --- .../tailwindcss-api/worker/load-theme.spec.ts | 37 +++++++++++++++++++ tests/stubs/css/padding.css | 11 ++++++ 2 files changed, 48 insertions(+) create mode 100644 tests/stubs/css/padding.css diff --git a/src/utils/tailwindcss-api/worker/load-theme.spec.ts b/src/utils/tailwindcss-api/worker/load-theme.spec.ts index 62d5d1a..4526491 100644 --- a/src/utils/tailwindcss-api/worker/load-theme.spec.ts +++ b/src/utils/tailwindcss-api/worker/load-theme.spec.ts @@ -51,6 +51,43 @@ test(`load theme from "normal.css"`, () => { expect(counter).toBe(0); }); +test(`load theme from "padding.css"`, () => { + const path = require.resolve("../../../../tests/stubs/css/padding.css"); + const theme = loadThemeWorker(path); + // But no way to determine predefined widths + // the Tailwind CSS v4 engine will build from discovered classnames usage + let counter = 0; + for (const [key, value] of theme.values) { + if (key.startsWith("--font-")) continue; + if (key.startsWith("--color-")) continue; + if (key.startsWith("--spacing-")) continue; + if (key.startsWith("--breakpoint-")) continue; + if (key.startsWith("--container-")) continue; + if (key.startsWith("--text-")) continue; + if (key.startsWith("--spacing")) continue; + if (key.startsWith("--tracking-")) continue; + if (key.startsWith("--animate-")) continue; + if (key.startsWith("--leading-")) continue; + if (key.startsWith("--radius-")) continue; + if (key.startsWith("--shadow-")) continue; + if (key.startsWith("--inset-")) continue; + if (key.startsWith("--drop-shadow-")) continue; + if (key.startsWith("--ease-")) continue; + if (key.startsWith("--blur-")) continue; + if (key.startsWith("--perspective-")) continue; + if (key.startsWith("--aspect-")) continue; + if (key.startsWith("--default-")) continue; + if (key.startsWith("--blur")) continue; + if (key.startsWith("--shadow")) continue; + if (key.startsWith("--drop-shadow")) continue; + if (key.startsWith("--radius")) continue; + if (key.startsWith("--max-width-prose")) continue; + console.log(key, value); + counter++; + } + expect(counter).toBe(3); +}); + // At this moment, no possibility to read the custom dark variant from the config /*/ test(`load theme from "custom-dark.css"`, () => { diff --git a/tests/stubs/css/padding.css b/tests/stubs/css/padding.css new file mode 100644 index 0000000..10036b4 --- /dev/null +++ b/tests/stubs/css/padding.css @@ -0,0 +1,11 @@ +@import "tailwindcss"; + +@theme { + /* On réinitialise l'échelle d'espacement globale pour le padding */ + --padding-*: initial; + + /* On définit les seules valeurs autorisées */ + --padding-small: 0.5rem; /* génère p-small */ + --padding-medium: 1rem; /* génère p-medium */ + --padding-large: 2rem; /* génère p-large */ +} From 4d2e4e81da246c86a170c279c05e56c50e276a02 Mon Sep 17 00:00:00 2001 From: francoismassart Date: Fri, 24 Apr 2026 20:43:37 +0200 Subject: [PATCH 4/4] WIP --- README.md | 1 + ROADMAP.md | 12 + docs/rules/enforces-shorthand.md | 13 ++ src/index.ts | 6 + src/rules/enforces-shorthand.spec.ts | 129 +++++++++++ src/rules/enforces-shorthand.ts | 316 +++++++++++++++++++++++++++ 6 files changed, 477 insertions(+) create mode 100644 docs/rules/enforces-shorthand.md create mode 100644 src/rules/enforces-shorthand.spec.ts create mode 100644 src/rules/enforces-shorthand.ts diff --git a/README.md b/README.md index 11d9c04..198adf5 100644 --- a/README.md +++ b/README.md @@ -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. | | ✅ | | 💡 | diff --git a/ROADMAP.md b/ROADMAP.md index 7e6a88f..6fbe406 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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` diff --git a/docs/rules/enforces-shorthand.md b/docs/rules/enforces-shorthand.md new file mode 100644 index 0000000..37a76d2 --- /dev/null +++ b/docs/rules/enforces-shorthand.md @@ -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). + + + +## Options + + + + diff --git a/src/index.ts b/src/index.ts index a899d0c..a109f56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -37,6 +41,7 @@ const plugin = { }, rules: { [CLASSNAMES_ORDER]: classnamesOrder, + [ENFORCES_SHORTHAND]: enforcesShorthand, [NO_CUSTOM_CLASSNAME]: noCustomClassname, [NO_CONTRADICTING_CLASSNAME]: noContradictingClassname, }, @@ -44,6 +49,7 @@ const plugin = { const recommended = { [CLASSNAMES_ORDER]: "warn", + [ENFORCES_SHORTHAND]: "warn", [NO_CUSTOM_CLASSNAME]: "warn", [NO_CONTRADICTING_CLASSNAME]: "error", } as const; diff --git a/src/rules/enforces-shorthand.spec.ts b/src/rules/enforces-shorthand.spec.ts new file mode 100644 index 0000000..1cb4459 --- /dev/null +++ b/src/rules/enforces-shorthand.spec.ts @@ -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, + shorthand: string, +): TestCaseError => { + 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 + `

valid

`, + `

modifiers

`, + ].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: `

margin

`, + errors: [generateError(["md:-mx-10", "md:-my-10"], "md:-m-10")], + output: `

margin

`, + languageOptions: withAngularParser, + }, + { + code: `

padding

`, + errors: [generateError(["md:pl-10", "md:pr-10"], "md:px-10")], + output: `

padding

`, + 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")`], + }, + ], +}); diff --git a/src/rules/enforces-shorthand.ts b/src/rules/enforces-shorthand.ts new file mode 100644 index 0000000..82775c8 --- /dev/null +++ b/src/rules/enforces-shorthand.ts @@ -0,0 +1,316 @@ +/** + * @fileoverview Avoid using multiple Tailwind CSS classnames when not required + * @example "mx-3 my-3" could be replaced by "m-3" + * @author François Massart + */ + +import { TSESTree } from "@typescript-eslint/utils"; +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, +} from "../utils/parse-plugin-settings"; +import { groupByModifiersPrefix } from "../utils/parser/groups"; +import { + dissectAtomicNode, + getClassnamesFromValue, +} from "../utils/parser/node"; +import { defineVisitors, GenericRuleContext } from "../utils/parser/visitors"; +import { + AtomicNode, + createScriptVisitors, + createTemplateVisitors, +} from "../utils/rule"; + +export { ESLintUtils } from "@typescript-eslint/utils"; + +export const RULE_NAME = "enforces-shorthand"; + +// Message IDs don't need to be prefixed, I just find it easier to keep track of them this way +export type MessageIds = "fix:use-shorthand"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type RuleOptions = {}; + +type Options = [RuleOptions]; + +type RuleContext = TSESLintRuleContext; + +// The Rule creator returns a function that is used to create a well-typed ESLint rule +// The parameter passed into RuleCreator is a URL generator function. +export const createRule = RuleCreator(urlCreator); + +const detectShorthand = ({ + classNames, + pattern, + strategies, +}: { + classNames: Array; + pattern: RegExp; + strategies: Map>; +}) => { + const shorthands = new Map>(); + const candidates = new Map>(); + + // Filter related classes + const targets = classNames.filter((cls) => pattern.test(cls)); + + // Escape hatch + if (targets.length <= 1) return shorthands; + + // Grouped by value + for (const cls of targets) { + const match = cls.match(pattern); + if (match && match.groups) { + const { prefix, value } = match.groups; + if (!prefix) continue; + // `value` group can be omitted for some shorthands + // like for `truncate` (which replaces `overflow-hidden` + `text-ellipsis` + `whitespace-nowrap`) + const valueKey = value || ""; + if (!candidates.has(valueKey)) candidates.set(valueKey, []); + candidates.get(valueKey)?.push(prefix); + } + } + + // Check each value group for potential shorthands + for (const [value, prefixes] of candidates.entries()) { + // Escape hatch + if (prefixes.length <= 1) continue; + // Handle potential negative values + const negativeCandidates = prefixes.filter((p) => p.startsWith("-")); + const positiveCandidates = prefixes.filter((p) => !p.startsWith("-")); + for (const [index, candidatePrefixes] of [ + negativeCandidates, + positiveCandidates, + ].entries()) { + const n = index === 0 ? "-" : ""; + for (const [shorthand, parts] of strategies.entries()) { + if (parts.every((part) => candidatePrefixes.includes(`${n}${part}`))) { + const valueSuffix = value ? `-${value}` : ""; + shorthands.set( + `${n}${shorthand}${valueSuffix}`, + parts.map((part) => `${n}${part}${valueSuffix}`), + ); + } + } + } + } + + // shorthands → Map(1) { '-my-10' => [ '-mt-10', '-mb-10' ] } + return shorthands; +}; + +const detectOverflowShorthand = (classNames: Array) => { + const pattern = + /^(?overflow-(x|y))-(?auto|hidden|clip|visible|scroll)$/; + const strategies = new Map>(); + strategies.set("overflow", ["overflow-x", "overflow-y"]); + return detectShorthand({ classNames, pattern, strategies }); +}; + +const detectOverscrollShorthand = (classNames: Array) => { + const pattern = /^(?overscroll-(x|y))-(?auto|contain|none)$/; + const strategies = new Map>(); + strategies.set("overscroll", ["overscroll-x", "overscroll-y"]); + return detectShorthand({ classNames, pattern, strategies }); +}; + +const detectTopRightBottomLeftShorthand = (classNames: Array) => { + const pattern = + /^(?-?(inset(-(x|y))?|top|right|bottom|left))-(?.+)$/; + const strategies = new Map>(); + strategies.set("inset-x", ["right", "left"]); + strategies.set("inset-y", ["top", "bottom"]); + strategies.set("inset", ["inset-x", "inset-y"]); + return detectShorthand({ classNames, pattern, strategies }); +}; + +const detectGapShorthand = (classNames: Array) => { + const pattern = /^(?(gap(-(x|y))?))-(?.+)$/; + const strategies = new Map>(); + strategies.set("gap", ["gap-x", "gap-y"]); + return detectShorthand({ classNames, pattern, strategies }); +}; + +const detectPaddingShorthand = (classNames: Array) => { + const pattern = /^(?-?(?:p|px|py|pt|pb|pl|pr))-(?.+)$/; + const strategies = new Map>(); + strategies.set("px", ["pl", "pr"]); + strategies.set("py", ["pt", "pb"]); + strategies.set("p", ["px", "py"]); + return detectShorthand({ classNames, pattern, strategies }); +}; + +const detectMarginShorthand = (classNames: Array) => { + const pattern = /^(?-?(?:m|mx|my|mt|mb|ml|mr))-(?.+)$/; + const strategies = new Map>(); + strategies.set("mx", ["ml", "mr"]); + strategies.set("my", ["mt", "mb"]); + strategies.set("m", ["mx", "my"]); + return detectShorthand({ classNames, pattern, strategies }); +}; + +const detectSizeShorthand = (classNames: Array) => { + const pattern = /^(?-?(?:w|h))-(?.+)$/; + const strategies = new Map>(); + strategies.set("size", ["w", "h"]); + return detectShorthand({ classNames, pattern, strategies }); +}; + +const detectTruncateShorthand = (classNames: Array) => { + const pattern = + /^(?(?:overflow-hidden|text-ellipsis|whitespace-nowrap))$/; + const strategies = new Map>(); + strategies.set("truncate", [ + "overflow-hidden", + "text-ellipsis", + "whitespace-nowrap", + ]); + return detectShorthand({ classNames, pattern, strategies }); +}; + +const replaceByShorthands = ( + context: RuleContext, + settings: PluginSettings, + options: RuleOptions, + literals: Array, +) => { + // console.log(options); + for (const node of literals) { + const { originalClassNamesValue, start, end, prefix, suffix } = + dissectAtomicNode(node, context as unknown as GenericRuleContext); + // Process the extracted classnames and report + const { classNames, whitespaces, headSpace, tailSpace } = + getClassnamesFromValue(originalClassNamesValue); + // Skip empty/Single className + if (classNames.length <= 1) continue; + + // Group by modifier + const groups = groupByModifiersPrefix(classNames); + + for (const [modifiers, baseCls] of groups.entries()) { + if (baseCls.length <= 1) continue; + + const overflowClasses = detectOverflowShorthand(baseCls); + const overscrollClasses = detectOverscrollShorthand(baseCls); + const topRightBottomLeftClasses = + detectTopRightBottomLeftShorthand(baseCls); + const gapClasses = detectGapShorthand(baseCls); + const paddingClasses = detectPaddingShorthand(baseCls); + const marginClasses = detectMarginShorthand(baseCls); + const sizeClasses = detectSizeShorthand(baseCls); + // TODO https://tailwindcss.com/docs/font-size vs https://tailwindcss.com/docs/line-height combo + const truncateClasses = detectTruncateShorthand(baseCls); + // rounded s e t p b l ss se ee es tl tr br bl https://tailwindcss.com/docs/border-radius + // border x y s e bs be t r b l https://tailwindcss.com/docs/border-width + // border color x y s e bs be t r b l + // border-spacing-x y + // scale x y z => scale scale-3d + // skew x y + // translate x y z => translate translate-3d + // scroll-m trbl x y... + // scroll-p... + + const allShorthands = new Map>([ + ...overflowClasses.entries(), + ...overscrollClasses.entries(), + ...topRightBottomLeftClasses.entries(), + ...gapClasses.entries(), + ...paddingClasses.entries(), + ...marginClasses.entries(), + ...sizeClasses.entries(), + ...truncateClasses.entries(), + ]); + + for (const [shorthand, obsoleteClasses] of allShorthands.entries()) { + const fullObsoleteClasses = new Set( + obsoleteClasses.map((cls) => `${modifiers}${cls}`), + ); + + const parsedClassNames = classNames.filter( + (cls) => !fullObsoleteClasses.has(cls), + ); + parsedClassNames.push(modifiers + shorthand); + + // Generates the validated/sorted attribute value + let validatedClassNamesValue = joiner({ + classNames: parsedClassNames, + whitespaces, + headSpace, + tailSpace, + }); + + if (originalClassNamesValue !== validatedClassNamesValue) { + // console.log("originalClassNamesValue:", [originalClassNamesValue]); + // console.log("validatedClassNamesValue:", [validatedClassNamesValue]); + validatedClassNamesValue = prefix + validatedClassNamesValue + suffix; + context.report({ + node: node as TSESTree.Node, + messageId: "fix:use-shorthand", + data: { + classnames: obsoleteClasses + .map((cls) => `'${modifiers + cls}'`) + .join(", "), + shorthand: `${modifiers}${shorthand}`, + }, + fix: function (fixer) { + return fixer.replaceTextRange( + [start, end], + validatedClassNamesValue, + ); + }, + }); + } + } + } + } +}; + +export const enforcesShorthand = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: + "Avoid using multiple Tailwind CSS classnames when not required.", + }, + hasSuggestions: false, + messages: { + "fix:use-shorthand": `Classnames {{classnames}} could be replaced by the '{{shorthand}}' shorthand!`, + }, + fixable: "code", + // Schema is also parsed by `eslint-doc-generator` + schema: [ + { + type: "object", + properties: {}, + additionalProperties: false, + }, + ], + defaultOptions: [{}], + type: "suggestion", + }, + /** + * About `defaultOptions`: + * - `defaultOptions` is not parsed to generate the documentation + * - `defaultOptions` is used when options are NOT provided in the rules configuration + * - If some configuration is provided as the second argument, `defaultOptions` is ignored completely (not merged) + * - In other words, the `defaultOptions` is only used when the rule is used WITHOUT any configuration + */ + defaultOptions: [{}], + create: (context, options) => { + // Merged settings + const settings = parsePluginSettings(context.settings); + + return defineVisitors( + context as unknown as Readonly, + // Template visitor is only used within Vue SFC files (inside