eslint-plugin-tailwind-variants 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +42 -37
  19. package/src/index.js +62 -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,28 @@
1
+ /**
2
+ * Create and return rule visitors that work for both Vue single-file components
3
+ * and regular script files.
4
+ * @param {import("eslint").Rule.RuleContext} context
5
+ * @param {import("eslint").Rule.RuleListener} templateVisitor
6
+ * @param {import("eslint").Rule.RuleListener} scriptVisitor
7
+ * @returns {import("eslint").Rule.RuleListener} Appropriate visitor based on the file type.
8
+ */
9
+ export const createRuleVisitors = (context, templateVisitor, scriptVisitor) => {
10
+ const fileName = context.filename;
11
+
12
+ if (fileName.endsWith(".vue")) {
13
+ const { sourceCode } = context;
14
+ const { parserServices } = sourceCode;
15
+
16
+ if (
17
+ typeof parserServices !== "undefined" &&
18
+ "defineTemplateBodyVisitor" in parserServices
19
+ ) {
20
+ return parserServices.defineTemplateBodyVisitor(
21
+ templateVisitor,
22
+ scriptVisitor,
23
+ );
24
+ }
25
+ }
26
+
27
+ return scriptVisitor;
28
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Return the expression container for a bound class attribute,
3
+ * or undefined if not applicable.
4
+ * @param {import("vue-eslint-parser").AST.VDirectiveKey} key
5
+ * @param {import("vue-eslint-parser").AST.VLiteral | import("vue-eslint-parser").AST.VExpressionContainer | null} value
6
+ * @returns {import("vue-eslint-parser").AST.VExpressionContainer | undefined} Expression container if valid.
7
+ */
8
+ export const getBindClassExpression = (key, value) => {
9
+ if (!isBindClassDirective(key)) {
10
+ return;
11
+ }
12
+
13
+ if (!value || value.type !== "VExpressionContainer") {
14
+ return;
15
+ }
16
+
17
+ return value;
18
+ };
19
+
20
+ /**
21
+ * Check if node is a valid directive with an expression value.
22
+ * @param {import("vue-eslint-parser").AST.VAttribute} node
23
+ * @returns {boolean} `true` if node is a valid directive with an expression value.
24
+ */
25
+ export const isValidDirective = (node) =>
26
+ Boolean(node.directive && node.value && "expression" in node.value);
27
+
28
+ /**
29
+ * Check if directive is a v-bind:class or :class directive.
30
+ * @param {import("vue-eslint-parser").AST.VDirectiveKey} key
31
+ * @returns {boolean} `true` if directive is a v-bind:class or :class directive.
32
+ */
33
+ const isBindClassDirective = (key) =>
34
+ key.name.name === "bind" &&
35
+ key.argument?.type === "VIdentifier" &&
36
+ key.argument.name === "class";
package/dist/index.d.ts DELETED
@@ -1,11 +0,0 @@
1
- import { ESLint, Linter } from "eslint";
2
- type PluginConfigs = {
3
- recommended: Linter.Config[];
4
- };
5
- declare const plugin: ESLint.Plugin;
6
- export declare const configs: PluginConfigs;
7
- export { plugin };
8
- declare const _default: ESLint.Plugin & {
9
- configs: PluginConfigs;
10
- };
11
- export default _default;
package/dist/index.js DELETED
@@ -1,43 +0,0 @@
1
- import css from "@eslint/css";
2
- import { name as packageName, version as packageVersion, } from "../package.json";
3
- import { rules } from "./rules/index.js";
4
- const pluginName = "tailwind-variants";
5
- const plugin = {
6
- meta: {
7
- name: packageName,
8
- version: packageVersion,
9
- },
10
- rules,
11
- };
12
- export const configs = {
13
- recommended: [
14
- {
15
- plugins: {
16
- [pluginName]: plugin,
17
- },
18
- rules: {
19
- [`${pluginName}/limited-inline-classes`]: "error",
20
- [`${pluginName}/require-variants-call-styles-name`]: "error",
21
- [`${pluginName}/require-variants-suffix`]: "error",
22
- },
23
- },
24
- {
25
- files: ["**/*.css"],
26
- language: "css/css",
27
- plugins: { css },
28
- rules: {
29
- [`${pluginName}/sort-custom-properties`]: [
30
- "error",
31
- {
32
- emptyLineBetweenGroups: true,
33
- },
34
- ],
35
- },
36
- },
37
- ],
38
- };
39
- export { plugin };
40
- export default {
41
- ...plugin,
42
- configs,
43
- };
@@ -1,2 +0,0 @@
1
- import { ESLint } from "eslint";
2
- export declare const rules: ESLint.Plugin["rules"];
@@ -1,23 +0,0 @@
1
- import { ESLintUtils } from "@typescript-eslint/utils";
2
- export declare const MESSAGE_IDS: {
3
- readonly limitedInlineClasses: "limitedInlineClasses";
4
- readonly noCnInClassName: "noCnInClassName";
5
- };
6
- export type MessageIds = (typeof MESSAGE_IDS)[keyof typeof MESSAGE_IDS];
7
- export 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
- };
@@ -1,198 +0,0 @@
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
- });
@@ -1,17 +0,0 @@
1
- import { ESLintUtils } from "@typescript-eslint/utils";
2
- export declare const MESSAGE_IDS: {
3
- readonly requireVariantsCallStylesName: "requireVariantsCallStylesName";
4
- };
5
- export type MessageIds = (typeof MESSAGE_IDS)[keyof typeof MESSAGE_IDS];
6
- export type Options = [
7
- {
8
- /**
9
- * Name required for variables assigned from tv()
10
- * @default "styles"
11
- */
12
- name?: string;
13
- }
14
- ];
15
- export declare const rule: ESLintUtils.RuleModule<"requireVariantsCallStylesName", Options, unknown, ESLintUtils.RuleListener> & {
16
- name: string;
17
- };
@@ -1,75 +0,0 @@
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 calling a function returned by tv() to be named {{name}}.",
11
- },
12
- fixable: "code",
13
- messages: {
14
- requireVariantsCallStylesName: "Require variables assigned from calling a function returned by tv() to 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
- // Track variant functions created by tv()
53
- if (init.callee.name === "tv") {
54
- variantFunctions.add(id.name);
55
- return;
56
- }
57
- if (variantFunctions.has(init.callee.name)) {
58
- const variableName = id.name;
59
- const functionName = init.callee.name;
60
- if (variableName === requiredName)
61
- return;
62
- context.report({
63
- data: {
64
- functionName,
65
- name: requiredName,
66
- },
67
- fix: (fixer) => fixer.replaceText(id, requiredName),
68
- messageId: MESSAGE_IDS.requireVariantsCallStylesName,
69
- node: id,
70
- });
71
- }
72
- },
73
- };
74
- },
75
- });
@@ -1,17 +0,0 @@
1
- import { ESLintUtils } from "@typescript-eslint/utils";
2
- export declare const MESSAGE_IDS: {
3
- readonly requireVariantsSuffix: "requireVariantsSuffix";
4
- };
5
- export type MessageIds = (typeof MESSAGE_IDS)[keyof typeof MESSAGE_IDS];
6
- export type Options = [
7
- {
8
- /**
9
- * Suffix required for variables assigned from tv()
10
- * @default "Variants"
11
- */
12
- suffix?: string;
13
- }
14
- ];
15
- export declare const rule: ESLintUtils.RuleModule<"requireVariantsSuffix", Options, unknown, ESLintUtils.RuleListener> & {
16
- name: string;
17
- };
@@ -1,66 +0,0 @@
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
- });
@@ -1,37 +0,0 @@
1
- import { ESLintUtils } from "@typescript-eslint/utils";
2
- export declare const MESSAGE_IDS: {
3
- readonly missingEmptyLineBetweenGroups: "missingEmptyLineBetweenGroups";
4
- readonly patternTooLong: "patternTooLong";
5
- readonly unsortedCustomProperties: "unsortedCustomProperties";
6
- };
7
- export type MessageIds = (typeof MESSAGE_IDS)[keyof typeof MESSAGE_IDS];
8
- export type Options = [
9
- {
10
- /**
11
- * Add empty line between different prefix groups
12
- * @default false
13
- */
14
- emptyLineBetweenGroups?: boolean;
15
- /**
16
- * Order of patterns (RegExp strings) for custom properties.
17
- * Properties matching the first pattern appear first.
18
- * @default [
19
- * "^--spacing-",
20
- * "^--size-",
21
- * "^--font-",
22
- * "^--weight-",
23
- * "^--leading-",
24
- * "^--tracking-",
25
- * "^--radius-",
26
- * "^--shadow-",
27
- * "^--animate-",
28
- * "^--transition-",
29
- * "^--color-",
30
- * ]
31
- */
32
- order?: string[];
33
- }
34
- ];
35
- export declare const rule: ESLintUtils.RuleModule<MessageIds, Options, unknown, ESLintUtils.RuleListener> & {
36
- name: string;
37
- };