@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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Expression } from 'estree-jsx';
|
|
2
|
+
import type { PluginOptions } from '../prettier';
|
|
3
|
+
import {
|
|
4
|
+
isArrayExpression,
|
|
5
|
+
isCallExpression,
|
|
6
|
+
isIdentifier,
|
|
7
|
+
isStringLiteral,
|
|
8
|
+
} from '../ast';
|
|
9
|
+
import { handleExpressionArray } from './handle-expressions';
|
|
10
|
+
import { handleStringLiteral } from './handle-string-literal';
|
|
11
|
+
|
|
12
|
+
/** Handles a class value expression (StringLiteral, CallExpression, or ArrayExpression) inside `cva(...)`. */
|
|
13
|
+
export function handleCvaClassValue(
|
|
14
|
+
expression: Expression,
|
|
15
|
+
options: PluginOptions,
|
|
16
|
+
): Expression {
|
|
17
|
+
// Case 1: Plain string literal, e.g. "px-4 py-2" -> converts to string array if threshold exceeded
|
|
18
|
+
if (isStringLiteral(expression)) {
|
|
19
|
+
return handleStringLiteral(expression, options, 'cva');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Case 2: Tailwind helper function call, e.g. cn("px-4", "py-2", dynamic)
|
|
23
|
+
if (
|
|
24
|
+
isCallExpression(expression) &&
|
|
25
|
+
isIdentifier(expression.callee, options.tailwindFunctions)
|
|
26
|
+
) {
|
|
27
|
+
const result = handleExpressionArray(expression.arguments, options);
|
|
28
|
+
if (Array.isArray(result)) {
|
|
29
|
+
expression.arguments = result;
|
|
30
|
+
return expression;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Case 3: Array of class expressions, e.g. ["px-4", "py-2", dynamic]
|
|
36
|
+
if (isArrayExpression(expression)) {
|
|
37
|
+
const result = handleExpressionArray(expression.elements, options);
|
|
38
|
+
if (Array.isArray(result)) {
|
|
39
|
+
expression.elements = result;
|
|
40
|
+
return expression;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fallback: Return expression unchanged if unrecognized
|
|
46
|
+
return expression;
|
|
47
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import dedent from 'dedent';
|
|
2
|
+
import { describe } from 'vitest';
|
|
3
|
+
import { testFormat } from '../__tests__/utils/test-format';
|
|
4
|
+
|
|
5
|
+
describe('handle-expressions: no static strings / dynamic only', () => {
|
|
6
|
+
testFormat(
|
|
7
|
+
'leaves empty function call untouched',
|
|
8
|
+
{ classNameThreshold: 5 },
|
|
9
|
+
dedent /* tsx */ ` <div className={cn()} />; `,
|
|
10
|
+
dedent /* tsx */ ` <div className={cn()} />; `,
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
testFormat(
|
|
14
|
+
'leaves dynamic-only arguments untouched',
|
|
15
|
+
{ classNameThreshold: 5 },
|
|
16
|
+
dedent /* tsx */ ` <div className={cn(isActive && 'text-sm')} />; `,
|
|
17
|
+
dedent /* tsx */ ` <div className={cn(isActive && 'text-sm')} />; `,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('handle-expressions: empty and whitespace-only static strings', () => {
|
|
22
|
+
testFormat(
|
|
23
|
+
'simplifies pure empty string literal to empty string literal',
|
|
24
|
+
{ classNameThreshold: 5 },
|
|
25
|
+
dedent /* tsx */ ` <div className={cn('')} />; `,
|
|
26
|
+
dedent /* tsx */ ` <div className="" />; `,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
testFormat(
|
|
30
|
+
'strips whitespace-only static strings when dynamic arguments exist',
|
|
31
|
+
{ classNameThreshold: 5 },
|
|
32
|
+
dedent /* tsx */ `
|
|
33
|
+
<div className={cn(' ', ' ', isEnabled && 'text-bold')} />;
|
|
34
|
+
`,
|
|
35
|
+
dedent /* tsx */ ` <div className={cn(isEnabled && 'text-bold')} />; `,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
testFormat(
|
|
39
|
+
'strips multiple pure empty strings when dynamic arguments exist',
|
|
40
|
+
{ classNameThreshold: 5 },
|
|
41
|
+
dedent /* tsx */ `
|
|
42
|
+
<div className={cn('', '', '', isActive && 'text-sm')} />;
|
|
43
|
+
`,
|
|
44
|
+
dedent /* tsx */ ` <div className={cn(isActive && 'text-sm')} />; `,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
testFormat(
|
|
48
|
+
'simplifies whitespace-only static string to empty string literal',
|
|
49
|
+
{ classNameThreshold: 5 },
|
|
50
|
+
dedent /* tsx */ ` <div className={cn(' ')} />; `,
|
|
51
|
+
dedent /* tsx */ ` <div className="" />; `,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('handle-expressions: class count within threshold (<= classNameThreshold)', () => {
|
|
56
|
+
testFormat(
|
|
57
|
+
'simplifies single static string to StringLiteral',
|
|
58
|
+
{ classNameThreshold: 5 },
|
|
59
|
+
dedent /* tsx */ ` <div className={cn('text-sm font-bold')} />; `,
|
|
60
|
+
dedent /* tsx */ ` <div className="text-sm font-bold" />; `,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
testFormat(
|
|
64
|
+
'merges multiple static string arguments into single StringLiteral',
|
|
65
|
+
{ classNameThreshold: 5 },
|
|
66
|
+
dedent /* tsx */ `
|
|
67
|
+
<div className={cn('flex items-center', 'text-sm font-bold')} />;
|
|
68
|
+
`,
|
|
69
|
+
dedent /* tsx */ `
|
|
70
|
+
<div className="flex items-center text-sm font-bold" />;
|
|
71
|
+
`,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
testFormat(
|
|
75
|
+
'merges static strings but preserves CallExpression when dynamic arguments exist',
|
|
76
|
+
{ classNameThreshold: 5 },
|
|
77
|
+
dedent /* tsx */ `
|
|
78
|
+
<div className={cn('flex', 'items-center', isActive && 'text-sm')} />;
|
|
79
|
+
`,
|
|
80
|
+
dedent /* tsx */ `
|
|
81
|
+
<div className={cn('flex items-center', isActive && 'text-sm')} />;
|
|
82
|
+
`,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('handle-expressions: class count exceeds threshold (> classNameThreshold)', () => {
|
|
87
|
+
testFormat(
|
|
88
|
+
'groups static classes into multiple StringLiterals',
|
|
89
|
+
{ classNameThreshold: 1 },
|
|
90
|
+
dedent /* tsx */ ` <div className={cn('text-sm font-bold')} />; `,
|
|
91
|
+
dedent /* tsx */ ` <div className={cn('text-sm font-bold')} />; `,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
testFormat(
|
|
95
|
+
'gathers scattered static strings to front, groups them, and keeps dynamic arguments at end',
|
|
96
|
+
{ classNameThreshold: 4 },
|
|
97
|
+
dedent /* tsx */ `
|
|
98
|
+
<div
|
|
99
|
+
className={cn(
|
|
100
|
+
isActive && 'bg-blue-500',
|
|
101
|
+
'flex items-center text-sm font-bold',
|
|
102
|
+
{ 'opacity-50': disabled },
|
|
103
|
+
'p-4',
|
|
104
|
+
)}
|
|
105
|
+
/>;
|
|
106
|
+
`,
|
|
107
|
+
dedent /* tsx */ `
|
|
108
|
+
<div
|
|
109
|
+
className={cn(
|
|
110
|
+
'flex items-center',
|
|
111
|
+
'p-4',
|
|
112
|
+
'text-sm font-bold',
|
|
113
|
+
isActive && 'bg-blue-500',
|
|
114
|
+
{ 'opacity-50': disabled },
|
|
115
|
+
)}
|
|
116
|
+
/>;
|
|
117
|
+
`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
testFormat(
|
|
121
|
+
'uses default options threshold for regrouping',
|
|
122
|
+
{},
|
|
123
|
+
dedent /* tsx */ `
|
|
124
|
+
<div
|
|
125
|
+
className={cn(
|
|
126
|
+
isActive && 'text-sm',
|
|
127
|
+
'c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 c16 c17 c18 c19 c20 c21',
|
|
128
|
+
)}
|
|
129
|
+
/>;
|
|
130
|
+
`,
|
|
131
|
+
dedent /* tsx */ `
|
|
132
|
+
<div
|
|
133
|
+
className={cn(
|
|
134
|
+
'c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 c16 c17 c18 c19 c20 c21',
|
|
135
|
+
isActive && 'text-sm',
|
|
136
|
+
)}
|
|
137
|
+
/>;
|
|
138
|
+
`,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Expression, SpreadElement } from 'estree-jsx';
|
|
2
|
+
import type { StringLiteral } from '../ast';
|
|
3
|
+
import type { PluginOptions } from '../prettier';
|
|
4
|
+
import {
|
|
5
|
+
buildStringLiteral,
|
|
6
|
+
isNodeWithComments,
|
|
7
|
+
isStringLiteral,
|
|
8
|
+
} from '../ast';
|
|
9
|
+
import { groupClasses } from '../group-classes/group-classes';
|
|
10
|
+
import { splitClasses } from '../utils/split-classes';
|
|
11
|
+
|
|
12
|
+
/** Extracts and regroups static class strings in an expression array while preserving dynamic elements. */
|
|
13
|
+
export function handleExpressionArray<
|
|
14
|
+
T extends Expression | SpreadElement | null,
|
|
15
|
+
>(expressions: T[], options: PluginOptions): T[] | StringLiteral {
|
|
16
|
+
const staticStrings: string[] = [];
|
|
17
|
+
const dynamicExpressions: T[] = [];
|
|
18
|
+
|
|
19
|
+
// Separate static string literals from dynamic expressions
|
|
20
|
+
for (const arg of expressions) {
|
|
21
|
+
if (isStringLiteral(arg)) {
|
|
22
|
+
// BAIL OUT: If the string literal has attached comments, we must not touch it.
|
|
23
|
+
// Modifying or regrouping strings would destroy the AST comment locations,
|
|
24
|
+
// causing Prettier to throw "Comment was not printed" errors or misplace comments.
|
|
25
|
+
if (isNodeWithComments(arg)) {
|
|
26
|
+
return expressions;
|
|
27
|
+
}
|
|
28
|
+
staticStrings.push(arg.value);
|
|
29
|
+
} else {
|
|
30
|
+
dynamicExpressions.push(arg);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Nothing to group if there are no static strings
|
|
35
|
+
if (staticStrings.length === 0) return expressions;
|
|
36
|
+
|
|
37
|
+
const combinedString = staticStrings.join(' ');
|
|
38
|
+
const classes = splitClasses(combinedString);
|
|
39
|
+
|
|
40
|
+
// Case 1: No static classes found (e.g. empty or whitespace-only strings)
|
|
41
|
+
if (classes.length === 0) {
|
|
42
|
+
return dynamicExpressions.length === 0
|
|
43
|
+
? buildStringLiteral('')
|
|
44
|
+
: dynamicExpressions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Case 2: Class count is within threshold -> keep as a single string literal
|
|
48
|
+
if (classes.length <= options.classNameThreshold) {
|
|
49
|
+
const node = buildStringLiteral(combinedString) as unknown as T;
|
|
50
|
+
return dynamicExpressions.length === 0
|
|
51
|
+
? (node as unknown as StringLiteral)
|
|
52
|
+
: [node, ...dynamicExpressions];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Case 3: Class count exceeds threshold -> group into multiple string literals
|
|
56
|
+
const grouped = groupClasses(classes, options.modifierThreshold).map(
|
|
57
|
+
buildStringLiteral,
|
|
58
|
+
) as unknown as T[];
|
|
59
|
+
|
|
60
|
+
return dynamicExpressions.length === 0
|
|
61
|
+
? grouped
|
|
62
|
+
: [...grouped, ...dynamicExpressions];
|
|
63
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import dedent from 'dedent';
|
|
2
|
+
import { describe } from 'vitest';
|
|
3
|
+
import { testFormat } from '../__tests__/utils/test-format';
|
|
4
|
+
|
|
5
|
+
describe('handle-string-literal: jsx target (target = "jsx")', () => {
|
|
6
|
+
testFormat(
|
|
7
|
+
'leaves StringLiteral unchanged when class count <= threshold',
|
|
8
|
+
{ classNameThreshold: 5 },
|
|
9
|
+
dedent /* tsx */ ` <div className="btn primary active" />; `,
|
|
10
|
+
dedent /* tsx */ ` <div className="btn primary active" />; `,
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
testFormat(
|
|
14
|
+
'wraps in JSXExpressionContainer with helper function call when > threshold',
|
|
15
|
+
{ classNameThreshold: 4 },
|
|
16
|
+
dedent /* tsx */ `
|
|
17
|
+
<div className="flex items-center justify-center text-sm font-bold bg-white shadow-md border-gray-200" />;
|
|
18
|
+
`,
|
|
19
|
+
dedent /* tsx */ `
|
|
20
|
+
<div
|
|
21
|
+
className={cn(
|
|
22
|
+
'flex items-center justify-center',
|
|
23
|
+
'text-sm font-bold',
|
|
24
|
+
'bg-white',
|
|
25
|
+
'shadow-md border-gray-200',
|
|
26
|
+
)}
|
|
27
|
+
/>;
|
|
28
|
+
`,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
testFormat(
|
|
32
|
+
'uses first function from tailwindFunctions as wrapper name',
|
|
33
|
+
{ classNameThreshold: 2, tailwindFunctions: ['clsx', 'cva'] },
|
|
34
|
+
dedent /* tsx */ `
|
|
35
|
+
<div className="flex items-center text-sm font-bold" />;
|
|
36
|
+
`,
|
|
37
|
+
dedent /* tsx */ `
|
|
38
|
+
<div className={clsx('flex items-center', 'text-sm font-bold')} />;
|
|
39
|
+
`,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('handle-string-literal: cva target (target = "cva")', () => {
|
|
44
|
+
testFormat(
|
|
45
|
+
'leaves StringLiteral unchanged when class count <= threshold',
|
|
46
|
+
{ classNameThreshold: 5 },
|
|
47
|
+
dedent /* ts */ ` const b = cva('flex items-center'); `,
|
|
48
|
+
dedent /* ts */ ` const b = cva('flex items-center'); `,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
testFormat(
|
|
52
|
+
'converts to StringArrayExpression when class count > threshold',
|
|
53
|
+
{ classNameThreshold: 4 },
|
|
54
|
+
dedent /* ts */ `
|
|
55
|
+
const b = cva(
|
|
56
|
+
'flex items-center justify-center text-sm font-bold bg-white',
|
|
57
|
+
);
|
|
58
|
+
`,
|
|
59
|
+
dedent /* ts */ `
|
|
60
|
+
const b = cva([
|
|
61
|
+
'flex items-center justify-center',
|
|
62
|
+
'text-sm font-bold',
|
|
63
|
+
'bg-white',
|
|
64
|
+
]);
|
|
65
|
+
`,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('handle-string-literal: complex class tokens and whitespace handling', () => {
|
|
70
|
+
testFormat(
|
|
71
|
+
'correctly groups important modifier ! classes',
|
|
72
|
+
{ classNameThreshold: 4 },
|
|
73
|
+
dedent /* tsx */ `
|
|
74
|
+
<div className="absolute top-0 !font-bold text-sm !bg-red-500 bg-white shadow-md" />;
|
|
75
|
+
`,
|
|
76
|
+
dedent /* tsx */ `
|
|
77
|
+
<div
|
|
78
|
+
className={cn(
|
|
79
|
+
'absolute top-0',
|
|
80
|
+
'!font-bold text-sm',
|
|
81
|
+
'!bg-red-500 bg-white',
|
|
82
|
+
'shadow-md',
|
|
83
|
+
)}
|
|
84
|
+
/>;
|
|
85
|
+
`,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
testFormat(
|
|
89
|
+
'correctly groups negative numeric - classes',
|
|
90
|
+
{ classNameThreshold: 4 },
|
|
91
|
+
dedent /* tsx */ `
|
|
92
|
+
<div className="-top-4 -left-2 text-sm font-bold -m-4 -p-2 shadow-md" />;
|
|
93
|
+
`,
|
|
94
|
+
dedent /* tsx */ `
|
|
95
|
+
<div
|
|
96
|
+
className={cn(
|
|
97
|
+
'-top-4 -left-2',
|
|
98
|
+
'-m-4 -p-2',
|
|
99
|
+
'text-sm font-bold',
|
|
100
|
+
'shadow-md',
|
|
101
|
+
)}
|
|
102
|
+
/>;
|
|
103
|
+
`,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
testFormat(
|
|
107
|
+
'correctly groups pseudo/variant prefixes with important modifier ! and negative -',
|
|
108
|
+
{ classNameThreshold: 4 },
|
|
109
|
+
dedent /* tsx */ `
|
|
110
|
+
<div className="md:hover:!text-sm hover:!bg-red-500 dark:hover:-top-2 [&_p]:-mt-4 shadow-md" />;
|
|
111
|
+
`,
|
|
112
|
+
dedent /* tsx */ `
|
|
113
|
+
<div
|
|
114
|
+
className={cn(
|
|
115
|
+
'dark:hover:-top-2',
|
|
116
|
+
'[&_p]:-mt-4',
|
|
117
|
+
'md:hover:!text-sm',
|
|
118
|
+
'hover:!bg-red-500',
|
|
119
|
+
'shadow-md',
|
|
120
|
+
)}
|
|
121
|
+
/>;
|
|
122
|
+
`,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
testFormat(
|
|
126
|
+
'handles extra whitespace in class string',
|
|
127
|
+
{ classNameThreshold: 2 },
|
|
128
|
+
dedent /* tsx */ `
|
|
129
|
+
<div className=" text-sm font-bold bg-white " />;
|
|
130
|
+
`,
|
|
131
|
+
dedent /* tsx */ `
|
|
132
|
+
<div className={cn('text-sm font-bold', 'bg-white')} />;
|
|
133
|
+
`,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { JSXExpressionContainer } from 'estree-jsx';
|
|
2
|
+
import type { StringArrayExpression, StringLiteral } from '../ast';
|
|
3
|
+
import type { PluginOptions } from '../prettier';
|
|
4
|
+
import {
|
|
5
|
+
buildCallExpression,
|
|
6
|
+
buildExpressionContainer,
|
|
7
|
+
buildStringArrayExpression,
|
|
8
|
+
buildStringLiteral,
|
|
9
|
+
isNodeWithComments,
|
|
10
|
+
} from '../ast';
|
|
11
|
+
import { groupClasses } from '../group-classes/group-classes';
|
|
12
|
+
import { splitClasses } from '../utils/split-classes';
|
|
13
|
+
|
|
14
|
+
export function handleStringLiteral(
|
|
15
|
+
node: StringLiteral,
|
|
16
|
+
options: PluginOptions,
|
|
17
|
+
target: 'jsx',
|
|
18
|
+
): JSXExpressionContainer | StringLiteral;
|
|
19
|
+
export function handleStringLiteral(
|
|
20
|
+
node: StringLiteral,
|
|
21
|
+
options: PluginOptions,
|
|
22
|
+
target: 'cva',
|
|
23
|
+
): StringArrayExpression | StringLiteral;
|
|
24
|
+
/** Processes a static string literal, grouping Tailwind CSS classes if threshold is exceeded. */
|
|
25
|
+
export function handleStringLiteral(
|
|
26
|
+
node: StringLiteral,
|
|
27
|
+
options: PluginOptions,
|
|
28
|
+
target: 'jsx' | 'cva',
|
|
29
|
+
): JSXExpressionContainer | StringArrayExpression | StringLiteral {
|
|
30
|
+
// BAIL OUT: If the string literal has attached comments, we must not touch it.
|
|
31
|
+
if (isNodeWithComments(node)) {
|
|
32
|
+
return node;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const classes = splitClasses(node.value);
|
|
36
|
+
|
|
37
|
+
// Keep as single string literal if class count is within threshold
|
|
38
|
+
if (classes.length <= options.classNameThreshold) return node;
|
|
39
|
+
|
|
40
|
+
// Group classes into logical clusters
|
|
41
|
+
const groups = groupClasses(classes, options.modifierThreshold);
|
|
42
|
+
|
|
43
|
+
// CVA context: transform into an ArrayExpression of string literals
|
|
44
|
+
if (target === 'cva') {
|
|
45
|
+
return buildStringArrayExpression(groups);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// JSX context: wrap grouped string literals in primary tailwind helper function (e.g. cn(...))
|
|
49
|
+
const wrapperName = options.tailwindFunctions[0];
|
|
50
|
+
const stringLiterals = groups.map(buildStringLiteral);
|
|
51
|
+
const expressionCall = buildCallExpression(wrapperName, stringLiterals);
|
|
52
|
+
return buildExpressionContainer(expressionCall);
|
|
53
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import dedent from 'dedent';
|
|
2
|
+
import { describe } from 'vitest';
|
|
3
|
+
import { testFormat } from '../__tests__/utils/test-format';
|
|
4
|
+
|
|
5
|
+
describe('target-cva-base: missing or spread base arguments', () => {
|
|
6
|
+
testFormat(
|
|
7
|
+
'ignores cva() without arguments',
|
|
8
|
+
{ classNameThreshold: 4 },
|
|
9
|
+
dedent /* ts */ ` const b = cva(); `,
|
|
10
|
+
dedent /* ts */ ` const b = cva(); `,
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
testFormat(
|
|
14
|
+
'ignores cva(...args) with SpreadElement base',
|
|
15
|
+
{ classNameThreshold: 4 },
|
|
16
|
+
dedent /* ts */ ` const b = cva(...args); `,
|
|
17
|
+
dedent /* ts */ ` const b = cva(...args); `,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('target-cva-base: stringLiteral base', () => {
|
|
22
|
+
testFormat(
|
|
23
|
+
'converts to StringArrayExpression when class count > threshold',
|
|
24
|
+
{ classNameThreshold: 4 },
|
|
25
|
+
dedent /* ts */ `
|
|
26
|
+
const b = cva(
|
|
27
|
+
'flex items-center justify-center text-sm font-bold bg-white',
|
|
28
|
+
);
|
|
29
|
+
`,
|
|
30
|
+
dedent /* ts */ `
|
|
31
|
+
const b = cva([
|
|
32
|
+
'flex items-center justify-center',
|
|
33
|
+
'text-sm font-bold',
|
|
34
|
+
'bg-white',
|
|
35
|
+
]);
|
|
36
|
+
`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
testFormat(
|
|
40
|
+
'stays StringLiteral when class count <= threshold',
|
|
41
|
+
{ classNameThreshold: 5 },
|
|
42
|
+
dedent /* ts */ ` const b = cva('flex items-center'); `,
|
|
43
|
+
dedent /* ts */ ` const b = cva('flex items-center'); `,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('target-cva-base: arrayExpression base', () => {
|
|
48
|
+
testFormat(
|
|
49
|
+
'flattens and regroups when class count > threshold',
|
|
50
|
+
{ classNameThreshold: 4 },
|
|
51
|
+
dedent /* ts */ `
|
|
52
|
+
const b = cva([
|
|
53
|
+
'flex items-center justify-center',
|
|
54
|
+
'text-sm font-bold bg-white',
|
|
55
|
+
]);
|
|
56
|
+
`,
|
|
57
|
+
dedent /* ts */ `
|
|
58
|
+
const b = cva([
|
|
59
|
+
'flex items-center justify-center',
|
|
60
|
+
'text-sm font-bold',
|
|
61
|
+
'bg-white',
|
|
62
|
+
]);
|
|
63
|
+
`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
testFormat(
|
|
67
|
+
'simplifies to StringLiteral when class count <= threshold',
|
|
68
|
+
{ classNameThreshold: 10 },
|
|
69
|
+
dedent /* ts */ ` const b = cva(['flex', 'items-center']); `,
|
|
70
|
+
dedent /* ts */ ` const b = cva('flex items-center'); `,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
testFormat(
|
|
74
|
+
'preserves dynamic elements in array base',
|
|
75
|
+
{ classNameThreshold: 1 },
|
|
76
|
+
dedent /* ts */ ` const b = cva([isActive && 'bg-blue-500']); `,
|
|
77
|
+
dedent /* ts */ ` const b = cva([isActive && 'bg-blue-500']); `,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('target-cva-base: callExpression base', () => {
|
|
82
|
+
testFormat(
|
|
83
|
+
'groups arguments when class count > threshold',
|
|
84
|
+
{ classNameThreshold: 4 },
|
|
85
|
+
dedent /* ts */ `
|
|
86
|
+
const b = cva(
|
|
87
|
+
cn('flex items-center justify-center text-sm font-bold bg-white'),
|
|
88
|
+
);
|
|
89
|
+
`,
|
|
90
|
+
dedent /* ts */ `
|
|
91
|
+
const b = cva(
|
|
92
|
+
cn('flex items-center justify-center', 'text-sm font-bold', 'bg-white'),
|
|
93
|
+
);
|
|
94
|
+
`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
testFormat(
|
|
98
|
+
'simplifies to StringLiteral when class count <= threshold',
|
|
99
|
+
{ classNameThreshold: 10 },
|
|
100
|
+
dedent /* ts */ ` const b = cva(cn('flex', 'items-center')); `,
|
|
101
|
+
dedent /* ts */ ` const b = cva('flex items-center'); `,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CallExpression } from 'estree-jsx';
|
|
2
|
+
import type { PluginOptions } from '../prettier';
|
|
3
|
+
import { handleCvaClassValue } from '../handle-node/handle-cva-class-value';
|
|
4
|
+
|
|
5
|
+
/** Processes base class value (params[0]) in a `cva(...)` call. */
|
|
6
|
+
export function handleTargetCvaBase(
|
|
7
|
+
cva: CallExpression,
|
|
8
|
+
options: PluginOptions,
|
|
9
|
+
): void {
|
|
10
|
+
const base = cva.arguments[0];
|
|
11
|
+
|
|
12
|
+
// Base argument does not exist or is a SpreadElement (...args)
|
|
13
|
+
if (!base || base.type === 'SpreadElement') return;
|
|
14
|
+
|
|
15
|
+
// Transform base class value (string literal, function call, or array)
|
|
16
|
+
cva.arguments[0] = handleCvaClassValue(base, options);
|
|
17
|
+
}
|