eslint-plugin-tailwind-canonical-classes 1.0.10 → 1.2.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 +184 -64
- 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,61 @@ 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 collectStringLiterals(node, result) {
|
|
58
|
+
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
59
|
+
const classes = splitClasses(node.value);
|
|
60
|
+
if (classes.length > 0) {
|
|
61
|
+
result.push({ value: node.value, node, classes });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (node.type === 'ConditionalExpression') {
|
|
65
|
+
collectStringLiterals(node.consequent, result);
|
|
66
|
+
collectStringLiterals(node.alternate, result);
|
|
67
|
+
}
|
|
68
|
+
else if (node.type === 'LogicalExpression') {
|
|
69
|
+
collectStringLiterals(node.left, result);
|
|
70
|
+
collectStringLiterals(node.right, result);
|
|
71
|
+
}
|
|
72
|
+
else if (node.type === 'TemplateLiteral' && !hasTemplateExpressions(node)) {
|
|
73
|
+
const value = node.quasis.map((q) => q.value.cooked).join('');
|
|
74
|
+
const classes = splitClasses(value);
|
|
75
|
+
if (classes.length > 0) {
|
|
76
|
+
result.push({ value, node, classes });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function extractStringArgsFromCallExpression(node, calleeFunctions) {
|
|
81
|
+
if (node.type !== 'CallExpression') {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const calleeName = getCalleeName(node.callee);
|
|
85
|
+
if (!calleeName || !calleeFunctions.includes(calleeName)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const args = [];
|
|
89
|
+
node.arguments.forEach((arg, index) => {
|
|
90
|
+
const literals = [];
|
|
91
|
+
collectStringLiterals(arg, literals);
|
|
92
|
+
literals.forEach((lit) => {
|
|
93
|
+
args.push({
|
|
94
|
+
value: lit.value,
|
|
95
|
+
index,
|
|
96
|
+
node: lit.node,
|
|
97
|
+
classes: lit.classes,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
return args.length > 0 ? { calleeName, args } : null;
|
|
102
|
+
}
|
|
48
103
|
const rule = {
|
|
49
104
|
meta: {
|
|
50
105
|
type: 'suggestion',
|
|
@@ -66,6 +121,12 @@ const rule = {
|
|
|
66
121
|
rootFontSize: {
|
|
67
122
|
type: 'number',
|
|
68
123
|
},
|
|
124
|
+
calleeFunctions: {
|
|
125
|
+
type: 'array',
|
|
126
|
+
items: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
69
130
|
},
|
|
70
131
|
required: ['cssPath'],
|
|
71
132
|
additionalProperties: false,
|
|
@@ -92,6 +153,13 @@ const rule = {
|
|
|
92
153
|
cssPath = path.normalize(path.resolve(process.cwd(), options.cssPath));
|
|
93
154
|
}
|
|
94
155
|
const rootFontSize = options.rootFontSize ?? 16;
|
|
156
|
+
const calleeFunctions = options.calleeFunctions ?? [
|
|
157
|
+
'cn',
|
|
158
|
+
'clsx',
|
|
159
|
+
'classNames',
|
|
160
|
+
'twMerge',
|
|
161
|
+
'cva',
|
|
162
|
+
];
|
|
95
163
|
if (!fs.existsSync(cssPath)) {
|
|
96
164
|
context.report({
|
|
97
165
|
node: context.getSourceCode().ast,
|
|
@@ -108,94 +176,146 @@ const rule = {
|
|
|
108
176
|
node.name.name !== 'className') {
|
|
109
177
|
return;
|
|
110
178
|
}
|
|
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
179
|
const sourceCode = context.getSourceCode();
|
|
120
180
|
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
|
-
});
|
|
181
|
+
const staticValue = extractStaticValue(node.value);
|
|
182
|
+
if (staticValue !== null) {
|
|
183
|
+
const classes = splitClasses(staticValue);
|
|
184
|
+
if (classes.length === 0) {
|
|
132
185
|
return;
|
|
133
186
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
187
|
+
const errors = [];
|
|
188
|
+
try {
|
|
189
|
+
const canonicalized = canonicalizeClasses(cssPath, classes, rootFontSize);
|
|
190
|
+
if (canonicalized === null) {
|
|
191
|
+
context.report({
|
|
138
192
|
node,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
193
|
+
messageId: 'cssNotFound',
|
|
194
|
+
data: {
|
|
195
|
+
path: cssPath,
|
|
196
|
+
},
|
|
142
197
|
});
|
|
198
|
+
return;
|
|
143
199
|
}
|
|
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;
|
|
200
|
+
classes.forEach((className, index) => {
|
|
201
|
+
const canonical = canonicalized[index];
|
|
202
|
+
if (canonical && canonical !== className) {
|
|
203
|
+
errors.push({
|
|
204
|
+
node,
|
|
205
|
+
original: className,
|
|
206
|
+
canonical,
|
|
207
|
+
index,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
161
210
|
});
|
|
162
|
-
const fixedValue = joinClasses(fixedClasses);
|
|
163
|
-
replacementText = `${quoteChar}${fixedValue}${quoteChar}`;
|
|
164
211
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
212
|
+
catch (error) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (errors.length > 0) {
|
|
216
|
+
const valueNode = node.value;
|
|
217
|
+
let fullRangeStart;
|
|
218
|
+
let fullRangeEnd;
|
|
219
|
+
let replacementText;
|
|
220
|
+
if (valueNode.type === 'Literal') {
|
|
168
221
|
fullRangeStart = valueNode.range[0];
|
|
169
222
|
fullRangeEnd = valueNode.range[1];
|
|
223
|
+
const quoteChar = getQuoteChar(sourceText, valueNode.range[0], valueNode.range[1]);
|
|
170
224
|
const fixedClasses = [...classes];
|
|
171
225
|
errors.forEach((error) => {
|
|
172
226
|
fixedClasses[error.index] = error.canonical;
|
|
173
227
|
});
|
|
174
228
|
const fixedValue = joinClasses(fixedClasses);
|
|
175
|
-
replacementText =
|
|
229
|
+
replacementText = `${quoteChar}${fixedValue}${quoteChar}`;
|
|
230
|
+
}
|
|
231
|
+
else if (valueNode.type === 'JSXExpressionContainer') {
|
|
232
|
+
const expr = valueNode.expression;
|
|
233
|
+
if (expr.type === 'TemplateLiteral') {
|
|
234
|
+
fullRangeStart = valueNode.range[0];
|
|
235
|
+
fullRangeEnd = valueNode.range[1];
|
|
236
|
+
const fixedClasses = [...classes];
|
|
237
|
+
errors.forEach((error) => {
|
|
238
|
+
fixedClasses[error.index] = error.canonical;
|
|
239
|
+
});
|
|
240
|
+
const fixedValue = joinClasses(fixedClasses);
|
|
241
|
+
replacementText = `{\`${fixedValue}\`}`;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
176
246
|
}
|
|
177
247
|
else {
|
|
178
248
|
return;
|
|
179
249
|
}
|
|
250
|
+
errors.forEach((error, errorIndex) => {
|
|
251
|
+
context.report({
|
|
252
|
+
node: error.node,
|
|
253
|
+
messageId: 'nonCanonical',
|
|
254
|
+
data: {
|
|
255
|
+
original: error.original,
|
|
256
|
+
canonical: error.canonical,
|
|
257
|
+
},
|
|
258
|
+
fix: errorIndex === 0
|
|
259
|
+
? (fixer) => {
|
|
260
|
+
return fixer.replaceTextRange([fullRangeStart, fullRangeEnd], replacementText);
|
|
261
|
+
}
|
|
262
|
+
: undefined,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
180
265
|
}
|
|
181
|
-
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (node.value?.type === 'JSXExpressionContainer') {
|
|
269
|
+
const expr = node.value.expression;
|
|
270
|
+
const callExprData = extractStringArgsFromCallExpression(expr, calleeFunctions);
|
|
271
|
+
if (!callExprData) {
|
|
182
272
|
return;
|
|
183
273
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
274
|
+
const { args } = callExprData;
|
|
275
|
+
try {
|
|
276
|
+
for (const arg of args) {
|
|
277
|
+
const canonicalized = canonicalizeClasses(cssPath, arg.classes, rootFontSize);
|
|
278
|
+
if (canonicalized === null) {
|
|
279
|
+
context.report({
|
|
280
|
+
node,
|
|
281
|
+
messageId: 'cssNotFound',
|
|
282
|
+
data: { path: cssPath },
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const errors = [];
|
|
287
|
+
arg.classes.forEach((className, idx) => {
|
|
288
|
+
const canonical = canonicalized[idx];
|
|
289
|
+
if (canonical && canonical !== className) {
|
|
290
|
+
errors.push({ original: className, canonical, idx });
|
|
195
291
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
292
|
+
});
|
|
293
|
+
if (errors.length === 0)
|
|
294
|
+
continue;
|
|
295
|
+
const fixedClasses = [...arg.classes];
|
|
296
|
+
errors.forEach((e) => { fixedClasses[e.idx] = e.canonical; });
|
|
297
|
+
const fixedValue = joinClasses(fixedClasses);
|
|
298
|
+
const quoteChar = getQuoteChar(sourceText, arg.node.range[0], arg.node.range[1]);
|
|
299
|
+
errors.forEach((error, errorIndex) => {
|
|
300
|
+
context.report({
|
|
301
|
+
node: arg.node,
|
|
302
|
+
messageId: 'nonCanonical',
|
|
303
|
+
data: {
|
|
304
|
+
original: error.original,
|
|
305
|
+
canonical: error.canonical,
|
|
306
|
+
},
|
|
307
|
+
fix: errorIndex === 0
|
|
308
|
+
? (fixer) => {
|
|
309
|
+
return fixer.replaceTextRange([arg.node.range[0], arg.node.range[1]], `${quoteChar}${fixedValue}${quoteChar}`);
|
|
310
|
+
}
|
|
311
|
+
: undefined,
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
199
319
|
}
|
|
200
320
|
},
|
|
201
321
|
};
|
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.2.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",
|