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 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,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 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
- });
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
- classes.forEach((className, index) => {
135
- const canonical = canonicalized[index];
136
- if (canonical && canonical !== className) {
137
- errors.push({
187
+ const errors = [];
188
+ try {
189
+ const canonicalized = canonicalizeClasses(cssPath, classes, rootFontSize);
190
+ if (canonicalized === null) {
191
+ context.report({
138
192
  node,
139
- original: className,
140
- canonical,
141
- index,
193
+ messageId: 'cssNotFound',
194
+ data: {
195
+ path: cssPath,
196
+ },
142
197
  });
198
+ return;
143
199
  }
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;
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
- else if (valueNode.type === 'JSXExpressionContainer') {
166
- const expr = valueNode.expression;
167
- if (expr.type === 'TemplateLiteral') {
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 = `{\`${fixedValue}\`}`;
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
- else {
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
- 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);
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
- : undefined,
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.10",
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",