eslint-plugin-tailwind-canonical-classes 1.0.7 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,31 +1,110 @@
1
1
  # eslint-plugin-tailwind-canonical-classes
2
2
 
3
- ESLint plugin to enforce canonical Tailwind CSS class names using Tailwind CSS v4's canonicalization API.
3
+ [![npm version](https://img.shields.io/npm/v/eslint-plugin-tailwind-canonical-classes.svg)](https://www.npmjs.com/package/eslint-plugin-tailwind-canonical-classes)
4
+ [![npm downloads](https://img.shields.io/npm/dm/eslint-plugin-tailwind-canonical-classes.svg)](https://www.npmjs.com/package/eslint-plugin-tailwind-canonical-classes)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)](https://nodejs.org/)
4
7
 
5
- ## Overview
8
+ > ESLint plugin to enforce canonical Tailwind CSS class names using Tailwind CSS v4's canonicalization API.
6
9
 
7
- This plugin helps maintain consistent Tailwind CSS class names across your codebase by automatically detecting and fixing non-canonical class names. It uses Tailwind CSS v4's `canonicalizeCandidates` API to ensure your classes follow the canonical format.
10
+ ## 📋 Table of Contents
8
11
 
9
- For example, it can convert:
10
- - `p-4px` → `p-1` (if 4px equals 1rem at your root font size)
11
- - `m-2rem` → `m-8` (if 2rem equals 8 at your scale)
12
+ - [Features](#-features)
13
+ - [Installation](#-installation)
14
+ - [Quick Start](#-quick-start)
15
+ - [Configuration](#-configuration)
16
+ - [Options](#-options)
17
+ - [Usage Examples](#-usage-examples)
18
+ - [How It Works](#-how-it-works)
19
+ - [Limitations](#-limitations)
20
+ - [Troubleshooting](#-troubleshooting)
21
+ - [Contributing](#-contributing)
22
+ - [License](#-license)
23
+ - [Related Links](#-related-links)
12
24
 
13
- ## Installation
25
+ ## ✨ Features
26
+
27
+ - 🔍 **Automatic Detection**: Automatically detects non-canonical Tailwind CSS class names in your JSX/TSX files
28
+ - 🔧 **Auto-fix Support**: Automatically fixes non-canonical classes using ESLint's auto-fix feature
29
+ - 🎯 **Tailwind CSS v4 Integration**: Uses Tailwind CSS v4's official `canonicalizeCandidates` API
30
+ - 📝 **Multiple Format Support**: Works with string literals, template literals, and JSX expressions
31
+ - ⚡ **Zero Config**: Minimal configuration required to get started
32
+
33
+ ## 📦 Installation
34
+
35
+ Install the plugin and its peer dependency:
14
36
 
15
37
  ```bash
16
38
  npm install --save-dev eslint-plugin-tailwind-canonical-classes @tailwindcss/node
17
39
  ```
18
40
 
19
- ## Requirements
41
+ Or with yarn:
20
42
 
21
- - Node.js >= 18.0.0
22
- - ESLint >= 8.0.0
23
- - Tailwind CSS v4
24
- - `@tailwindcss/node` package
43
+ ```bash
44
+ yarn add -D eslint-plugin-tailwind-canonical-classes @tailwindcss/node
45
+ ```
25
46
 
26
- ## Configuration
47
+ Or with pnpm:
27
48
 
28
- Add the plugin to your ESLint configuration file (e.g., `eslint.config.mjs` or `.eslintrc.js`):
49
+ ```bash
50
+ pnpm add -D eslint-plugin-tailwind-canonical-classes @tailwindcss/node
51
+ ```
52
+
53
+ ### Requirements
54
+
55
+ - **Node.js** >= 18.0.0
56
+ - **ESLint** >= 8.0.0
57
+ - **Tailwind CSS** v4
58
+ - **@tailwindcss/node** package
59
+
60
+ ## 🚀 Quick Start
61
+
62
+ 1. **Install the plugin** (see [Installation](#-installation))
63
+
64
+ 2. **Add to your ESLint config**:
65
+
66
+ **Flat Config (ESLint 9+)** - `eslint.config.mjs`:
67
+ ```javascript
68
+ import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes';
69
+
70
+ export default [
71
+ {
72
+ plugins: {
73
+ 'tailwind-canonical-classes': tailwindCanonicalClasses,
74
+ },
75
+ rules: {
76
+ 'tailwind-canonical-classes/tailwind-canonical-classes': [
77
+ 'warn',
78
+ {
79
+ cssPath: './app/styles/globals.css',
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ ];
85
+ ```
86
+
87
+ **Legacy Config** - `.eslintrc.js`:
88
+ ```javascript
89
+ module.exports = {
90
+ plugins: ['tailwind-canonical-classes'],
91
+ rules: {
92
+ 'tailwind-canonical-classes/tailwind-canonical-classes': [
93
+ 'warn',
94
+ {
95
+ cssPath: './app/styles/globals.css',
96
+ },
97
+ ],
98
+ },
99
+ };
100
+ ```
101
+
102
+ 3. **Run ESLint**:
103
+ ```bash
104
+ npx eslint . --fix
105
+ ```
106
+
107
+ ## ⚙️ Configuration
29
108
 
30
109
  ### Flat Config (ESLint 9+)
31
110
 
@@ -39,10 +118,10 @@ export default [
39
118
  },
40
119
  rules: {
41
120
  'tailwind-canonical-classes/tailwind-canonical-classes': [
42
- 'warn',
121
+ 'warn', // or 'error'
43
122
  {
44
- cssPath: './app/styles/globals.css', // Path to your Tailwind CSS file
45
- rootFontSize: 16, // Optional: root font size in pixels (default: 16)
123
+ cssPath: './app/styles/globals.css', // Required
124
+ rootFontSize: 16, // Optional, default: 16
46
125
  },
47
126
  ],
48
127
  },
@@ -67,34 +146,38 @@ module.exports = {
67
146
  };
68
147
  ```
69
148
 
70
- ## Options
149
+ ## 📖 Options
71
150
 
72
151
  ### `cssPath` (required)
73
152
 
74
- Type: `string`
75
-
76
- Path to your Tailwind CSS file. Can be:
77
- - **Relative path**: Resolved relative to your project root (where ESLint config is located)
78
- - **Absolute path**: Full filesystem path to your CSS file
153
+ - **Type**: `string`
154
+ - **Description**: Path to your Tailwind CSS file
155
+ - **Supported formats**:
156
+ - Relative path: Resolved relative to your project root (where ESLint config is located)
157
+ - Absolute path: Full filesystem path to your CSS file
79
158
 
80
- Example:
159
+ **Examples**:
81
160
  ```javascript
82
- cssPath: './app/styles/globals.css' // Relative to project root
83
- cssPath: '/absolute/path/to/styles.css' // Absolute path
161
+ cssPath: './app/styles/globals.css' // Relative to project root
162
+ cssPath: './src/index.css' // Another relative example
163
+ cssPath: '/absolute/path/to/styles.css' // Absolute path
84
164
  ```
85
165
 
86
166
  ### `rootFontSize` (optional)
87
167
 
88
- Type: `number`
89
- Default: `16`
168
+ - **Type**: `number`
169
+ - **Default**: `16`
170
+ - **Description**: Root font size in pixels for rem calculations. This should match your CSS root font size setting.
90
171
 
91
- Root font size in pixels for rem calculations. This should match your CSS root font size setting.
92
-
93
- ## Usage
172
+ **Example**:
173
+ ```javascript
174
+ rootFontSize: 16 // Default (16px = 1rem)
175
+ rootFontSize: 14 // If your root font size is 14px
176
+ ```
94
177
 
95
- Once configured, ESLint will automatically check your JSX `className` attributes and suggest canonical alternatives.
178
+ ## 💡 Usage Examples
96
179
 
97
- ### Example
180
+ ### Basic Example
98
181
 
99
182
  **Before:**
100
183
  ```tsx
@@ -106,35 +189,137 @@ Once configured, ESLint will automatically check your JSX `className` attributes
106
189
  <div className="p-1 m-8">Content</div>
107
190
  ```
108
191
 
109
- The plugin supports:
110
- - String literals: `className="p-4"`
111
- - Template literals (without expressions): `className={`p-4 ${someVar}`}` (only static parts are checked)
112
- - JSX expression containers with static values
192
+ ### Supported Formats
193
+
194
+ The plugin supports various class name formats:
195
+
196
+ 1. **String literals**:
197
+ ```tsx
198
+ <div className="p-4 m-2">Content</div>
199
+ ```
200
+
201
+ 2. **Template literals** (static parts only):
202
+ ```tsx
203
+ <div className={`p-4 ${someVar}`}>Content</div>
204
+ // Only "p-4" will be checked, dynamic parts are skipped
205
+ ```
206
+
207
+ 3. **JSX expression containers**:
208
+ ```tsx
209
+ <div className={"p-4px"}>Content</div>
210
+ ```
211
+
212
+ ### Real-world Example
213
+
214
+ ```tsx
215
+ // Before
216
+ function Card({ children }) {
217
+ return (
218
+ <div className="p-16px m-2rem rounded-8px shadow-lg">
219
+ {children}
220
+ </div>
221
+ );
222
+ }
223
+
224
+ // After auto-fix
225
+ function Card({ children }) {
226
+ return (
227
+ <div className="p-4 m-8 rounded-2 shadow-lg">
228
+ {children}
229
+ </div>
230
+ );
231
+ }
232
+ ```
233
+
234
+ ## 🔧 How It Works
113
235
 
114
- ## How It Works
236
+ 1. **Load Design System**: The plugin loads your Tailwind CSS file using `@tailwindcss/node`'s `__unstable__loadDesignSystem` API
237
+ 2. **Extract Classes**: It extracts class names from JSX `className` attributes in your code
238
+ 3. **Canonicalize**: For each class, it uses Tailwind's `canonicalizeCandidates` to find the canonical form
239
+ 4. **Report & Fix**: If a non-canonical class is found, it reports an error/warning and can auto-fix it
115
240
 
116
- 1. The plugin loads your Tailwind CSS file using `@tailwindcss/node`'s `__unstable__loadDesignSystem` API
117
- 2. It extracts class names from JSX `className` attributes
118
- 3. For each class, it uses Tailwind's `canonicalizeCandidates` to find the canonical form
119
- 4. If a non-canonical class is found, it reports an error/warning and can auto-fix it
241
+ ## ⚠️ Limitations
120
242
 
121
- ## Limitations
243
+ - **Static classes only**: Only works with static class names (no dynamic expressions)
244
+ - **Tailwind CSS v4 required**: Requires Tailwind CSS v4 (not compatible with v3)
245
+ - **CSS file accessibility**: CSS file must be accessible from the ESLint process
246
+ - **Template literals**: Template literals with expressions are partially supported (only static parts are checked)
122
247
 
123
- - Only works with static class names (no dynamic expressions)
124
- - Requires Tailwind CSS v4
125
- - CSS file must be accessible from the ESLint process
126
- - Template literals with expressions are skipped
248
+ ## 🐛 Troubleshooting
127
249
 
128
- ## Contributing
250
+ ### Plugin not detecting classes
251
+
252
+ **Problem**: The plugin isn't reporting any issues with non-canonical classes.
253
+
254
+ **Solutions**:
255
+ 1. Verify your `cssPath` is correct and points to a valid Tailwind CSS file
256
+ 2. Ensure the CSS file is accessible from where ESLint runs
257
+ 3. Check that your Tailwind CSS file contains valid Tailwind directives (`@import "tailwindcss"` or similar)
258
+ 4. Verify ESLint is processing your JSX/TSX files (check your ESLint config includes these file types)
259
+
260
+ ### Path resolution issues
261
+
262
+ **Problem**: ESLint can't find your CSS file.
263
+
264
+ **Solutions**:
265
+ - Use an absolute path if relative paths aren't working
266
+ - Ensure the path is relative to your ESLint config file location
267
+ - Check file permissions
268
+
269
+ ### Auto-fix not working
270
+
271
+ **Problem**: ESLint reports issues but doesn't auto-fix them.
272
+
273
+ **Solutions**:
274
+ - Run ESLint with the `--fix` flag: `npx eslint . --fix`
275
+ - Ensure your editor's ESLint extension has auto-fix enabled
276
+ - Check that the rule severity is set to `'warn'` or `'error'` (not `'off'`)
277
+
278
+ ## 🤝 Contributing
129
279
 
130
280
  Contributions are welcome! Please feel free to submit a Pull Request.
131
281
 
132
- ## License
282
+ 1. Fork the repository
283
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
284
+ 3. Commit your changes (`git commit -m 'feat: add some amazing feature'`)
285
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
286
+ 5. Open a Pull Request
287
+
288
+ ### Development Setup
289
+
290
+ ```bash
291
+ # Clone the repository
292
+ git clone https://github.com/MaisonnatM/eslint-plugin-tailwind-canonical-classes.git
293
+ cd eslint-plugin-tailwind-canonical-classes
294
+
295
+ # Install dependencies
296
+ npm install
297
+
298
+ # Build the project
299
+ npm run build
300
+
301
+ # Run tests
302
+ npm test
303
+ ```
304
+
305
+ ### Release Process
306
+
307
+ This project uses [semantic-release](https://github.com/semantic-release/semantic-release) for automated version management and npm publishing. Releases are automatically triggered when commits are pushed to the `main` branch.
308
+
309
+ **Commit Message Format**:
310
+ - `fix:` - Patch release (1.0.8 → 1.0.9)
311
+ - `feat:` - Minor release (1.0.8 → 1.1.0)
312
+ - `feat!:` or `BREAKING CHANGE:` - Major release (1.0.8 → 2.0.0)
313
+
314
+ ## 📄 License
133
315
 
134
- MIT
316
+ MIT © [Maisonnat Maxence](https://github.com/MaisonnatM)
135
317
 
136
- ## Related
318
+ ## 🔗 Related Links
137
319
 
138
- - [Tailwind CSS v4](https://tailwindcss.com/)
139
- - [ESLint](https://eslint.org/)
320
+ - [Tailwind CSS v4 Documentation](https://tailwindcss.com/)
321
+ - [ESLint Documentation](https://eslint.org/)
322
+ - [npm Package](https://www.npmjs.com/package/eslint-plugin-tailwind-canonical-classes)
323
+ - [GitHub Repository](https://github.com/MaisonnatM/eslint-plugin-tailwind-canonical-classes)
324
+ - [Report an Issue](https://github.com/MaisonnatM/eslint-plugin-tailwind-canonical-classes/issues)
140
325
 
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ declare const _default: {
2
+ rules: {
3
+ 'tailwind-canonical-classes': import("eslint").Rule.RuleModule;
4
+ };
5
+ };
6
+ export default _default;
package/lib/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import tailwindCanonicalClasses from './rules/tailwind-canonical-classes.js';
2
+ export default {
3
+ rules: {
4
+ 'tailwind-canonical-classes': tailwindCanonicalClasses,
5
+ },
6
+ };
@@ -0,0 +1,3 @@
1
+ import type { Rule } from 'eslint';
2
+ declare const rule: Rule.RuleModule;
3
+ export default rule;
@@ -0,0 +1,204 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createSyncFn } from 'synckit';
5
+ const workerPath = fileURLToPath(new URL('./tailwind-worker.js', import.meta.url));
6
+ const canonicalizeSync = createSyncFn(workerPath);
7
+ function canonicalizeClasses(cssPath, candidates, rootFontSize = 16) {
8
+ if (!fs.existsSync(cssPath)) {
9
+ return null;
10
+ }
11
+ const cssContent = fs.readFileSync(cssPath, 'utf-8');
12
+ const basePath = path.dirname(cssPath);
13
+ return canonicalizeSync(cssContent, basePath, candidates, { rem: rootFontSize });
14
+ }
15
+ function splitClasses(className) {
16
+ return className.trim().split(/\s+/).filter(Boolean);
17
+ }
18
+ function joinClasses(classes) {
19
+ return classes.join(' ');
20
+ }
21
+ function hasTemplateExpressions(node) {
22
+ if (node.type !== 'TemplateLiteral') {
23
+ return false;
24
+ }
25
+ return node.expressions && node.expressions.length > 0;
26
+ }
27
+ function extractStaticValue(node) {
28
+ if (node.type === 'Literal' && typeof node.value === 'string') {
29
+ return node.value;
30
+ }
31
+ if (node.type === 'JSXExpressionContainer') {
32
+ return extractStaticValue(node.expression);
33
+ }
34
+ if (node.type === 'TemplateLiteral' && !hasTemplateExpressions(node)) {
35
+ return node.quasis.map((q) => q.value.cooked).join('');
36
+ }
37
+ return null;
38
+ }
39
+ function getQuoteChar(source, start, end) {
40
+ const quoteChars = ['"', "'", '`'];
41
+ for (const char of quoteChars) {
42
+ if (source[start] === char && source[end - 1] === char) {
43
+ return char;
44
+ }
45
+ }
46
+ return '"';
47
+ }
48
+ const rule = {
49
+ meta: {
50
+ type: 'suggestion',
51
+ docs: {
52
+ description: 'Enforce canonical Tailwind CSS class names using Tailwind CSS v4 canonicalization API',
53
+ },
54
+ fixable: 'code',
55
+ messages: {
56
+ nonCanonical: "Class '{{original}}' should be '{{canonical}}'",
57
+ cssNotFound: 'Could not load Tailwind CSS file: {{path}}',
58
+ },
59
+ schema: [
60
+ {
61
+ type: 'object',
62
+ properties: {
63
+ cssPath: {
64
+ type: 'string',
65
+ },
66
+ rootFontSize: {
67
+ type: 'number',
68
+ },
69
+ },
70
+ required: ['cssPath'],
71
+ additionalProperties: false,
72
+ },
73
+ ],
74
+ },
75
+ create(context) {
76
+ const options = context.options[0];
77
+ if (!options || !options.cssPath) {
78
+ context.report({
79
+ node: context.getSourceCode().ast,
80
+ messageId: 'cssNotFound',
81
+ data: {
82
+ path: 'not specified',
83
+ },
84
+ });
85
+ return {};
86
+ }
87
+ let cssPath;
88
+ if (path.isAbsolute(options.cssPath)) {
89
+ cssPath = path.normalize(options.cssPath);
90
+ }
91
+ else {
92
+ cssPath = path.normalize(path.resolve(process.cwd(), options.cssPath));
93
+ }
94
+ const rootFontSize = options.rootFontSize ?? 16;
95
+ if (!fs.existsSync(cssPath)) {
96
+ context.report({
97
+ node: context.getSourceCode().ast,
98
+ messageId: 'cssNotFound',
99
+ data: {
100
+ path: cssPath,
101
+ },
102
+ });
103
+ return {};
104
+ }
105
+ return {
106
+ JSXAttribute(node) {
107
+ if (node.name.type !== 'JSXIdentifier' ||
108
+ node.name.name !== 'className') {
109
+ return;
110
+ }
111
+ const staticValue = extractStaticValue(node.value);
112
+ if (staticValue === null) {
113
+ return;
114
+ }
115
+ const classes = splitClasses(staticValue);
116
+ if (classes.length === 0) {
117
+ return;
118
+ }
119
+ const sourceCode = context.getSourceCode();
120
+ const sourceText = sourceCode.getText();
121
+ const errors = [];
122
+ try {
123
+ const canonicalized = canonicalizeClasses(cssPath, classes, rootFontSize);
124
+ if (canonicalized === null) {
125
+ context.report({
126
+ node,
127
+ messageId: 'cssNotFound',
128
+ data: {
129
+ path: cssPath,
130
+ },
131
+ });
132
+ return;
133
+ }
134
+ classes.forEach((className, index) => {
135
+ const canonical = canonicalized[index];
136
+ if (canonical && canonical !== className) {
137
+ errors.push({
138
+ node,
139
+ original: className,
140
+ canonical,
141
+ index,
142
+ });
143
+ }
144
+ });
145
+ }
146
+ catch (error) {
147
+ return;
148
+ }
149
+ if (errors.length > 0) {
150
+ const valueNode = node.value;
151
+ let fullRangeStart;
152
+ let fullRangeEnd;
153
+ let replacementText;
154
+ if (valueNode.type === 'Literal') {
155
+ fullRangeStart = valueNode.range[0];
156
+ fullRangeEnd = valueNode.range[1];
157
+ const quoteChar = getQuoteChar(sourceText, valueNode.range[0], valueNode.range[1]);
158
+ const fixedClasses = [...classes];
159
+ errors.forEach((error) => {
160
+ fixedClasses[error.index] = error.canonical;
161
+ });
162
+ const fixedValue = joinClasses(fixedClasses);
163
+ replacementText = `${quoteChar}${fixedValue}${quoteChar}`;
164
+ }
165
+ else if (valueNode.type === 'JSXExpressionContainer') {
166
+ const expr = valueNode.expression;
167
+ if (expr.type === 'TemplateLiteral') {
168
+ fullRangeStart = valueNode.range[0];
169
+ fullRangeEnd = valueNode.range[1];
170
+ const fixedClasses = [...classes];
171
+ errors.forEach((error) => {
172
+ fixedClasses[error.index] = error.canonical;
173
+ });
174
+ const fixedValue = joinClasses(fixedClasses);
175
+ replacementText = `{\`${fixedValue}\`}`;
176
+ }
177
+ else {
178
+ return;
179
+ }
180
+ }
181
+ else {
182
+ return;
183
+ }
184
+ errors.forEach((error, errorIndex) => {
185
+ context.report({
186
+ node: error.node,
187
+ messageId: 'nonCanonical',
188
+ data: {
189
+ original: error.original,
190
+ canonical: error.canonical,
191
+ },
192
+ fix: errorIndex === 0
193
+ ? (fixer) => {
194
+ return fixer.replaceTextRange([fullRangeStart, fullRangeEnd], replacementText);
195
+ }
196
+ : undefined,
197
+ });
198
+ });
199
+ }
200
+ },
201
+ };
202
+ },
203
+ };
204
+ export default rule;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { runAsWorker } from 'synckit';
2
+ import { __unstable__loadDesignSystem } from '@tailwindcss/node';
3
+ const designSystemCache = new Map();
4
+ async function canonicalizeInWorker(cssContent, basePath, candidates, options = {}) {
5
+ const cacheKey = basePath;
6
+ let designSystem = designSystemCache.get(cacheKey);
7
+ if (!designSystem) {
8
+ designSystem = await __unstable__loadDesignSystem(cssContent, { base: basePath });
9
+ designSystemCache.set(cacheKey, designSystem);
10
+ }
11
+ return designSystem.canonicalizeCandidates(candidates, options);
12
+ }
13
+ runAsWorker(canonicalizeInWorker);
package/package.json CHANGED
@@ -1,23 +1,21 @@
1
1
  {
2
2
  "name": "eslint-plugin-tailwind-canonical-classes",
3
- "version": "1.0.7",
3
+ "version": "1.0.10",
4
4
  "description": "ESLint plugin to enforce canonical Tailwind CSS class names using Tailwind CSS v4's canonicalization API",
5
5
  "type": "module",
6
- "main": "index.js",
7
- "types": "index.d.ts",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./index.d.ts",
11
- "default": "./index.js"
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
12
  },
13
13
  "./rule": {
14
- "types": "./lib/tailwind-canonical-classes.d.ts",
15
- "default": "./lib/tailwind-canonical-classes.js"
14
+ "types": "./lib/rules/tailwind-canonical-classes.d.ts",
15
+ "default": "./lib/rules/tailwind-canonical-classes.js"
16
16
  }
17
17
  },
18
18
  "files": [
19
- "index.js",
20
- "index.d.ts",
21
19
  "lib"
22
20
  ],
23
21
  "keywords": [
@@ -42,13 +40,60 @@
42
40
  "engines": {
43
41
  "node": ">=18.0.0"
44
42
  },
43
+ "scripts": {
44
+ "build": "tsc",
45
+ "build:watch": "tsc --watch",
46
+ "test": "npm run build && vitest",
47
+ "test:run": "npm run build && vitest run",
48
+ "prepublishOnly": "npm run build",
49
+ "release": "semantic-release"
50
+ },
45
51
  "peerDependencies": {
46
52
  "eslint": ">=8.0.0"
47
53
  },
48
54
  "dependencies": {
49
- "@tailwindcss/node": "^4.0.0"
55
+ "@tailwindcss/node": "^4.0.0",
56
+ "synckit": "^0.9.3"
50
57
  },
51
58
  "devDependencies": {
52
- "eslint": "^9.0.0"
59
+ "@babel/eslint-parser": "^7.28.5",
60
+ "@babel/preset-react": "^7.28.5",
61
+ "@semantic-release/changelog": "^6.0.3",
62
+ "@semantic-release/git": "^10.0.1",
63
+ "@semantic-release/github": "^9.2.6",
64
+ "@semantic-release/npm": "^12.0.1",
65
+ "@types/node": "^20.0.0",
66
+ "eslint": "^9.0.0",
67
+ "semantic-release": "^24.0.0",
68
+ "typescript": "^5.0.0",
69
+ "vitest": "^4.0.16"
70
+ },
71
+ "release": {
72
+ "branches": [
73
+ "main"
74
+ ],
75
+ "plugins": [
76
+ "@semantic-release/commit-analyzer",
77
+ "@semantic-release/release-notes-generator",
78
+ [
79
+ "@semantic-release/changelog",
80
+ {
81
+ "changelogFile": "CHANGELOG.md"
82
+ }
83
+ ],
84
+ "@semantic-release/npm",
85
+ [
86
+ "@semantic-release/git",
87
+ {
88
+ "assets": [
89
+ "package.json",
90
+ "package-lock.json",
91
+ "CHANGELOG.md"
92
+ ],
93
+ "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
94
+ }
95
+ ],
96
+ "@semantic-release/github"
97
+ ]
53
98
  }
54
99
  }
package/index.d.ts DELETED
@@ -1,10 +0,0 @@
1
- import tailwindCanonicalClasses from './lib/tailwind-canonical-classes.js';
2
-
3
- declare const plugin: {
4
- rules: {
5
- 'tailwind-canonical-classes': typeof tailwindCanonicalClasses;
6
- };
7
- };
8
-
9
- export default plugin;
10
-
package/index.js DELETED
@@ -1,7 +0,0 @@
1
- import tailwindCanonicalClasses from './lib/tailwind-canonical-classes.js';
2
-
3
- export default {
4
- rules: {
5
- 'tailwind-canonical-classes': tailwindCanonicalClasses,
6
- },
7
- };
@@ -1,11 +0,0 @@
1
- import { Rule } from 'eslint';
2
-
3
- export interface RuleOptions {
4
- cssPath: string;
5
- rootFontSize?: number;
6
- }
7
-
8
- declare const rule: Rule.RuleModule;
9
-
10
- export default rule;
11
-
@@ -1,360 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { __unstable__loadDesignSystem } from "@tailwindcss/node";
4
-
5
- // Cache the design system to avoid reloading on every file
6
- let designSystemCache = null;
7
- let designSystemPromise = null;
8
- let designSystemError = null;
9
- let cssPathCache = null;
10
-
11
- function resolveCssPath(cssPath, context) {
12
- if (!cssPath) {
13
- return null;
14
- }
15
-
16
- // If absolute path, use as-is
17
- if (path.isAbsolute(cssPath)) {
18
- return cssPath;
19
- }
20
-
21
- // If relative path, resolve relative to the project root (where ESLint config is)
22
- // Try to find the project root by looking for common config files
23
- const filename = context.getFilename();
24
- const fileDir = path.dirname(filename);
25
-
26
- // Try to resolve relative to the current file's directory first
27
- const relativeToFile = path.resolve(fileDir, cssPath);
28
- if (fs.existsSync(relativeToFile)) {
29
- return relativeToFile;
30
- }
31
-
32
- // Try to resolve relative to the working directory (project root)
33
- const relativeToCwd = path.resolve(process.cwd(), cssPath);
34
- if (fs.existsSync(relativeToCwd)) {
35
- return relativeToCwd;
36
- }
37
-
38
- return relativeToCwd; // Return even if doesn't exist, let error handling catch it
39
- }
40
-
41
- function getDesignSystemSync(cssPath, context) {
42
- // Reset cache if CSS path changed
43
- if (cssPathCache !== cssPath) {
44
- designSystemCache = null;
45
- designSystemPromise = null;
46
- designSystemError = null;
47
- cssPathCache = cssPath;
48
- }
49
-
50
- if (designSystemCache) {
51
- return designSystemCache;
52
- }
53
-
54
- if (designSystemError) {
55
- return null;
56
- }
57
-
58
- if (designSystemPromise && !designSystemCache) {
59
- return null;
60
- }
61
-
62
- if (!designSystemPromise) {
63
- try {
64
- const resolvedPath = resolveCssPath(cssPath, context);
65
- if (!resolvedPath || !fs.existsSync(resolvedPath)) {
66
- designSystemError = new Error(`CSS file not found: ${cssPath}`);
67
- return null;
68
- }
69
-
70
- const cssContent = fs.readFileSync(resolvedPath, "utf-8");
71
- const basePath = path.dirname(resolvedPath);
72
-
73
- designSystemPromise = __unstable__loadDesignSystem(cssContent, {
74
- base: basePath,
75
- })
76
- .then((ds) => {
77
- designSystemCache = ds;
78
- return ds;
79
- })
80
- .catch((error) => {
81
- designSystemError = error;
82
- return null;
83
- });
84
- } catch (error) {
85
- designSystemError = error;
86
- return null;
87
- }
88
- }
89
-
90
- return null;
91
- }
92
-
93
- async function getDesignSystemAsync(cssPath, context) {
94
- // Reset cache if CSS path changed
95
- if (cssPathCache !== cssPath) {
96
- designSystemCache = null;
97
- designSystemPromise = null;
98
- designSystemError = null;
99
- cssPathCache = cssPath;
100
- }
101
-
102
- if (designSystemCache) {
103
- return designSystemCache;
104
- }
105
-
106
- if (designSystemError) {
107
- return null;
108
- }
109
-
110
- if (!designSystemPromise) {
111
- getDesignSystemSync(cssPath, context);
112
- }
113
-
114
- if (designSystemPromise) {
115
- try {
116
- return await designSystemPromise;
117
- } catch (error) {
118
- designSystemError = error;
119
- return null;
120
- }
121
- }
122
-
123
- return null;
124
- }
125
-
126
- function extractClassNames(classNameValue, sourceCode) {
127
- const classes = [];
128
-
129
- if (!classNameValue) {
130
- return classes;
131
- }
132
-
133
- if (
134
- classNameValue.type === "Literal" &&
135
- typeof classNameValue.value === "string"
136
- ) {
137
- const classString = classNameValue.value;
138
- return classString.split(/\s+/).filter((cls) => cls.trim().length > 0);
139
- }
140
-
141
- if (classNameValue.type === "TemplateLiteral") {
142
- if (classNameValue.expressions && classNameValue.expressions.length > 0) {
143
- return [];
144
- }
145
-
146
- const parts = [];
147
- for (const quasi of classNameValue.quasis) {
148
- const cooked = quasi.value?.cooked || "";
149
- if (cooked.trim()) {
150
- parts.push(cooked.trim());
151
- }
152
- }
153
-
154
- const combined = parts.join(" ");
155
- return combined.split(/\s+/).filter((cls) => cls.trim().length > 0);
156
- }
157
-
158
- if (classNameValue.type === "JSXExpressionContainer") {
159
- return extractClassNames(classNameValue.expression, sourceCode);
160
- }
161
-
162
- return classes;
163
- }
164
-
165
- export default {
166
- meta: {
167
- type: "suggestion",
168
- docs: {
169
- description: "Enforce canonical Tailwind CSS class names",
170
- category: "Best Practices",
171
- recommended: false,
172
- },
173
- fixable: "code",
174
- schema: [
175
- {
176
- type: "object",
177
- properties: {
178
- cssPath: {
179
- type: "string",
180
- description:
181
- "Path to your Tailwind CSS file (relative to project root or absolute)",
182
- },
183
- rootFontSize: {
184
- type: "number",
185
- default: 16,
186
- description: "Root font size in pixels for rem calculations",
187
- },
188
- },
189
- required: ["cssPath"],
190
- additionalProperties: false,
191
- },
192
- ],
193
- messages: {
194
- nonCanonical:
195
- "The class `{{original}}` can be written as `{{canonical}}`",
196
- cssNotFound:
197
- "CSS file not found: {{path}}. Please check your cssPath configuration.",
198
- },
199
- },
200
-
201
- create(context) {
202
- const options = context.options[0] || {};
203
- const cssPath = options.cssPath;
204
- const rootFontSize = options.rootFontSize || 16;
205
- const sourceCode = context.sourceCode;
206
-
207
- if (!cssPath) {
208
- context.report({
209
- loc: { line: 1, column: 0 },
210
- messageId: "cssNotFound",
211
- data: { path: "not specified" },
212
- });
213
- return {};
214
- }
215
-
216
- return {
217
- async JSXAttribute(node) {
218
- if (node.name.name !== "className") {
219
- return;
220
- }
221
-
222
- const value = node.value;
223
- if (!value) {
224
- return;
225
- }
226
-
227
- let classNameValue = value;
228
- if (value.type === "JSXExpressionContainer") {
229
- classNameValue = value.expression;
230
- }
231
-
232
- if (
233
- classNameValue.type !== "Literal" &&
234
- classNameValue.type !== "TemplateLiteral" &&
235
- classNameValue.type !== "JSXExpressionContainer"
236
- ) {
237
- return;
238
- }
239
-
240
- const classNames = extractClassNames(classNameValue, sourceCode);
241
- if (classNames.length === 0) {
242
- return;
243
- }
244
-
245
- // Try synchronous first
246
- let designSystem = getDesignSystemSync(cssPath, context);
247
-
248
- // If not loaded yet, wait for async load
249
- if (!designSystem) {
250
- designSystem = await getDesignSystemAsync(cssPath, context);
251
- }
252
-
253
- if (designSystem && designSystem.canonicalizeCandidates) {
254
- processClasses(
255
- designSystem,
256
- classNames,
257
- classNameValue,
258
- sourceCode,
259
- rootFontSize,
260
- context
261
- );
262
- }
263
- },
264
- };
265
- },
266
- };
267
-
268
- function processClasses(
269
- designSystem,
270
- classNames,
271
- classNameValue,
272
- sourceCode,
273
- rootFontSize,
274
- context
275
- ) {
276
- const issues = [];
277
-
278
- for (let i = 0; i < classNames.length; i++) {
279
- const className = classNames[i];
280
-
281
- try {
282
- const canonicalized = designSystem.canonicalizeCandidates([className], {
283
- rem: rootFontSize,
284
- })[0];
285
-
286
- if (canonicalized !== className) {
287
- issues.push({
288
- original: className,
289
- canonical: canonicalized,
290
- index: i,
291
- });
292
- }
293
- } catch {
294
- continue;
295
- }
296
- }
297
-
298
- if (issues.length > 0) {
299
- const originalText = sourceCode.getText(classNameValue);
300
-
301
- const canonicalMap = new Map();
302
- issues.forEach((issue) => {
303
- canonicalMap.set(issue.original, issue.canonical);
304
- });
305
-
306
- const fixedClassNames = classNames.map((className) => {
307
- return canonicalMap.get(className) || className;
308
- });
309
- const fixedClassString = fixedClassNames.join(" ");
310
-
311
- let fixedText;
312
-
313
- const quoteMatch = originalText.match(/^(["'`])(.*)\1$/);
314
-
315
- if (quoteMatch) {
316
- fixedText = `${quoteMatch[1]}${fixedClassString}${quoteMatch[1]}`;
317
- } else if (classNameValue.type === "TemplateLiteral") {
318
- fixedText = `\`${fixedClassString}\``;
319
- } else {
320
- const startQuoteMatch = originalText.match(/^(["'`])/);
321
- if (startQuoteMatch) {
322
- const quote = startQuoteMatch[1];
323
- const endQuoteIndex = originalText.lastIndexOf(quote);
324
- if (endQuoteIndex > 0) {
325
- fixedText = `${quote}${fixedClassString}${quote}`;
326
- } else {
327
- fixedText = fixedClassString;
328
- }
329
- } else {
330
- fixedText = fixedClassString;
331
- }
332
- }
333
-
334
- context.report({
335
- node: classNameValue,
336
- messageId: "nonCanonical",
337
- data: {
338
- original: issues[0].original,
339
- canonical: issues[0].canonical,
340
- },
341
- fix(fixer) {
342
- if (fixedText !== originalText) {
343
- return fixer.replaceText(classNameValue, fixedText);
344
- }
345
- return null;
346
- },
347
- });
348
-
349
- for (let i = 1; i < issues.length; i++) {
350
- context.report({
351
- node: classNameValue,
352
- messageId: "nonCanonical",
353
- data: {
354
- original: issues[i].original,
355
- canonical: issues[i].canonical,
356
- },
357
- });
358
- }
359
- }
360
- }