@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +558 -0
  4. package/dist/index.d.mts +673 -0
  5. package/dist/index.mjs +4194 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/package.json +66 -0
  8. package/src/__tests__/comments.test.ts +171 -0
  9. package/src/__tests__/plugin-official.test.ts +32 -0
  10. package/src/__tests__/readme.test.ts +13 -0
  11. package/src/__tests__/utils/ast-estree.ts +22 -0
  12. package/src/__tests__/utils/ast-md.ts +101 -0
  13. package/src/__tests__/utils/test-format.ts +35 -0
  14. package/src/ast/ast-array-expression.test.ts +33 -0
  15. package/src/ast/ast-array-expression.ts +20 -0
  16. package/src/ast/ast-call-expression.test.ts +17 -0
  17. package/src/ast/ast-call-expression.ts +5 -0
  18. package/src/ast/ast-comment.test.ts +45 -0
  19. package/src/ast/ast-comment.ts +25 -0
  20. package/src/ast/ast-common.ts +29 -0
  21. package/src/ast/ast-jsx.test.ts +56 -0
  22. package/src/ast/ast-jsx.ts +53 -0
  23. package/src/ast/ast-literal.test.ts +47 -0
  24. package/src/ast/ast-literal.ts +17 -0
  25. package/src/ast/ast-property.test.ts +96 -0
  26. package/src/ast/ast-property.ts +32 -0
  27. package/src/ast/index.ts +8 -0
  28. package/src/ast/key.test.ts +21 -0
  29. package/src/ast/key.ts +4 -0
  30. package/src/group-classes/group-classes.bucket.ts +61 -0
  31. package/src/group-classes/group-classes.format.ts +39 -0
  32. package/src/group-classes/group-classes.test.ts +126 -0
  33. package/src/group-classes/group-classes.ts +34 -0
  34. package/src/group-classes/group-classes.types.ts +5 -0
  35. package/src/handle-node/handle-cva-class-value.test.ts +133 -0
  36. package/src/handle-node/handle-cva-class-value.ts +47 -0
  37. package/src/handle-node/handle-expressions.test.ts +140 -0
  38. package/src/handle-node/handle-expressions.ts +63 -0
  39. package/src/handle-node/handle-string-literal.test.ts +135 -0
  40. package/src/handle-node/handle-string-literal.ts +53 -0
  41. package/src/handle-targets/index.ts +3 -0
  42. package/src/handle-targets/target-cva-base.test.ts +103 -0
  43. package/src/handle-targets/target-cva-base.ts +17 -0
  44. package/src/handle-targets/target-cva-compound-variants.test.ts +239 -0
  45. package/src/handle-targets/target-cva-compound-variants.ts +45 -0
  46. package/src/handle-targets/target-cva-variants.test.ts +178 -0
  47. package/src/handle-targets/target-cva-variants.ts +50 -0
  48. package/src/handle-targets/target-cva.test.ts +126 -0
  49. package/src/handle-targets/target-cva.ts +33 -0
  50. package/src/handle-targets/target-jsx.test.ts +242 -0
  51. package/src/handle-targets/target-jsx.ts +65 -0
  52. package/src/handle-targets/target-object-properties.test.ts +140 -0
  53. package/src/handle-targets/target-object-properties.ts +41 -0
  54. package/src/index.ts +13 -0
  55. package/src/options.test.ts +73 -0
  56. package/src/options.ts +94 -0
  57. package/src/orders/class-groups.test.ts +74 -0
  58. package/src/orders/class-groups.ts +178 -0
  59. package/src/orders/class-order.tailwind-merge.ts +26 -0
  60. package/src/orders/modifier-order.prettier-plugin-tailwindcss.test.ts +33 -0
  61. package/src/orders/modifier-order.prettier-plugin-tailwindcss.ts +23 -0
  62. package/src/prettier.d.ts +21 -0
  63. package/src/printers.test.ts +58 -0
  64. package/src/printers.ts +29 -0
  65. package/src/printers.walk-ast.test.ts +58 -0
  66. package/src/printers.walk-ast.ts +41 -0
  67. package/src/utils/split-classes.ts +3 -0
