@tailwind-ts/eslint-plugin 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eslint-plugin-tailwind-ts contributors
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,123 @@
1
+ # @tailwind-ts/eslint-plugin
2
+
3
+ ESLint plugin for detecting invalid Tailwind CSS v4 class names — typos, undefined utilities, and bad `@apply` usage — in JavaScript, TypeScript, CSS, and Vue projects.
4
+
5
+ ## Features
6
+
7
+ - **`cva()` variant validation** — lints base classes and variant values inside `class-variance-authority` configs
8
+ - **CSS `@apply` linting** — catches invalid classes inside `.css` files
9
+ - **Typo suggestions** — reports `Did you mean "flex"?` when a close match exists in your design system
10
+ - **Import-aware helpers** — validates `cn()` / `clsx()` even when imported from `@/lib/utils`
11
+ - **Vue `:class` support** — lints static and object-style class bindings in `.vue` files
12
+ - **More helpers** — `tv()` (tailwind-variants) added to defaults
13
+ - **Config presets** — `react`, `vue`, and `css` presets for faster setup
14
+ - **Multi-entry CSS** — `cssConfigPath` accepts a string or array for monorepos
15
+ - **Batch validation** — faster lint runs via batched worker calls and shared design-system cache
16
+ - Tailwind CSS v4 support via `@tailwindcss/node`
17
+ - ESLint flat config (`eslint.config.js`)
18
+ - TypeScript-native settings with exported `PluginSettings` type
19
+
20
+ ## Requirements
21
+
22
+ - Node.js `>= 20.19.0`
23
+ - ESLint `^9.0.0 || ^10.0.0`
24
+ - Tailwind CSS `^4.0.0`
25
+ - `@tailwindcss/node` `^4.0.0`
26
+ - `vue-eslint-parser` (optional, for Vue SFC support)
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ npm install -D eslint @tailwind-ts/eslint-plugin tailwindcss @tailwindcss/node
32
+ ```
33
+
34
+ For Vue projects:
35
+
36
+ ```bash
37
+ npm install -D vue-eslint-parser
38
+ ```
39
+
40
+ ## Quick start
41
+
42
+ ```js
43
+ // eslint.config.js
44
+ import tailwindTs from "@tailwind-ts/eslint-plugin";
45
+ import { defineConfig } from "eslint/config";
46
+
47
+ export default defineConfig([
48
+ {
49
+ extends: [tailwindTs.configs.recommended],
50
+ settings: {
51
+ tailwindTs: {
52
+ cssConfigPath: "./src/styles/tailwind.css",
53
+ },
54
+ },
55
+ },
56
+ ]);
57
+ ```
58
+
59
+ Your CSS entry file should import Tailwind v4:
60
+
61
+ ```css
62
+ @import "tailwindcss";
63
+ ```
64
+
65
+ ## Config presets
66
+
67
+ ```js
68
+ import tailwindTs from "@tailwind-ts/eslint-plugin";
69
+
70
+ export default [
71
+ {
72
+ extends: [tailwindTs.configs.react],
73
+ settings: {
74
+ tailwindTs: { cssConfigPath: "./src/styles/tailwind.css" },
75
+ },
76
+ },
77
+ ];
78
+ ```
79
+
80
+ | Preset | Files | Notes |
81
+ |--------|-------|-------|
82
+ | `recommended` | `*.{js,jsx,ts,tsx}` | Default JSX + helper validation |
83
+ | `react` | Same as recommended | Alias for React/Next.js apps |
84
+ | `vue` | `*.{vue,js,ts}` | Enables `:class` parsing (requires `vue-eslint-parser`) |
85
+ | `css` | `**/*.css` | Enables `@apply` validation |
86
+
87
+ ## Rules
88
+
89
+ | Rule | Description | Default |
90
+ |------|-------------|---------|
91
+ | [`no-invalid-classname`](./docs/rules/no-invalid-classname.md) | Disallow invalid Tailwind classes in JSX, helpers, and Vue bindings | `error` |
92
+ | [`no-invalid-apply`](./docs/rules/no-invalid-apply.md) | Disallow invalid classes in CSS `@apply` directives | `error` |
93
+
94
+ ## Settings
95
+
96
+ | Option | Type | Default | Description |
97
+ |--------|------|---------|-------------|
98
+ | `cssConfigPath` | `string \| string[]` | — | **Required.** Tailwind v4 CSS entry path(s) |
99
+ | `attributes` | `string[]` | `["class", "className"]` | JSX/Vue attributes to lint |
100
+ | `functions` | `string[]` | `["cn", "clsx", "cva", "tv", "twMerge", "twJoin"]` | Helper calls to lint |
101
+ | `whitelist` | `string[]` | `[]` | Regex patterns for allowed non-Tailwind classes |
102
+ | `suggestSimilarClasses` | `boolean` | `true` | Include "Did you mean …?" hints for typos |
103
+
104
+ ### Typesafe settings
105
+
106
+ ```js
107
+ settings: {
108
+ tailwindTs:
109
+ /** @type {import('@tailwind-ts/eslint-plugin').PluginSettings} */
110
+ ({
111
+ cssConfigPath: "./src/styles/tailwind.css",
112
+ }),
113
+ },
114
+ ```
115
+
116
+ ## Limitations
117
+
118
+ - Dynamic template interpolation (e.g. `` className={`p-${size}`} ``) is not validated
119
+ - Vue support requires `vue-eslint-parser` in your ESLint config
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,37 @@
1
+ # no-invalid-apply
2
+
3
+ Disallow invalid Tailwind CSS class names inside `@apply` directives in CSS files.
4
+
5
+ ## Rule details
6
+
7
+ Parses `@apply` blocks in `*.css` files and validates each class token against your Tailwind v4 design system.
8
+
9
+ ### Examples
10
+
11
+ ```css
12
+ /* bad */
13
+ .card {
14
+ @apply flexs items-center rounded-lg;
15
+ }
16
+
17
+ /* good */
18
+ .card {
19
+ @apply flex items-center rounded-lg;
20
+ }
21
+ ```
22
+
23
+ ## Options
24
+
25
+ ```ts
26
+ {
27
+ whitelist?: string[];
28
+ }
29
+ ```
30
+
31
+ ## Enable
32
+
33
+ Use the `css` preset or enable manually:
34
+
35
+ ```js
36
+ extends: [tailwindTs.configs.css]
37
+ ```
@@ -0,0 +1,39 @@
1
+ # `no-invalid-classname`
2
+
3
+ Disallow invalid or typo Tailwind CSS class names in JSX attributes and common utility functions.
4
+
5
+ ## Rule details
6
+
7
+ This rule validates class strings against your project's Tailwind CSS v4 design system loaded from `settings.tailwindTs.cssConfigPath`.
8
+
9
+ ### Examples
10
+
11
+ ```jsx
12
+ // bad
13
+ <div className="flexs items-center" />
14
+
15
+ // good
16
+ <div className="flex items-center" />
17
+ ```
18
+
19
+ ```ts
20
+ // bad
21
+ const cls = cn("p-4", "text-lgg");
22
+
23
+ // good
24
+ const cls = cn("p-4", "text-lg");
25
+ ```
26
+
27
+ ## Options
28
+
29
+ ```ts
30
+ {
31
+ whitelist?: string[];
32
+ }
33
+ ```
34
+
35
+ Regex patterns for classes that should be ignored by this rule.
36
+
37
+ ## Shared settings
38
+
39
+ This rule uses `settings.tailwindTs` from the plugin. See the package README for the full settings reference.
package/lib/index.cjs ADDED
@@ -0,0 +1,370 @@
1
+ 'use strict';
2
+
3
+ const utils = require('@typescript-eslint/utils');
4
+ const node_fs = require('node:fs');
5
+ const node_path = require('node:path');
6
+ const node_url = require('node:url');
7
+ const synckit = require('synckit');
8
+
9
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
10
+ const name = "@tailwind-ts/eslint-plugin";
11
+ const version = "0.1.0";
12
+ const packageJson = {
13
+ name: name,
14
+ version: version};
15
+
16
+ function isStringLiteral(node) {
17
+ return node.type === "Literal" && typeof node.value === "string";
18
+ }
19
+ function collectFromExpression(node, ignoredKeys, functionNames, literals) {
20
+ if (!node || node.type === "SpreadElement") {
21
+ return;
22
+ }
23
+ if (isStringLiteral(node)) {
24
+ literals.push({ value: node.value, node });
25
+ return;
26
+ }
27
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
28
+ const value = node.quasis.map((quasi) => quasi.value.cooked ?? "").join("");
29
+ literals.push({ value, node });
30
+ return;
31
+ }
32
+ if (node.type === "LogicalExpression") {
33
+ collectFromExpression(node.left, ignoredKeys, functionNames, literals);
34
+ collectFromExpression(node.right, ignoredKeys, functionNames, literals);
35
+ return;
36
+ }
37
+ if (node.type === "ConditionalExpression") {
38
+ collectFromExpression(node.consequent, ignoredKeys, functionNames, literals);
39
+ collectFromExpression(node.alternate, ignoredKeys, functionNames, literals);
40
+ return;
41
+ }
42
+ if (node.type === "ArrayExpression") {
43
+ for (const element of node.elements) {
44
+ collectFromExpression(element, ignoredKeys, functionNames, literals);
45
+ }
46
+ return;
47
+ }
48
+ if (node.type === "ObjectExpression") {
49
+ for (const property of node.properties) {
50
+ if (property.type === "Property") {
51
+ if (property.key.type === "Identifier") {
52
+ if (ignoredKeys.has(property.key.name)) {
53
+ continue;
54
+ }
55
+ }
56
+ collectFromExpression(
57
+ property.value,
58
+ ignoredKeys,
59
+ functionNames,
60
+ literals
61
+ );
62
+ }
63
+ }
64
+ return;
65
+ }
66
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
67
+ if (functionNames.has(node.callee.name)) {
68
+ for (const argument of node.arguments) {
69
+ collectFromExpression(
70
+ argument,
71
+ ignoredKeys,
72
+ functionNames,
73
+ literals
74
+ );
75
+ }
76
+ }
77
+ }
78
+ }
79
+ function extractClassLiteralsFromJsxAttribute(attribute, options) {
80
+ const literals = [];
81
+ const ignoredKeys = new Set(options.ignoredKeys);
82
+ const functionNames = new Set(options.functions);
83
+ if (!attribute.value) {
84
+ return literals;
85
+ }
86
+ if (attribute.value.type === "Literal" && typeof attribute.value.value === "string") {
87
+ literals.push({ value: attribute.value.value, node: attribute.value });
88
+ return literals;
89
+ }
90
+ if (attribute.value.type === "JSXExpressionContainer") {
91
+ collectFromExpression(
92
+ attribute.value.expression,
93
+ ignoredKeys,
94
+ functionNames,
95
+ literals
96
+ );
97
+ }
98
+ return literals;
99
+ }
100
+ function extractClassLiteralsFromCallExpression(callExpression, options) {
101
+ if (callExpression.callee.type !== "Identifier") {
102
+ return [];
103
+ }
104
+ const functionNames = new Set(options.functions);
105
+ if (!functionNames.has(callExpression.callee.name)) {
106
+ return [];
107
+ }
108
+ const literals = [];
109
+ const ignoredKeys = new Set(options.ignoredKeys);
110
+ for (const argument of callExpression.arguments) {
111
+ collectFromExpression(
112
+ argument,
113
+ ignoredKeys,
114
+ functionNames,
115
+ literals
116
+ );
117
+ }
118
+ return literals;
119
+ }
120
+
121
+ const DEFAULT_ATTRIBUTES = ["class", "className"];
122
+ const DEFAULT_FUNCTIONS = [
123
+ "cn",
124
+ "clsx",
125
+ "cva",
126
+ "twMerge",
127
+ "twJoin"
128
+ ];
129
+ const DEFAULT_IGNORED_KEYS = [
130
+ "defaultVariants",
131
+ "compoundVariants",
132
+ "compoundSlots"
133
+ ];
134
+ function toRegExp(pattern) {
135
+ return new RegExp(pattern);
136
+ }
137
+ function parsePluginSettings(settings) {
138
+ if (!settings?.cssConfigPath) {
139
+ throw new Error(
140
+ "eslint-plugin-tailwind-ts: settings.tailwindTs.cssConfigPath is required (path to your Tailwind v4 CSS entry file)."
141
+ );
142
+ }
143
+ const whitelist = (settings.whitelist ?? []).map(toRegExp);
144
+ return {
145
+ cssConfigPath: settings.cssConfigPath,
146
+ attributes: settings.attributes ?? [...DEFAULT_ATTRIBUTES],
147
+ functions: settings.functions ?? [...DEFAULT_FUNCTIONS],
148
+ whitelist,
149
+ ignoredKeys: settings.ignoredKeys ?? [...DEFAULT_IGNORED_KEYS]
150
+ };
151
+ }
152
+ function isWhitelisted(className, whitelist) {
153
+ return whitelist.some((pattern) => pattern.test(className));
154
+ }
155
+ function splitClassTokens(classString) {
156
+ return classString.trim().split(/\s+/).filter(Boolean);
157
+ }
158
+
159
+ const CACHE_TTL_MS = 10 * 60 * 1e3;
160
+ const resultCache = /* @__PURE__ */ new Map();
161
+ const compilerMtimeCache = /* @__PURE__ */ new Map();
162
+ function resolveWorkerPath() {
163
+ const dir = node_path.dirname(node_url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
164
+ const candidates = [
165
+ node_path.join(dir, "../worker/validate.mjs"),
166
+ node_path.join(dir, "../../worker/validate.mjs")
167
+ ];
168
+ for (const candidate of candidates) {
169
+ if (node_fs.existsSync(candidate)) {
170
+ return candidate;
171
+ }
172
+ }
173
+ throw new Error("eslint-plugin-tailwind-ts: validation worker not found");
174
+ }
175
+ const runValidate = synckit.createSyncFn(resolveWorkerPath());
176
+ function resolveCssConfigPath(cssConfigPath, cwd) {
177
+ return node_path.isAbsolute(cssConfigPath) ? cssConfigPath : node_path.resolve(cwd, cssConfigPath);
178
+ }
179
+ function getCssMtime(cssConfigPath) {
180
+ return node_fs.statSync(cssConfigPath).mtimeMs;
181
+ }
182
+ function isCompilerCacheValid(cssConfigPath) {
183
+ const cached = compilerMtimeCache.get(cssConfigPath);
184
+ if (!cached) {
185
+ return false;
186
+ }
187
+ try {
188
+ return cached.mtimeMs === getCssMtime(cssConfigPath);
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+ function touchCompilerCache(cssConfigPath) {
194
+ compilerMtimeCache.set(cssConfigPath, {
195
+ mtimeMs: getCssMtime(cssConfigPath)
196
+ });
197
+ }
198
+ function invalidateResultCacheForConfig(cssConfigPath) {
199
+ for (const key of resultCache.keys()) {
200
+ if (key.startsWith(`${cssConfigPath}::`)) {
201
+ resultCache.delete(key);
202
+ }
203
+ }
204
+ }
205
+ function validateTailwindClass(request) {
206
+ const resolvedCssPath = resolveCssConfigPath(
207
+ request.cssConfigPath,
208
+ request.cwd
209
+ );
210
+ if (!isCompilerCacheValid(resolvedCssPath)) {
211
+ invalidateResultCacheForConfig(resolvedCssPath);
212
+ touchCompilerCache(resolvedCssPath);
213
+ }
214
+ const cacheKey = `${resolvedCssPath}::${request.className}`;
215
+ const cached = resultCache.get(cacheKey);
216
+ if (cached && cached.expiresAt > Date.now()) {
217
+ return { valid: cached.valid };
218
+ }
219
+ const response = runValidate({
220
+ cssConfigPath: resolvedCssPath,
221
+ className: request.className,
222
+ cwd: request.cwd
223
+ });
224
+ resultCache.set(cacheKey, {
225
+ valid: response.valid,
226
+ expiresAt: Date.now() + CACHE_TTL_MS
227
+ });
228
+ return response;
229
+ }
230
+
231
+ const RULE_NAME = "no-invalid-classname";
232
+ const noInvalidClassname = utils.ESLintUtils.RuleCreator.withoutDocs({
233
+ meta: {
234
+ type: "problem",
235
+ docs: {
236
+ description: "Disallow invalid or typo Tailwind CSS class names in JSX and utility functions."
237
+ },
238
+ schema: [
239
+ {
240
+ type: "object",
241
+ additionalProperties: false,
242
+ properties: {
243
+ whitelist: {
244
+ type: "array",
245
+ items: { type: "string" }
246
+ }
247
+ }
248
+ }
249
+ ],
250
+ messages: {
251
+ invalidClass: 'Class "{{ className }}" is not a valid Tailwind CSS class.'
252
+ }
253
+ },
254
+ defaultOptions: [{}],
255
+ create(context) {
256
+ const pluginSettings = context.settings.tailwindTs;
257
+ let resolvedSettings;
258
+ try {
259
+ resolvedSettings = parsePluginSettings(pluginSettings);
260
+ } catch (error) {
261
+ const message = error instanceof Error ? error.message : "Invalid plugin settings.";
262
+ context.report({
263
+ loc: { line: 1, column: 0 },
264
+ message
265
+ });
266
+ return {};
267
+ }
268
+ const ruleWhitelist = (context.options[0]?.whitelist ?? []).map(
269
+ (pattern) => new RegExp(pattern)
270
+ );
271
+ const whitelist = [...resolvedSettings.whitelist, ...ruleWhitelist];
272
+ const attributeNames = new Set(resolvedSettings.attributes);
273
+ const cwd = context.cwd ?? process.cwd();
274
+ function reportInvalidClasses(literals) {
275
+ for (const literal of literals) {
276
+ for (const className of splitClassTokens(literal.value)) {
277
+ if (isWhitelisted(className, whitelist)) {
278
+ continue;
279
+ }
280
+ const result = validateTailwindClass({
281
+ cssConfigPath: resolvedSettings.cssConfigPath,
282
+ className,
283
+ cwd
284
+ });
285
+ if (!result.valid) {
286
+ context.report({
287
+ node: literal.node,
288
+ messageId: "invalidClass",
289
+ data: { className }
290
+ });
291
+ }
292
+ }
293
+ }
294
+ }
295
+ return {
296
+ JSXAttribute(node) {
297
+ if (node.name.type !== "JSXIdentifier" || !attributeNames.has(node.name.name)) {
298
+ return;
299
+ }
300
+ reportInvalidClasses(
301
+ extractClassLiteralsFromJsxAttribute(node, {
302
+ functions: resolvedSettings.functions,
303
+ ignoredKeys: resolvedSettings.ignoredKeys
304
+ })
305
+ );
306
+ },
307
+ CallExpression(node) {
308
+ reportInvalidClasses(
309
+ extractClassLiteralsFromCallExpression(node, {
310
+ functions: resolvedSettings.functions,
311
+ ignoredKeys: resolvedSettings.ignoredKeys
312
+ })
313
+ );
314
+ }
315
+ };
316
+ }
317
+ });
318
+
319
+ const createConfig = (rules) => {
320
+ const result = {};
321
+ for (const [ruleName, value] of Object.entries(rules)) {
322
+ result[`tailwind-ts/${ruleName}`] = value;
323
+ }
324
+ return result;
325
+ };
326
+ const plugin = {
327
+ meta: {
328
+ name: packageJson.name,
329
+ version: packageJson.version
330
+ },
331
+ configs: {
332
+ get recommended() {
333
+ return sharedConfigs.recommended;
334
+ }
335
+ },
336
+ rules: {
337
+ [RULE_NAME]: noInvalidClassname
338
+ }
339
+ };
340
+ const recommendedRules = {
341
+ [RULE_NAME]: "error"
342
+ };
343
+ const configBase = {
344
+ name: "tailwind-ts/base",
345
+ plugins: {
346
+ "tailwind-ts": plugin
347
+ },
348
+ settings: {
349
+ tailwindTs: {}
350
+ },
351
+ files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
352
+ languageOptions: {
353
+ parserOptions: {
354
+ ecmaVersion: "latest",
355
+ sourceType: "module",
356
+ ecmaFeatures: {
357
+ jsx: true
358
+ }
359
+ }
360
+ }
361
+ };
362
+ const sharedConfigs = {
363
+ recommended: {
364
+ ...configBase,
365
+ name: "tailwind-ts/recommended",
366
+ rules: createConfig(recommendedRules)
367
+ }
368
+ };
369
+
370
+ module.exports = plugin;
@@ -0,0 +1,33 @@
1
+ import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
2
+ import { FlatConfig } from '@typescript-eslint/utils/ts-eslint';
3
+
4
+ interface PluginSettings {
5
+ /** Required. Path to Tailwind v4 CSS entry, e.g. "./src/styles/tailwind.css" */
6
+ cssConfigPath: string;
7
+ /** Attributes/props that may contain Tailwind CSS classes */
8
+ attributes?: string[];
9
+ /** Functions whose arguments will be parsed for class strings */
10
+ functions?: string[];
11
+ /** Whitelist regex patterns for non-Tailwind classes */
12
+ whitelist?: string[];
13
+ /** Keys to ignore in object expressions */
14
+ ignoredKeys?: string[];
15
+ }
16
+
17
+ declare const plugin: {
18
+ meta: {
19
+ name: string;
20
+ version: string;
21
+ };
22
+ configs: {
23
+ readonly recommended: FlatConfig.Config | FlatConfig.ConfigArray;
24
+ };
25
+ rules: {
26
+ "no-invalid-classname": _typescript_eslint_utils_ts_eslint.RuleModule<"invalidClass", [({
27
+ whitelist?: string[];
28
+ } | undefined)?], unknown, _typescript_eslint_utils_ts_eslint.RuleListener>;
29
+ };
30
+ };
31
+
32
+ export = plugin;
33
+ export type { PluginSettings };
@@ -0,0 +1,33 @@
1
+ import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
2
+ import { FlatConfig } from '@typescript-eslint/utils/ts-eslint';
3
+
4
+ interface PluginSettings {
5
+ /** Required. Path to Tailwind v4 CSS entry, e.g. "./src/styles/tailwind.css" */
6
+ cssConfigPath: string;
7
+ /** Attributes/props that may contain Tailwind CSS classes */
8
+ attributes?: string[];
9
+ /** Functions whose arguments will be parsed for class strings */
10
+ functions?: string[];
11
+ /** Whitelist regex patterns for non-Tailwind classes */
12
+ whitelist?: string[];
13
+ /** Keys to ignore in object expressions */
14
+ ignoredKeys?: string[];
15
+ }
16
+
17
+ declare const plugin: {
18
+ meta: {
19
+ name: string;
20
+ version: string;
21
+ };
22
+ configs: {
23
+ readonly recommended: FlatConfig.Config | FlatConfig.ConfigArray;
24
+ };
25
+ rules: {
26
+ "no-invalid-classname": _typescript_eslint_utils_ts_eslint.RuleModule<"invalidClass", [({
27
+ whitelist?: string[];
28
+ } | undefined)?], unknown, _typescript_eslint_utils_ts_eslint.RuleListener>;
29
+ };
30
+ };
31
+
32
+ export { plugin as default };
33
+ export type { PluginSettings };
package/lib/index.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
2
+ import { FlatConfig } from '@typescript-eslint/utils/ts-eslint';
3
+
4
+ interface PluginSettings {
5
+ /** Required. Path to Tailwind v4 CSS entry, e.g. "./src/styles/tailwind.css" */
6
+ cssConfigPath: string;
7
+ /** Attributes/props that may contain Tailwind CSS classes */
8
+ attributes?: string[];
9
+ /** Functions whose arguments will be parsed for class strings */
10
+ functions?: string[];
11
+ /** Whitelist regex patterns for non-Tailwind classes */
12
+ whitelist?: string[];
13
+ /** Keys to ignore in object expressions */
14
+ ignoredKeys?: string[];
15
+ }
16
+
17
+ declare const plugin: {
18
+ meta: {
19
+ name: string;
20
+ version: string;
21
+ };
22
+ configs: {
23
+ readonly recommended: FlatConfig.Config | FlatConfig.ConfigArray;
24
+ };
25
+ rules: {
26
+ "no-invalid-classname": _typescript_eslint_utils_ts_eslint.RuleModule<"invalidClass", [({
27
+ whitelist?: string[];
28
+ } | undefined)?], unknown, _typescript_eslint_utils_ts_eslint.RuleListener>;
29
+ };
30
+ };
31
+
32
+ export = plugin;
33
+ export type { PluginSettings };
package/lib/index.mjs ADDED
@@ -0,0 +1,367 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import { dirname, join, isAbsolute, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createSyncFn } from 'synckit';
6
+
7
+ const name = "@tailwind-ts/eslint-plugin";
8
+ const version = "0.1.0";
9
+ const packageJson = {
10
+ name: name,
11
+ version: version};
12
+
13
+ function isStringLiteral(node) {
14
+ return node.type === "Literal" && typeof node.value === "string";
15
+ }
16
+ function collectFromExpression(node, ignoredKeys, functionNames, literals) {
17
+ if (!node || node.type === "SpreadElement") {
18
+ return;
19
+ }
20
+ if (isStringLiteral(node)) {
21
+ literals.push({ value: node.value, node });
22
+ return;
23
+ }
24
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
25
+ const value = node.quasis.map((quasi) => quasi.value.cooked ?? "").join("");
26
+ literals.push({ value, node });
27
+ return;
28
+ }
29
+ if (node.type === "LogicalExpression") {
30
+ collectFromExpression(node.left, ignoredKeys, functionNames, literals);
31
+ collectFromExpression(node.right, ignoredKeys, functionNames, literals);
32
+ return;
33
+ }
34
+ if (node.type === "ConditionalExpression") {
35
+ collectFromExpression(node.consequent, ignoredKeys, functionNames, literals);
36
+ collectFromExpression(node.alternate, ignoredKeys, functionNames, literals);
37
+ return;
38
+ }
39
+ if (node.type === "ArrayExpression") {
40
+ for (const element of node.elements) {
41
+ collectFromExpression(element, ignoredKeys, functionNames, literals);
42
+ }
43
+ return;
44
+ }
45
+ if (node.type === "ObjectExpression") {
46
+ for (const property of node.properties) {
47
+ if (property.type === "Property") {
48
+ if (property.key.type === "Identifier") {
49
+ if (ignoredKeys.has(property.key.name)) {
50
+ continue;
51
+ }
52
+ }
53
+ collectFromExpression(
54
+ property.value,
55
+ ignoredKeys,
56
+ functionNames,
57
+ literals
58
+ );
59
+ }
60
+ }
61
+ return;
62
+ }
63
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
64
+ if (functionNames.has(node.callee.name)) {
65
+ for (const argument of node.arguments) {
66
+ collectFromExpression(
67
+ argument,
68
+ ignoredKeys,
69
+ functionNames,
70
+ literals
71
+ );
72
+ }
73
+ }
74
+ }
75
+ }
76
+ function extractClassLiteralsFromJsxAttribute(attribute, options) {
77
+ const literals = [];
78
+ const ignoredKeys = new Set(options.ignoredKeys);
79
+ const functionNames = new Set(options.functions);
80
+ if (!attribute.value) {
81
+ return literals;
82
+ }
83
+ if (attribute.value.type === "Literal" && typeof attribute.value.value === "string") {
84
+ literals.push({ value: attribute.value.value, node: attribute.value });
85
+ return literals;
86
+ }
87
+ if (attribute.value.type === "JSXExpressionContainer") {
88
+ collectFromExpression(
89
+ attribute.value.expression,
90
+ ignoredKeys,
91
+ functionNames,
92
+ literals
93
+ );
94
+ }
95
+ return literals;
96
+ }
97
+ function extractClassLiteralsFromCallExpression(callExpression, options) {
98
+ if (callExpression.callee.type !== "Identifier") {
99
+ return [];
100
+ }
101
+ const functionNames = new Set(options.functions);
102
+ if (!functionNames.has(callExpression.callee.name)) {
103
+ return [];
104
+ }
105
+ const literals = [];
106
+ const ignoredKeys = new Set(options.ignoredKeys);
107
+ for (const argument of callExpression.arguments) {
108
+ collectFromExpression(
109
+ argument,
110
+ ignoredKeys,
111
+ functionNames,
112
+ literals
113
+ );
114
+ }
115
+ return literals;
116
+ }
117
+
118
+ const DEFAULT_ATTRIBUTES = ["class", "className"];
119
+ const DEFAULT_FUNCTIONS = [
120
+ "cn",
121
+ "clsx",
122
+ "cva",
123
+ "twMerge",
124
+ "twJoin"
125
+ ];
126
+ const DEFAULT_IGNORED_KEYS = [
127
+ "defaultVariants",
128
+ "compoundVariants",
129
+ "compoundSlots"
130
+ ];
131
+ function toRegExp(pattern) {
132
+ return new RegExp(pattern);
133
+ }
134
+ function parsePluginSettings(settings) {
135
+ if (!settings?.cssConfigPath) {
136
+ throw new Error(
137
+ "eslint-plugin-tailwind-ts: settings.tailwindTs.cssConfigPath is required (path to your Tailwind v4 CSS entry file)."
138
+ );
139
+ }
140
+ const whitelist = (settings.whitelist ?? []).map(toRegExp);
141
+ return {
142
+ cssConfigPath: settings.cssConfigPath,
143
+ attributes: settings.attributes ?? [...DEFAULT_ATTRIBUTES],
144
+ functions: settings.functions ?? [...DEFAULT_FUNCTIONS],
145
+ whitelist,
146
+ ignoredKeys: settings.ignoredKeys ?? [...DEFAULT_IGNORED_KEYS]
147
+ };
148
+ }
149
+ function isWhitelisted(className, whitelist) {
150
+ return whitelist.some((pattern) => pattern.test(className));
151
+ }
152
+ function splitClassTokens(classString) {
153
+ return classString.trim().split(/\s+/).filter(Boolean);
154
+ }
155
+
156
+ const CACHE_TTL_MS = 10 * 60 * 1e3;
157
+ const resultCache = /* @__PURE__ */ new Map();
158
+ const compilerMtimeCache = /* @__PURE__ */ new Map();
159
+ function resolveWorkerPath() {
160
+ const dir = dirname(fileURLToPath(import.meta.url));
161
+ const candidates = [
162
+ join(dir, "../worker/validate.mjs"),
163
+ join(dir, "../../worker/validate.mjs")
164
+ ];
165
+ for (const candidate of candidates) {
166
+ if (existsSync(candidate)) {
167
+ return candidate;
168
+ }
169
+ }
170
+ throw new Error("eslint-plugin-tailwind-ts: validation worker not found");
171
+ }
172
+ const runValidate = createSyncFn(resolveWorkerPath());
173
+ function resolveCssConfigPath(cssConfigPath, cwd) {
174
+ return isAbsolute(cssConfigPath) ? cssConfigPath : resolve(cwd, cssConfigPath);
175
+ }
176
+ function getCssMtime(cssConfigPath) {
177
+ return statSync(cssConfigPath).mtimeMs;
178
+ }
179
+ function isCompilerCacheValid(cssConfigPath) {
180
+ const cached = compilerMtimeCache.get(cssConfigPath);
181
+ if (!cached) {
182
+ return false;
183
+ }
184
+ try {
185
+ return cached.mtimeMs === getCssMtime(cssConfigPath);
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+ function touchCompilerCache(cssConfigPath) {
191
+ compilerMtimeCache.set(cssConfigPath, {
192
+ mtimeMs: getCssMtime(cssConfigPath)
193
+ });
194
+ }
195
+ function invalidateResultCacheForConfig(cssConfigPath) {
196
+ for (const key of resultCache.keys()) {
197
+ if (key.startsWith(`${cssConfigPath}::`)) {
198
+ resultCache.delete(key);
199
+ }
200
+ }
201
+ }
202
+ function validateTailwindClass(request) {
203
+ const resolvedCssPath = resolveCssConfigPath(
204
+ request.cssConfigPath,
205
+ request.cwd
206
+ );
207
+ if (!isCompilerCacheValid(resolvedCssPath)) {
208
+ invalidateResultCacheForConfig(resolvedCssPath);
209
+ touchCompilerCache(resolvedCssPath);
210
+ }
211
+ const cacheKey = `${resolvedCssPath}::${request.className}`;
212
+ const cached = resultCache.get(cacheKey);
213
+ if (cached && cached.expiresAt > Date.now()) {
214
+ return { valid: cached.valid };
215
+ }
216
+ const response = runValidate({
217
+ cssConfigPath: resolvedCssPath,
218
+ className: request.className,
219
+ cwd: request.cwd
220
+ });
221
+ resultCache.set(cacheKey, {
222
+ valid: response.valid,
223
+ expiresAt: Date.now() + CACHE_TTL_MS
224
+ });
225
+ return response;
226
+ }
227
+
228
+ const RULE_NAME = "no-invalid-classname";
229
+ const noInvalidClassname = ESLintUtils.RuleCreator.withoutDocs({
230
+ meta: {
231
+ type: "problem",
232
+ docs: {
233
+ description: "Disallow invalid or typo Tailwind CSS class names in JSX and utility functions."
234
+ },
235
+ schema: [
236
+ {
237
+ type: "object",
238
+ additionalProperties: false,
239
+ properties: {
240
+ whitelist: {
241
+ type: "array",
242
+ items: { type: "string" }
243
+ }
244
+ }
245
+ }
246
+ ],
247
+ messages: {
248
+ invalidClass: 'Class "{{ className }}" is not a valid Tailwind CSS class.'
249
+ }
250
+ },
251
+ defaultOptions: [{}],
252
+ create(context) {
253
+ const pluginSettings = context.settings.tailwindTs;
254
+ let resolvedSettings;
255
+ try {
256
+ resolvedSettings = parsePluginSettings(pluginSettings);
257
+ } catch (error) {
258
+ const message = error instanceof Error ? error.message : "Invalid plugin settings.";
259
+ context.report({
260
+ loc: { line: 1, column: 0 },
261
+ message
262
+ });
263
+ return {};
264
+ }
265
+ const ruleWhitelist = (context.options[0]?.whitelist ?? []).map(
266
+ (pattern) => new RegExp(pattern)
267
+ );
268
+ const whitelist = [...resolvedSettings.whitelist, ...ruleWhitelist];
269
+ const attributeNames = new Set(resolvedSettings.attributes);
270
+ const cwd = context.cwd ?? process.cwd();
271
+ function reportInvalidClasses(literals) {
272
+ for (const literal of literals) {
273
+ for (const className of splitClassTokens(literal.value)) {
274
+ if (isWhitelisted(className, whitelist)) {
275
+ continue;
276
+ }
277
+ const result = validateTailwindClass({
278
+ cssConfigPath: resolvedSettings.cssConfigPath,
279
+ className,
280
+ cwd
281
+ });
282
+ if (!result.valid) {
283
+ context.report({
284
+ node: literal.node,
285
+ messageId: "invalidClass",
286
+ data: { className }
287
+ });
288
+ }
289
+ }
290
+ }
291
+ }
292
+ return {
293
+ JSXAttribute(node) {
294
+ if (node.name.type !== "JSXIdentifier" || !attributeNames.has(node.name.name)) {
295
+ return;
296
+ }
297
+ reportInvalidClasses(
298
+ extractClassLiteralsFromJsxAttribute(node, {
299
+ functions: resolvedSettings.functions,
300
+ ignoredKeys: resolvedSettings.ignoredKeys
301
+ })
302
+ );
303
+ },
304
+ CallExpression(node) {
305
+ reportInvalidClasses(
306
+ extractClassLiteralsFromCallExpression(node, {
307
+ functions: resolvedSettings.functions,
308
+ ignoredKeys: resolvedSettings.ignoredKeys
309
+ })
310
+ );
311
+ }
312
+ };
313
+ }
314
+ });
315
+
316
+ const createConfig = (rules) => {
317
+ const result = {};
318
+ for (const [ruleName, value] of Object.entries(rules)) {
319
+ result[`tailwind-ts/${ruleName}`] = value;
320
+ }
321
+ return result;
322
+ };
323
+ const plugin = {
324
+ meta: {
325
+ name: packageJson.name,
326
+ version: packageJson.version
327
+ },
328
+ configs: {
329
+ get recommended() {
330
+ return sharedConfigs.recommended;
331
+ }
332
+ },
333
+ rules: {
334
+ [RULE_NAME]: noInvalidClassname
335
+ }
336
+ };
337
+ const recommendedRules = {
338
+ [RULE_NAME]: "error"
339
+ };
340
+ const configBase = {
341
+ name: "tailwind-ts/base",
342
+ plugins: {
343
+ "tailwind-ts": plugin
344
+ },
345
+ settings: {
346
+ tailwindTs: {}
347
+ },
348
+ files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
349
+ languageOptions: {
350
+ parserOptions: {
351
+ ecmaVersion: "latest",
352
+ sourceType: "module",
353
+ ecmaFeatures: {
354
+ jsx: true
355
+ }
356
+ }
357
+ }
358
+ };
359
+ const sharedConfigs = {
360
+ recommended: {
361
+ ...configBase,
362
+ name: "tailwind-ts/recommended",
363
+ rules: createConfig(recommendedRules)
364
+ }
365
+ };
366
+
367
+ export { plugin as default };
@@ -0,0 +1,65 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { __unstable__loadDesignSystem } from "@tailwindcss/node";
5
+
6
+ /** @typedef {{ cssConfigPath: string, className: string, cwd: string }} ValidateClassRequest */
7
+ /** @typedef {{ valid: boolean, error?: string }} ValidateClassResponse */
8
+
9
+ /** @type {Map<string, { mtimeMs: number, designSystem: Awaited<ReturnType<typeof __unstable__loadDesignSystem>> }>} */
10
+ const designSystemCache = new Map();
11
+
12
+ /**
13
+ * @param {string} cssConfigPath
14
+ */
15
+ async function getDesignSystem(cssConfigPath) {
16
+ const mtimeMs = statSync(cssConfigPath).mtimeMs;
17
+ const cached = designSystemCache.get(cssConfigPath);
18
+
19
+ if (cached && cached.mtimeMs === mtimeMs) {
20
+ return cached.designSystem;
21
+ }
22
+
23
+ const css = readFileSync(cssConfigPath, "utf8");
24
+ const designSystem = await __unstable__loadDesignSystem(css, {
25
+ base: dirname(cssConfigPath),
26
+ });
27
+
28
+ designSystemCache.set(cssConfigPath, { mtimeMs, designSystem });
29
+ return designSystem;
30
+ }
31
+
32
+ /**
33
+ * @param {ValidateClassRequest} request
34
+ * @returns {Promise<ValidateClassResponse>}
35
+ */
36
+ async function validateClass(request) {
37
+ try {
38
+ const designSystem = await getDesignSystem(request.cssConfigPath);
39
+ const cssResults = designSystem.candidatesToCss([request.className]);
40
+ const valid = cssResults[0] !== null;
41
+
42
+ return { valid };
43
+ } catch (error) {
44
+ return {
45
+ valid: false,
46
+ error: error instanceof Error ? error.message : String(error),
47
+ };
48
+ }
49
+ }
50
+
51
+ /**
52
+ * @param {ValidateClassRequest} request
53
+ * @returns {ValidateClassResponse}
54
+ */
55
+ function run(request) {
56
+ return validateClass(request);
57
+ }
58
+
59
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
60
+ const { runAsWorker } = await import("synckit");
61
+
62
+ runAsWorker(run);
63
+ }
64
+
65
+ export { run };
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@tailwind-ts/eslint-plugin",
3
+ "version": "0.1.0",
4
+ "description": "ESLint plugin for detecting invalid Tailwind CSS v4 class names with cva, @apply, and typo suggestions",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "author": "",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/tailwind-ts/eslint-plugin.git"
11
+ },
12
+ "keywords": [
13
+ "eslint",
14
+ "eslintplugin",
15
+ "eslint-plugin",
16
+ "tailwind",
17
+ "tailwindcss",
18
+ "typescript",
19
+ "v4",
20
+ "cva",
21
+ "apply"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20.19.0"
25
+ },
26
+ "main": "./lib/index.cjs",
27
+ "module": "./lib/index.mjs",
28
+ "types": "./lib/index.d.cts",
29
+ "exports": {
30
+ ".": {
31
+ "module": {
32
+ "types": "./lib/index.d.mts",
33
+ "default": "./lib/index.mjs"
34
+ },
35
+ "import": {
36
+ "types": "./lib/index.d.mts",
37
+ "default": "./lib/index.mjs"
38
+ },
39
+ "require": {
40
+ "types": "./lib/index.d.cts",
41
+ "default": "./lib/index.cjs"
42
+ }
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "docs/",
48
+ "lib/"
49
+ ],
50
+ "scripts": {
51
+ "build": "unbuild --config unbuild.config.ts && node scripts/copy-worker.mjs",
52
+ "test": "vitest run",
53
+ "lint": "eslint .",
54
+ "prepublishOnly": "npm run build && npm test"
55
+ },
56
+ "dependencies": {
57
+ "@typescript-eslint/utils": "^8.37.0",
58
+ "synckit": "^0.11.11"
59
+ },
60
+ "peerDependencies": {
61
+ "@tailwindcss/node": "^4.0.0",
62
+ "eslint": "^9.0.0 || ^10.0.0",
63
+ "tailwindcss": "^4.0.0"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "typescript": {
67
+ "optional": true
68
+ },
69
+ "vue-eslint-parser": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "devDependencies": {
74
+ "@eslint/js": "^9.31.0",
75
+ "@tailwindcss/node": "^4.3.2",
76
+ "@types/node": "^22.15.0",
77
+ "@typescript-eslint/parser": "^8.37.0",
78
+ "@typescript-eslint/rule-tester": "^8.37.0",
79
+ "eslint": "^9.31.0",
80
+ "tailwindcss": "^4.3.2",
81
+ "typescript": "^5.9.2",
82
+ "typescript-eslint": "^8.42.0",
83
+ "unbuild": "^3.3.1",
84
+ "vitest": "^3.2.4"
85
+ }
86
+ }