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/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/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