Skip to content

Commit c2b9cd6

Browse files
WIP
1 parent 4594b66 commit c2b9cd6

7 files changed

Lines changed: 1123 additions & 2 deletions

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import {
2525
noCustomClassname,
2626
RULE_NAME as NO_CUSTOM_CLASSNAME,
2727
} from "./rules/no-custom-classname";
28+
import {
29+
noUnnecessaryArbitraryValue,
30+
RULE_NAME as NO_UNNECESSARY_ARBITRARY_VALUE,
31+
} from "./rules/no-unnecessary-arbitrary-value";
2832

2933
const createConfig = <R extends Linter.RulesRecord>(rules: R) => {
3034
const result = {} as {
@@ -54,6 +58,7 @@ const plugin = {
5458
[NO_ARBITRARY_VALUE]: noArbitraryValue,
5559
[NO_CUSTOM_CLASSNAME]: noCustomClassname,
5660
[NO_CONTRADICTING_CLASSNAME]: noContradictingClassname,
61+
[NO_UNNECESSARY_ARBITRARY_VALUE]: noUnnecessaryArbitraryValue,
5762
},
5863
} satisfies FlatConfig.Plugin;
5964

@@ -64,6 +69,7 @@ const recommended = {
6469
[NO_ARBITRARY_VALUE]: "off",
6570
[NO_CUSTOM_CLASSNAME]: "warn",
6671
[NO_CONTRADICTING_CLASSNAME]: "error",
72+
[NO_UNNECESSARY_ARBITRARY_VALUE]: "warn",
6773
} as const;
6874

6975
const configBase: FlatConfig.Config = {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as Parser from "@typescript-eslint/parser";
2+
import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester";
3+
4+
import { withAllPresetsSettings } from "../utils/parser/test-helpers";
5+
import {
6+
type MessageIds,
7+
noUnnecessaryArbitraryValue,
8+
RULE_NAME,
9+
} from "./no-unnecessary-arbitrary-value";
10+
11+
const generateError = (
12+
oldClassName: string,
13+
newClassName: string,
14+
): TestCaseError<MessageIds> => {
15+
return {
16+
messageId: "fix:unnecessary-arbitrary",
17+
data: { oldClassName: oldClassName, newClassName: newClassName },
18+
};
19+
};
20+
21+
const ruleTester = new RuleTester({
22+
languageOptions: {
23+
parser: Parser,
24+
parserOptions: {
25+
ecmaFeatures: {
26+
jsx: true,
27+
},
28+
},
29+
},
30+
settings: {
31+
tailwindcss: {
32+
...withAllPresetsSettings,
33+
},
34+
},
35+
});
36+
37+
ruleTester.run(RULE_NAME, noUnnecessaryArbitraryValue, {
38+
valid:
39+
// JSX
40+
["ctl('m-[1.5px]')"].map((testedJsxCode) => ({
41+
code: testedJsxCode,
42+
settings: { tailwindcss: withAllPresetsSettings },
43+
})),
44+
invalid: [
45+
...[
46+
{
47+
code: `ctl('aspect-[4/3]')`,
48+
invalidClass: "aspect-[4/3]",
49+
replacementClass: "aspect-retro",
50+
},
51+
].map(({ code, invalidClass, replacementClass }) => ({
52+
code: code,
53+
settings: { tailwindcss: withAllPresetsSettings },
54+
errors: [generateError(invalidClass, replacementClass)],
55+
})),
56+
...[
57+
{
58+
code: `
59+
ctl(\`
60+
lg:columns-side-menu
61+
aspect-auto
62+
-top-[3.14px]
63+
\`)`,
64+
invalidClasses: ["lg:columns-[16rem]", "-top-[3.14px]"],
65+
replacementClasses: ["lg:columns-side-menu", "-top-pi"],
66+
},
67+
].map(({ code, invalidClasses, replacementClasses }) => ({
68+
code: code,
69+
settings: { tailwindcss: withAllPresetsSettings },
70+
errors: invalidClasses.map((invalidClass, index) =>
71+
generateError(invalidClass, replacementClasses[index]),
72+
),
73+
})),
74+
],
75+
});
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* @fileoverview Avoid unjustified arbitrary 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 { joiner } from "../utils/joiner";
11+
import {
12+
parsePluginSettings,
13+
PluginSettings,
14+
} from "../utils/parse-plugin-settings";
15+
import {
16+
getBaseClassname,
17+
getModifiersPrefix,
18+
} from "../utils/parser/classname";
19+
import {
20+
dissectAtomicNode,
21+
generateLocForClassname,
22+
getClassnamesFromValue,
23+
} from "../utils/parser/node";
24+
import { withAllPresetsSettings } from "../utils/parser/test-helpers";
25+
import { defineVisitors, GenericRuleContext } from "../utils/parser/visitors";
26+
import {
27+
AtomicNode,
28+
createScriptVisitors,
29+
createTemplateVisitors,
30+
} from "../utils/rule";
31+
import { loadThemeWorker } from "../utils/tailwindcss-api";
32+
33+
export { ESLintUtils } from "@typescript-eslint/utils";
34+
35+
export const RULE_NAME = "no-unnecessary-arbitrary-value";
36+
37+
// Message IDs don't need to be prefixed, I just find it easier to keep track of them this way
38+
export type MessageIds = "fix:unnecessary-arbitrary";
39+
40+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
41+
export type RuleOptions = {};
42+
43+
type Options = [RuleOptions];
44+
45+
type RuleContext = TSESLintRuleContext<MessageIds, Options>;
46+
47+
// The Rule creator returns a function that is used to create a well-typed ESLint rule
48+
// The parameter passed into RuleCreator is a URL generator function.
49+
export const createRule = RuleCreator(urlCreator);
50+
51+
const arbitraryRegEx = /-\[(?<arbitraryValue>.*)\]$/;
52+
53+
const checkArbitraryClassnames = (
54+
context: RuleContext,
55+
settings: PluginSettings,
56+
options: RuleOptions,
57+
literals: Array<AtomicNode>,
58+
) => {
59+
// console.log(options);
60+
const genericContext = context as unknown as GenericRuleContext;
61+
for (const node of literals) {
62+
const { originalClassNamesValue, start, end, prefix, suffix } =
63+
dissectAtomicNode(node, genericContext);
64+
// Process the extracted classnames and report
65+
const { classNames, whitespaces, headSpace, tailSpace } =
66+
getClassnamesFromValue(originalClassNamesValue);
67+
for (const [index, targetClassName] of classNames.entries()) {
68+
const baseClass = getBaseClassname(targetClassName);
69+
const modifiers = getModifiersPrefix(targetClassName);
70+
const match = baseClass.match(arbitraryRegEx);
71+
72+
if (!match?.groups) continue;
73+
74+
// if arbitrary value matches a preset value, report it as an error
75+
// TODO: '4 / 3' preset should also be matched for '4/3' arbitrary value, and vice versa
76+
const theme = loadThemeWorker(withAllPresetsSettings.cssConfigPath);
77+
// Get the config prefix based on the classname (e.g. `aspect-[...]` => `--aspect-`)
78+
// Retrieves all the keys for the config prefix (e.g. `--aspect-` => `--aspect-auto`, `--aspect-square`, `--aspect-video`, etc.)
79+
// Filter the list of keys to keep only the ones that match the arbitrary value pattern (e.g. `aspect-video` matches the arbitrary value "16/9")
80+
// If several keys are found we use suggestions instead of a fix, otherwise we can directly fix the classname
81+
const preset = theme.values.get("--aspect-retro");
82+
console.log("preset", preset);
83+
84+
const patchedLoc = generateLocForClassname(
85+
node,
86+
targetClassName,
87+
originalClassNamesValue,
88+
genericContext,
89+
);
90+
91+
const { arbitraryValue = "" } = match.groups;
92+
const patchedClass = `${modifiers}xxx-[${arbitraryValue.toUpperCase()}]`;
93+
94+
// Patch the problematic classname
95+
classNames[index] = patchedClass;
96+
97+
// Generates the "cleaned" attribute value
98+
let patchedValue = joiner({
99+
classNames,
100+
whitespaces,
101+
headSpace,
102+
tailSpace,
103+
validator: (candidate) => candidate !== targetClassName,
104+
});
105+
patchedValue = prefix + patchedValue + suffix;
106+
107+
context.report({
108+
loc: patchedLoc,
109+
messageId: "fix:unnecessary-arbitrary",
110+
data: {
111+
oldClassName: targetClassName,
112+
newClassName: patchedClass,
113+
},
114+
fix: (fixer) => fixer.replaceTextRange([start, end], patchedValue),
115+
});
116+
}
117+
}
118+
};
119+
120+
export const noUnnecessaryArbitraryValue = createRule<Options, MessageIds>({
121+
name: RULE_NAME,
122+
meta: {
123+
docs: {
124+
description: "Avoid unjustified arbitrary classnames.",
125+
},
126+
hasSuggestions: false,
127+
messages: {
128+
"fix:unnecessary-arbitrary": `No need for arbitrary '{{oldClassName}}', use '{{newClassName}}' instead`,
129+
},
130+
fixable: "code",
131+
// Schema is also parsed by `eslint-doc-generator`
132+
schema: [
133+
{
134+
type: "object",
135+
properties: {},
136+
additionalProperties: false,
137+
},
138+
],
139+
defaultOptions: [{}],
140+
type: "problem",
141+
},
142+
/**
143+
* About `defaultOptions`:
144+
* - `defaultOptions` is not parsed to generate the documentation
145+
* - `defaultOptions` is used when options are NOT provided in the rules configuration
146+
* - If some configuration is provided as the second argument, `defaultOptions` is ignored completely (not merged)
147+
* - In other words, the `defaultOptions` is only used when the rule is used WITHOUT any configuration
148+
*/
149+
defaultOptions: [{}],
150+
create: (context, options) => {
151+
// Merged settings
152+
const settings = parsePluginSettings(context.settings);
153+
154+
return defineVisitors(
155+
context as unknown as Readonly<GenericRuleContext>,
156+
// Template visitor is only used within Vue SFC files (inside <template> section).
157+
createTemplateVisitors(
158+
context,
159+
settings,
160+
options,
161+
checkArbitraryClassnames,
162+
),
163+
// Script visitor is used within both JSX and Vue SFC files (inside <script> section).
164+
createScriptVisitors(
165+
context,
166+
settings,
167+
options,
168+
checkArbitraryClassnames,
169+
),
170+
);
171+
},
172+
});
173+
174+
// TODO new rule to check
175+
// --text-tiny: 0.625rem; /* text-tiny */
176+
// /* https://tailwindcss.com/docs/font-size#customizing-your-theme */
177+
// --text-tiny--line-height: 1.5rem; /* text-tiny-line-height */
178+
// --text-tiny--letter-spacing: 0.125rem; /* text-tiny-letter-spacing */
179+
// --text-tiny--font-weight: 500; /* text-tiny-font-weight */
180+
// for unnecessary line-height, letter-spacing and font-weight classnames

0 commit comments

Comments
 (0)