@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,74 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { classGroups, classGroupsCount } from './class-groups';
3
+ import { config } from './class-order.tailwind-merge';
4
+
5
+ describe('classGroups validation', () => {
6
+ const tailwindGroupIds = Object.keys(config.classGroups);
7
+
8
+ it('should match classGroupsCount with classGroups length + 1', () => {
9
+ expect(classGroupsCount).toBe(classGroups.length + 1);
10
+ });
11
+
12
+ it('should not contain any duplicate groupIds across class groups', () => {
13
+ const seen = new Set<string>();
14
+ const duplicates: string[] = [];
15
+
16
+ for (const groupList of classGroups) {
17
+ for (const id of groupList) {
18
+ if (seen.has(id)) {
19
+ duplicates.push(id);
20
+ }
21
+ seen.add(id);
22
+ }
23
+ }
24
+
25
+ expect(
26
+ duplicates,
27
+ `Found duplicate groupIds: ${duplicates.join(', ')}`,
28
+ ).toEqual([]);
29
+ });
30
+
31
+ it('should not contain any extraneous groupIds not present in tailwind-merge', () => {
32
+ const validGroupIdsSet = new Set(tailwindGroupIds);
33
+ const extraGroupIds: string[] = [];
34
+
35
+ for (const groupList of classGroups) {
36
+ for (const id of groupList) {
37
+ if (!validGroupIdsSet.has(id)) {
38
+ extraGroupIds.push(id);
39
+ }
40
+ }
41
+ }
42
+
43
+ expect(
44
+ extraGroupIds,
45
+ `Found extraneous groupIds not in tailwind-merge: ${extraGroupIds.join(', ')}`,
46
+ ).toEqual([]);
47
+ });
48
+
49
+ it('should not omit any groupIds defined in tailwind-merge', () => {
50
+ const declaredGroupIdsSet = new Set<string>();
51
+ for (const groupList of classGroups) {
52
+ for (const id of groupList) {
53
+ declaredGroupIdsSet.add(id);
54
+ }
55
+ }
56
+
57
+ const missingGroupIds = tailwindGroupIds.filter(
58
+ id => !declaredGroupIdsSet.has(id),
59
+ );
60
+
61
+ expect(
62
+ missingGroupIds,
63
+ `Missing the following groupIds from tailwind-merge: ${missingGroupIds.join(', ')}`,
64
+ ).toEqual([]);
65
+ });
66
+
67
+ it('should have the exact total declared count equal to tailwind-merge classGroups count', () => {
68
+ const totalDeclared = classGroups.reduce(
69
+ (sum, groupList) => sum + groupList.length,
70
+ 0,
71
+ );
72
+ expect(totalDeclared).toBe(tailwindGroupIds.length);
73
+ });
74
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Groups Tailwind CSS classGroupIds into 14 primary categories (+ 1 unknown group) for the plugin.
3
+ * Each category contains a list of corresponding groupIds from tailwind-merge.
4
+ */
5
+ export const classGroups: readonly (readonly string[])[] = [
6
+ //! Context & Container
7
+ // prettier-multiline-arrays-next-line-pattern: 4
8
+ [
9
+ 'group', 'peer', 'container-type', 'container-named',
10
+ ],
11
+
12
+ //! Positioning
13
+ // prettier-multiline-arrays-next-line-pattern: 1 7 4 3
14
+ [
15
+ 'position', // position
16
+ 'inset', 'inset-x', 'inset-y', 'start', 'end', 'inset-bs', 'inset-be', // inset
17
+ 'top', 'right', 'bottom', 'left', // top, right, bottom, left
18
+ 'float', 'clear', 'z', // float, clear, z-index
19
+ ],
20
+
21
+ //! Display, Flex, Grid
22
+ // prettier-multiline-arrays-next-line-pattern: 4 7 4 4 3 3 3 3 3 1
23
+ [
24
+ 'display', 'sr', 'isolation', 'visibility', // display & visibility
25
+ 'flex-direction', 'flex-wrap', 'flex', 'grow', 'shrink', 'basis', 'order', // flex
26
+ 'grid-cols', 'col-start-end', 'col-start', 'col-end', // grid column
27
+ 'grid-rows', 'row-start-end', 'row-start', 'row-end', // grid row
28
+ 'grid-flow', 'auto-cols', 'auto-rows', // grid flow
29
+ 'gap', 'gap-x', 'gap-y', // gap
30
+ 'justify-content', 'justify-items', 'justify-self', // justify
31
+ 'align-content', 'align-items', 'align-self', // align
32
+ 'place-content', 'place-items', 'place-self', // place
33
+ 'columns', // columns
34
+ ],
35
+
36
+ //! Box Model & Spacing
37
+ // prettier-multiline-arrays-next-line-pattern: 8 3 3 1 1 3 11 11 4
38
+ [
39
+ 'w', 'min-w', 'max-w', 'h', 'min-h', 'max-h', 'size', 'aspect', // size
40
+ 'inline-size', 'min-inline-size', 'max-inline-size', // inline-size
41
+ 'block-size', 'min-block-size', 'max-block-size', // block-size
42
+ 'box', // box-sizing
43
+ 'container', // container
44
+ 'break-after', 'break-before', 'break-inside', // break
45
+ 'p', 'px', 'py', 'ps', 'pe', 'pbs', 'pbe', 'pt', 'pr', 'pb', 'pl', // padding
46
+ 'm', 'mx', 'my', 'ms', 'me', 'mbs', 'mbe', 'mt', 'mr', 'mb', 'ml', // margin
47
+ 'space-x', 'space-x-reverse', 'space-y', 'space-y-reverse', // space
48
+ ],
49
+
50
+ //! Overflow & Scroll
51
+ // prettier-multiline-arrays-next-line-pattern: 6 1 4 11 11
52
+ [
53
+ 'overflow', 'overflow-x', 'overflow-y', 'overscroll', 'overscroll-x', 'overscroll-y', // overflow
54
+ 'scroll-behavior', // scroll-behavior
55
+ 'scrollbar-thumb-color', 'scrollbar-track-color', 'scrollbar-gutter', 'scrollbar-w', // scrollbar
56
+ 'scroll-m', 'scroll-mx', 'scroll-my', 'scroll-ms', 'scroll-me', 'scroll-mbs', 'scroll-mbe', 'scroll-mt', 'scroll-mr', 'scroll-mb', 'scroll-ml', // scroll margin
57
+ 'scroll-p', 'scroll-px', 'scroll-py', 'scroll-ps', 'scroll-pe', 'scroll-pbs', 'scroll-pbe', 'scroll-pt', 'scroll-pr', 'scroll-pb', 'scroll-pl', // scroll padding
58
+ ],
59
+
60
+ //! Typography
61
+ // prettier-multiline-arrays-next-line-pattern: 7 6 3 2 2 5 7 3 2 2 1
62
+ [
63
+ 'font-size', 'font-smoothing', 'font-style', 'font-weight', 'font-stretch', 'font-family', 'font-features', // font
64
+ 'fvn-normal', 'fvn-ordinal', 'fvn-slashed-zero', 'fvn-figure', 'fvn-spacing', 'fvn-fraction', // font-variant-numeric
65
+ 'list-image', 'list-style-position', 'list-style-type', // list
66
+ 'placeholder-color', 'text-color', // color
67
+ 'text-shadow', 'text-shadow-color', // text-shadow
68
+ 'text-decoration', 'text-decoration-style', 'text-decoration-thickness', 'text-decoration-color', 'underline-offset', // underline
69
+ 'line-clamp', 'text-overflow', 'text-wrap', 'whitespace', 'break', 'wrap', 'hyphens', // text wrap
70
+ 'tracking', 'leading', 'text-transform', // transform
71
+ 'indent', 'tab-size', // indent
72
+ 'vertical-align', 'text-alignment', // align
73
+ 'content', // content
74
+ ],
75
+
76
+ //! Background
77
+ // prettier-multiline-arrays-next-line-pattern: 8 6 1 2
78
+ [
79
+ 'bg-attachment', 'bg-clip', 'bg-origin', 'bg-position', 'bg-repeat', 'bg-size', 'bg-image', 'bg-color', // background
80
+ 'gradient-from-pos', 'gradient-via-pos', 'gradient-to-pos', 'gradient-from', 'gradient-via', 'gradient-to', // gradient
81
+ 'box-decoration', // box-decoration
82
+ 'object-fit', 'object-position', // object (image)
83
+ ],
84
+
85
+ //! Filter
86
+ // prettier-multiline-arrays-next-line-pattern: 11 10
87
+ [
88
+ 'filter', 'blur', 'brightness', 'contrast', 'drop-shadow', 'drop-shadow-color', 'grayscale', 'hue-rotate', 'invert', 'saturate', 'sepia', // filter
89
+ 'backdrop-filter', 'backdrop-blur', 'backdrop-brightness', 'backdrop-contrast', 'backdrop-grayscale', 'backdrop-hue-rotate', 'backdrop-invert', 'backdrop-opacity', 'backdrop-saturate', 'backdrop-sepia', // backdrop-filter
90
+ ],
91
+
92
+ //! Border & Shadow
93
+ // prettier-multiline-arrays-next-line-pattern: 3 4 4 1 3 4 4 4 2 3 4 4 4 4 2 2 5 2 2
94
+ [
95
+ 'border-w', 'border-w-x', 'border-w-y', // border width
96
+ 'border-w-s', 'border-w-e', 'border-w-bs', 'border-w-be',
97
+ 'border-w-t', 'border-w-r', 'border-w-b', 'border-w-l',
98
+
99
+ 'border-style',
100
+ 'border-color', 'border-color-x', 'border-color-y', // border color
101
+ 'border-color-s', 'border-color-e', 'border-color-bs', 'border-color-be',
102
+ 'border-color-t', 'border-color-r', 'border-color-b', 'border-color-l',
103
+
104
+ 'divide-x', 'divide-x-reverse', 'divide-y', 'divide-y-reverse', // divide
105
+ 'divide-style', 'divide-color', // divide style & divide color
106
+
107
+ 'rounded', 'rounded-s', 'rounded-e', // rounded
108
+ 'rounded-t', 'rounded-r', 'rounded-b', 'rounded-l',
109
+ 'rounded-ss', 'rounded-se', 'rounded-ee', 'rounded-es',
110
+ 'rounded-tl', 'rounded-tr', 'rounded-br', 'rounded-bl',
111
+
112
+ 'outline-style', 'outline-offset', 'outline-w', 'outline-color', // outline
113
+
114
+ 'shadow', 'shadow-color', // shadow
115
+ 'inset-shadow', 'inset-shadow-color', // inset-shadow
116
+ 'ring-w', 'ring-w-inset', 'ring-color', 'ring-offset-w', 'ring-offset-color', // ring
117
+ 'inset-ring-w', 'inset-ring-color', // inset-ring
118
+ ],
119
+
120
+ //! Effect & Mask
121
+ // prettier-multiline-arrays-next-line-pattern: 3 2 5 4 4 4 4 4 4 5 3 5 7
122
+ [
123
+ 'opacity', 'mix-blend', 'bg-blend',
124
+ 'mask-clip', 'mask-composite',
125
+ 'mask-image-linear-pos', 'mask-image-linear-from-pos', 'mask-image-linear-to-pos', 'mask-image-linear-from-color', 'mask-image-linear-to-color', // mask-image-linear
126
+ 'mask-image-t-from-pos', 'mask-image-t-to-pos', 'mask-image-t-from-color', 'mask-image-t-to-color', // mask-image-t
127
+ 'mask-image-r-from-pos', 'mask-image-r-to-pos', 'mask-image-r-from-color', 'mask-image-r-to-color', // mask-image-r
128
+ 'mask-image-b-from-pos', 'mask-image-b-to-pos', 'mask-image-b-from-color', 'mask-image-b-to-color', // mask-image-b
129
+ 'mask-image-l-from-pos', 'mask-image-l-to-pos', 'mask-image-l-from-color', 'mask-image-l-to-color', // mask-image-l
130
+ 'mask-image-x-from-pos', 'mask-image-x-to-pos', 'mask-image-x-from-color', 'mask-image-x-to-color', // mask-image-x
131
+ 'mask-image-y-from-pos', 'mask-image-y-to-pos', 'mask-image-y-from-color', 'mask-image-y-to-color', // mask-image-y
132
+
133
+ 'mask-image-radial', 'mask-image-radial-from-pos', 'mask-image-radial-to-pos', 'mask-image-radial-from-color', 'mask-image-radial-to-color', // mask-image-radial
134
+ 'mask-image-radial-shape', 'mask-image-radial-size', 'mask-image-radial-pos', // mask-image-radial
135
+ 'mask-image-conic-pos', 'mask-image-conic-from-pos', 'mask-image-conic-to-pos', 'mask-image-conic-from-color', 'mask-image-conic-to-color', // mask-image-conic
136
+ 'mask-mode', 'mask-origin', 'mask-position', 'mask-repeat', 'mask-size', 'mask-type', 'mask-image', // mask others
137
+ ],
138
+
139
+ //! Table
140
+ // prettier-multiline-arrays-next-line-pattern: 6
141
+ [
142
+ 'border-collapse', 'border-spacing', 'border-spacing-x', 'border-spacing-y', 'table-layout', 'caption',
143
+ ],
144
+
145
+ //! Transition & Transform
146
+ // prettier-multiline-arrays-next-line-pattern: 5 4 4 5 3 8 1
147
+ [
148
+ 'transition', 'transition-behavior', 'duration', 'ease', 'delay', // transition & animation
149
+ 'animate', 'backface', 'perspective', 'perspective-origin', // transforms
150
+ 'rotate', 'rotate-x', 'rotate-y', 'rotate-z', // rotate
151
+ 'scale', 'scale-x', 'scale-y', 'scale-z', 'scale-3d', // scale
152
+ 'skew', 'skew-x', 'skew-y', // skew
153
+ 'transform', 'transform-origin', 'transform-style', 'translate', 'translate-x', 'translate-y', 'translate-z', 'translate-none', // transform
154
+ 'zoom', // zoom
155
+ ],
156
+
157
+ //! Interactivity
158
+ // prettier-multiline-arrays-next-line-pattern: 4 3 2 4 4 2
159
+ [
160
+ 'accent', 'forced-color-adjust', 'appearance', 'color-scheme',
161
+ 'caret-color', 'cursor', 'pointer-events', // caret & cursor
162
+ 'field-sizing', 'resize', // resize
163
+ 'snap-align', 'snap-stop', 'snap-type', 'snap-strictness', // snap
164
+ 'touch', 'touch-x', 'touch-y', 'touch-pz', // touch
165
+ 'select', 'will-change', //
166
+ ],
167
+
168
+ //! SVG
169
+ // prettier-multiline-arrays-next-line-pattern: 3
170
+ [
171
+ 'fill', 'stroke-w', 'stroke',
172
+ ],
173
+ ];
174
+
175
+ /**
176
+ * Total class group count (14 standard groups + 1 Unknown/Custom group at the end).
177
+ */
178
+ export const classGroupsCount = classGroups.length + 1;
@@ -0,0 +1,26 @@
1
+ import type { ConfigExtension } from 'tailwind-merge-ts';
2
+ import type { AnyThemeGroupIds } from 'tailwind-merge-ts/lib/types';
3
+ import { getDefaultConfig, mergeConfigs } from 'tailwind-merge-ts';
4
+ import { createConfigUtils } from 'tailwind-merge-ts/lib/config-utils';
5
+
6
+ type ExtendedGroups = 'group' | 'peer';
7
+
8
+ const isGroupNamed = (value: string) => value.startsWith('group/');
9
+ const isPeerNamed = (value: string) => value.startsWith('peer/');
10
+
11
+ const configExtend: ConfigExtension<
12
+ ExtendedGroups,
13
+ AnyThemeGroupIds
14
+ >['extend'] = {
15
+ classGroups: {
16
+ group: ['group', isGroupNamed],
17
+ peer: ['peer', isPeerNamed],
18
+ },
19
+ };
20
+
21
+ // Initialize singleton config utils once at module evaluation
22
+ export const config = mergeConfigs<ExtendedGroups>(getDefaultConfig(), {
23
+ extend: configExtend,
24
+ });
25
+
26
+ export const configUtils = createConfigUtils(config);
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { sortModifierKeys } from './modifier-order.prettier-plugin-tailwindcss';
3
+
4
+ describe('sortModifierKeys with Tailwind CSS Compiler sorter', () => {
5
+ it('returns array as is when length <= 1', () => {
6
+ expect(sortModifierKeys([])).toEqual([]);
7
+ expect(sortModifierKeys(['hover'])).toEqual(['hover']);
8
+ });
9
+
10
+ it('correctly sorts standard Tailwind CSS modifiers', () => {
11
+ const input = ['dark:hover', 'hover', 'md', 'dark', 'sm'];
12
+ const sorted = sortModifierKeys(input);
13
+
14
+ expect(sorted).toEqual(['hover', 'sm', 'md', 'dark', 'dark:hover']);
15
+ });
16
+
17
+ it('handles arbitrary variants and bracket syntax without corruption', () => {
18
+ const input = [
19
+ 'hover',
20
+ '[@supports(display:grid)]:hover',
21
+ 'dark',
22
+ '[&_svg]:hover',
23
+ ];
24
+ const sorted = sortModifierKeys(input);
25
+
26
+ expect(sorted).toEqual([
27
+ 'hover',
28
+ 'dark',
29
+ '[&_svg]:hover',
30
+ '[@supports(display:grid)]:hover',
31
+ ]);
32
+ });
33
+ });
@@ -0,0 +1,23 @@
1
+ import { createSorter } from 'prettier-plugin-tailwindcss/sorter';
2
+
3
+ // Initialize Tailwind Compiler sorter once at module evaluation
4
+ const sorter = await createSorter({});
5
+ const DUMMY_SUFFIX = ':flex';
6
+
7
+ /**
8
+ * Sorts modifier keys according to Tailwind CSS Compiler AST order
9
+ * using dummy classes (e.g. 'hover' -> 'hover:flex').
10
+ *
11
+ * @param modifierKeys Array of modifier strings (e.g. ['dark:hover', 'hover', 'md', 'dark', 'sm'])
12
+ * @returns Sorted array of modifier keys adhering to official Tailwind Compiler order
13
+ */
14
+ export function sortModifierKeys(modifierKeys: string[]): string[] {
15
+ if (modifierKeys.length <= 1) {
16
+ return modifierKeys;
17
+ }
18
+
19
+ const dummyClasses = modifierKeys.map(mod => `${mod}${DUMMY_SUFFIX}`);
20
+ const [sortedDummyClasses] = sorter.sortClassLists([dummyClasses]);
21
+
22
+ return sortedDummyClasses.map(cls => cls.slice(0, -DUMMY_SUFFIX.length));
23
+ }
@@ -0,0 +1,21 @@
1
+ import type { Options } from 'prettier';
2
+
3
+ /**
4
+ * Ambient type augmentation for Prettier Options.
5
+ *
6
+ * NOTE: These type definitions correspond directly to the runtime options defined in:
7
+ * @see {@link ./options.ts}
8
+ */
9
+ declare module 'prettier' {
10
+ interface Options {
11
+ classNameThreshold?: number;
12
+ modifierThreshold?: number;
13
+ tailwindFunctions?: string[];
14
+ }
15
+ }
16
+
17
+ export interface PluginOptions extends Options {
18
+ classNameThreshold: number;
19
+ modifierThreshold: number;
20
+ tailwindFunctions: string[];
21
+ }
@@ -0,0 +1,58 @@
1
+ import type { Node } from 'estree-jsx';
2
+ import type { ParserOptions } from 'prettier';
3
+ import dedent from 'dedent';
4
+ import { afterEach, beforeEach, describe } from 'vitest';
5
+ import { testFormat } from './__tests__/utils/test-format';
6
+ import { estree } from './ast';
7
+
8
+ describe('printers preprocess handling: async Promise AST preprocess', () => {
9
+ let originalPreprocess:
10
+ | undefined
11
+ | ((ast: Node, _options: ParserOptions<Node>) => Node | Promise<Node>);
12
+
13
+ beforeEach(() => {
14
+ originalPreprocess = estree.preprocess;
15
+ estree.preprocess = (ast, _options) => Promise.resolve(ast);
16
+ });
17
+
18
+ afterEach(() => {
19
+ estree.preprocess = originalPreprocess;
20
+ });
21
+
22
+ testFormat(
23
+ 'correctly handles async Promise returned by estree.preprocess',
24
+ { classNameThreshold: 2 },
25
+ dedent /* tsx */ `
26
+ <div className="flex items-center text-sm font-bold" />;
27
+ `,
28
+ dedent /* tsx */ `
29
+ <div className={cn('flex items-center', 'text-sm font-bold')} />;
30
+ `,
31
+ );
32
+ });
33
+
34
+ describe('printers preprocess handling: fallback when estree.preprocess is undefined (sync path)', () => {
35
+ let originalPreprocess:
36
+ | undefined
37
+ | ((ast: Node, _options: ParserOptions<Node>) => Node | Promise<Node>);
38
+
39
+ beforeEach(() => {
40
+ originalPreprocess = estree.preprocess;
41
+ estree.preprocess = undefined;
42
+ });
43
+
44
+ afterEach(() => {
45
+ estree.preprocess = originalPreprocess;
46
+ });
47
+
48
+ testFormat(
49
+ 'uses ast directly if estree.preprocess is undefined',
50
+ { classNameThreshold: 2 },
51
+ dedent /* tsx */ `
52
+ <div className="flex items-center text-sm font-bold" />;
53
+ `,
54
+ dedent /* tsx */ `
55
+ <div className={cn('flex items-center', 'text-sm font-bold')} />;
56
+ `,
57
+ );
58
+ });
@@ -0,0 +1,29 @@
1
+ import type { Node } from 'estree-jsx';
2
+ import type { Plugin } from 'prettier';
3
+ import estreePlugin from 'prettier/plugins/estree.js';
4
+ import { estree } from './ast';
5
+ import { resolveOptions } from './options';
6
+ import { walkAst } from './printers.walk-ast';
7
+
8
+ export const printers: Plugin<Node>['printers'] = {
9
+ estree: {
10
+ ...estreePlugin.printers.estree,
11
+ preprocess(ast, options) {
12
+ const pluginOptions = resolveOptions(options);
13
+
14
+ const processedAST = estree.preprocess
15
+ ? estree.preprocess(ast, options)
16
+ : ast;
17
+
18
+ if (processedAST instanceof Promise) {
19
+ return processedAST.then(resolvedAst => {
20
+ walkAst(resolvedAst, pluginOptions);
21
+ return resolvedAst;
22
+ });
23
+ }
24
+
25
+ walkAst(processedAST, pluginOptions);
26
+ return processedAST;
27
+ },
28
+ },
29
+ };
@@ -0,0 +1,58 @@
1
+ import type { Node } from 'estree-jsx';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { estree } from './ast';
4
+ import { resolveOptions } from './options';
5
+ import { walkAst } from './printers.walk-ast';
6
+
7
+ describe('walkAst unit tests', () => {
8
+ const options = resolveOptions({});
9
+
10
+ it('returns early when input is not a valid AST Node', () => {
11
+ expect(() => walkAst(null as unknown as Node, options)).not.toThrow();
12
+ expect(() => walkAst(undefined as unknown as Node, options)).not.toThrow();
13
+ expect(() => walkAst(123 as unknown as Node, options)).not.toThrow();
14
+ expect(() =>
15
+ walkAst('not-a-node' as unknown as Node, options),
16
+ ).not.toThrow();
17
+ expect(() => walkAst({} as unknown as Node, options)).not.toThrow();
18
+ });
19
+
20
+ it('handles array child properties containing non-node items', () => {
21
+ const mockNode: any = {
22
+ type: 'Program',
23
+ body: [null, undefined, 123, 'string-item', { type: 'EmptyStatement' }],
24
+ };
25
+
26
+ expect(() => walkAst(mockNode, options)).not.toThrow();
27
+ });
28
+
29
+ it('handles non-array child properties that are nodes', () => {
30
+ const mockNode: any = {
31
+ type: 'ExpressionStatement',
32
+ expression: {
33
+ type: 'Identifier',
34
+ name: 'foo',
35
+ },
36
+ };
37
+
38
+ expect(() => walkAst(mockNode, options)).not.toThrow();
39
+ });
40
+
41
+ it('handles truthy non-array child properties that are not nodes', () => {
42
+ const originalGetVisitorKeys = estree.getVisitorKeys;
43
+ vi.spyOn(estree, 'getVisitorKeys').mockReturnValue(['someProp'] as any);
44
+
45
+ try {
46
+ const mockNode: any = {
47
+ type: 'CustomNode',
48
+ someProp: 'truthy-non-node-primitive',
49
+ };
50
+
51
+ expect(() => walkAst(mockNode, options)).not.toThrow();
52
+ } finally {
53
+ if (originalGetVisitorKeys) {
54
+ estree.getVisitorKeys = originalGetVisitorKeys;
55
+ }
56
+ }
57
+ });
58
+ });
@@ -0,0 +1,41 @@
1
+ import type { Node } from 'estree-jsx';
2
+ import type { PluginOptions } from './prettier';
3
+ import { estree, isNode } from './ast';
4
+ import {
5
+ handleTargetCVA,
6
+ handleTargetJSX,
7
+ handleTargetObjectProperty,
8
+ } from './handle-targets';
9
+
10
+ const NON_TRAVERSABLE_KEYS = new Set<string>(['parent', 'comments']);
11
+
12
+ const TARGET_HANDLERS = [
13
+ handleTargetJSX,
14
+ handleTargetCVA,
15
+ handleTargetObjectProperty,
16
+ ] as const;
17
+
18
+ export function walkAst(node: Node, options: PluginOptions): void {
19
+ if (!isNode(node)) return;
20
+
21
+ for (const handle of TARGET_HANDLERS) {
22
+ if (handle(node, options)) break;
23
+ }
24
+
25
+ const visitorKeys = estree.getVisitorKeys!(node, NON_TRAVERSABLE_KEYS);
26
+
27
+ for (const key of visitorKeys) {
28
+ const child = node[key as Exclude<keyof typeof node, 'type'>];
29
+ if (!child) continue;
30
+
31
+ if (Array.isArray(child)) {
32
+ for (const item of child) {
33
+ if (isNode(item)) {
34
+ walkAst(item, options);
35
+ }
36
+ }
37
+ } else if (isNode(child)) {
38
+ walkAst(child, options);
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,3 @@
1
+ export function splitClasses(classString: string): string[] {
2
+ return classString.trim().split(/\s+/).filter(Boolean);
3
+ }