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 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 `__unstable__loadDesignSystem` API
237
- 2. **Extract Classes**: It extracts class names from JSX `className` attributes in your code
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 errors = [];
122
- try {
123
- const canonicalized = canonicalizeClasses(cssPath, classes, rootFontSize);
124
- if (canonicalized === null) {
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
- classes.forEach((className, index) => {
135
- const canonical = canonicalized[index];
136
- if (canonical && canonical !== className) {
137
- errors.push({
165
+ const errors = [];
166
+ try {
167
+ const canonicalized = canonicalizeClasses(cssPath, classes, rootFontSize);
168
+ if (canonicalized === null) {
169
+ context.report({
138
170
  node,
139
- original: className,
140
- canonical,
141
- index,
171
+ messageId: 'cssNotFound',
172
+ data: {
173
+ path: cssPath,
174
+ },
142
175
  });
176
+ return;
143
177
  }
144
- });
145
- }
146
- catch (error) {
147
- return;
148
- }
149
- if (errors.length > 0) {
150
- const valueNode = node.value;
151
- let fullRangeStart;
152
- let fullRangeEnd;
153
- let replacementText;
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
- else if (valueNode.type === 'JSXExpressionContainer') {
166
- const expr = valueNode.expression;
167
- if (expr.type === 'TemplateLiteral') {
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 = `{\`${fixedValue}\`}`;
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
- else {
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
- errors.forEach((error, errorIndex) => {
185
- context.report({
186
- node: error.node,
187
- messageId: 'nonCanonical',
188
- data: {
189
- original: error.original,
190
- canonical: error.canonical,
191
- },
192
- fix: errorIndex === 0
193
- ? (fixer) => {
194
- return fixer.replaceTextRange([fullRangeStart, fullRangeEnd], replacementText);
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
- : undefined,
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.10",
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",