eslint-plugin-tailwind-variants 0.0.0 → 1.0.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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +24 -0
- package/dist/rules/index.d.ts +19 -0
- package/dist/rules/index.js +8 -0
- package/dist/rules/limited-inline-classes.d.ts +24 -0
- package/dist/rules/limited-inline-classes.js +198 -0
- package/dist/rules/require-variants-call-styles-name.d.ts +17 -0
- package/dist/rules/require-variants-call-styles-name.js +86 -0
- package/dist/rules/require-variants-suffix.d.ts +17 -0
- package/dist/rules/require-variants-suffix.js +66 -0
- package/dist/utils/create-rule-visitors.d.ts +6 -0
- package/dist/utils/create-rule-visitors.js +18 -0
- package/dist/utils/get-bind-class-expression.d.ts +6 -0
- package/dist/utils/get-bind-class-expression.js +20 -0
- package/package.json +63 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Made by Dom | Dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img height="75px" alt="eslint-plugin-tailwind-variants logo" src="./.github/assets/eslint-plugin-tailwind-variants-logo.svg" />
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">eslint-plugin-tailwind-variants</h1>
|
|
6
|
+
|
|
7
|
+
<div align="center">
|
|
8
|
+
<img alt="GitHub License MIT" src="https://img.shields.io/github/license/domingasp/eslint-plugin-tailwind-variants?color=615fff"/>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<br />
|
|
12
|
+
|
|
13
|
+
ESLint plugin to enforce best practices and consistent naming conventions for tailwind-variants.
|
|
14
|
+
|
|
15
|
+
Automatically enforces proper variable naming, limits excessive inline classes, and promotes clean tv() usage with auto-fix support. This plugin supports a wide range of projects, including React, Vue, plain JavaScript or TypeScript.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm i -D eslint eslint-plugin-tailwind-variants
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
1. Depending on your environment you may need to install the following dependencies:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
# TypeScript
|
|
29
|
+
npm i -D @typescript-eslint/parser
|
|
30
|
+
|
|
31
|
+
# Vue
|
|
32
|
+
npm i -D vue-eslint-parser
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
2. Enable the `recommended` config in your ESLint config:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
// eslint.config.{js|ts)
|
|
39
|
+
|
|
40
|
+
// ...
|
|
41
|
+
import tailwindVariants from "eslint-plugin-tailwind-variants";
|
|
42
|
+
|
|
43
|
+
export default defineConfig([
|
|
44
|
+
tailwindVariants.configs.recommended,
|
|
45
|
+
]);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Rules
|
|
49
|
+
|
|
50
|
+
| Name | Description | `recommended` | autofix |
|
|
51
|
+
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------- | ------- |
|
|
52
|
+
| [require-variants-call-styles-name](docs/rules/require-variants-call-styles-name.md) | enforce that the result of tailwind-variants ( `tv()` ) is assigned to a variable named `styles` (or a configurable name) | ✔ | ✔ |
|
|
53
|
+
| [require-variants-suffix](docs/rules/require-variants-suffix.md) | require variables assigned from tv() to end with a specific suffix | ✔ | ✔ |
|
|
54
|
+
| [limited-inline-classes](docs/rules/limited-inline-classes.md) | enforce limited number of inline class names and prohibit cn() usage | ✔ | |
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Linter } from "@typescript-eslint/utils/ts-eslint";
|
|
2
|
+
declare const plugin: {
|
|
3
|
+
configs: Record<string, Linter.Config>;
|
|
4
|
+
meta: {
|
|
5
|
+
name: string;
|
|
6
|
+
namespace: string;
|
|
7
|
+
version: string;
|
|
8
|
+
};
|
|
9
|
+
rules: {
|
|
10
|
+
"limited-inline-classes": import("@typescript-eslint/utils/ts-eslint").RuleModule<"limitedInlineClasses" | "noCnInClassName", [{
|
|
11
|
+
directoryPattern?: string;
|
|
12
|
+
maxInlineClasses?: number;
|
|
13
|
+
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
14
|
+
name: string;
|
|
15
|
+
};
|
|
16
|
+
"require-variants-call-styles-name": import("@typescript-eslint/utils/ts-eslint").RuleModule<"requireVariantsCallStylesName", [{
|
|
17
|
+
name?: string;
|
|
18
|
+
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
19
|
+
name: string;
|
|
20
|
+
};
|
|
21
|
+
"require-variants-suffix": import("@typescript-eslint/utils/ts-eslint").RuleModule<"requireVariantsSuffix", [{
|
|
22
|
+
suffix?: string;
|
|
23
|
+
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
24
|
+
name: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export default plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { rules } from "./rules/index.js";
|
|
2
|
+
const plugin = {
|
|
3
|
+
// Plugin type expects Config rather than ConfigType
|
|
4
|
+
configs: {},
|
|
5
|
+
meta: {
|
|
6
|
+
name: "eslint-plugin-tailwind-variants",
|
|
7
|
+
namespace: "tailwind-variants",
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
},
|
|
10
|
+
rules,
|
|
11
|
+
};
|
|
12
|
+
Object.assign(plugin.configs, {
|
|
13
|
+
recommended: {
|
|
14
|
+
plugins: {
|
|
15
|
+
"eslint-plugin-tailwind-variants": plugin,
|
|
16
|
+
},
|
|
17
|
+
rules: {
|
|
18
|
+
"tailwind-variants/limited-inline-classes": "error",
|
|
19
|
+
"tailwind-variants/require-variants-call-styles-name": "error",
|
|
20
|
+
"tailwind-variants/require-variants-suffix": "error",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
export default plugin;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
export declare const rules: {
|
|
3
|
+
"limited-inline-classes": TSESLint.RuleModule<"limitedInlineClasses" | "noCnInClassName", [{
|
|
4
|
+
directoryPattern?: string;
|
|
5
|
+
maxInlineClasses?: number;
|
|
6
|
+
}], unknown, TSESLint.RuleListener> & {
|
|
7
|
+
name: string;
|
|
8
|
+
};
|
|
9
|
+
"require-variants-call-styles-name": TSESLint.RuleModule<"requireVariantsCallStylesName", [{
|
|
10
|
+
name?: string;
|
|
11
|
+
}], unknown, TSESLint.RuleListener> & {
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
"require-variants-suffix": TSESLint.RuleModule<"requireVariantsSuffix", [{
|
|
15
|
+
suffix?: string;
|
|
16
|
+
}], unknown, TSESLint.RuleListener> & {
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { rule as limitedInlineClasses } from "./limited-inline-classes.js";
|
|
2
|
+
import { rule as requireVariantsCallStylesName } from "./require-variants-call-styles-name.js";
|
|
3
|
+
import { rule as requireVariantsSuffix } from "./require-variants-suffix.js";
|
|
4
|
+
export const rules = {
|
|
5
|
+
"limited-inline-classes": limitedInlineClasses,
|
|
6
|
+
"require-variants-call-styles-name": requireVariantsCallStylesName,
|
|
7
|
+
"require-variants-suffix": requireVariantsSuffix,
|
|
8
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
export declare const MESSAGE_IDS: {
|
|
3
|
+
readonly limitedInlineClasses: "limitedInlineClasses";
|
|
4
|
+
readonly noCnInClassName: "noCnInClassName";
|
|
5
|
+
};
|
|
6
|
+
type MessageIds = (typeof MESSAGE_IDS)[keyof typeof MESSAGE_IDS];
|
|
7
|
+
type Options = [
|
|
8
|
+
{
|
|
9
|
+
/**
|
|
10
|
+
* Directory pattern to match
|
|
11
|
+
* @default "/components/"
|
|
12
|
+
*/
|
|
13
|
+
directoryPattern?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Maximum number of inline classes allowed
|
|
16
|
+
* @default 5
|
|
17
|
+
*/
|
|
18
|
+
maxInlineClasses?: number;
|
|
19
|
+
}
|
|
20
|
+
];
|
|
21
|
+
export declare const rule: ESLintUtils.RuleModule<MessageIds, Options, unknown, ESLintUtils.RuleListener> & {
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, ESLintUtils, } from "@typescript-eslint/utils";
|
|
2
|
+
import { createRuleVisitors } from "../utils/create-rule-visitors";
|
|
3
|
+
import { getBindClassExpression } from "../utils/get-bind-class-expression";
|
|
4
|
+
const createRule = ESLintUtils.RuleCreator((name) => name);
|
|
5
|
+
export const MESSAGE_IDS = {
|
|
6
|
+
limitedInlineClasses: "limitedInlineClasses",
|
|
7
|
+
noCnInClassName: "noCnInClassName",
|
|
8
|
+
};
|
|
9
|
+
function countClasses(value) {
|
|
10
|
+
return value.trim().split(/\s+/).filter(Boolean).length;
|
|
11
|
+
}
|
|
12
|
+
/** Recursively validate classes in any expression and detect cn() calls */
|
|
13
|
+
function validateExpression(node, expr, context, maxInlineClasses = 5) {
|
|
14
|
+
if (!expr)
|
|
15
|
+
return false;
|
|
16
|
+
switch (expr.type) {
|
|
17
|
+
case AST_NODE_TYPES.ArrayExpression:
|
|
18
|
+
return expr.elements
|
|
19
|
+
.filter((el) => {
|
|
20
|
+
return el !== null && el.type !== AST_NODE_TYPES.SpreadElement;
|
|
21
|
+
})
|
|
22
|
+
.some((el) => validateExpression(node, el, context, maxInlineClasses));
|
|
23
|
+
case AST_NODE_TYPES.BinaryExpression:
|
|
24
|
+
return (validateExpression(node, expr.left, context, maxInlineClasses) ||
|
|
25
|
+
validateExpression(node, expr.right, context, maxInlineClasses));
|
|
26
|
+
case AST_NODE_TYPES.CallExpression:
|
|
27
|
+
if (expr.callee.type === AST_NODE_TYPES.Identifier &&
|
|
28
|
+
expr.callee.name === "cn") {
|
|
29
|
+
context.report({
|
|
30
|
+
messageId: MESSAGE_IDS.noCnInClassName,
|
|
31
|
+
node: node,
|
|
32
|
+
});
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return expr.arguments
|
|
36
|
+
.filter((arg) => {
|
|
37
|
+
return arg.type !== AST_NODE_TYPES.SpreadElement;
|
|
38
|
+
})
|
|
39
|
+
.some((arg) => validateExpression(node, arg, context, maxInlineClasses));
|
|
40
|
+
case AST_NODE_TYPES.ConditionalExpression:
|
|
41
|
+
return (validateExpression(node, expr.consequent, context, maxInlineClasses) ||
|
|
42
|
+
validateExpression(node, expr.alternate, context, maxInlineClasses));
|
|
43
|
+
case AST_NODE_TYPES.Identifier:
|
|
44
|
+
return false;
|
|
45
|
+
case AST_NODE_TYPES.Literal:
|
|
46
|
+
if (typeof expr.value === "string") {
|
|
47
|
+
if (countClasses(expr.value) > maxInlineClasses) {
|
|
48
|
+
context.report({
|
|
49
|
+
data: { max: maxInlineClasses.toString() },
|
|
50
|
+
messageId: MESSAGE_IDS.limitedInlineClasses,
|
|
51
|
+
node: node,
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
case AST_NODE_TYPES.LogicalExpression:
|
|
58
|
+
return (validateExpression(node, expr.left, context, maxInlineClasses) ||
|
|
59
|
+
validateExpression(node, expr.right, context, maxInlineClasses));
|
|
60
|
+
case AST_NODE_TYPES.ObjectExpression:
|
|
61
|
+
return expr.properties.some((prop) => {
|
|
62
|
+
if (prop.type === AST_NODE_TYPES.Property) {
|
|
63
|
+
return validateExpression(node, prop.value &&
|
|
64
|
+
prop.value.type !== "ObjectPattern" &&
|
|
65
|
+
prop.value.type !== "ArrayPattern"
|
|
66
|
+
? prop.value
|
|
67
|
+
: null, context, maxInlineClasses);
|
|
68
|
+
}
|
|
69
|
+
// Ignore SpreadElement and other non-Property types
|
|
70
|
+
return false;
|
|
71
|
+
});
|
|
72
|
+
case AST_NODE_TYPES.TemplateLiteral:
|
|
73
|
+
// Static template literal
|
|
74
|
+
if (expr.expressions.length === 0) {
|
|
75
|
+
const raw = expr.quasis[0]?.value.cooked ?? "";
|
|
76
|
+
if (countClasses(raw) > maxInlineClasses) {
|
|
77
|
+
context.report({
|
|
78
|
+
data: { max: maxInlineClasses.toString() },
|
|
79
|
+
messageId: MESSAGE_IDS.limitedInlineClasses,
|
|
80
|
+
node: node,
|
|
81
|
+
});
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Recurse into expressions
|
|
86
|
+
return expr.expressions.some((el) => validateExpression(node, el, context, maxInlineClasses));
|
|
87
|
+
case AST_NODE_TYPES.ThisExpression:
|
|
88
|
+
return false;
|
|
89
|
+
default:
|
|
90
|
+
console.log("Unhandled expression type:", expr.type);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export const rule = createRule({
|
|
95
|
+
name: "limited-inline-classes",
|
|
96
|
+
meta: {
|
|
97
|
+
docs: {
|
|
98
|
+
description: `Allow a configurable number of inline class names; require use of tailwind-variants.`,
|
|
99
|
+
},
|
|
100
|
+
messages: {
|
|
101
|
+
limitedInlineClasses: `Inline className may contain at most {{max}} class. Use tailwind-variants instead.`,
|
|
102
|
+
noCnInClassName: "Using cn() in className is not allowed in component definition. Use tailwind-variants instead.",
|
|
103
|
+
},
|
|
104
|
+
schema: [
|
|
105
|
+
{
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
properties: {
|
|
108
|
+
directoryPattern: {
|
|
109
|
+
default: "/components/",
|
|
110
|
+
description: 'Directory pattern to match, e.g., "/components/".',
|
|
111
|
+
type: "string",
|
|
112
|
+
},
|
|
113
|
+
maxInlineClasses: {
|
|
114
|
+
default: 5,
|
|
115
|
+
description: "Maximum number of inline classes allowed (default: 5).",
|
|
116
|
+
minimum: 1,
|
|
117
|
+
type: "number",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
type: "object",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
type: "problem",
|
|
124
|
+
},
|
|
125
|
+
defaultOptions: [
|
|
126
|
+
{
|
|
127
|
+
directoryPattern: "/components/",
|
|
128
|
+
maxInlineClasses: 5,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
create: (context) => {
|
|
132
|
+
const options = context.options[0] || {};
|
|
133
|
+
const directoryPattern = options.directoryPattern || "/components/";
|
|
134
|
+
const maxInlineClasses = options.maxInlineClasses ?? 5;
|
|
135
|
+
const fileName = context.filename;
|
|
136
|
+
if (!fileName.replace(/\\/g, "/").includes(directoryPattern)) {
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
// Script visitors (for JSX in Vue <script> or React files)
|
|
140
|
+
const scriptVisitor = {
|
|
141
|
+
JSXAttribute(node) {
|
|
142
|
+
const jsxAttr = node;
|
|
143
|
+
if (jsxAttr.name.name !== "className")
|
|
144
|
+
return;
|
|
145
|
+
const value = jsxAttr.value;
|
|
146
|
+
if (!value)
|
|
147
|
+
return;
|
|
148
|
+
// className="..."
|
|
149
|
+
if (value.type === AST_NODE_TYPES.Literal &&
|
|
150
|
+
typeof value.value === "string") {
|
|
151
|
+
if (countClasses(value.value) > maxInlineClasses) {
|
|
152
|
+
context.report({
|
|
153
|
+
data: { max: maxInlineClasses.toString() },
|
|
154
|
+
messageId: MESSAGE_IDS.limitedInlineClasses,
|
|
155
|
+
node: jsxAttr,
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// className={`...`} / className={"..."}
|
|
161
|
+
if (value.type === AST_NODE_TYPES.JSXExpressionContainer) {
|
|
162
|
+
const expr = value.expression;
|
|
163
|
+
if (expr.type !== AST_NODE_TYPES.JSXEmptyExpression) {
|
|
164
|
+
validateExpression(jsxAttr, expr, context, maxInlineClasses);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
// Template visitors (for Vue <template>)
|
|
170
|
+
const templateVisitor = {
|
|
171
|
+
VAttribute(node) {
|
|
172
|
+
const vAttr = node;
|
|
173
|
+
if (!vAttr.value)
|
|
174
|
+
return;
|
|
175
|
+
// class="..."
|
|
176
|
+
if (!vAttr.directive &&
|
|
177
|
+
vAttr.key.type === "VIdentifier" &&
|
|
178
|
+
vAttr.key.name === "class") {
|
|
179
|
+
if (countClasses(vAttr.value.value) > maxInlineClasses) {
|
|
180
|
+
context.report({
|
|
181
|
+
data: { max: maxInlineClasses.toString() },
|
|
182
|
+
messageId: MESSAGE_IDS.limitedInlineClasses,
|
|
183
|
+
node: vAttr,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const container = getBindClassExpression(vAttr);
|
|
188
|
+
if (!container)
|
|
189
|
+
return;
|
|
190
|
+
// :class="..." / v-bind:class
|
|
191
|
+
if (container.expression) {
|
|
192
|
+
validateExpression(vAttr, container.expression, context, maxInlineClasses);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
return createRuleVisitors(context, templateVisitor, scriptVisitor);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
export declare const MESSAGE_IDS: {
|
|
3
|
+
readonly requireVariantsCallStylesName: "requireVariantsCallStylesName";
|
|
4
|
+
};
|
|
5
|
+
type Options = [
|
|
6
|
+
{
|
|
7
|
+
/**
|
|
8
|
+
* Name required for variables assigned from tv()
|
|
9
|
+
* @default "styles"
|
|
10
|
+
*/
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
];
|
|
14
|
+
export declare const rule: ESLintUtils.RuleModule<"requireVariantsCallStylesName", Options, unknown, ESLintUtils.RuleListener> & {
|
|
15
|
+
name: string;
|
|
16
|
+
};
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
const createRule = ESLintUtils.RuleCreator((name) => name);
|
|
3
|
+
export const MESSAGE_IDS = {
|
|
4
|
+
requireVariantsCallStylesName: "requireVariantsCallStylesName",
|
|
5
|
+
};
|
|
6
|
+
export const rule = createRule({
|
|
7
|
+
name: "require-variants-call-styles-name",
|
|
8
|
+
meta: {
|
|
9
|
+
docs: {
|
|
10
|
+
description: "Require variables assigned from tv() to be named {{name}}.",
|
|
11
|
+
},
|
|
12
|
+
fixable: "code",
|
|
13
|
+
messages: {
|
|
14
|
+
requireVariantsCallStylesName: "Variable assigned from tv() must be named '{{name}}'.",
|
|
15
|
+
},
|
|
16
|
+
schema: [
|
|
17
|
+
{
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
properties: {
|
|
20
|
+
name: {
|
|
21
|
+
default: "styles",
|
|
22
|
+
description: "Name required for variables assigned from tv().",
|
|
23
|
+
type: "string",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
type: "object",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
type: "suggestion",
|
|
30
|
+
},
|
|
31
|
+
defaultOptions: [
|
|
32
|
+
{
|
|
33
|
+
name: "styles",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
create: (context) => {
|
|
37
|
+
const options = context.options[0] || {};
|
|
38
|
+
const requiredName = options.name || "styles";
|
|
39
|
+
const variantFunctions = new Set();
|
|
40
|
+
return {
|
|
41
|
+
VariableDeclarator(node) {
|
|
42
|
+
const init = node.init;
|
|
43
|
+
const id = node.id;
|
|
44
|
+
if (!init)
|
|
45
|
+
return;
|
|
46
|
+
if (init.type !== "CallExpression")
|
|
47
|
+
return;
|
|
48
|
+
if (init.callee.type !== "Identifier")
|
|
49
|
+
return;
|
|
50
|
+
if (id.type !== "Identifier")
|
|
51
|
+
return;
|
|
52
|
+
// Always enforce required name for direct tv() assignment
|
|
53
|
+
if (init.callee.name === "tv") {
|
|
54
|
+
if (id.name !== requiredName) {
|
|
55
|
+
context.report({
|
|
56
|
+
data: {
|
|
57
|
+
functionName: "tv",
|
|
58
|
+
name: requiredName,
|
|
59
|
+
},
|
|
60
|
+
fix: (fixer) => fixer.replaceText(id, requiredName),
|
|
61
|
+
messageId: MESSAGE_IDS.requireVariantsCallStylesName,
|
|
62
|
+
node: id,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
variantFunctions.add(id.name);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (variantFunctions.has(init.callee.name)) {
|
|
69
|
+
const variableName = id.name;
|
|
70
|
+
const functionName = init.callee.name;
|
|
71
|
+
if (variableName === requiredName)
|
|
72
|
+
return;
|
|
73
|
+
context.report({
|
|
74
|
+
data: {
|
|
75
|
+
functionName,
|
|
76
|
+
name: requiredName,
|
|
77
|
+
},
|
|
78
|
+
fix: (fixer) => fixer.replaceText(id, requiredName),
|
|
79
|
+
messageId: MESSAGE_IDS.requireVariantsCallStylesName,
|
|
80
|
+
node: id,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
export declare const MESSAGE_IDS: {
|
|
3
|
+
readonly requireVariantsSuffix: "requireVariantsSuffix";
|
|
4
|
+
};
|
|
5
|
+
type Options = [
|
|
6
|
+
{
|
|
7
|
+
/**
|
|
8
|
+
* Suffix required for variables assigned from tv()
|
|
9
|
+
* @default "Variants"
|
|
10
|
+
*/
|
|
11
|
+
suffix?: string;
|
|
12
|
+
}
|
|
13
|
+
];
|
|
14
|
+
export declare const rule: ESLintUtils.RuleModule<"requireVariantsSuffix", Options, unknown, ESLintUtils.RuleListener> & {
|
|
15
|
+
name: string;
|
|
16
|
+
};
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
const createRule = ESLintUtils.RuleCreator((name) => name);
|
|
3
|
+
export const MESSAGE_IDS = {
|
|
4
|
+
requireVariantsSuffix: "requireVariantsSuffix",
|
|
5
|
+
};
|
|
6
|
+
export const rule = createRule({
|
|
7
|
+
name: "require-variants-suffix",
|
|
8
|
+
meta: {
|
|
9
|
+
docs: {
|
|
10
|
+
description: "Require variables assigned from tv() to end with {{suffix}}.",
|
|
11
|
+
},
|
|
12
|
+
fixable: "code",
|
|
13
|
+
messages: {
|
|
14
|
+
requireVariantsSuffix: "Variable assigned from tv() must end with '{{suffix}}'.",
|
|
15
|
+
},
|
|
16
|
+
schema: [
|
|
17
|
+
{
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
properties: {
|
|
20
|
+
suffix: {
|
|
21
|
+
default: "Variants",
|
|
22
|
+
description: "Suffix required for variables assigned from tv().",
|
|
23
|
+
type: "string",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
type: "object",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
type: "suggestion",
|
|
30
|
+
},
|
|
31
|
+
defaultOptions: [
|
|
32
|
+
{
|
|
33
|
+
suffix: "Variants",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
create: (context) => {
|
|
37
|
+
const options = context.options[0] || {};
|
|
38
|
+
const suffix = options.suffix || "Variants";
|
|
39
|
+
return {
|
|
40
|
+
VariableDeclarator(node) {
|
|
41
|
+
const init = node.init;
|
|
42
|
+
if (!init)
|
|
43
|
+
return;
|
|
44
|
+
if (init.type !== "CallExpression")
|
|
45
|
+
return;
|
|
46
|
+
if (init.callee.type !== "Identifier")
|
|
47
|
+
return;
|
|
48
|
+
if (init.callee.name !== "tv")
|
|
49
|
+
return;
|
|
50
|
+
const { id } = node;
|
|
51
|
+
if (id.type !== "Identifier")
|
|
52
|
+
return;
|
|
53
|
+
if (id.name.endsWith(suffix))
|
|
54
|
+
return;
|
|
55
|
+
context.report({
|
|
56
|
+
data: { suffix },
|
|
57
|
+
fix: (fixer) => {
|
|
58
|
+
return fixer.insertTextAfter(id, suffix);
|
|
59
|
+
},
|
|
60
|
+
messageId: MESSAGE_IDS.requireVariantsSuffix,
|
|
61
|
+
node: id,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { RuleContext, RuleListener } from "@typescript-eslint/utils/ts-eslint";
|
|
2
|
+
/**
|
|
3
|
+
* Creates rule visitors that work for both Vue single-file components and
|
|
4
|
+
* regular script files (e.g., React).
|
|
5
|
+
*/
|
|
6
|
+
export declare function createRuleVisitors<TMessageIds extends string, TOptions extends readonly unknown[]>(context: RuleContext<TMessageIds, TOptions>, templateVisitor: RuleListener, scriptVisitor: RuleListener): RuleListener;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates rule visitors that work for both Vue single-file components and
|
|
3
|
+
* regular script files (e.g., React).
|
|
4
|
+
*/
|
|
5
|
+
export function createRuleVisitors(context, templateVisitor, scriptVisitor) {
|
|
6
|
+
const fileName = context.filename;
|
|
7
|
+
if (fileName.endsWith(".vue")) {
|
|
8
|
+
const sourceCode = context.sourceCode;
|
|
9
|
+
const parserServices = sourceCode.parserServices;
|
|
10
|
+
if (isVueParserServices(parserServices)) {
|
|
11
|
+
return parserServices.defineTemplateBodyVisitor(templateVisitor, scriptVisitor);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return scriptVisitor;
|
|
15
|
+
}
|
|
16
|
+
function isVueParserServices(services) {
|
|
17
|
+
return services !== undefined && "defineTemplateBodyVisitor" in services;
|
|
18
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return the expression container for a bound class attribute,
|
|
3
|
+
* or null if not applicable
|
|
4
|
+
*/
|
|
5
|
+
export function getBindClassExpression(node) {
|
|
6
|
+
if (!node.directive)
|
|
7
|
+
return null;
|
|
8
|
+
if (!node.value)
|
|
9
|
+
return null;
|
|
10
|
+
const key = node.key;
|
|
11
|
+
if (key.name.name !== "bind")
|
|
12
|
+
return null;
|
|
13
|
+
if (key.argument?.type !== "VIdentifier")
|
|
14
|
+
return null;
|
|
15
|
+
if (key.argument.name !== "class")
|
|
16
|
+
return null;
|
|
17
|
+
if (!("expression" in node.value))
|
|
18
|
+
return null;
|
|
19
|
+
return node.value;
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-tailwind-variants",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ESLint plugin for Tailwind Variants",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/domingasp/eslint-plugin-tailwind-variants.git"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"lint": "eslint . --ext .ts",
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"prepublishOnly": "pnpm run build",
|
|
21
|
+
"test": "vitest",
|
|
22
|
+
"test:debug": "vitest --reporter=verbose",
|
|
23
|
+
"prepare": "husky"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"eslint",
|
|
27
|
+
"eslint-plugin",
|
|
28
|
+
"tailwind",
|
|
29
|
+
"tailwind-variants"
|
|
30
|
+
],
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"author": "madebydomdev",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"packageManager": "pnpm@10.22.0",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@typescript-eslint/parser": ">8.0.0",
|
|
42
|
+
"eslint": ">9.0.0",
|
|
43
|
+
"vue-eslint-parser": "^9.4.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@typescript-eslint/utils": "^8.51.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@commitlint/cli": "^20.3.0",
|
|
50
|
+
"@commitlint/config-conventional": "^20.3.0",
|
|
51
|
+
"@eslint/js": "^9.39.2",
|
|
52
|
+
"@types/node": "^25.0.3",
|
|
53
|
+
"@typescript-eslint/parser": "^8.51.0",
|
|
54
|
+
"@typescript-eslint/rule-tester": "^8.51.0",
|
|
55
|
+
"eslint": "^9.39.2",
|
|
56
|
+
"eslint-plugin-perfectionist": "^5.2.0",
|
|
57
|
+
"globals": "^17.0.0",
|
|
58
|
+
"husky": "^9.1.7",
|
|
59
|
+
"jiti": "^2.6.1",
|
|
60
|
+
"typescript": "^5.9.3",
|
|
61
|
+
"typescript-eslint": "^8.51.0",
|
|
62
|
+
"vitest": "^4.0.16",
|
|
63
|
+
"vue-eslint-parser": "^10.2.0"
|
|
64
|
+
}
|
|
65
|
+
}
|