From 2511da29e797dc080e68e89ace35c4a7b8678b54 Mon Sep 17 00:00:00 2001 From: francoismassart Date: Tue, 19 May 2026 12:07:56 +0200 Subject: [PATCH] feat: no-arbitrary-value --- README.md | 1 + docs/rules/no-arbitrary-value.md | 43 +++++++++ src/index.ts | 6 ++ src/rules/no-arbitrary-value.spec.ts | 65 ++++++++++++++ src/rules/no-arbitrary-value.ts | 127 +++++++++++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 docs/rules/no-arbitrary-value.md create mode 100644 src/rules/no-arbitrary-value.spec.ts create mode 100644 src/rules/no-arbitrary-value.ts diff --git a/README.md b/README.md index dbafdb4..8211f67 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ | [classnames-order](docs/rules/classnames-order.md) | Enforces a consistent order for the Tailwind CSS classnames, based on the compiler. | | βœ… | πŸ”§ | | | [enforces-negative-arbitrary-values](docs/rules/enforces-negative-arbitrary-values.md) | Warns about `-` prefixed classnames using arbitrary values. | | βœ… | πŸ”§ | | | [enforces-shorthand](docs/rules/enforces-shorthand.md) | Avoid using multiple Tailwind CSS classnames when not required. | | βœ… | πŸ”§ | | +| [no-arbitrary-value](docs/rules/no-arbitrary-value.md) | Forbid using arbitrary values in classnames. | | | | | | [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/docs/rules/no-arbitrary-value.md b/docs/rules/no-arbitrary-value.md new file mode 100644 index 0000000..fbc7cf4 --- /dev/null +++ b/docs/rules/no-arbitrary-value.md @@ -0,0 +1,43 @@ +# Forbid using arbitrary values in classnames + +🚫 This rule is _disabled_ in the βœ… `recommended` config. + + + +This rule will shout at you if you use any arbitrary value in your classnames. + +Only enable this rule if you want to stricly stick with your TailwindCSS config's presets. + +It will not complain if you are using classnames like `border-`. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```html +
Custom width
+``` + +Examples of **correct** code for this rule: + +```html +
Custom width
+``` + +with a config such as: + +```css +@import "tailwindcss"; + +@theme { + --width-custom-preset: 20rem; +} +``` + +## Options + + + + + +There are no specific options for this rule, yet it uses the general [settings](../../README.md#settings). diff --git a/src/index.ts b/src/index.ts index 3a1018b..56c4d18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,10 @@ import { enforcesShorthand, RULE_NAME as ENFORCES_SHORTHAND, } from "./rules/enforces-shorthand"; +import { + noArbitraryValue, + RULE_NAME as NO_ARBITRARY_VALUE, +} from "./rules/no-arbitrary-value"; import { noContradictingClassname, RULE_NAME as NO_CONTRADICTING_CLASSNAME, @@ -47,6 +51,7 @@ const plugin = { [CLASSNAMES_ORDER]: classnamesOrder, [ENFORCES_NEGATIVE_ARBITRARY_VALUES]: enforcesNegativeArbitraryValues, [ENFORCES_SHORTHAND]: enforcesShorthand, + [NO_ARBITRARY_VALUE]: noArbitraryValue, [NO_CUSTOM_CLASSNAME]: noCustomClassname, [NO_CONTRADICTING_CLASSNAME]: noContradictingClassname, }, @@ -56,6 +61,7 @@ const recommended = { [CLASSNAMES_ORDER]: "warn", [ENFORCES_NEGATIVE_ARBITRARY_VALUES]: "warn", [ENFORCES_SHORTHAND]: "warn", + [NO_ARBITRARY_VALUE]: "off", [NO_CUSTOM_CLASSNAME]: "warn", [NO_CONTRADICTING_CLASSNAME]: "error", } as const; diff --git a/src/rules/no-arbitrary-value.spec.ts b/src/rules/no-arbitrary-value.spec.ts new file mode 100644 index 0000000..85e787a --- /dev/null +++ b/src/rules/no-arbitrary-value.spec.ts @@ -0,0 +1,65 @@ +import * as Parser from "@typescript-eslint/parser"; +import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester"; + +import { generalSettings } from "../utils/parser/test-helpers"; +import { + type MessageIds, + noArbitraryValue, + RULE_NAME, +} from "./no-arbitrary-value"; + +const generateError = (className: string): TestCaseError => { + return { + messageId: "issue:arbitrary-value", + data: { className }, + }; +}; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: Parser, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + settings: { + tailwindcss: { + ...generalSettings, + }, + }, +}); + +ruleTester.run(RULE_NAME, noArbitraryValue, { + valid: + // JSX + ["ctl('m-0')"].map((testedJsxCode) => ({ + code: testedJsxCode, + })), + invalid: [ + ...[ + { + code: `ctl('dark:m-[10px]')`, + invalidClass: "dark:m-[10px]", + }, + ].map(({ code, invalidClass }) => ({ + code: code, + errors: [generateError(invalidClass)], + })), + ...[ + { + code: ` + ctl(\` + lg:pt-[3px] + dark:-m-[-123px] + -m-[6px] + \`)`, + invalidClasses: ["lg:pt-[3px]", "dark:-m-[-123px]", "-m-[6px]"], + }, + ].map(({ code, invalidClasses }) => ({ + code: code, + errors: invalidClasses.map((invalidClass) => generateError(invalidClass)), + })), + ], +}); diff --git a/src/rules/no-arbitrary-value.ts b/src/rules/no-arbitrary-value.ts new file mode 100644 index 0000000..31d69a2 --- /dev/null +++ b/src/rules/no-arbitrary-value.ts @@ -0,0 +1,127 @@ +/** + * @fileoverview Forbid using arbitrary values in classnames. + * @author FranΓ§ois Massart + */ + +import { RuleCreator } from "@typescript-eslint/utils/eslint-utils"; +import { RuleContext as TSESLintRuleContext } from "@typescript-eslint/utils/ts-eslint"; + +import urlCreator from "../url-creator"; +import { + parsePluginSettings, + PluginSettings, +} from "../utils/parse-plugin-settings"; +import { getBaseClassname } from "../utils/parser/classname"; +import { + dissectAtomicNode, + generateLocForClassname, + 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 = "no-arbitrary-value"; + +// Message IDs don't need to be prefixed, I just find it easier to keep track of them this way +export type MessageIds = "issue:arbitrary-value"; + +// 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); + +// Matches classnames that contain arbitrary values (e.g., m-[5px]) +const regexPattern = /^[-a-z]+-\[.*\]$/; + +const arbitraryClassnames = ( + context: RuleContext, + settings: PluginSettings, + options: RuleOptions, + literals: Array, +) => { + // console.log(options); + const genericContext = context as unknown as GenericRuleContext; + for (const node of literals) { + const { originalClassNamesValue } = dissectAtomicNode(node, genericContext); + // Process the extracted classnames and report + const { classNames } = getClassnamesFromValue(originalClassNamesValue); + for (const targetClassName of classNames) { + const baseClass = getBaseClassname(targetClassName); + const match = regexPattern.test(baseClass); + + if (!match) continue; + + const patchedLoc = generateLocForClassname( + node, + targetClassName, + originalClassNamesValue, + genericContext, + ); + + context.report({ + loc: patchedLoc, + messageId: "issue:arbitrary-value", + data: { + className: targetClassName, + }, + }); + } + } +}; + +export const noArbitraryValue = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: "Forbid using arbitrary values in classnames.", + }, + hasSuggestions: false, + messages: { + "issue:arbitrary-value": "Arbitrary value detected in '{{className}}'", + }, + // Schema is also parsed by `eslint-doc-generator` + schema: [ + { + type: "object", + properties: {}, + additionalProperties: false, + }, + ], + defaultOptions: [{}], + type: "problem", + }, + /** + * 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