Skip to content

Commit d9e18b2

Browse files
feat: no-arbitrary-value (#450)
1 parent a7b9f0f commit d9e18b2

5 files changed

Lines changed: 242 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
| [classnames-order](docs/rules/classnames-order.md) | Enforces a consistent order for the Tailwind CSS classnames, based on the compiler. | || 🔧 | |
1818
| [enforces-negative-arbitrary-values](docs/rules/enforces-negative-arbitrary-values.md) | Warns about `-` prefixed classnames using arbitrary values. | || 🔧 | |
1919
| [enforces-shorthand](docs/rules/enforces-shorthand.md) | Avoid using multiple Tailwind CSS classnames when not required. | || 🔧 | |
20+
| [no-arbitrary-value](docs/rules/no-arbitrary-value.md) | Forbid using arbitrary values in classnames. | | | | |
2021
| [no-contradicting-classname](docs/rules/no-contradicting-classname.md) | Avoid contradicting Tailwind CSS classnames. || | | 💡 |
2122
| [no-custom-classname](docs/rules/no-custom-classname.md) | Detects classnames which do not belong to Tailwind CSS. | || | 💡 |
2223

docs/rules/no-arbitrary-value.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Forbid using arbitrary values in classnames
2+
3+
🚫 This rule is _disabled_ in the ✅ `recommended` config.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
This rule will shout at you if you use any arbitrary value in your classnames.
8+
9+
Only enable this rule if you want to stricly stick with your TailwindCSS config's presets.
10+
11+
It will not complain if you are using classnames like `border-<number>`.
12+
13+
## Rule Details
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```html
18+
<div class="w-[20rem]">Custom width</div>
19+
```
20+
21+
Examples of **correct** code for this rule:
22+
23+
```html
24+
<div class="w-custom-preset">Custom width</div>
25+
```
26+
27+
with a config such as:
28+
29+
```css
30+
@import "tailwindcss";
31+
32+
@theme {
33+
--width-custom-preset: 20rem;
34+
}
35+
```
36+
37+
## Options
38+
39+
<!-- begin auto-generated rule options list -->
40+
41+
<!-- end auto-generated rule options list -->
42+
43+
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
@@ -13,6 +13,10 @@ import {
1313
enforcesShorthand,
1414
RULE_NAME as ENFORCES_SHORTHAND,
1515
} from "./rules/enforces-shorthand";
16+
import {
17+
noArbitraryValue,
18+
RULE_NAME as NO_ARBITRARY_VALUE,
19+
} from "./rules/no-arbitrary-value";
1620
import {
1721
noContradictingClassname,
1822
RULE_NAME as NO_CONTRADICTING_CLASSNAME,
@@ -47,6 +51,7 @@ const plugin = {
4751
[CLASSNAMES_ORDER]: classnamesOrder,
4852
[ENFORCES_NEGATIVE_ARBITRARY_VALUES]: enforcesNegativeArbitraryValues,
4953
[ENFORCES_SHORTHAND]: enforcesShorthand,
54+
[NO_ARBITRARY_VALUE]: noArbitraryValue,
5055
[NO_CUSTOM_CLASSNAME]: noCustomClassname,
5156
[NO_CONTRADICTING_CLASSNAME]: noContradictingClassname,
5257
},
@@ -56,6 +61,7 @@ const recommended = {
5661
[CLASSNAMES_ORDER]: "warn",
5762
[ENFORCES_NEGATIVE_ARBITRARY_VALUES]: "warn",
5863
[ENFORCES_SHORTHAND]: "warn",
64+
[NO_ARBITRARY_VALUE]: "off",
5965
[NO_CUSTOM_CLASSNAME]: "warn",
6066
[NO_CONTRADICTING_CLASSNAME]: "error",
6167
} as const;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import * as Parser from "@typescript-eslint/parser";
2+
import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester";
3+
4+
import { generalSettings } from "../utils/parser/test-helpers";
5+
import {
6+
type MessageIds,
7+
noArbitraryValue,
8+
RULE_NAME,
9+
} from "./no-arbitrary-value";
10+
11+
const generateError = (className: string): TestCaseError<MessageIds> => {
12+
return {
13+
messageId: "issue:arbitrary-value",
14+
data: { className },
15+
};
16+
};
17+
18+
const ruleTester = new RuleTester({
19+
languageOptions: {
20+
parser: Parser,
21+
parserOptions: {
22+
ecmaFeatures: {
23+
jsx: true,
24+
},
25+
},
26+
},
27+
settings: {
28+
tailwindcss: {
29+
...generalSettings,
30+
},
31+
},
32+
});
33+
34+
ruleTester.run(RULE_NAME, noArbitraryValue, {
35+
valid:
36+
// JSX
37+
["ctl('m-0')"].map((testedJsxCode) => ({
38+
code: testedJsxCode,
39+
})),
40+
invalid: [
41+
...[
42+
{
43+
code: `ctl('dark:m-[10px]')`,
44+
invalidClass: "dark:m-[10px]",
45+
},
46+
].map(({ code, invalidClass }) => ({
47+
code: code,
48+
errors: [generateError(invalidClass)],
49+
})),
50+
...[
51+
{
52+
code: `
53+
ctl(\`
54+
lg:pt-[3px]
55+
dark:-m-[-123px]
56+
-m-[6px]
57+
\`)`,
58+
invalidClasses: ["lg:pt-[3px]", "dark:-m-[-123px]", "-m-[6px]"],
59+
},
60+
].map(({ code, invalidClasses }) => ({
61+
code: code,
62+
errors: invalidClasses.map((invalidClass) => generateError(invalidClass)),
63+
})),
64+
],
65+
});

src/rules/no-arbitrary-value.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* @fileoverview Forbid using arbitrary values in classnames.
3+
* @author François Massart
4+
*/
5+
6+
import { RuleCreator } from "@typescript-eslint/utils/eslint-utils";
7+
import { RuleContext as TSESLintRuleContext } from "@typescript-eslint/utils/ts-eslint";
8+
9+
import urlCreator from "../url-creator";
10+
import {
11+
parsePluginSettings,
12+
PluginSettings,
13+
} from "../utils/parse-plugin-settings";
14+
import { getBaseClassname } from "../utils/parser/classname";
15+
import {
16+
dissectAtomicNode,
17+
generateLocForClassname,
18+
getClassnamesFromValue,
19+
} from "../utils/parser/node";
20+
import { defineVisitors, GenericRuleContext } from "../utils/parser/visitors";
21+
import {
22+
AtomicNode,
23+
createScriptVisitors,
24+
createTemplateVisitors,
25+
} from "../utils/rule";
26+
27+
export { ESLintUtils } from "@typescript-eslint/utils";
28+
29+
export const RULE_NAME = "no-arbitrary-value";
30+
31+
// Message IDs don't need to be prefixed, I just find it easier to keep track of them this way
32+
export type MessageIds = "issue:arbitrary-value";
33+
34+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
35+
export type RuleOptions = {};
36+
37+
type Options = [RuleOptions];
38+
39+
type RuleContext = TSESLintRuleContext<MessageIds, Options>;
40+
41+
// The Rule creator returns a function that is used to create a well-typed ESLint rule
42+
// The parameter passed into RuleCreator is a URL generator function.
43+
export const createRule = RuleCreator(urlCreator);
44+
45+
// Matches classnames that contain arbitrary values (e.g., m-[5px])
46+
const regexPattern = /^[-a-z]+-\[.*\]$/;
47+
48+
const arbitraryClassnames = (
49+
context: RuleContext,
50+
settings: PluginSettings,
51+
options: RuleOptions,
52+
literals: Array<AtomicNode>,
53+
) => {
54+
// console.log(options);
55+
const genericContext = context as unknown as GenericRuleContext;
56+
for (const node of literals) {
57+
const { originalClassNamesValue } = dissectAtomicNode(node, genericContext);
58+
// Process the extracted classnames and report
59+
const { classNames } = getClassnamesFromValue(originalClassNamesValue);
60+
for (const targetClassName of classNames) {
61+
const baseClass = getBaseClassname(targetClassName);
62+
const match = regexPattern.test(baseClass);
63+
64+
if (!match) continue;
65+
66+
const patchedLoc = generateLocForClassname(
67+
node,
68+
targetClassName,
69+
originalClassNamesValue,
70+
genericContext,
71+
);
72+
73+
context.report({
74+
loc: patchedLoc,
75+
messageId: "issue:arbitrary-value",
76+
data: {
77+
className: targetClassName,
78+
},
79+
});
80+
}
81+
}
82+
};
83+
84+
export const noArbitraryValue = createRule<Options, MessageIds>({
85+
name: RULE_NAME,
86+
meta: {
87+
docs: {
88+
description: "Forbid using arbitrary values in classnames.",
89+
},
90+
hasSuggestions: false,
91+
messages: {
92+
"issue:arbitrary-value": "Arbitrary value detected in '{{className}}'",
93+
},
94+
// Schema is also parsed by `eslint-doc-generator`
95+
schema: [
96+
{
97+
type: "object",
98+
properties: {},
99+
additionalProperties: false,
100+
},
101+
],
102+
defaultOptions: [{}],
103+
type: "problem",
104+
},
105+
/**
106+
* About `defaultOptions`:
107+
* - `defaultOptions` is not parsed to generate the documentation
108+
* - `defaultOptions` is used when options are NOT provided in the rules configuration
109+
* - If some configuration is provided as the second argument, `defaultOptions` is ignored completely (not merged)
110+
* - In other words, the `defaultOptions` is only used when the rule is used WITHOUT any configuration
111+
*/
112+
defaultOptions: [{}],
113+
create: (context, options) => {
114+
// Merged settings
115+
const settings = parsePluginSettings(context.settings);
116+
117+
return defineVisitors(
118+
context as unknown as Readonly<GenericRuleContext>,
119+
// Template visitor is only used within Vue SFC files (inside <template> section).
120+
createTemplateVisitors(context, settings, options, arbitraryClassnames),
121+
// Script visitor is used within both JSX and Vue SFC files (inside <script> section).
122+
createScriptVisitors(context, settings, options, arbitraryClassnames),
123+
);
124+
},
125+
});
126+
127+
// TODO: option or new rule to disallow number values like `border-1` in favor of `border-preset1`

0 commit comments

Comments
 (0)