@culur/prettier-plugin-tailwindcss-group 1.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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +558 -0
- package/dist/index.d.mts +673 -0
- package/dist/index.mjs +4194 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/comments.test.ts +171 -0
- package/src/__tests__/plugin-official.test.ts +32 -0
- package/src/__tests__/readme.test.ts +13 -0
- package/src/__tests__/utils/ast-estree.ts +22 -0
- package/src/__tests__/utils/ast-md.ts +101 -0
- package/src/__tests__/utils/test-format.ts +35 -0
- package/src/ast/ast-array-expression.test.ts +33 -0
- package/src/ast/ast-array-expression.ts +20 -0
- package/src/ast/ast-call-expression.test.ts +17 -0
- package/src/ast/ast-call-expression.ts +5 -0
- package/src/ast/ast-comment.test.ts +45 -0
- package/src/ast/ast-comment.ts +25 -0
- package/src/ast/ast-common.ts +29 -0
- package/src/ast/ast-jsx.test.ts +56 -0
- package/src/ast/ast-jsx.ts +53 -0
- package/src/ast/ast-literal.test.ts +47 -0
- package/src/ast/ast-literal.ts +17 -0
- package/src/ast/ast-property.test.ts +96 -0
- package/src/ast/ast-property.ts +32 -0
- package/src/ast/index.ts +8 -0
- package/src/ast/key.test.ts +21 -0
- package/src/ast/key.ts +4 -0
- package/src/group-classes/group-classes.bucket.ts +61 -0
- package/src/group-classes/group-classes.format.ts +39 -0
- package/src/group-classes/group-classes.test.ts +126 -0
- package/src/group-classes/group-classes.ts +34 -0
- package/src/group-classes/group-classes.types.ts +5 -0
- package/src/handle-node/handle-cva-class-value.test.ts +133 -0
- package/src/handle-node/handle-cva-class-value.ts +47 -0
- package/src/handle-node/handle-expressions.test.ts +140 -0
- package/src/handle-node/handle-expressions.ts +63 -0
- package/src/handle-node/handle-string-literal.test.ts +135 -0
- package/src/handle-node/handle-string-literal.ts +53 -0
- package/src/handle-targets/index.ts +3 -0
- package/src/handle-targets/target-cva-base.test.ts +103 -0
- package/src/handle-targets/target-cva-base.ts +17 -0
- package/src/handle-targets/target-cva-compound-variants.test.ts +239 -0
- package/src/handle-targets/target-cva-compound-variants.ts +45 -0
- package/src/handle-targets/target-cva-variants.test.ts +178 -0
- package/src/handle-targets/target-cva-variants.ts +50 -0
- package/src/handle-targets/target-cva.test.ts +126 -0
- package/src/handle-targets/target-cva.ts +33 -0
- package/src/handle-targets/target-jsx.test.ts +242 -0
- package/src/handle-targets/target-jsx.ts +65 -0
- package/src/handle-targets/target-object-properties.test.ts +140 -0
- package/src/handle-targets/target-object-properties.ts +41 -0
- package/src/index.ts +13 -0
- package/src/options.test.ts +73 -0
- package/src/options.ts +94 -0
- package/src/orders/class-groups.test.ts +74 -0
- package/src/orders/class-groups.ts +178 -0
- package/src/orders/class-order.tailwind-merge.ts +26 -0
- package/src/orders/modifier-order.prettier-plugin-tailwindcss.test.ts +33 -0
- package/src/orders/modifier-order.prettier-plugin-tailwindcss.ts +23 -0
- package/src/prettier.d.ts +21 -0
- package/src/printers.test.ts +58 -0
- package/src/printers.ts +29 -0
- package/src/printers.walk-ast.test.ts +58 -0
- package/src/printers.walk-ast.ts +41 -0
- package/src/utils/split-classes.ts +3 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @culur/prettier-plugin-tailwindcss-group
|
|
2
|
+
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 🎉 Initialize `@culur/prettier-plugin-tailwindcss-group` Prettier plugin ([`5c7e5f6`](https://github.com/culur/culur/commit/5c7e5f65ea5b15cee03464ad3d5b1b4982c3dbbc)) ([@phamhongphuc](https://github.com/phamhongphuc)).
|
|
8
|
+
|
|
9
|
+
- **Intelligent Tailwind CSS Class Grouping**: Automatically splits and groups long utility class strings into 14 logical CSS categories (Layout, Typography, Backgrounds, Borders, Effects, Transitions, Interactivity, SVG, etc.) based on `tailwind-merge` class groups and sorted by `prettier-plugin-tailwindcss` compiler order.
|
|
10
|
+
- **Category & Modifier Threshold Pipeline**: Separates base classes and modifier variants within each category, keeping compact variants on the same line (`≤ modifierThreshold`) or splitting into dedicated variant clusters (`> modifierThreshold`).
|
|
11
|
+
- **Comprehensive Target Support**:
|
|
12
|
+
- **JSX/TSX Attributes**: Formats `className` and custom attributes ending with `ClassName` / `className` (e.g. `wrapperClassName`, `containerClassName`).
|
|
13
|
+
- **Object Properties**: Formats helper calls within objects (e.g. `{ className: cn(...) }`), preserving the function call wrapper.
|
|
14
|
+
- **Class Variance Authority (`cva`)**: Formats base classes, variant options, and compound variant definitions (`class` / `className`) across string, array, and function formats.
|
|
15
|
+
- **Short String Simplification**: Automatically simplifies redundant single-line expressions (e.g. `{"..."}`, `['...']`, or `cn('...')`) back to plain static strings when under `classNameThreshold`.
|
|
16
|
+
- **Dynamic Expression Organization**: Reorganizes static class strings to the front while preserving dynamic expressions, conditionals, and object arguments in `cn(...)`.
|
|
17
|
+
- **Comment Preservation (Bail-Out Strategy)**: Safely detects and preserves code comments (`//` and `/* ... */`) by bailing out on commented nodes, avoiding Prettier `"Comment was not printed"` errors.
|
|
18
|
+
- **Configurable Options**:
|
|
19
|
+
- `classNameThreshold`: Threshold of class names to trigger wrapping (default: `10`).
|
|
20
|
+
- `modifierThreshold`: Threshold of modifier classes per category before splitting into separate lines (default: `2`).
|
|
21
|
+
- `tailwindFunctions`: List of custom helper function names to format (default: `['cn']`).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Culur
|
|
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,558 @@
|
|
|
1
|
+
# `@culur/prettier-plugin-tailwindcss-group`
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@culur/prettier-plugin-tailwindcss-group)
|
|
4
|
+
[](https://www.npmjs.com/package/@culur/prettier-plugin-tailwindcss-group)
|
|
5
|
+
[](../../LICENSE)
|
|
6
|
+
|
|
7
|
+
[](https://www.codefactor.io/repository/github/culur/culur)
|
|
8
|
+
[](https://app.codecov.io/gh/culur/culur)
|
|
9
|
+
[](https://github.com/culur/culur/actions/workflows/build-and-release.yml)
|
|
10
|
+
|
|
11
|
+
> Prettier plugin to automatically group, format, and simplify Tailwind CSS class names across JSX attributes, object properties, and CVA variant definitions.
|
|
12
|
+
|
|
13
|
+
## ✨ Features
|
|
14
|
+
|
|
15
|
+
This plugin enforces a clean and consistent structure for Tailwind CSS classes. It provides:
|
|
16
|
+
|
|
17
|
+
- **Automatic Grouping:** Automatically breaks down long class strings into logically grouped arguments by utility category (Layout, Typography, Visuals, etc.).
|
|
18
|
+
- **Multi-Target Support:** Formats JSX attributes (`className`, `wrapperClassName`), object properties (`className`, `wrapperclassName`), and `cva()` parameters from `class-variance-authority` (commonly used in `shadcn/ui`).
|
|
19
|
+
- **Redundancy Simplification:** Flattens unnecessary wrapper functions into pure strings if they are under the threshold.
|
|
20
|
+
- **Comment Preservation:** Safely detects and preserves code comments (`//` and `/* ... */`) by bailing out on commented nodes to avoid Prettier `"Comment was not printed"` errors.
|
|
21
|
+
- **Idempotency & Compatibility:** Strictly manipulates the Prettier AST. The output is fully compliant with standard Prettier formatting and integrates flawlessly with `prettier-plugin-tailwindcss`.
|
|
22
|
+
|
|
23
|
+
## 💿 Installation
|
|
24
|
+
|
|
25
|
+
Add `@culur/prettier-plugin-tailwindcss-group` and `prettier-plugin-tailwindcss` to your project. `prettier-plugin-tailwindcss` is a required peer dependency used to resolve official Tailwind CSS compiler sorting and variant hierarchies.
|
|
26
|
+
|
|
27
|
+
> [!NOTE]
|
|
28
|
+
> **No need to install `tailwind-merge`:** The required `tailwind-merge` engine is already bundled directly inside `@culur/prettier-plugin-tailwindcss-group`. You do **not** need to install `tailwind-merge` as a separate dependency unless your application code directly imports utility functions like `twMerge()` or `cn()`.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Using npm
|
|
32
|
+
npm install @culur/prettier-plugin-tailwindcss-group prettier-plugin-tailwindcss --save-dev
|
|
33
|
+
|
|
34
|
+
# Using pnpm
|
|
35
|
+
pnpm install @culur/prettier-plugin-tailwindcss-group prettier-plugin-tailwindcss --save-dev
|
|
36
|
+
|
|
37
|
+
# Using yarn
|
|
38
|
+
yarn add @culur/prettier-plugin-tailwindcss-group prettier-plugin-tailwindcss --dev
|
|
39
|
+
|
|
40
|
+
# Using bun
|
|
41
|
+
bun add @culur/prettier-plugin-tailwindcss-group prettier-plugin-tailwindcss --dev
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
_Note: You also need `prettier` (>=3.0.0) installed in your project._
|
|
45
|
+
|
|
46
|
+
## 📖 Usage & Integration
|
|
47
|
+
|
|
48
|
+
Add to your `.prettierrc` configuration:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"plugins": [
|
|
53
|
+
"@culur/prettier-plugin-tailwindcss-group",
|
|
54
|
+
"prettier-plugin-tailwindcss"
|
|
55
|
+
],
|
|
56
|
+
"classNameThreshold": 10,
|
|
57
|
+
"modifierThreshold": 2,
|
|
58
|
+
"tailwindFunctions": ["cn"]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Both plugins share the `tailwindFunctions` configuration option, so helper functions like `cn()` or `clsx()` are sorted and grouped seamlessly.
|
|
63
|
+
|
|
64
|
+
## ⚙️ Options
|
|
65
|
+
|
|
66
|
+
| Option | Type | Default | Description |
|
|
67
|
+
| -------------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
68
|
+
| `classNameThreshold` | `int` | `10` | Threshold of class names (split by whitespace) to trigger wrapping. |
|
|
69
|
+
| `modifierThreshold` | `int` | `2` | Threshold of modifier/variant classes within a category before splitting into separate lines. |
|
|
70
|
+
| `tailwindFunctions` | `string[]` | `["cn"]` | List of helper function names. The first element is used as default wrapper function. **Note:** `tailwindFunctions` is also an official option of `prettier-plugin-tailwindcss`. |
|
|
71
|
+
|
|
72
|
+
## 📐 Formatting Rules & Concepts
|
|
73
|
+
|
|
74
|
+
This plugin inspects AST nodes to find valid styling targets and applies formatting rules.
|
|
75
|
+
|
|
76
|
+
### 🔠 Representation Modes
|
|
77
|
+
|
|
78
|
+
Depending on the target context and rule thresholds, class strings are represented in one of the following AST forms:
|
|
79
|
+
|
|
80
|
+
- **StringLiteral:** A raw static string literal `"class-1 class-2"`.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
// JSX Attribute
|
|
84
|
+
<div className="flex items-center" />;
|
|
85
|
+
|
|
86
|
+
// CVA Parameter
|
|
87
|
+
const button = cva('flex items-center');
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- **ExpressionStringLiteral:** A JSX expression container wrapping a single string literal `{"class-1 class-2"}` (JSX Attributes only).
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
<div className={'flex items-center'} />
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- **ExpressionCall:** A helper function call such as `{cn('class-1 class-2')}` (JSX Attributes) or `cn('class-1 class-2')` (Object Properties / CVA Parameters).
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
// JSX Attribute Expression Call
|
|
100
|
+
<div className={cn('flex items-center')} />;
|
|
101
|
+
|
|
102
|
+
// Object Property Expression Call
|
|
103
|
+
const config = { className: cn('flex items-center') };
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- **ExpressionArrayString:** An array literal containing string items `['class-1', 'class-2']` (used in CVA parameters).
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const button = cva([
|
|
110
|
+
'flex items-center justify-center',
|
|
111
|
+
'bg-white shadow-sm',
|
|
112
|
+
]);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### 🎯 Supported Targets
|
|
118
|
+
|
|
119
|
+
The plugin automatically identifies and formats the following targets in your codebase:
|
|
120
|
+
|
|
121
|
+
- **JSX Attributes:** Attributes named `className` or ending with a case-sensitive suffix `ClassName` (e.g. `wrapperClassName`, `containerClassName`, `headerClassName`).
|
|
122
|
+
- **Attribute Pattern Matching:** Matched using strict suffix matching (`ends with 'ClassName'`). Exact names like `className`, `wrapperClassName`, and `containerClassName` match, whereas uppercase variants like `CLASSNAME` or unrelated attributes like `class` / `id` are ignored.
|
|
123
|
+
- **Bypassed Cases:** Attributes with no value (e.g. `<div className />`) or empty strings (`className=""`) are safely bypassed without transformation.
|
|
124
|
+
- **Generated Modes:** **StringLiteral** or **ExpressionCall** based on the class count threshold.
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
<button
|
|
128
|
+
className="flex items-center p-4"
|
|
129
|
+
containerClassName="bg-white shadow-sm"
|
|
130
|
+
/>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- **Object Properties:** Static properties named `className` or ending with `<prefix>ClassName` / `<prefix>className` (case-sensitive, e.g., `wrapperClassName`, `innerclassName`).
|
|
134
|
+
- **Generated Modes:** **ExpressionCall** only.
|
|
135
|
+
|
|
136
|
+
> **Note:** Formatted **only** when calling a helper function in `tailwindFunctions` (e.g. `cn(...)`) to avoid false positives on non-CSS properties. The `ExpressionCall` wrapper is intentionally preserved (never simplified to `StringLiteral`) so you can freely add classes later without manual re-wrapping, and sorting plugins continue to work seamlessly. Computed keys are not supported.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const styles = {
|
|
140
|
+
// Formatted because it calls cn(...)
|
|
141
|
+
className: cn(
|
|
142
|
+
'flex items-center text-sm font-bold',
|
|
143
|
+
isActive && 'bg-blue-500',
|
|
144
|
+
),
|
|
145
|
+
wrapperClassName: cn('bg-white shadow-sm'),
|
|
146
|
+
};
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- **CVA Function Calls:** Arguments inside `cva(...)` functions from `class-variance-authority` (commonly used in libraries like `shadcn/ui`):
|
|
150
|
+
- Base classes: `params[0]`
|
|
151
|
+
- Variant classes: `params[1]['variants'][<variant_name>][<option_name>]`
|
|
152
|
+
- Compound variant classes: `params[1]['compoundVariants'][<index>]['class']` or `['className']`
|
|
153
|
+
- **Ignored / Non-Class Keys:** Configuration objects like `defaultVariants` (which define variant option identifiers) and condition selector keys inside `compoundVariants` (such as `intent: 'primary'`, `size: 'small'`) are non-class properties and are strictly left untouched.
|
|
154
|
+
- **Spread Elements:** When `cva` is called with a spread config (e.g. `cva(base, ...spreadConfig)`), the plugin formats `params[0]` (base classes) and safely skips `...spreadConfig` since dynamic objects cannot be statically grouped at format time.
|
|
155
|
+
- **Generated Modes:** **StringLiteral**, **ExpressionArrayString**, or **ExpressionCall** based on the input node type and class count threshold.
|
|
156
|
+
|
|
157
|
+
> **Note:** Matches direct `cva(...)` function calls. Aliased imports (e.g. `import { cva as createVariants }`) are intentionally not tracked to maintain high performance and simplicity without requiring complex AST scope analysis.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { cva } from 'class-variance-authority';
|
|
161
|
+
|
|
162
|
+
const buttonVariants = cva(
|
|
163
|
+
'flex items-center justify-center rounded-md text-sm font-medium', // params[0]
|
|
164
|
+
{
|
|
165
|
+
variants: {
|
|
166
|
+
variant: {
|
|
167
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90', // params[1]['variants']['variant']['default']
|
|
168
|
+
outline: ['border border-input bg-background hover:bg-accent'],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
compoundVariants: [
|
|
172
|
+
{
|
|
173
|
+
intent: 'primary',
|
|
174
|
+
disabled: false,
|
|
175
|
+
class: 'hover:bg-blue-600', // params[1]['compoundVariants'][0]['class']
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
defaultVariants: {
|
|
179
|
+
intent: 'primary',
|
|
180
|
+
size: 'medium',
|
|
181
|
+
disabled: false,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### ⚡ Core Rules
|
|
190
|
+
|
|
191
|
+
#### 1. Short String Simplification (`≤ classNameThreshold`)
|
|
192
|
+
|
|
193
|
+
Targets with class count `≤ classNameThreshold` are simplified back to **StringLiteral** format. Redundant expressions like `{"..."}`, `['...']`, or `cn('...')` containing a single static string under the threshold are simplified to plain strings.
|
|
194
|
+
|
|
195
|
+
<!-- test-case: rule-1-short-strings-jsx-expression -->
|
|
196
|
+
|
|
197
|
+
**Before:**
|
|
198
|
+
|
|
199
|
+
```jsx
|
|
200
|
+
<div className={'flex items-center'} />
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**After:**
|
|
204
|
+
|
|
205
|
+
```jsx
|
|
206
|
+
<div className="flex items-center" />
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
<!-- test-case: rule-1-short-strings-function-call -->
|
|
210
|
+
|
|
211
|
+
**Before:**
|
|
212
|
+
|
|
213
|
+
```jsx
|
|
214
|
+
<div className={cn('flex', 'items-center')} />
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**After:**
|
|
218
|
+
|
|
219
|
+
```jsx
|
|
220
|
+
<div className="flex items-center" />
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
#### 2. Automatic Grouping (`> classNameThreshold`)
|
|
226
|
+
|
|
227
|
+
When static classes exceed `classNameThreshold`, the string is automatically converted to grouped arguments based on target context:
|
|
228
|
+
|
|
229
|
+
- **JSX Attributes & Object Properties:** Converted to **ExpressionCall** format (`cn(...)`).
|
|
230
|
+
- **CVA Parameters (`params[0]`, `variants`, `compoundVariants`):** Converted to **ExpressionArrayString** format (`['group-1', 'group-2']`) for strings/arrays, or preserved as **ExpressionCall** (`cn('group-1', 'group-2', ...)`) with grouped arguments.
|
|
231
|
+
|
|
232
|
+
<!-- test-case: rule-2-automatic-grouping classNameThreshold=4 -->
|
|
233
|
+
|
|
234
|
+
**Before:**
|
|
235
|
+
|
|
236
|
+
```jsx
|
|
237
|
+
<div className="flex items-center justify-center text-sm font-bold bg-white shadow-md border-gray-200" />
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**After:**
|
|
241
|
+
|
|
242
|
+
```jsx
|
|
243
|
+
<div
|
|
244
|
+
className={cn(
|
|
245
|
+
'flex items-center justify-center',
|
|
246
|
+
'text-sm font-bold',
|
|
247
|
+
'bg-white',
|
|
248
|
+
'border-gray-200 shadow-md',
|
|
249
|
+
)}
|
|
250
|
+
/>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
#### 3. Argument Organization & CVA Handling
|
|
256
|
+
|
|
257
|
+
- **JSX Attributes & Object Properties:** In **ExpressionCall** formats like `cn(...)` containing dynamic arguments (conditionals, variables, objects), static class strings are grouped and moved to the **front**, while dynamic arguments remain at the **end**.
|
|
258
|
+
- **Supported Wrapper Argument Structure:**
|
|
259
|
+
The plugin optimizes **flat argument lists** of the form `cn(string, string, dynamicExpression, ...)`. It does **not** unpack or recursively extract static class strings from complex nested static structures supported by libraries like `clsx`:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// ❌ Complex / nested array syntax from clsx is NOT unpacked:
|
|
263
|
+
clsx(['foo', 0, false, 'bar']);
|
|
264
|
+
clsx(['foo'], ['', 0, false, 'bar'], [['baz', [['hello'], 'there']]]);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Any argument that is not a direct static `StringLiteral` (such as an array literal `[...]` or object literal `{...}` passed into `cn(...)`) is treated as a dynamic argument and preserved at the end of the argument list.
|
|
268
|
+
|
|
269
|
+
- **CVA Parameters (`params[0]`, `variants`, `compoundVariants`):**
|
|
270
|
+
CVA class values are transformed based on their node type:
|
|
271
|
+
- **StringLiteral:**
|
|
272
|
+
- `≤ classNameThreshold` ➔ **StringLiteral** (`"class-1 class-2"`)
|
|
273
|
+
- `> classNameThreshold` ➔ **ExpressionArrayString** (`['group-1', 'group-2']`)
|
|
274
|
+
- **ExpressionArrayString (`['...']`):**
|
|
275
|
+
- `≤ classNameThreshold` ➔ **StringLiteral** (`"class-1 class-2"`) (if all static)
|
|
276
|
+
- `> classNameThreshold` ➔ **ExpressionArrayString** (`['group-1', 'group-2']`) (static strings grouped)
|
|
277
|
+
- **ExpressionCall (`cn(...)`):**
|
|
278
|
+
- `≤ classNameThreshold` ➔ **StringLiteral** (`"class-1 class-2"`) (if all static)
|
|
279
|
+
- `> classNameThreshold` ➔ **ExpressionCall** (`cn('group-1', 'group-2', ...)`) (arguments grouped and organized)
|
|
280
|
+
- **Dynamic Expressions** (e.g. `[isActive && 'bg-blue-500']` without static strings) ➔ **Preserved**.
|
|
281
|
+
|
|
282
|
+
> [!WARNING]
|
|
283
|
+
> **Warning on `tailwind-merge` class precedence:** Moving static string arguments to the front changes argument evaluation order in `cn()` / `clsx()`. If you rely on `tailwind-merge` to resolve overlapping classes between static strings and dynamic expressions, dynamic arguments placed at the end will now override static classes placed at the front. Ensure there are no conflicting Tailwind utility classes between static and dynamic arguments.
|
|
284
|
+
|
|
285
|
+
<!-- test-case: rule-3-argument-organization classNameThreshold=4 -->
|
|
286
|
+
|
|
287
|
+
**Before:**
|
|
288
|
+
|
|
289
|
+
```tsx
|
|
290
|
+
<div
|
|
291
|
+
className={cn(
|
|
292
|
+
isActive && 'bg-blue-500',
|
|
293
|
+
'flex items-center text-sm font-bold',
|
|
294
|
+
{ 'opacity-50': disabled },
|
|
295
|
+
'p-4',
|
|
296
|
+
)}
|
|
297
|
+
/>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**After:**
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
<div
|
|
304
|
+
className={cn(
|
|
305
|
+
'flex items-center',
|
|
306
|
+
'p-4',
|
|
307
|
+
'text-sm font-bold',
|
|
308
|
+
isActive && 'bg-blue-500',
|
|
309
|
+
{ 'opacity-50': disabled },
|
|
310
|
+
)}
|
|
311
|
+
/>
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
#### 4. Comment Preservation (Bail-Out Strategy)
|
|
317
|
+
|
|
318
|
+
To prevent code corruption and avoid Prettier's `"Comment was not printed"` syntax errors, the plugin adopts a strict **Bail-Out Strategy** whenever comments (`//` or `/* ... */`) are detected inside class expressions or arguments being formatted:
|
|
319
|
+
|
|
320
|
+
- **Bail-Out Behavior:** If any static string literal or function argument contains attached leading, trailing, or inline comments (e.g. inside `className={cn(...)}`, `className=/* ... */"..."`, or `cva([...])`), the plugin immediately **skips transformation** for that target.
|
|
321
|
+
- **Comment Integrity:** The original AST structure is preserved untouched, ensuring Prettier and `prettier-plugin-tailwindcss` can safely print all comments in their exact intended positions.
|
|
322
|
+
- **Outside Comments:** Comments placed outside the formatted arguments (e.g. above JSX attributes or component declarations) do not trigger a bail-out and continue to format normally.
|
|
323
|
+
|
|
324
|
+
<!-- test-case: rule-4-comment-preservation -->
|
|
325
|
+
|
|
326
|
+
**Before:**
|
|
327
|
+
|
|
328
|
+
```tsx
|
|
329
|
+
<div
|
|
330
|
+
className={cn(
|
|
331
|
+
// Inner layout comment
|
|
332
|
+
'flex items-center', // Trailing comment
|
|
333
|
+
'bg-red-500 text-white font-bold p-4 shadow-md',
|
|
334
|
+
)}
|
|
335
|
+
/>
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**After (Preserved):**
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
<div
|
|
342
|
+
className={cn(
|
|
343
|
+
// Inner layout comment
|
|
344
|
+
'flex items-center', // Trailing comment
|
|
345
|
+
'bg-red-500 p-4 font-bold text-white shadow-md',
|
|
346
|
+
)}
|
|
347
|
+
/>
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## 🏗️ Core Architecture & Engine Reuse
|
|
351
|
+
|
|
352
|
+
Under the hood, `@culur/prettier-plugin-tailwindcss-group` coordinates two battle-tested engines to provide zero-guesswork, 100% specification-compliant grouping and sorting:
|
|
353
|
+
|
|
354
|
+
```txt
|
|
355
|
+
Input Classes
|
|
356
|
+
│
|
|
357
|
+
┌─────────────────────┴─────────────────────┐
|
|
358
|
+
▼ ▼
|
|
359
|
+
tailwind-merge (Parser Engine) prettier-plugin-tailwindcss
|
|
360
|
+
┌───────────────────────────────────┐ ┌─────────────────────────────────────┐
|
|
361
|
+
│ • Resolves base ClassGroup IDs │ │ • Official Tailwind Compiler Engine │
|
|
362
|
+
│ • Strips postfix modifiers (/50) │ │ • AST Stylesheet order evaluation │
|
|
363
|
+
│ • Maps classes to 14 Categories │ │ • Sorts modifiers (hover, dark, sm) │
|
|
364
|
+
└─────────────────┬─────────────────┘ └──────────────────┬──────────────────┘
|
|
365
|
+
│ │
|
|
366
|
+
└────────────────────┬───────────────────────┘
|
|
367
|
+
▼
|
|
368
|
+
Category-First Modifier Pipeline
|
|
369
|
+
(Threshold Rule: modifierThreshold = 2)
|
|
370
|
+
│
|
|
371
|
+
┌────────────────┴────────────────┐
|
|
372
|
+
▼ ▼
|
|
373
|
+
Total Modifiers ≤ 2 Total Modifiers > 2
|
|
374
|
+
┌───────────────────────────┐ ┌─────────────────────────────┐
|
|
375
|
+
│ Merge into a single line │ │ Line 1: Base classes │
|
|
376
|
+
│ with base classes. │ │ Line 2..N: Modifier groups │
|
|
377
|
+
└───────────────────────────┘ └─────────────────────────────┘
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
1. **`tailwind-merge`**: Parses class groups, strips postfix modifiers (such as `group/button` or opacity modifiers `bg-red-500/50`), and reliably classifies utilities into corresponding CSS categories.
|
|
381
|
+
2. **`prettier-plugin-tailwindcss`**: Leverages the official Tailwind CSS Compiler AST order to sort all modifiers and variants (`group-hover`, `hover`, `focus`, `dark`, `sm`, `md`, `@container`, etc.) strictly according to Tailwind CSS specificity standards.
|
|
382
|
+
|
|
383
|
+
### 🔄 AST Traversal & Target Handler Priority
|
|
384
|
+
|
|
385
|
+
During Prettier AST preprocessing (`walkAst`), each node is checked against target handlers in a prioritized, short-circuiting pipeline:
|
|
386
|
+
|
|
387
|
+
$$\text{JSX Attributes} \longrightarrow \text{CVA Calls} \longrightarrow \text{Object Properties}$$
|
|
388
|
+
|
|
389
|
+
Once a target handler matches and formats a node, traversal stops for that node and moves on to child nodes. This deterministic hierarchy ensures no overlapping or conflicting AST transformations.
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## 🧩 Class Grouping Strategy
|
|
394
|
+
|
|
395
|
+
When breaking a long `className` string into multiple arguments, the plugin uses a **Category-First + Modifier Threshold Pipeline**:
|
|
396
|
+
|
|
397
|
+
1. **Category Grouping**: Classes are partitioned into **14 primary categories** (plus 1 Unknown/Custom category for unrecognized or external classes). For the complete ordered category definition and Tailwind CSS utility mappings, see [src/orders/class-groups.ts](src/orders/class-groups.ts).
|
|
398
|
+
2. **Base vs. Modifiers**: Within each category, classes without modifiers (base classes) and classes with modifiers (`hover:`, `dark:`, `sm:`, etc.) are separated.
|
|
399
|
+
3. **Threshold Decision (`modifierThreshold`, default `2`)**:
|
|
400
|
+
- **`≤ modifierThreshold` (≤ 2 modifiers)**: Modifiers are kept on the **same line** as the base classes for compactness (e.g. `'text-sm font-medium hover:text-blue-600'`).
|
|
401
|
+
- **`> modifierThreshold` (> 2 modifiers)**: Base classes are placed on the **first line**, followed by each modifier variant cluster on its **own line**, sorted by Tailwind's official variant hierarchy.
|
|
402
|
+
|
|
403
|
+
## 💡 Examples
|
|
404
|
+
|
|
405
|
+
### Button - [shadcn/ui](https://ui.shadcn.com/docs/components/base/button)
|
|
406
|
+
|
|
407
|
+
Here is how `@culur/prettier-plugin-tailwindcss-group` automatically formats and groups complex Tailwind utility classes in a `Button` component from Button (using `cva` and `@base-ui/react`):
|
|
408
|
+
|
|
409
|
+
<!-- test-case: shadcn classNameThreshold=5 modifierThreshold=2 -->
|
|
410
|
+
|
|
411
|
+
<details>
|
|
412
|
+
<summary><strong>Before (Single-line unformatted string)</strong></summary>
|
|
413
|
+
|
|
414
|
+
```tsx
|
|
415
|
+
import { Button as ButtonPrimitive } from '@base-ui/react/button';
|
|
416
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
417
|
+
import { cn } from '@/lib/utils';
|
|
418
|
+
|
|
419
|
+
const buttonVariants = cva(
|
|
420
|
+
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
421
|
+
{
|
|
422
|
+
variants: {
|
|
423
|
+
variant: {
|
|
424
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
|
425
|
+
outline:
|
|
426
|
+
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
|
427
|
+
destructive:
|
|
428
|
+
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
|
429
|
+
},
|
|
430
|
+
size: {
|
|
431
|
+
default:
|
|
432
|
+
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
433
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
434
|
+
icon: 'size-8',
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
defaultVariants: {
|
|
438
|
+
variant: 'default',
|
|
439
|
+
size: 'default',
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
function Button({
|
|
445
|
+
className,
|
|
446
|
+
variant = 'default',
|
|
447
|
+
size = 'default',
|
|
448
|
+
...props
|
|
449
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
450
|
+
return (
|
|
451
|
+
<ButtonPrimitive
|
|
452
|
+
data-slot="button"
|
|
453
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
454
|
+
{...props}
|
|
455
|
+
/>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export { Button, buttonVariants };
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
</details>
|
|
463
|
+
|
|
464
|
+
<details>
|
|
465
|
+
<summary><strong>After (Formatted with Category & Modifier Grouping)</strong></summary>
|
|
466
|
+
|
|
467
|
+
```tsx
|
|
468
|
+
import { Button as ButtonPrimitive } from '@base-ui/react/button';
|
|
469
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
470
|
+
import { cn } from '@/lib/utils';
|
|
471
|
+
|
|
472
|
+
const buttonVariants = cva(
|
|
473
|
+
[
|
|
474
|
+
'group/button',
|
|
475
|
+
'inline-flex shrink-0 items-center justify-center [&_svg]:shrink-0',
|
|
476
|
+
"[&_svg:not([class*='size-'])]:size-4",
|
|
477
|
+
'text-sm font-medium whitespace-nowrap',
|
|
478
|
+
'bg-clip-padding',
|
|
479
|
+
'rounded-lg border border-transparent outline-none',
|
|
480
|
+
'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50',
|
|
481
|
+
'aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20',
|
|
482
|
+
'dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
|
483
|
+
'disabled:opacity-50',
|
|
484
|
+
'transition-all active:not-aria-[haspopup]:translate-y-px',
|
|
485
|
+
'select-none disabled:pointer-events-none [&_svg]:pointer-events-none',
|
|
486
|
+
],
|
|
487
|
+
{
|
|
488
|
+
variants: {
|
|
489
|
+
variant: {
|
|
490
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
|
491
|
+
outline: [
|
|
492
|
+
'hover:text-foreground aria-expanded:text-foreground',
|
|
493
|
+
'bg-background',
|
|
494
|
+
'hover:bg-muted',
|
|
495
|
+
'aria-expanded:bg-muted',
|
|
496
|
+
'dark:bg-input/30',
|
|
497
|
+
'dark:hover:bg-input/50',
|
|
498
|
+
'border-border dark:border-input',
|
|
499
|
+
],
|
|
500
|
+
destructive: [
|
|
501
|
+
'text-destructive',
|
|
502
|
+
'bg-destructive/10',
|
|
503
|
+
'hover:bg-destructive/20',
|
|
504
|
+
'dark:bg-destructive/20',
|
|
505
|
+
'dark:hover:bg-destructive/30',
|
|
506
|
+
'focus-visible:border-destructive/40 focus-visible:ring-destructive/20',
|
|
507
|
+
'dark:focus-visible:ring-destructive/40',
|
|
508
|
+
],
|
|
509
|
+
},
|
|
510
|
+
size: {
|
|
511
|
+
default:
|
|
512
|
+
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
513
|
+
sm: [
|
|
514
|
+
'gap-1',
|
|
515
|
+
'h-7 px-2.5',
|
|
516
|
+
'has-data-[icon=inline-end]:pr-1.5',
|
|
517
|
+
'has-data-[icon=inline-start]:pl-1.5',
|
|
518
|
+
"[&_svg:not([class*='size-'])]:size-3.5",
|
|
519
|
+
'text-[0.8rem]',
|
|
520
|
+
'rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
|
521
|
+
],
|
|
522
|
+
icon: 'size-8',
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
defaultVariants: {
|
|
526
|
+
variant: 'default',
|
|
527
|
+
size: 'default',
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
function Button({
|
|
533
|
+
className,
|
|
534
|
+
variant = 'default',
|
|
535
|
+
size = 'default',
|
|
536
|
+
...props
|
|
537
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
538
|
+
return (
|
|
539
|
+
<ButtonPrimitive
|
|
540
|
+
data-slot="button"
|
|
541
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
542
|
+
{...props}
|
|
543
|
+
/>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export { Button, buttonVariants };
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
</details>
|
|
551
|
+
|
|
552
|
+
## 🗃️ Changelog
|
|
553
|
+
|
|
554
|
+
See [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
|
|
555
|
+
|
|
556
|
+
## 🔒 License
|
|
557
|
+
|
|
558
|
+
See [LICENSE](../../LICENSE) for license rights and limitations (MIT).
|