eslint-plugin-tailwind-variants 2.0.3 → 2.1.1
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 +6 -4
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/rules/index.d.ts +7 -0
- package/dist/src/rules/index.d.ts.map +1 -0
- package/dist/src/rules/limited-inline-classes.d.ts +41 -0
- package/dist/src/rules/limited-inline-classes.d.ts.map +1 -0
- package/dist/src/rules/require-variants-call-styles-name.d.ts +19 -0
- package/dist/src/rules/require-variants-call-styles-name.d.ts.map +1 -0
- package/dist/src/rules/require-variants-suffix.d.ts +19 -0
- package/dist/src/rules/require-variants-suffix.d.ts.map +1 -0
- package/dist/src/rules/sort-custom-properties.d.ts +86 -0
- package/dist/src/rules/sort-custom-properties.d.ts.map +1 -0
- package/dist/src/utils/create-rule-visitors.d.ts +2 -0
- package/dist/src/utils/create-rule-visitors.d.ts.map +1 -0
- package/dist/src/utils/get-bind-class-expression.d.ts +3 -0
- package/dist/src/utils/get-bind-class-expression.d.ts.map +1 -0
- package/package.json +43 -37
- package/src/index.js +61 -0
- package/{dist → src}/rules/index.js +5 -5
- package/src/rules/limited-inline-classes.js +440 -0
- package/src/rules/limited-inline-classes.test.js +268 -0
- package/src/rules/require-variants-call-styles-name.js +195 -0
- package/src/rules/require-variants-call-styles-name.test.js +105 -0
- package/src/rules/require-variants-suffix.js +146 -0
- package/src/rules/require-variants-suffix.test.js +81 -0
- package/src/rules/sort-custom-properties.js +596 -0
- package/src/rules/sort-custom-properties.test.js +758 -0
- package/src/utils/create-rule-visitors.js +28 -0
- package/src/utils/get-bind-class-expression.js +36 -0
- package/dist/index.d.ts +0 -11
- package/dist/index.js +0 -43
- package/dist/rules/index.d.ts +0 -2
- package/dist/rules/limited-inline-classes.d.ts +0 -23
- package/dist/rules/limited-inline-classes.js +0 -198
- package/dist/rules/require-variants-call-styles-name.d.ts +0 -17
- package/dist/rules/require-variants-call-styles-name.js +0 -75
- package/dist/rules/require-variants-suffix.d.ts +0 -17
- package/dist/rules/require-variants-suffix.js +0 -66
- package/dist/rules/sort-custom-properties.d.ts +0 -37
- package/dist/rules/sort-custom-properties.js +0 -210
- package/dist/utils/create-rule-visitors.d.ts +0 -6
- package/dist/utils/create-rule-visitors.js +0 -18
- package/dist/utils/get-bind-class-expression.d.ts +0 -6
- package/dist/utils/get-bind-class-expression.js +0 -20
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { createRuleVisitors } from "../utils/create-rule-visitors.js";
|
|
2
|
+
import {
|
|
3
|
+
getBindClassExpression,
|
|
4
|
+
isValidDirective,
|
|
5
|
+
} from "../utils/get-bind-class-expression.js";
|
|
6
|
+
|
|
7
|
+
export const MESSAGE_IDS = {
|
|
8
|
+
limitedInlineClasses: "limitedInlineClasses",
|
|
9
|
+
noCnInClassName: "noCnInClassName",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** @typedef {typeof MESSAGE_IDS[keyof typeof MESSAGE_IDS]} MessageIds */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} RuleOptions
|
|
16
|
+
* @property {string} [directoryPattern="/components/"] Directory pattern to match for processing files.
|
|
17
|
+
* @property {number} [maxInlineClasses=5] Maximum number of inline classes allowed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @type {import("eslint").Rule.RuleModule} */
|
|
21
|
+
export const rule = {
|
|
22
|
+
create: (context) => {
|
|
23
|
+
const [options = {}] = /** @type {[RuleOptions]} */ (context.options);
|
|
24
|
+
const directoryPattern = options.directoryPattern ?? "/components/";
|
|
25
|
+
const DEFAULT_MAX_INLINE_CLASSES = 5;
|
|
26
|
+
const maxInlineClasses =
|
|
27
|
+
options.maxInlineClasses ?? DEFAULT_MAX_INLINE_CLASSES;
|
|
28
|
+
|
|
29
|
+
if (!shouldProcessFile(context.filename, directoryPattern)) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const scriptVisitor = createScriptVisitor(context, maxInlineClasses);
|
|
34
|
+
const templateVisitor = createTemplateVisitor(context, maxInlineClasses);
|
|
35
|
+
|
|
36
|
+
return createRuleVisitors(context, templateVisitor, scriptVisitor);
|
|
37
|
+
},
|
|
38
|
+
meta: {
|
|
39
|
+
defaultOptions: [
|
|
40
|
+
{
|
|
41
|
+
directoryPattern: "/components/",
|
|
42
|
+
maxInlineClasses: 5,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
docs: {
|
|
46
|
+
description: `Allow a configurable number of inline class names; require use of tailwind-variants.`,
|
|
47
|
+
},
|
|
48
|
+
messages: {
|
|
49
|
+
limitedInlineClasses: `Inline className may contain at most {{max}} classes. Use tailwind-variants instead.`,
|
|
50
|
+
noCnInClassName:
|
|
51
|
+
"Using cn() in className is not allowed in component definition. Use tailwind-variants instead.",
|
|
52
|
+
},
|
|
53
|
+
schema: [
|
|
54
|
+
{
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
properties: {
|
|
57
|
+
directoryPattern: {
|
|
58
|
+
default: "/components/",
|
|
59
|
+
description: 'Directory pattern to match, e.g., "/components/".',
|
|
60
|
+
type: "string",
|
|
61
|
+
},
|
|
62
|
+
maxInlineClasses: {
|
|
63
|
+
default: 5,
|
|
64
|
+
description:
|
|
65
|
+
"Maximum number of inline classes allowed (default: 5).",
|
|
66
|
+
minimum: 1,
|
|
67
|
+
type: "integer",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
type: "object",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
type: "problem",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Count the number of non-empty class names in a string.
|
|
79
|
+
* @param {string} value
|
|
80
|
+
* @returns {number} Number of non-empty class names in the string.
|
|
81
|
+
*/
|
|
82
|
+
const countClasses = (value) =>
|
|
83
|
+
value.trim().split(/\s+/).filter(Boolean).length;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Shared validation options for all expression validators.
|
|
87
|
+
* @typedef {object} ValidationOptions
|
|
88
|
+
* @property {import("estree").Node |
|
|
89
|
+
* import("vue-eslint-parser").AST.VAttribute} node Node being validated.
|
|
90
|
+
* @property {import("eslint").Rule.RuleContext} context RuleContext for reporting.
|
|
91
|
+
* @property {number} maxInlineClasses Maximum number of inline classes allowed.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Detect violations in array expressions by recursively checking each element.
|
|
96
|
+
* @param {import("estree").ArrayExpression |
|
|
97
|
+
* import("vue-eslint-parser").AST.ESLintArrayExpression} expr
|
|
98
|
+
* @param {ValidationOptions} options
|
|
99
|
+
* @returns {boolean} `true` if violation found in any array element.
|
|
100
|
+
*/
|
|
101
|
+
const detectArrayViolation = (expr, options) =>
|
|
102
|
+
expr.elements
|
|
103
|
+
.filter((el) => el !== null && el.type !== "SpreadElement")
|
|
104
|
+
.some((el) => detectExpressionViolation(el, options));
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Detect violations in binary and logical expressions by checking both operands.
|
|
108
|
+
* @param {import("estree").BinaryExpression |
|
|
109
|
+
* import("estree").LogicalExpression |
|
|
110
|
+
* import("vue-eslint-parser").AST.ESLintBinaryExpression |
|
|
111
|
+
* import("vue-eslint-parser").AST.ESLintLogicalExpression} expr
|
|
112
|
+
* @param {ValidationOptions} options
|
|
113
|
+
* @returns {boolean} `true` if violation found in left or right operand.
|
|
114
|
+
*/
|
|
115
|
+
const detectBinaryViolation = (expr, options) =>
|
|
116
|
+
detectExpressionViolation(expr.left, options) ||
|
|
117
|
+
detectExpressionViolation(expr.right, options);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Detect violations in call expressions. Reports cn() usage and recursively
|
|
121
|
+
* checks arguments for excessive inline classes.
|
|
122
|
+
* @param {import("estree").CallExpression |
|
|
123
|
+
* import("vue-eslint-parser").AST.ESLintCallExpression} expr
|
|
124
|
+
* @param {ValidationOptions} options
|
|
125
|
+
* @returns {boolean} `true` if cn() call or argument violations found.
|
|
126
|
+
*/
|
|
127
|
+
const detectCallViolation = (expr, options) => {
|
|
128
|
+
if (expr.callee.type === "Identifier" && expr.callee.name === "cn") {
|
|
129
|
+
options.context.report({
|
|
130
|
+
messageId: MESSAGE_IDS.noCnInClassName,
|
|
131
|
+
node: options.node,
|
|
132
|
+
});
|
|
133
|
+
// Nested cn() calls only count as a single violation
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return expr.arguments
|
|
138
|
+
.filter((arg) => arg.type !== "SpreadElement")
|
|
139
|
+
.some((arg) => detectExpressionViolation(arg, options));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Detect violations in conditional (ternary) expressions by checking both
|
|
144
|
+
* branches.
|
|
145
|
+
* @param {import("estree").ConditionalExpression |
|
|
146
|
+
* import("vue-eslint-parser").AST.ESLintConditionalExpression} expr
|
|
147
|
+
* @param {ValidationOptions} options
|
|
148
|
+
* @returns {boolean} `true` if any branch contains excessive inline classes.
|
|
149
|
+
*/
|
|
150
|
+
const detectConditionalViolation = (expr, options) =>
|
|
151
|
+
detectExpressionViolation(expr.consequent, options) ||
|
|
152
|
+
detectExpressionViolation(expr.alternate, options);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Detect violations in string literals by checking class count against limit.
|
|
156
|
+
* @param {import("estree").Literal |
|
|
157
|
+
* import("vue-eslint-parser").AST.ESLintLiteral} expr
|
|
158
|
+
* @param {ValidationOptions} options
|
|
159
|
+
* @returns {boolean} `true` if string literal exceeds max inline classes.
|
|
160
|
+
*/
|
|
161
|
+
const detectLiteralViolation = (expr, options) => {
|
|
162
|
+
if (
|
|
163
|
+
typeof expr.value === "string" &&
|
|
164
|
+
countClasses(expr.value) > options.maxInlineClasses
|
|
165
|
+
) {
|
|
166
|
+
options.context.report({
|
|
167
|
+
data: { max: options.maxInlineClasses.toString() },
|
|
168
|
+
messageId: MESSAGE_IDS.limitedInlineClasses,
|
|
169
|
+
node: options.node,
|
|
170
|
+
});
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return false;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Detect violations in object expressions by recursively checking property
|
|
179
|
+
* values.
|
|
180
|
+
* @param {import("estree").ObjectExpression |
|
|
181
|
+
* import("vue-eslint-parser").AST.ESLintObjectExpression} expr
|
|
182
|
+
* @param {ValidationOptions} options
|
|
183
|
+
* @returns {boolean} `true` if violation found in any property value.
|
|
184
|
+
*/
|
|
185
|
+
const detectObjectViolation = (expr, options) =>
|
|
186
|
+
expr.properties.some((prop) => {
|
|
187
|
+
if (prop.type === "Property") {
|
|
188
|
+
if (prop.value === null) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (
|
|
193
|
+
prop.value.type === "ArrayExpression" ||
|
|
194
|
+
prop.value.type === "BinaryExpression" ||
|
|
195
|
+
prop.value.type === "CallExpression" ||
|
|
196
|
+
prop.value.type === "ConditionalExpression" ||
|
|
197
|
+
prop.value.type === "Identifier" ||
|
|
198
|
+
prop.value.type === "Literal" ||
|
|
199
|
+
prop.value.type === "LogicalExpression" ||
|
|
200
|
+
prop.value.type === "ObjectExpression" ||
|
|
201
|
+
prop.value.type === "TemplateLiteral" ||
|
|
202
|
+
prop.value.type === "ThisExpression"
|
|
203
|
+
) {
|
|
204
|
+
return detectExpressionViolation(prop.value, options);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Detect violations in template literals by checking static class count and
|
|
212
|
+
* recursively validating embedded expressions.
|
|
213
|
+
* @param {import("estree").TemplateLiteral |
|
|
214
|
+
* import("vue-eslint-parser").AST.ESLintTemplateLiteral} expr
|
|
215
|
+
* @param {ValidationOptions} options
|
|
216
|
+
* @returns {boolean} `true` if static part exceeds max inline classes or any embedded expressions have violations.
|
|
217
|
+
*/
|
|
218
|
+
const detectTemplateLiteralViolation = (expr, options) => {
|
|
219
|
+
// Static template literal
|
|
220
|
+
// oxlint-disable-next-line no-magic-numbers
|
|
221
|
+
if (expr.expressions.length === 0) {
|
|
222
|
+
const [firstQuasi] = expr.quasis;
|
|
223
|
+
const raw = firstQuasi.value.cooked;
|
|
224
|
+
if (raw === null || typeof raw === "undefined") {
|
|
225
|
+
// Skip validation for malformed template literals
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (countClasses(raw) > options.maxInlineClasses) {
|
|
230
|
+
options.context.report({
|
|
231
|
+
data: { max: options.maxInlineClasses.toString() },
|
|
232
|
+
messageId: MESSAGE_IDS.limitedInlineClasses,
|
|
233
|
+
node: options.node,
|
|
234
|
+
});
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Recurse into expressions
|
|
239
|
+
return expr.expressions.some((el) => detectExpressionViolation(el, options));
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Detect violations in any expression type by dispatching to specialized
|
|
244
|
+
* handlers.
|
|
245
|
+
* @param {import("estree").Expression |
|
|
246
|
+
* import("vue-eslint-parser").AST.ESLintExpression |
|
|
247
|
+
* import("estree").PrivateIdentifier |
|
|
248
|
+
* import("vue-eslint-parser").AST.ESLintPrivateIdentifier} expr
|
|
249
|
+
* @param {ValidationOptions} options
|
|
250
|
+
* @returns {boolean} True if any violation found in the expression or any nested expressions.
|
|
251
|
+
*/
|
|
252
|
+
// oxlint-disable-next-line max-statements max-lines-per-function
|
|
253
|
+
const detectExpressionViolation = (expr, options) => {
|
|
254
|
+
if (!expr || !("type" in expr)) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const exprType = expr.type;
|
|
259
|
+
|
|
260
|
+
// oxlint-disable-next-line typescript/switch-exhaustiveness-check
|
|
261
|
+
switch (exprType) {
|
|
262
|
+
case "ArrayExpression": {
|
|
263
|
+
return detectArrayViolation(expr, options);
|
|
264
|
+
}
|
|
265
|
+
case "BinaryExpression": {
|
|
266
|
+
return detectBinaryViolation(expr, options);
|
|
267
|
+
}
|
|
268
|
+
case "CallExpression": {
|
|
269
|
+
return detectCallViolation(expr, options);
|
|
270
|
+
}
|
|
271
|
+
case "ConditionalExpression": {
|
|
272
|
+
return detectConditionalViolation(expr, options);
|
|
273
|
+
}
|
|
274
|
+
case "Identifier": {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
case "Literal": {
|
|
278
|
+
return detectLiteralViolation(expr, options);
|
|
279
|
+
}
|
|
280
|
+
case "LogicalExpression": {
|
|
281
|
+
return detectBinaryViolation(expr, options);
|
|
282
|
+
}
|
|
283
|
+
case "ObjectExpression": {
|
|
284
|
+
return detectObjectViolation(expr, options);
|
|
285
|
+
}
|
|
286
|
+
case "TemplateLiteral": {
|
|
287
|
+
return detectTemplateLiteralViolation(expr, options);
|
|
288
|
+
}
|
|
289
|
+
case "ThisExpression": {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
default: {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Validate JSX className attributes for violations. Handles both static string
|
|
300
|
+
* literals and dynamic expressions.
|
|
301
|
+
* @param {import("estree-jsx").JSXAttribute} node
|
|
302
|
+
* @param {import("eslint").Rule.RuleContext} context
|
|
303
|
+
* @param {number} maxInlineClasses
|
|
304
|
+
*/
|
|
305
|
+
const checkJSXClassName = (node, context, maxInlineClasses) => {
|
|
306
|
+
const { value } = node;
|
|
307
|
+
if (!value) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ClassName="..."
|
|
312
|
+
if (
|
|
313
|
+
value.type === "Literal" &&
|
|
314
|
+
typeof value.value === "string" &&
|
|
315
|
+
countClasses(value.value) > maxInlineClasses
|
|
316
|
+
) {
|
|
317
|
+
context.report({
|
|
318
|
+
data: { max: maxInlineClasses.toString() },
|
|
319
|
+
messageId: MESSAGE_IDS.limitedInlineClasses,
|
|
320
|
+
node,
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ClassName={`...`} / className={"..."}
|
|
326
|
+
if (value.type === "JSXExpressionContainer") {
|
|
327
|
+
const expr = value.expression;
|
|
328
|
+
if (expr.type !== "JSXEmptyExpression") {
|
|
329
|
+
detectExpressionViolation(expr, {
|
|
330
|
+
context,
|
|
331
|
+
maxInlineClasses,
|
|
332
|
+
node,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Check Vue static class attributes for excessive inline classes.
|
|
340
|
+
*
|
|
341
|
+
* @param {import("vue-eslint-parser").AST.VAttribute} node
|
|
342
|
+
* @param {import("eslint").Rule.RuleContext} context
|
|
343
|
+
* @param {number} maxInlineClasses
|
|
344
|
+
*/
|
|
345
|
+
const checkVueStaticClass = (node, context, maxInlineClasses) => {
|
|
346
|
+
if (!node.value) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Class="..."
|
|
351
|
+
if (
|
|
352
|
+
!node.directive &&
|
|
353
|
+
node.key.type === "VIdentifier" &&
|
|
354
|
+
node.key.name === "class" &&
|
|
355
|
+
countClasses(node.value.value) > maxInlineClasses
|
|
356
|
+
) {
|
|
357
|
+
context.report({
|
|
358
|
+
data: { max: maxInlineClasses.toString() },
|
|
359
|
+
messageId: MESSAGE_IDS.limitedInlineClasses,
|
|
360
|
+
node,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Check Vue dynamic class bindings for violations. Handles :class and
|
|
367
|
+
* v-bind:class directives by recursively validating the bound expression.
|
|
368
|
+
* @param {import("vue-eslint-parser").AST.VAttribute} node
|
|
369
|
+
* @param {import("eslint").Rule.RuleContext} context
|
|
370
|
+
* @param {number} maxInlineClasses
|
|
371
|
+
*/
|
|
372
|
+
const checkVueDynamicClass = (node, context, maxInlineClasses) => {
|
|
373
|
+
if (!isValidDirective(node)) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const container = getBindClassExpression(
|
|
378
|
+
/** @type {import("vue-eslint-parser").AST.VDirectiveKey} */ (
|
|
379
|
+
/** @type{unknown} */ (node.key)
|
|
380
|
+
),
|
|
381
|
+
node.value,
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
if (!container) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// :class="..." / v-bind:class
|
|
388
|
+
if (container.expression) {
|
|
389
|
+
detectExpressionViolation(
|
|
390
|
+
/** @type {import("vue-eslint-parser").AST.ESLintExpression} */ (
|
|
391
|
+
container.expression
|
|
392
|
+
),
|
|
393
|
+
{
|
|
394
|
+
context,
|
|
395
|
+
maxInlineClasses,
|
|
396
|
+
node,
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Create ESLint visitor for JSX className attributes in script sections.
|
|
404
|
+
* @param {import("eslint").Rule.RuleContext} context
|
|
405
|
+
* @param {number} maxInlineClasses
|
|
406
|
+
* @returns {import("eslint").Rule.RuleListener} Visitor that checks JSX className attributes.
|
|
407
|
+
*/
|
|
408
|
+
const createScriptVisitor = (context, maxInlineClasses) => ({
|
|
409
|
+
JSXAttribute(/** @type {import("estree-jsx").JSXAttribute} */ node) {
|
|
410
|
+
if (node.name.name !== "className") {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
checkJSXClassName(node, context, maxInlineClasses);
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Create ESLint visitor for Vue template class attributes in template sections.
|
|
419
|
+
* @param {import("eslint").Rule.RuleContext} context
|
|
420
|
+
* @param {number} maxInlineClasses
|
|
421
|
+
* @returns {import("eslint").Rule.RuleListener} Visitor that checks Vue class attributes.
|
|
422
|
+
*/
|
|
423
|
+
const createTemplateVisitor = (context, maxInlineClasses) => ({
|
|
424
|
+
VAttribute(/** @type {unknown} */ node) {
|
|
425
|
+
const vueNode = /** @type {import("vue-eslint-parser").AST.VAttribute} */ (
|
|
426
|
+
node
|
|
427
|
+
);
|
|
428
|
+
checkVueStaticClass(vueNode, context, maxInlineClasses);
|
|
429
|
+
checkVueDynamicClass(vueNode, context, maxInlineClasses);
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Determine if a file should be processed.
|
|
435
|
+
* @param {string} fileName Name of the file being linted.
|
|
436
|
+
* @param {string} directoryPattern Directory pattern to match for processing.
|
|
437
|
+
* @returns {boolean} `true` if file should be processed.
|
|
438
|
+
*/
|
|
439
|
+
const shouldProcessFile = (fileName, directoryPattern) =>
|
|
440
|
+
fileName.replaceAll("\\", "/").includes(directoryPattern);
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { RuleTester } from "eslint";
|
|
2
|
+
import vueParser from "vue-eslint-parser";
|
|
3
|
+
|
|
4
|
+
import { MESSAGE_IDS, rule } from "./limited-inline-classes.js";
|
|
5
|
+
|
|
6
|
+
const tester = new RuleTester({
|
|
7
|
+
languageOptions: {
|
|
8
|
+
parserOptions: {
|
|
9
|
+
ecmaFeatures: {
|
|
10
|
+
jsx: true,
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const classes = {
|
|
17
|
+
FIVE: "bg-red-500 text-white p-2 m-1 rounded",
|
|
18
|
+
FOUR: "bg-red-500 text-white p-2 m-1",
|
|
19
|
+
SINGLE: "bg-red-500",
|
|
20
|
+
SIX: "bg-red-500 text-white p-2 m-1 rounded border",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const vueParserConfig = {
|
|
24
|
+
languageOptions: { parser: vueParser },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** @type {import("eslint").RuleTester.ValidTestCase[]} */
|
|
28
|
+
const valid = [
|
|
29
|
+
// Vue: static class with acceptable number of classes
|
|
30
|
+
{
|
|
31
|
+
code: `<template><div class="${classes.FIVE}"></div></template>`,
|
|
32
|
+
filename: "/components/Button.vue",
|
|
33
|
+
name: "Vue: class with 5 classes",
|
|
34
|
+
...vueParserConfig,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
code: `<template><div class="${classes.FOUR}"></div></template>`,
|
|
38
|
+
filename: "/components/Button.vue",
|
|
39
|
+
name: "Vue: class with 4 classes",
|
|
40
|
+
...vueParserConfig,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
code: `<template><div class="${classes.SINGLE}"></div></template>`,
|
|
44
|
+
filename: "/components/Button.vue",
|
|
45
|
+
name: "Vue: class with 1 class",
|
|
46
|
+
...vueParserConfig,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
code: `<template><div class=""></div></template>`,
|
|
50
|
+
filename: "/components/Button.vue",
|
|
51
|
+
name: "Vue: empty class",
|
|
52
|
+
...vueParserConfig,
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Vue: dynamic class with acceptable number of classes
|
|
56
|
+
{
|
|
57
|
+
code: `<template><div :class="'${classes.FIVE}'"></div></template>`,
|
|
58
|
+
filename: "/components/Button.vue",
|
|
59
|
+
name: "Vue: :class with 5 classes (string literal)",
|
|
60
|
+
...vueParserConfig,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
code: `<template><div :class="\`${classes.FIVE}\`"></div></template>`,
|
|
64
|
+
filename: "/components/Button.vue",
|
|
65
|
+
name: "Vue: :class with 5 classes (template literal)",
|
|
66
|
+
...vueParserConfig,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
code: `<template><div :class="buttonVariants"></div></template>`,
|
|
70
|
+
filename: "/components/Button.vue",
|
|
71
|
+
name: "Vue: :class variable reference",
|
|
72
|
+
...vueParserConfig,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
code: `<template><div :class="['${classes.FOUR}', '${classes.SINGLE}']"></div></template>`,
|
|
76
|
+
filename: "/components/Button.vue",
|
|
77
|
+
name: "Vue: :class array with acceptable class count",
|
|
78
|
+
...vueParserConfig,
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// JSX: static class with acceptable number of classes
|
|
82
|
+
{
|
|
83
|
+
code: `const element = <div className="${classes.FIVE}"></div>;`,
|
|
84
|
+
filename: "/components/Button.jsx",
|
|
85
|
+
name: "JSX: className with 5 classes",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
code: `const element = <div className="${classes.FOUR}"></div>;`,
|
|
89
|
+
filename: "/components/Button.jsx",
|
|
90
|
+
name: "JSX: className with 4 classes",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
code: `const element = <div className="${classes.SINGLE}"></div>;`,
|
|
94
|
+
filename: "/components/Button.jsx",
|
|
95
|
+
name: "JSX: className with 1 class",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
code: `const element = <div className=""></div>;`,
|
|
99
|
+
filename: "/components/Button.jsx",
|
|
100
|
+
name: "JSX: empty className",
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
// JSX: Dynamic className with acceptable number of classes
|
|
104
|
+
{
|
|
105
|
+
code: `const element = <div className={'${classes.FIVE}'}></div>;`,
|
|
106
|
+
filename: "/components/Button.jsx",
|
|
107
|
+
name: "JSX: className with 5 classes (string literal)",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
code: `const element = <div className={\`${classes.FIVE}\`}></div>;`,
|
|
111
|
+
filename: "/components/Button.jsx",
|
|
112
|
+
name: "JSX: className with 5 classes (template literal)",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
code: `const element = <div className={buttonVariants}></div>;`,
|
|
116
|
+
filename: "/components/Button.jsx",
|
|
117
|
+
name: "JSX: className variable reference",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
code: `const element = <div className={condition ? '${classes.FOUR}' : '${classes.SINGLE}'}></div>;`,
|
|
121
|
+
filename: "/components/Button.jsx",
|
|
122
|
+
name: "JSX: className with ternary (both sides <= 5 classes)",
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// Options: Custom maxInlineClasses
|
|
126
|
+
{
|
|
127
|
+
code: `<template><div class="${classes.FOUR}"></div></template>`,
|
|
128
|
+
filename: "/components/Button.vue",
|
|
129
|
+
name: "Vue: 4 classes allowed with custom maxInlineClasses",
|
|
130
|
+
options: [{ maxInlineClasses: 4 }],
|
|
131
|
+
...vueParserConfig,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
code: `const element = <div className="${classes.FOUR}"></div>;`,
|
|
135
|
+
filename: "/components/Button.jsx",
|
|
136
|
+
name: "JSX: 4 classes allowed with custom maxInlineClasses",
|
|
137
|
+
options: [{ maxInlineClasses: 4 }],
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// Options: directoryPattern exclusion
|
|
141
|
+
{
|
|
142
|
+
code: `<template><div class="${classes.SIX}"></div></template>`,
|
|
143
|
+
filename: "/utils/helpers.vue",
|
|
144
|
+
name: "Vue: 6 classes allowed outside of directoryPattern",
|
|
145
|
+
options: [{ directoryPattern: "/components/" }],
|
|
146
|
+
...vueParserConfig,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
code: `const element = <div className="${classes.SIX}"></div>;`,
|
|
150
|
+
filename: "/utils/helpers.jsx",
|
|
151
|
+
name: "JSX: 6 classes allowed outside of directoryPattern",
|
|
152
|
+
options: [{ directoryPattern: "/components/" }],
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
/** @type {import("eslint").RuleTester.InvalidTestCase[]} */
|
|
157
|
+
const invalid = [
|
|
158
|
+
// Vue: static class exceeding maxInlineClasses
|
|
159
|
+
{
|
|
160
|
+
code: `<template><div class="${classes.SIX}"></div></template>`,
|
|
161
|
+
errors: [{ messageId: MESSAGE_IDS.limitedInlineClasses }],
|
|
162
|
+
filename: "/components/Button.vue",
|
|
163
|
+
name: "Vue: class with 6 classes",
|
|
164
|
+
...vueParserConfig,
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// Vue: dynamic class exceeding maxInlineClasses
|
|
168
|
+
{
|
|
169
|
+
code: `<template><div :class="'${classes.SIX}'"></div></template>`,
|
|
170
|
+
errors: [{ messageId: MESSAGE_IDS.limitedInlineClasses }],
|
|
171
|
+
filename: "/components/Button.vue",
|
|
172
|
+
name: "Vue: :class with 6 classes (string literal)",
|
|
173
|
+
...vueParserConfig,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
code: `<template><div :class="\`${classes.SIX}\`"></div></template>`,
|
|
177
|
+
errors: [{ messageId: MESSAGE_IDS.limitedInlineClasses }],
|
|
178
|
+
filename: "/components/Button.vue",
|
|
179
|
+
name: "Vue: :class with 6 classes (template literal)",
|
|
180
|
+
...vueParserConfig,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
code: `<template><div :class="['${classes.SIX}', '${classes.SINGLE}']"></div></template>`,
|
|
184
|
+
errors: [{ messageId: MESSAGE_IDS.limitedInlineClasses }],
|
|
185
|
+
filename: "/components/Button.vue",
|
|
186
|
+
name: "Vue: :class array with one item exceeding maxInlineClasses",
|
|
187
|
+
...vueParserConfig,
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// JSX: static className exceeding maxInlineClasses
|
|
191
|
+
{
|
|
192
|
+
code: `const element = <div className="${classes.SIX}"></div>;`,
|
|
193
|
+
errors: [{ messageId: MESSAGE_IDS.limitedInlineClasses }],
|
|
194
|
+
filename: "/components/Button.jsx",
|
|
195
|
+
name: "JSX: className with 6 classes",
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// JSX: dynamic className exceeding maxInlineClasses
|
|
199
|
+
{
|
|
200
|
+
code: `const element = <div className={'${classes.SIX}'}></div>;`,
|
|
201
|
+
errors: [{ messageId: MESSAGE_IDS.limitedInlineClasses }],
|
|
202
|
+
filename: "/components/Button.jsx",
|
|
203
|
+
name: "JSX: className with 6 classes (string literal)",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
code: `const element = <div className={\`${classes.SIX}\`}></div>;`,
|
|
207
|
+
errors: [{ messageId: MESSAGE_IDS.limitedInlineClasses }],
|
|
208
|
+
filename: "/components/Button.jsx",
|
|
209
|
+
name: "JSX: className with 6 classes (template literal)",
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
code: `const element = <div className={condition ? '${classes.SIX}' : '${classes.SINGLE}'}></div>;`,
|
|
213
|
+
errors: [{ messageId: MESSAGE_IDS.limitedInlineClasses }],
|
|
214
|
+
filename: "/components/Button.jsx",
|
|
215
|
+
name: "JSX: className with ternary with 6 classes on left side",
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
// Cn() usage violations
|
|
219
|
+
{
|
|
220
|
+
code: `<template><div :class="cn('${classes.SINGLE}')"></div></template>`,
|
|
221
|
+
errors: [{ messageId: MESSAGE_IDS.noCnInClassName }],
|
|
222
|
+
filename: "/components/Button.vue",
|
|
223
|
+
name: "Vue: cn() in :class",
|
|
224
|
+
...vueParserConfig,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
code: `<template><div :class="cn('${classes.SINGLE}', cn('${classes.SINGLE}'))"></div></template>`,
|
|
228
|
+
errors: [{ messageId: MESSAGE_IDS.noCnInClassName }],
|
|
229
|
+
filename: "/components/Button.vue",
|
|
230
|
+
name: "Vue: nested cn() in :class",
|
|
231
|
+
...vueParserConfig,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
code: `const element = <div className={cn('${classes.SINGLE}')}></div>;`,
|
|
235
|
+
errors: [{ messageId: MESSAGE_IDS.noCnInClassName }],
|
|
236
|
+
filename: "/components/Button.jsx",
|
|
237
|
+
name: "JSX: cn() in className",
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
code: `const element = <div className={cn('${classes.SINGLE}', cn('${classes.SINGLE}'))}></div>;`,
|
|
241
|
+
errors: [{ messageId: MESSAGE_IDS.noCnInClassName }],
|
|
242
|
+
filename: "/components/Button.jsx",
|
|
243
|
+
name: "JSX: nested cn() in className",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
code: `const element = <div className={\`base \${cn('${classes.SINGLE}')}\`}></div>;`,
|
|
247
|
+
errors: [{ messageId: MESSAGE_IDS.noCnInClassName }],
|
|
248
|
+
filename: "/components/Button.jsx",
|
|
249
|
+
name: "JSX: cn() in template literal",
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// Multiple violations in one file
|
|
253
|
+
{
|
|
254
|
+
code: `<template><div :class="'${classes.SIX}'" :class="cn('${classes.SINGLE}')"></div></template>`,
|
|
255
|
+
errors: [
|
|
256
|
+
{ messageId: MESSAGE_IDS.limitedInlineClasses },
|
|
257
|
+
{ messageId: MESSAGE_IDS.noCnInClassName },
|
|
258
|
+
],
|
|
259
|
+
filename: "/components/Button.vue",
|
|
260
|
+
name: "Vue: multiple :class violations",
|
|
261
|
+
...vueParserConfig,
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
tester.run("limited-inline-classes", rule, {
|
|
266
|
+
invalid,
|
|
267
|
+
valid,
|
|
268
|
+
});
|