eslint-plugin-tailwind-canonical-classes 1.0.10 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -2
- package/lib/rules/tailwind-canonical-classes.js +193 -63
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
- 🔧 **Auto-fix Support**: Automatically fixes non-canonical classes using ESLint's auto-fix feature
|
|
29
29
|
- 🎯 **Tailwind CSS v4 Integration**: Uses Tailwind CSS v4's official `canonicalizeCandidates` API
|
|
30
30
|
- 📝 **Multiple Format Support**: Works with string literals, template literals, and JSX expressions
|
|
31
|
+
- 🛠️ **Utility Function Support**: Detects and fixes classes in utility functions like `cn()`, `clsx()`, `classNames()`, `twMerge()`, and `cva()`
|
|
31
32
|
- ⚡ **Zero Config**: Minimal configuration required to get started
|
|
32
33
|
|
|
33
34
|
## 📦 Installation
|
|
@@ -122,6 +123,7 @@ export default [
|
|
|
122
123
|
{
|
|
123
124
|
cssPath: './app/styles/globals.css', // Required
|
|
124
125
|
rootFontSize: 16, // Optional, default: 16
|
|
126
|
+
calleeFunctions: ['cn', 'clsx'], // Optional, default: ['cn', 'clsx', 'classNames', 'twMerge', 'cva']
|
|
125
127
|
},
|
|
126
128
|
],
|
|
127
129
|
},
|
|
@@ -140,6 +142,7 @@ module.exports = {
|
|
|
140
142
|
{
|
|
141
143
|
cssPath: './app/styles/globals.css',
|
|
142
144
|
rootFontSize: 16,
|
|
145
|
+
calleeFunctions: ['cn', 'clsx'],
|
|
143
146
|
},
|
|
144
147
|
],
|
|
145
148
|
},
|
|
@@ -175,6 +178,18 @@ rootFontSize: 16 // Default (16px = 1rem)
|
|
|
175
178
|
rootFontSize: 14 // If your root font size is 14px
|
|
176
179
|
```
|
|
177
180
|
|
|
181
|
+
### `calleeFunctions` (optional)
|
|
182
|
+
|
|
183
|
+
- **Type**: `string[]`
|
|
184
|
+
- **Default**: `['cn', 'clsx', 'classNames', 'twMerge', 'cva']`
|
|
185
|
+
- **Description**: Array of utility function names to check for Tailwind classes. The plugin will detect and canonicalize classes passed as string arguments to these functions.
|
|
186
|
+
|
|
187
|
+
**Example**:
|
|
188
|
+
```javascript
|
|
189
|
+
calleeFunctions: ['cn', 'clsx'] // Only check cn() and clsx()
|
|
190
|
+
calleeFunctions: ['customFn'] // Check a custom utility function
|
|
191
|
+
```
|
|
192
|
+
|
|
178
193
|
## 💡 Usage Examples
|
|
179
194
|
|
|
180
195
|
### Basic Example
|
|
@@ -209,6 +224,13 @@ The plugin supports various class name formats:
|
|
|
209
224
|
<div className={"p-4px"}>Content</div>
|
|
210
225
|
```
|
|
211
226
|
|
|
227
|
+
4. **Utility functions** (e.g., `cn()`, `clsx()`, `classNames()`, `twMerge()`, `cva()`):
|
|
228
|
+
```tsx
|
|
229
|
+
<div className={cn("p-4px", "m-2rem")}>Content</div>
|
|
230
|
+
<div className={clsx("w-[16px]", condition && "hidden")}>Content</div>
|
|
231
|
+
// Only string literal arguments are checked; dynamic expressions are skipped
|
|
232
|
+
```
|
|
233
|
+
|
|
212
234
|
### Real-world Example
|
|
213
235
|
|
|
214
236
|
```tsx
|
|
@@ -231,10 +253,38 @@ function Card({ children }) {
|
|
|
231
253
|
}
|
|
232
254
|
```
|
|
233
255
|
|
|
256
|
+
### Utility Function Example
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
// Before
|
|
260
|
+
import { cn } from '@/lib/utils';
|
|
261
|
+
|
|
262
|
+
function Button({ variant, className }) {
|
|
263
|
+
return (
|
|
264
|
+
<button className={cn("w-[16px]", "h-[32px]", className)}>
|
|
265
|
+
Click me
|
|
266
|
+
</button>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// After auto-fix
|
|
271
|
+
import { cn } from '@/lib/utils';
|
|
272
|
+
|
|
273
|
+
function Button({ variant, className }) {
|
|
274
|
+
return (
|
|
275
|
+
<button className={cn("w-4", "h-8", className)}>
|
|
276
|
+
Click me
|
|
277
|
+
</button>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
234
282
|
## 🔧 How It Works
|
|
235
283
|
|
|
236
|
-
1. **Load Design System**: The plugin loads your Tailwind CSS file using `@tailwindcss/node`'s
|
|
237
|
-
2. **Extract Classes**: It extracts class names from
|
|
284
|
+
1. **Load Design System**: The plugin loads your Tailwind CSS file using `@tailwindcss/node`'s worker API
|
|
285
|
+
2. **Extract Classes**: It extracts class names from:
|
|
286
|
+
- JSX `className` attributes (string literals, template literals, JSX expressions)
|
|
287
|
+
- Utility function calls (e.g., `cn()`, `clsx()`) - only string literal arguments are checked
|
|
238
288
|
3. **Canonicalize**: For each class, it uses Tailwind's `canonicalizeCandidates` to find the canonical form
|
|
239
289
|
4. **Report & Fix**: If a non-canonical class is found, it reports an error/warning and can auto-fix it
|
|
240
290
|
|
|
@@ -244,6 +294,7 @@ function Card({ children }) {
|
|
|
244
294
|
- **Tailwind CSS v4 required**: Requires Tailwind CSS v4 (not compatible with v3)
|
|
245
295
|
- **CSS file accessibility**: CSS file must be accessible from the ESLint process
|
|
246
296
|
- **Template literals**: Template literals with expressions are partially supported (only static parts are checked)
|
|
297
|
+
- **Utility functions**: Only string literal arguments are checked; dynamic expressions, variables, and conditional logic within utility functions are skipped
|
|
247
298
|
|
|
248
299
|
## 🐛 Troubleshooting
|
|
249
300
|
|
|
@@ -45,6 +45,39 @@ function getQuoteChar(source, start, end) {
|
|
|
45
45
|
}
|
|
46
46
|
return '"';
|
|
47
47
|
}
|
|
48
|
+
function getCalleeName(node) {
|
|
49
|
+
if (node.type === 'Identifier') {
|
|
50
|
+
return node.name;
|
|
51
|
+
}
|
|
52
|
+
if (node.type === 'MemberExpression' && node.property.type === 'Identifier') {
|
|
53
|
+
return node.property.name;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function extractStringArgsFromCallExpression(node, calleeFunctions) {
|
|
58
|
+
if (node.type !== 'CallExpression') {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const calleeName = getCalleeName(node.callee);
|
|
62
|
+
if (!calleeName || !calleeFunctions.includes(calleeName)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const args = [];
|
|
66
|
+
node.arguments.forEach((arg, index) => {
|
|
67
|
+
if (arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
68
|
+
const classes = splitClasses(arg.value);
|
|
69
|
+
if (classes.length > 0) {
|
|
70
|
+
args.push({
|
|
71
|
+
value: arg.value,
|
|
72
|
+
index,
|
|
73
|
+
node: arg,
|
|
74
|
+
classes,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return args.length > 0 ? { calleeName, args } : null;
|
|
80
|
+
}
|
|
48
81
|
const rule = {
|
|
49
82
|
meta: {
|
|
50
83
|
type: 'suggestion',
|
|
@@ -66,6 +99,12 @@ const rule = {
|
|
|
66
99
|
rootFontSize: {
|
|
67
100
|
type: 'number',
|
|
68
101
|
},
|
|
102
|
+
calleeFunctions: {
|
|
103
|
+
type: 'array',
|
|
104
|
+
items: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
69
108
|
},
|
|
70
109
|
required: ['cssPath'],
|
|
71
110
|
additionalProperties: false,
|
|
@@ -92,6 +131,13 @@ const rule = {
|
|
|
92
131
|
cssPath = path.normalize(path.resolve(process.cwd(), options.cssPath));
|
|
93
132
|
}
|
|
94
133
|
const rootFontSize = options.rootFontSize ?? 16;
|
|
134
|
+
const calleeFunctions = options.calleeFunctions ?? [
|
|
135
|
+
'cn',
|
|
136
|
+
'clsx',
|
|
137
|
+
'classNames',
|
|
138
|
+
'twMerge',
|
|
139
|
+
'cva',
|
|
140
|
+
];
|
|
95
141
|
if (!fs.existsSync(cssPath)) {
|
|
96
142
|
context.report({
|
|
97
143
|
node: context.getSourceCode().ast,
|
|
@@ -108,94 +154,178 @@ const rule = {
|
|
|
108
154
|
node.name.name !== 'className') {
|
|
109
155
|
return;
|
|
110
156
|
}
|
|
111
|
-
const staticValue = extractStaticValue(node.value);
|
|
112
|
-
if (staticValue === null) {
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
const classes = splitClasses(staticValue);
|
|
116
|
-
if (classes.length === 0) {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
157
|
const sourceCode = context.getSourceCode();
|
|
120
158
|
const sourceText = sourceCode.getText();
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
125
|
-
context.report({
|
|
126
|
-
node,
|
|
127
|
-
messageId: 'cssNotFound',
|
|
128
|
-
data: {
|
|
129
|
-
path: cssPath,
|
|
130
|
-
},
|
|
131
|
-
});
|
|
159
|
+
const staticValue = extractStaticValue(node.value);
|
|
160
|
+
if (staticValue !== null) {
|
|
161
|
+
const classes = splitClasses(staticValue);
|
|
162
|
+
if (classes.length === 0) {
|
|
132
163
|
return;
|
|
133
164
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
165
|
+
const errors = [];
|
|
166
|
+
try {
|
|
167
|
+
const canonicalized = canonicalizeClasses(cssPath, classes, rootFontSize);
|
|
168
|
+
if (canonicalized === null) {
|
|
169
|
+
context.report({
|
|
138
170
|
node,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
171
|
+
messageId: 'cssNotFound',
|
|
172
|
+
data: {
|
|
173
|
+
path: cssPath,
|
|
174
|
+
},
|
|
142
175
|
});
|
|
176
|
+
return;
|
|
143
177
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (valueNode.type === 'Literal') {
|
|
155
|
-
fullRangeStart = valueNode.range[0];
|
|
156
|
-
fullRangeEnd = valueNode.range[1];
|
|
157
|
-
const quoteChar = getQuoteChar(sourceText, valueNode.range[0], valueNode.range[1]);
|
|
158
|
-
const fixedClasses = [...classes];
|
|
159
|
-
errors.forEach((error) => {
|
|
160
|
-
fixedClasses[error.index] = error.canonical;
|
|
178
|
+
classes.forEach((className, index) => {
|
|
179
|
+
const canonical = canonicalized[index];
|
|
180
|
+
if (canonical && canonical !== className) {
|
|
181
|
+
errors.push({
|
|
182
|
+
node,
|
|
183
|
+
original: className,
|
|
184
|
+
canonical,
|
|
185
|
+
index,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
161
188
|
});
|
|
162
|
-
const fixedValue = joinClasses(fixedClasses);
|
|
163
|
-
replacementText = `${quoteChar}${fixedValue}${quoteChar}`;
|
|
164
189
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
190
|
+
catch (error) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (errors.length > 0) {
|
|
194
|
+
const valueNode = node.value;
|
|
195
|
+
let fullRangeStart;
|
|
196
|
+
let fullRangeEnd;
|
|
197
|
+
let replacementText;
|
|
198
|
+
if (valueNode.type === 'Literal') {
|
|
168
199
|
fullRangeStart = valueNode.range[0];
|
|
169
200
|
fullRangeEnd = valueNode.range[1];
|
|
201
|
+
const quoteChar = getQuoteChar(sourceText, valueNode.range[0], valueNode.range[1]);
|
|
170
202
|
const fixedClasses = [...classes];
|
|
171
203
|
errors.forEach((error) => {
|
|
172
204
|
fixedClasses[error.index] = error.canonical;
|
|
173
205
|
});
|
|
174
206
|
const fixedValue = joinClasses(fixedClasses);
|
|
175
|
-
replacementText =
|
|
207
|
+
replacementText = `${quoteChar}${fixedValue}${quoteChar}`;
|
|
208
|
+
}
|
|
209
|
+
else if (valueNode.type === 'JSXExpressionContainer') {
|
|
210
|
+
const expr = valueNode.expression;
|
|
211
|
+
if (expr.type === 'TemplateLiteral') {
|
|
212
|
+
fullRangeStart = valueNode.range[0];
|
|
213
|
+
fullRangeEnd = valueNode.range[1];
|
|
214
|
+
const fixedClasses = [...classes];
|
|
215
|
+
errors.forEach((error) => {
|
|
216
|
+
fixedClasses[error.index] = error.canonical;
|
|
217
|
+
});
|
|
218
|
+
const fixedValue = joinClasses(fixedClasses);
|
|
219
|
+
replacementText = `{\`${fixedValue}\`}`;
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
176
224
|
}
|
|
177
225
|
else {
|
|
178
226
|
return;
|
|
179
227
|
}
|
|
228
|
+
errors.forEach((error, errorIndex) => {
|
|
229
|
+
context.report({
|
|
230
|
+
node: error.node,
|
|
231
|
+
messageId: 'nonCanonical',
|
|
232
|
+
data: {
|
|
233
|
+
original: error.original,
|
|
234
|
+
canonical: error.canonical,
|
|
235
|
+
},
|
|
236
|
+
fix: errorIndex === 0
|
|
237
|
+
? (fixer) => {
|
|
238
|
+
return fixer.replaceTextRange([fullRangeStart, fullRangeEnd], replacementText);
|
|
239
|
+
}
|
|
240
|
+
: undefined,
|
|
241
|
+
});
|
|
242
|
+
});
|
|
180
243
|
}
|
|
181
|
-
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (node.value?.type === 'JSXExpressionContainer') {
|
|
247
|
+
const expr = node.value.expression;
|
|
248
|
+
const callExprData = extractStringArgsFromCallExpression(expr, calleeFunctions);
|
|
249
|
+
if (!callExprData) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const { args } = callExprData;
|
|
253
|
+
const allClasses = [];
|
|
254
|
+
const argClassMap = new Map();
|
|
255
|
+
args.forEach((arg) => {
|
|
256
|
+
const startIndex = allClasses.length;
|
|
257
|
+
allClasses.push(...arg.classes);
|
|
258
|
+
const endIndex = allClasses.length;
|
|
259
|
+
argClassMap.set(arg.index, { start: startIndex, end: endIndex });
|
|
260
|
+
});
|
|
261
|
+
if (allClasses.length === 0) {
|
|
182
262
|
return;
|
|
183
263
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
264
|
+
try {
|
|
265
|
+
const canonicalized = canonicalizeClasses(cssPath, allClasses, rootFontSize);
|
|
266
|
+
if (canonicalized === null) {
|
|
267
|
+
context.report({
|
|
268
|
+
node,
|
|
269
|
+
messageId: 'cssNotFound',
|
|
270
|
+
data: {
|
|
271
|
+
path: cssPath,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const errorsByArg = new Map();
|
|
277
|
+
allClasses.forEach((className, classIndex) => {
|
|
278
|
+
const canonical = canonicalized[classIndex];
|
|
279
|
+
if (canonical && canonical !== className) {
|
|
280
|
+
for (const [argIndex, range] of argClassMap.entries()) {
|
|
281
|
+
if (classIndex >= range.start && classIndex < range.end) {
|
|
282
|
+
if (!errorsByArg.has(argIndex)) {
|
|
283
|
+
errorsByArg.set(argIndex, []);
|
|
284
|
+
}
|
|
285
|
+
errorsByArg.get(argIndex).push({
|
|
286
|
+
original: className,
|
|
287
|
+
canonical,
|
|
288
|
+
classIndex,
|
|
289
|
+
});
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
195
292
|
}
|
|
196
|
-
|
|
293
|
+
}
|
|
197
294
|
});
|
|
198
|
-
|
|
295
|
+
if (errorsByArg.size > 0) {
|
|
296
|
+
errorsByArg.forEach((errors, argIndex) => {
|
|
297
|
+
const arg = args.find((a) => a.index === argIndex);
|
|
298
|
+
if (!arg)
|
|
299
|
+
return;
|
|
300
|
+
const argRange = argClassMap.get(argIndex);
|
|
301
|
+
const fixedClasses = [...arg.classes];
|
|
302
|
+
errors.forEach((error) => {
|
|
303
|
+
const localIndex = error.classIndex - argRange.start;
|
|
304
|
+
fixedClasses[localIndex] = error.canonical;
|
|
305
|
+
});
|
|
306
|
+
const fixedValue = joinClasses(fixedClasses);
|
|
307
|
+
const quoteChar = getQuoteChar(sourceText, arg.node.range[0], arg.node.range[1]);
|
|
308
|
+
errors.forEach((error, errorIndex) => {
|
|
309
|
+
context.report({
|
|
310
|
+
node: arg.node,
|
|
311
|
+
messageId: 'nonCanonical',
|
|
312
|
+
data: {
|
|
313
|
+
original: error.original,
|
|
314
|
+
canonical: error.canonical,
|
|
315
|
+
},
|
|
316
|
+
fix: errorIndex === 0
|
|
317
|
+
? (fixer) => {
|
|
318
|
+
return fixer.replaceTextRange([arg.node.range[0], arg.node.range[1]], `${quoteChar}${fixedValue}${quoteChar}`);
|
|
319
|
+
}
|
|
320
|
+
: undefined,
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
199
329
|
}
|
|
200
330
|
},
|
|
201
331
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-tailwind-canonical-classes",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "ESLint plugin to enforce canonical Tailwind CSS class names using Tailwind CSS v4's canonicalization API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|