@@ -0,0 +1,242 @@
1
+ import dedent from 'dedent';
2
+ import { describe } from 'vitest';
3
+ import { testFormat } from '../__tests__/utils/test-format';
4
+
5
+ describe('target-jsx: validation and bypassing unrelated attributes', () => {
6
+ testFormat(
7
+ 'ignores non-className attributes such as id and style',
8
+ { classNameThreshold: 1 },
9
+ dedent /* tsx */ `
10
+ <div id="flex items-center text-sm font-bold" style="color: red" />;
11
+ `,
12
+ dedent /* tsx */ `
13
+ <div id="flex items-center text-sm font-bold" style="color: red" />;
14
+ `,
15
+ );
16
+
17
+ testFormat(
18
+ 'ignores empty className attribute',
19
+ { classNameThreshold: 5 },
20
+ dedent /* tsx */ ` <div className="" />; `,
21
+ dedent /* tsx */ ` <div className="" />; `,
22
+ );
23
+
24
+ testFormat(
25
+ 'ignores valueless className attribute',
26
+ { classNameThreshold: 5 },
27
+ dedent /* tsx */ ` <div className />; `,
28
+ dedent /* tsx */ ` <div className />; `,
29
+ );
30
+ });
31
+
32
+ describe('target-jsx: matching supported className patterns', () => {
33
+ testFormat(
34
+ 'formats wrapperClassName when class count > threshold',
35
+ { classNameThreshold: 4 },
36
+ dedent /* tsx */ `
37
+ <div wrapperClassName="flex items-center justify-center text-sm font-bold bg-white shadow-md" />;
38
+ `,
39
+ dedent /* tsx */ `
40
+ <div
41
+ wrapperClassName={cn(
42
+ 'flex items-center justify-center',
43
+ 'text-sm font-bold',
44
+ 'bg-white',
45
+ 'shadow-md',
46
+ )}
47
+ />;
48
+ `,
49
+ );
50
+
51
+ testFormat(
52
+ 'formats containerClassName when class count > threshold',
53
+ { classNameThreshold: 4 },
54
+ dedent /* tsx */ `
55
+ <div containerClassName="flex items-center justify-center text-sm font-bold bg-white shadow-md" />;
56
+ `,
57
+ dedent /* tsx */ `
58
+ <div
59
+ containerClassName={cn(
60
+ 'flex items-center justify-center',
61
+ 'text-sm font-bold',
62
+ 'bg-white',
63
+ 'shadow-md',
64
+ )}
65
+ />;
66
+ `,
67
+ );
68
+ });
69
+
70
+ describe('target-jsx: case 1: stringLiteral value', () => {
71
+ testFormat(
72
+ 'leaves StringLiteral unchanged when class count <= threshold',
73
+ { classNameThreshold: 5 },
74
+ dedent /* tsx */ ` <div className="btn primary active" />; `,
75
+ dedent /* tsx */ ` <div className="btn primary active" />; `,
76
+ );
77
+
78
+ testFormat(
79
+ 'wraps in function call when class count > threshold',
80
+ { classNameThreshold: 4 },
81
+ dedent /* tsx */ `
82
+ <div className="flex items-center justify-center text-sm font-bold bg-white shadow-md border-gray-200" />;
83
+ `,
84
+ dedent /* tsx */ `
85
+ <div
86
+ className={cn(
87
+ 'flex items-center justify-center',
88
+ 'text-sm font-bold',
89
+ 'bg-white',
90
+ 'shadow-md border-gray-200',
91
+ )}
92
+ />;
93
+ `,
94
+ );
95
+ });
96
+
97
+ describe('target-jsx: case 2: JSXExpressionContainer with stringLiteral', () => {
98
+ testFormat(
99
+ 'simplifies to StringLiteral when class count <= threshold',
100
+ { classNameThreshold: 5 },
101
+ dedent /* tsx */ ` <div className={'btn primary active'} />; `,
102
+ dedent /* tsx */ ` <div className="btn primary active" />; `,
103
+ );
104
+
105
+ testFormat(
106
+ 'wraps in function call when class count > threshold',
107
+ { classNameThreshold: 2 },
108
+ dedent /* tsx */ `
109
+ <div className={'flex items-center text-sm font-bold'} />;
110
+ `,
111
+ dedent /* tsx */ `
112
+ <div className={cn('flex items-center', 'text-sm font-bold')} />;
113
+ `,
114
+ );
115
+ });
116
+
117
+ describe('target-jsx: case 3: JSXExpressionContainer with callExpression', () => {
118
+ testFormat(
119
+ 'simplifies single static string argument to StringLiteral if <= threshold',
120
+ { classNameThreshold: 5 },
121
+ dedent /* tsx */ ` <div className={cn('text-sm font-bold')} />; `,
122
+ dedent /* tsx */ ` <div className="text-sm font-bold" />; `,
123
+ );
124
+
125
+ testFormat(
126
+ 'merges multiple static string arguments and simplifies to StringLiteral if <= threshold',
127
+ { classNameThreshold: 5 },
128
+ dedent /* tsx */ ` <div className={cn('text-sm', 'font-bold')} />; `,
129
+ dedent /* tsx */ ` <div className="text-sm font-bold" />; `,
130
+ );
131
+
132
+ testFormat(
133
+ 'groups static strings to front and preserves dynamic arguments when > threshold',
134
+ { classNameThreshold: 4 },
135
+ dedent /* tsx */ `
136
+ <div
137
+ className={cn(
138
+ isActive && 'bg-blue-500',
139
+ 'flex items-center text-sm font-bold',
140
+ { 'opacity-50': disabled },
141
+ 'p-4',
142
+ )}
143
+ />;
144
+ `,
145
+ dedent /* tsx */ `
146
+ <div
147
+ className={cn(
148
+ 'flex items-center',
149
+ 'p-4',
150
+ 'text-sm font-bold',
151
+ isActive && 'bg-blue-500',
152
+ { 'opacity-50': disabled },
153
+ )}
154
+ />;
155
+ `,
156
+ );
157
+
158
+ testFormat(
159
+ 'does not simplify when dynamic arguments exist even if static count <= threshold',
160
+ { classNameThreshold: 5 },
161
+ dedent /* tsx */ ` <div className={cn('flex', isActive && 'text-sm')} />; `,
162
+ dedent /* tsx */ ` <div className={cn('flex', isActive && 'text-sm')} />; `,
163
+ );
164
+
165
+ testFormat(
166
+ 'ignores CallExpression with callee not in tailwindFunctions',
167
+ { classNameThreshold: 1, tailwindFunctions: ['cn'] },
168
+ dedent /* tsx */ `
169
+ <div className={unrelatedFn('flex items-center text-sm font-bold')} />;
170
+ `,
171
+ dedent /* tsx */ `
172
+ <div className={unrelatedFn('flex items-center text-sm font-bold')} />;
173
+ `,
174
+ );
175
+
176
+ testFormat(
177
+ 'supports custom tailwindFunctions',
178
+ { classNameThreshold: 2, tailwindFunctions: ['clsx', 'cva'] },
179
+ dedent /* tsx */ `
180
+ <div
181
+ className={clsx(
182
+ 'flex items-center text-sm font-bold',
183
+ isActive && 'bg-white',
184
+ )}
185
+ />;
186
+ `,
187
+ dedent /* tsx */ `
188
+ <div
189
+ className={clsx(
190
+ 'flex items-center',
191
+ 'text-sm font-bold',
192
+ isActive && 'bg-white',
193
+ )}
194
+ />;
195
+ `,
196
+ );
197
+
198
+ testFormat(
199
+ 'preserves nested JSX element inside cn() arguments as dynamic expression',
200
+ { classNameThreshold: 2 },
201
+ dedent /* tsx */ `
202
+ <div
203
+ className={cn(
204
+ <span />,
205
+ 'flex items-center justify-center text-sm font-bold bg-white',
206
+ )}
207
+ />;
208
+ `,
209
+ dedent /* tsx */ `
210
+ <div
211
+ className={cn(
212
+ 'flex items-center justify-center',
213
+ 'text-sm font-bold',
214
+ 'bg-white',
215
+ <span />,
216
+ )}
217
+ />;
218
+ `,
219
+ );
220
+
221
+ testFormat(
222
+ 'handles nested helper function call cn(...) inside outer cn(...) as dynamic argument',
223
+ { classNameThreshold: 2 },
224
+ dedent /* tsx */ `
225
+ <div
226
+ className={cn(
227
+ cn('text-sm font-bold', isActive && 'text-blue-500'),
228
+ 'flex items-center bg-white',
229
+ )}
230
+ />;
231
+ `,
232
+ dedent /* tsx */ `
233
+ <div
234
+ className={cn(
235
+ 'flex items-center',
236
+ 'bg-white',
237
+ cn('text-sm font-bold', isActive && 'text-blue-500'),
238
+ )}
239
+ />;
240
+ `,
241
+ );
242
+ });
@@ -0,0 +1,65 @@
1
+ import type { Node } from 'estree-jsx';
2
+ import type { PluginOptions } from '../prettier';
3
+ import {
4
+ isCallExpression,
5
+ isClassNameKey,
6
+ isIdentifier,
7
+ isJSXAttribute,
8
+ isJSXExpressionContainer,
9
+ isJSXIdentifier,
10
+ isNodeWithComments,
11
+ isStringLiteral,
12
+ } from '../ast';
13
+ import { handleExpressionArray } from '../handle-node/handle-expressions';
14
+ import { handleStringLiteral } from '../handle-node/handle-string-literal';
15
+
16
+ /** Handles JSX attribute formatting for `className` and `*ClassName` attributes. */
17
+ export function handleTargetJSX(node: Node, options: PluginOptions): boolean {
18
+ // Validate that node is a JSXAttribute with a name matching className / *ClassName
19
+ if (
20
+ !isJSXAttribute(node) ||
21
+ !isJSXIdentifier(node.name) ||
22
+ !isClassNameKey(node.name.name) ||
23
+ !node.value
24
+ ) {
25
+ return false;
26
+ }
27
+
28
+ // Case 1: Plain string literal attribute value, e.g. `className="..."`
29
+ if (isStringLiteral(node.value)) {
30
+ node.value = handleStringLiteral(node.value, options, 'jsx');
31
+ }
32
+ // Case 2: JSX expression container with a string literal, e.g. `className={"..."}`
33
+ else if (
34
+ isJSXExpressionContainer(node.value) &&
35
+ isStringLiteral(node.value.expression)
36
+ ) {
37
+ if (
38
+ isNodeWithComments(node.value) ||
39
+ isNodeWithComments(node.value.expression)
40
+ ) {
41
+ return false;
42
+ }
43
+ node.value = handleStringLiteral(node.value.expression, options, 'jsx');
44
+ }
45
+ // Case 3: JSX expression container calling a tailwind helper function, e.g. `className={cn(...)}`
46
+ else if (
47
+ isJSXExpressionContainer(node.value) &&
48
+ isCallExpression(node.value.expression) &&
49
+ isIdentifier(node.value.expression.callee, options.tailwindFunctions)
50
+ ) {
51
+ const result = handleExpressionArray(
52
+ node.value.expression.arguments,
53
+ options,
54
+ );
55
+
56
+ // If result is an expression array, update function arguments; otherwise replace expression
57
+ if (Array.isArray(result)) {
58
+ node.value.expression.arguments = result;
59
+ } else {
60
+ node.value = result;
61
+ }
62
+ }
63
+
64
+ return true;
65
+ }
@@ -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('target-object-properties: validation and bypassing', () => {
6
+ testFormat(
7
+ 'ignores plain string value (not a CallExpression)',
8
+ { classNameThreshold: 1 },
9
+ dedent /* ts */ ` const s = { className: 'flex items-center' }; `,
10
+ dedent /* ts */ ` const s = { className: 'flex items-center' }; `,
11
+ );
12
+
13
+ testFormat(
14
+ 'ignores unrelated object properties',
15
+ { classNameThreshold: 1 },
16
+ dedent /* ts */ `
17
+ const s = { style: cn('flex items-center'), id: cn('foo bar') };
18
+ `,
19
+ dedent /* ts */ `
20
+ const s = { style: cn('flex items-center'), id: cn('foo bar') };
21
+ `,
22
+ );
23
+
24
+ testFormat(
25
+ 'ignores CallExpression with callee not in tailwindFunctions',
26
+ { classNameThreshold: 1, tailwindFunctions: ['cn'] },
27
+ dedent /* ts */ `
28
+ const s = { className: otherFn('flex items-center text-sm font-bold') };
29
+ `,
30
+ dedent /* ts */ `
31
+ const s = { className: otherFn('flex items-center text-sm font-bold') };
32
+ `,
33
+ );
34
+ });
35
+
36
+ describe('target-object-properties: key pattern matching', () => {
37
+ testFormat(
38
+ 'formats wrapperClassName object property',
39
+ { classNameThreshold: 4 },
40
+ dedent /* ts */ `
41
+ const s = {
42
+ wrapperClassName: cn(
43
+ 'flex items-center justify-center text-sm font-bold bg-white',
44
+ ),
45
+ };
46
+ `,
47
+ dedent /* ts */ `
48
+ const s = {
49
+ wrapperClassName: cn(
50
+ 'flex items-center justify-center',
51
+ 'text-sm font-bold',
52
+ 'bg-white',
53
+ ),
54
+ };
55
+ `,
56
+ );
57
+
58
+ testFormat(
59
+ 'formats wrapperclassName (lowercase c) object property',
60
+ { classNameThreshold: 4 },
61
+ dedent /* ts */ `
62
+ const s = {
63
+ wrapperclassName: cn(
64
+ 'flex items-center justify-center text-sm font-bold bg-white',
65
+ ),
66
+ };
67
+ `,
68
+ dedent /* ts */ `
69
+ const s = {
70
+ wrapperclassName: cn(
71
+ 'flex items-center justify-center',
72
+ 'text-sm font-bold',
73
+ 'bg-white',
74
+ ),
75
+ };
76
+ `,
77
+ );
78
+ });
79
+
80
+ describe('target-object-properties: formatting behavior', () => {
81
+ testFormat(
82
+ 'regroups static strings when > threshold',
83
+ { classNameThreshold: 4 },
84
+ dedent /* ts */ `
85
+ const s = {
86
+ className: cn(
87
+ 'flex items-center justify-center text-sm font-bold bg-white',
88
+ ),
89
+ };
90
+ `,
91
+ dedent /* ts */ `
92
+ const s = {
93
+ className: cn(
94
+ 'flex items-center justify-center',
95
+ 'text-sm font-bold',
96
+ 'bg-white',
97
+ ),
98
+ };
99
+ `,
100
+ );
101
+
102
+ testFormat(
103
+ 'merges multiple static args into single string in cn() when <= threshold (never simplifies to StringLiteral)',
104
+ { classNameThreshold: 5 },
105
+ dedent /* ts */ ` const s = { className: cn('flex', 'items-center') }; `,
106
+ dedent /* ts */ ` const s = { className: cn('flex items-center') }; `,
107
+ );
108
+
109
+ testFormat(
110
+ 'moves static args to front and preserves dynamic args at end',
111
+ { classNameThreshold: 4 },
112
+ dedent /* ts */ `
113
+ const s = {
114
+ className: cn(
115
+ isActive && 'bg-blue-500',
116
+ 'flex items-center text-sm font-bold bg-white',
117
+ { 'opacity-50': disabled },
118
+ ),
119
+ };
120
+ `,
121
+ dedent /* ts */ `
122
+ const s = {
123
+ className: cn(
124
+ 'flex items-center',
125
+ 'text-sm font-bold',
126
+ 'bg-white',
127
+ isActive && 'bg-blue-500',
128
+ { 'opacity-50': disabled },
129
+ ),
130
+ };
131
+ `,
132
+ );
133
+
134
+ testFormat(
135
+ 'leaves untouched when only dynamic args exist',
136
+ { classNameThreshold: 4 },
137
+ dedent /* ts */ ` const s = { className: cn(isActive && 'bg-blue-500') }; `,
138
+ dedent /* ts */ ` const s = { className: cn(isActive && 'bg-blue-500') }; `,
139
+ );
140
+ });
@@ -0,0 +1,41 @@
1
+ import type { Node } from 'estree-jsx';
2
+ import type { PluginOptions } from '../prettier';
3
+ import {
4
+ isCallExpression,
5
+ isClassNameKey,
6
+ isIdentifier,
7
+ isStaticProperty,
8
+ } from '../ast';
9
+ import { handleExpressionArray } from '../handle-node/handle-expressions';
10
+
11
+ /**
12
+ * Handles Object Property class attributes whose value is a CallExpression
13
+ * calling a function in `tailwindFunctions` (e.g. `{ className: cn(...) }`).
14
+ */
15
+ export function handleTargetObjectProperty(
16
+ node: Node,
17
+ options: PluginOptions,
18
+ ): boolean {
19
+ // Validate that node is an ObjectProperty with matching className key and tailwind function call value
20
+ if (
21
+ !isStaticProperty(node, isClassNameKey) ||
22
+ !isCallExpression(node.value) ||
23
+ !isIdentifier(node.value.callee, options.tailwindFunctions)
24
+ ) {
25
+ return false;
26
+ }
27
+
28
+ // Process arguments through expression array handler
29
+ const result = handleExpressionArray(node.value.arguments, options);
30
+
31
+ // Update arguments while preserving the CallExpression wrapper
32
+ if (Array.isArray(result)) {
33
+ node.value.arguments = result;
34
+ } else {
35
+ node.value.arguments = [result];
36
+ }
37
+
38
+ return true;
39
+ }
40
+
41
+ export const handleTargetObjectProperties = handleTargetObjectProperty;
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { Plugin } from 'prettier';
2
+ import { options } from './options';
3
+ import { printers } from './printers';
4
+
5
+ export { options } from './options';
6
+ export { printers } from './printers';
7
+
8
+ const plugin: Plugin = {
9
+ options,
10
+ printers,
11
+ };
12
+
13
+ export default plugin;
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveOptions } from './options';
3
+
4
+ describe('resolveOptions validation', () => {
5
+ it('resolves valid options correctly', () => {
6
+ const resolved = resolveOptions({ tailwindFunctions: ['cn', 'clsx'] });
7
+ expect(resolved.tailwindFunctions).toEqual(['cn', 'clsx']);
8
+ expect(resolved.classNameThreshold).toBe(10);
9
+ expect(resolved.modifierThreshold).toBe(2);
10
+ });
11
+
12
+ it('customizes classNameThreshold correctly', () => {
13
+ const resolved = resolveOptions({ classNameThreshold: 5 });
14
+ expect(resolved.classNameThreshold).toBe(5);
15
+ expect(resolved.modifierThreshold).toBe(2);
16
+ expect(resolved.tailwindFunctions).toEqual(['cn']);
17
+ });
18
+
19
+ it('customizes modifierThreshold correctly', () => {
20
+ const resolved = resolveOptions({ modifierThreshold: 4 });
21
+ expect(resolved.modifierThreshold).toBe(4);
22
+ });
23
+
24
+ it('falls back to ["cn"] if tailwindFunctions is empty array or undefined', () => {
25
+ expect(resolveOptions({ tailwindFunctions: [] }).tailwindFunctions).toEqual(
26
+ ['cn'],
27
+ );
28
+ expect(resolveOptions({}).tailwindFunctions).toEqual(['cn']);
29
+ expect(resolveOptions(null).tailwindFunctions).toEqual(['cn']);
30
+ });
31
+
32
+ it('throws error if tailwindFunctions contains invalid JS/TS identifier', () => {
33
+ expect(() => resolveOptions({ tailwindFunctions: ['invalid-fn'] })).toThrow(
34
+ 'Option "tailwindFunctions" contains invalid function name "invalid-fn". Function names must be valid JS/TS identifiers.',
35
+ );
36
+ expect(() => resolveOptions({ tailwindFunctions: ['123fn'] })).toThrow(
37
+ 'Option "tailwindFunctions" contains invalid function name "123fn". Function names must be valid JS/TS identifiers.',
38
+ );
39
+ });
40
+
41
+ it('throws error if tailwindFunctions is not an array', () => {
42
+ expect(() =>
43
+ resolveOptions({ tailwindFunctions: 'clsx' as unknown as string[] }),
44
+ ).toThrow('Option "tailwindFunctions" must be an array of strings.');
45
+ expect(() =>
46
+ resolveOptions({ tailwindFunctions: 123 as unknown as string[] }),
47
+ ).toThrow('Option "tailwindFunctions" must be an array of strings.');
48
+ });
49
+
50
+ it('throws error if classNameThreshold is not a non-negative integer', () => {
51
+ expect(() => resolveOptions({ classNameThreshold: -1 })).toThrow(
52
+ 'Option "classNameThreshold" must be a non-negative integer.',
53
+ );
54
+ expect(() => resolveOptions({ classNameThreshold: 2.5 })).toThrow(
55
+ 'Option "classNameThreshold" must be a non-negative integer.',
56
+ );
57
+ expect(() =>
58
+ resolveOptions({ classNameThreshold: '5' as unknown as number }),
59
+ ).toThrow('Option "classNameThreshold" must be a non-negative integer.');
60
+ });
61
+
62
+ it('throws error if modifierThreshold is not a non-negative integer', () => {
63
+ expect(() => resolveOptions({ modifierThreshold: -1 })).toThrow(
64
+ 'Option "modifierThreshold" must be a non-negative integer.',
65
+ );
66
+ expect(() => resolveOptions({ modifierThreshold: 1.5 })).toThrow(
67
+ 'Option "modifierThreshold" must be a non-negative integer.',
68
+ );
69
+ expect(() =>
70
+ resolveOptions({ modifierThreshold: '2' as unknown as number }),
71
+ ).toThrow('Option "modifierThreshold" must be a non-negative integer.');
72
+ });
73
+ });
package/src/options.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { SupportOption } from 'prettier';
2
+ import type { PluginOptions } from './prettier.d';
3
+
4
+ /**
5
+ * Runtime Prettier plugin options configuration.
6
+ *
7
+ * NOTE: TypeScript type augmentation for Prettier's `Options` interface is declared in:
8
+ * @see {@link ./prettier.d.ts}
9
+ */
10
+
11
+ export const options = {
12
+ classNameThreshold: {
13
+ type: 'int',
14
+ category: 'Format',
15
+ default: 10,
16
+ description:
17
+ 'Threshold of class names (split by whitespace) to trigger wrapping.',
18
+ },
19
+ modifierThreshold: {
20
+ type: 'int',
21
+ category: 'Format',
22
+ default: 2,
23
+ description:
24
+ 'Threshold of modifier classes within a category before splitting into separate lines.',
25
+ },
26
+ tailwindFunctions: {
27
+ type: 'string',
28
+ array: true,
29
+ category: 'Format',
30
+ default: [{ value: ['cn'] }],
31
+ description:
32
+ 'List of custom function names that contain Tailwind CSS class names.',
33
+ },
34
+ } satisfies Record<string, SupportOption>;
35
+
36
+ const JS_IDENTIFIER_REGEX = /^[a-z_$][\w$]*$/i;
37
+
38
+ /**
39
+ * Validates and resolves plugin options from Prettier runtime options.
40
+ */
41
+ export function resolveOptions(opts: unknown): PluginOptions {
42
+ const rawOptions = (opts ?? {}) as Partial<PluginOptions>;
43
+
44
+ let tailwindFunctions = ['cn'];
45
+ if (rawOptions.tailwindFunctions !== undefined) {
46
+ if (!Array.isArray(rawOptions.tailwindFunctions)) {
47
+ throw new TypeError(
48
+ 'Option "tailwindFunctions" must be an array of strings.',
49
+ );
50
+ }
51
+ if (rawOptions.tailwindFunctions.length > 0) {
52
+ tailwindFunctions = rawOptions.tailwindFunctions;
53
+ }
54
+ }
55
+
56
+ for (const fnName of tailwindFunctions) {
57
+ if (typeof fnName !== 'string' || !JS_IDENTIFIER_REGEX.test(fnName)) {
58
+ throw new Error(
59
+ `Option "tailwindFunctions" contains invalid function name "${fnName}". Function names must be valid JS/TS identifiers.`,
60
+ );
61
+ }
62
+ }
63
+
64
+ if (rawOptions.classNameThreshold !== undefined) {
65
+ if (
66
+ typeof rawOptions.classNameThreshold !== 'number' ||
67
+ !Number.isInteger(rawOptions.classNameThreshold) ||
68
+ rawOptions.classNameThreshold < 0
69
+ ) {
70
+ throw new Error(
71
+ 'Option "classNameThreshold" must be a non-negative integer.',
72
+ );
73
+ }
74
+ }
75
+
76
+ if (rawOptions.modifierThreshold !== undefined) {
77
+ if (
78
+ typeof rawOptions.modifierThreshold !== 'number' ||
79
+ !Number.isInteger(rawOptions.modifierThreshold) ||
80
+ rawOptions.modifierThreshold < 0
81
+ ) {
82
+ throw new Error(
83
+ 'Option "modifierThreshold" must be a non-negative integer.',
84
+ );
85
+ }
86
+ }
87
+
88
+ return {
89
+ classNameThreshold: rawOptions.classNameThreshold ?? 10,
90
+ modifierThreshold: rawOptions.modifierThreshold ?? 2,
91
+ ...rawOptions,
92
+ tailwindFunctions,
93
+ } as PluginOptions;
94
+ }