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.
Files changed (45) hide show
  1. package/README.md +6 -4
  2. package/dist/src/index.d.ts +31 -0
  3. package/dist/src/index.d.ts.map +1 -0
  4. package/dist/src/rules/index.d.ts +7 -0
  5. package/dist/src/rules/index.d.ts.map +1 -0
  6. package/dist/src/rules/limited-inline-classes.d.ts +41 -0
  7. package/dist/src/rules/limited-inline-classes.d.ts.map +1 -0
  8. package/dist/src/rules/require-variants-call-styles-name.d.ts +19 -0
  9. package/dist/src/rules/require-variants-call-styles-name.d.ts.map +1 -0
  10. package/dist/src/rules/require-variants-suffix.d.ts +19 -0
  11. package/dist/src/rules/require-variants-suffix.d.ts.map +1 -0
  12. package/dist/src/rules/sort-custom-properties.d.ts +86 -0
  13. package/dist/src/rules/sort-custom-properties.d.ts.map +1 -0
  14. package/dist/src/utils/create-rule-visitors.d.ts +2 -0
  15. package/dist/src/utils/create-rule-visitors.d.ts.map +1 -0
  16. package/dist/src/utils/get-bind-class-expression.d.ts +3 -0
  17. package/dist/src/utils/get-bind-class-expression.d.ts.map +1 -0
  18. package/package.json +43 -37
  19. package/src/index.js +61 -0
  20. package/{dist → src}/rules/index.js +5 -5
  21. package/src/rules/limited-inline-classes.js +440 -0
  22. package/src/rules/limited-inline-classes.test.js +268 -0
  23. package/src/rules/require-variants-call-styles-name.js +195 -0
  24. package/src/rules/require-variants-call-styles-name.test.js +105 -0
  25. package/src/rules/require-variants-suffix.js +146 -0
  26. package/src/rules/require-variants-suffix.test.js +81 -0
  27. package/src/rules/sort-custom-properties.js +596 -0
  28. package/src/rules/sort-custom-properties.test.js +758 -0
  29. package/src/utils/create-rule-visitors.js +28 -0
  30. package/src/utils/get-bind-class-expression.js +36 -0
  31. package/dist/index.d.ts +0 -11
  32. package/dist/index.js +0 -43
  33. package/dist/rules/index.d.ts +0 -2
  34. package/dist/rules/limited-inline-classes.d.ts +0 -23
  35. package/dist/rules/limited-inline-classes.js +0 -198
  36. package/dist/rules/require-variants-call-styles-name.d.ts +0 -17
  37. package/dist/rules/require-variants-call-styles-name.js +0 -75
  38. package/dist/rules/require-variants-suffix.d.ts +0 -17
  39. package/dist/rules/require-variants-suffix.js +0 -66
  40. package/dist/rules/sort-custom-properties.d.ts +0 -37
  41. package/dist/rules/sort-custom-properties.js +0 -210
  42. package/dist/utils/create-rule-visitors.d.ts +0 -6
  43. package/dist/utils/create-rule-visitors.js +0 -18
  44. package/dist/utils/get-bind-class-expression.d.ts +0 -6
  45. 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
+ });