@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,53 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CallExpression,
|
|
3
|
+
Expression,
|
|
4
|
+
Identifier,
|
|
5
|
+
JSXAttribute,
|
|
6
|
+
JSXEmptyExpression,
|
|
7
|
+
JSXExpressionContainer,
|
|
8
|
+
JSXIdentifier,
|
|
9
|
+
Node,
|
|
10
|
+
} from 'estree-jsx';
|
|
11
|
+
|
|
12
|
+
export function isJSXAttribute(node: Node): node is JSXAttribute {
|
|
13
|
+
return node.type === 'JSXAttribute';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isJSXIdentifier(
|
|
17
|
+
node: Node,
|
|
18
|
+
name?: string,
|
|
19
|
+
): node is JSXIdentifier {
|
|
20
|
+
if (node.type !== 'JSXIdentifier') return false;
|
|
21
|
+
if (name !== undefined && node.name !== name) return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isJSXExpressionContainer(
|
|
26
|
+
node: Node,
|
|
27
|
+
): node is JSXExpressionContainer {
|
|
28
|
+
return node.type === 'JSXExpressionContainer';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildExpressionContainer(
|
|
32
|
+
expression: Expression | JSXEmptyExpression,
|
|
33
|
+
): JSXExpressionContainer {
|
|
34
|
+
return {
|
|
35
|
+
type: 'JSXExpressionContainer',
|
|
36
|
+
expression,
|
|
37
|
+
} satisfies JSXExpressionContainer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildCallExpression(
|
|
41
|
+
wrapperName: string,
|
|
42
|
+
args: Expression[],
|
|
43
|
+
): CallExpression {
|
|
44
|
+
return {
|
|
45
|
+
type: 'CallExpression',
|
|
46
|
+
callee: {
|
|
47
|
+
type: 'Identifier',
|
|
48
|
+
name: wrapperName,
|
|
49
|
+
} satisfies Identifier,
|
|
50
|
+
arguments: args,
|
|
51
|
+
optional: false,
|
|
52
|
+
} satisfies CallExpression;
|
|
53
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseExpression } from '../__tests__/utils/ast-estree';
|
|
3
|
+
import { buildStringLiteral, isLiteral, isStringLiteral } from './ast-literal';
|
|
4
|
+
|
|
5
|
+
describe('literal utils', () => {
|
|
6
|
+
describe('isLiteral', () => {
|
|
7
|
+
it('returns true for Literal nodes', () => {
|
|
8
|
+
const nodeStr = parseExpression('"test"');
|
|
9
|
+
const nodeNum = parseExpression('123');
|
|
10
|
+
expect(isLiteral(nodeStr)).toBe(true);
|
|
11
|
+
expect(isLiteral(nodeNum)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns false for non-Literal nodes', () => {
|
|
15
|
+
const node = parseExpression('foo');
|
|
16
|
+
expect(isLiteral(node)).toBe(false);
|
|
17
|
+
expect(isLiteral(null)).toBe(false);
|
|
18
|
+
expect(isLiteral(undefined)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('isStringLiteral', () => {
|
|
23
|
+
it('returns true for StringLiteral nodes', () => {
|
|
24
|
+
const node = parseExpression('"test"');
|
|
25
|
+
expect(isStringLiteral(node)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns false for non-string literals or non-literals', () => {
|
|
29
|
+
const nodeNum = parseExpression('123');
|
|
30
|
+
const nodeIdent = parseExpression('foo');
|
|
31
|
+
expect(isStringLiteral(nodeNum)).toBe(false);
|
|
32
|
+
expect(isStringLiteral(nodeIdent)).toBe(false);
|
|
33
|
+
expect(isStringLiteral(null)).toBe(false);
|
|
34
|
+
expect(isStringLiteral(undefined)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('buildStringLiteral', () => {
|
|
39
|
+
it('creates a StringLiteral with value and JSON raw', () => {
|
|
40
|
+
expect(buildStringLiteral('btn primary')).toEqual({
|
|
41
|
+
type: 'Literal',
|
|
42
|
+
value: 'btn primary',
|
|
43
|
+
raw: JSON.stringify('btn primary'),
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Literal, Node, SimpleLiteral } from 'estree-jsx';
|
|
2
|
+
|
|
3
|
+
export function isLiteral(node?: Node | null): node is Literal {
|
|
4
|
+
return Boolean(node && node.type === 'Literal');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type StringLiteral = SimpleLiteral & { value: string };
|
|
8
|
+
|
|
9
|
+
export function isStringLiteral(node?: Node | null): node is StringLiteral {
|
|
10
|
+
return isLiteral(node) && typeof node.value === 'string';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const buildStringLiteral = (value: string): StringLiteral => ({
|
|
14
|
+
type: 'Literal',
|
|
15
|
+
value,
|
|
16
|
+
raw: JSON.stringify(value),
|
|
17
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ObjectExpression } from 'estree-jsx';
|
|
2
|
+
import { assert, describe, expect, it } from 'vitest';
|
|
3
|
+
import { parseExpression } from '../__tests__/utils/ast-estree';
|
|
4
|
+
import {
|
|
5
|
+
isObjectExpression,
|
|
6
|
+
isProperty,
|
|
7
|
+
isStaticProperty,
|
|
8
|
+
} from './ast-property';
|
|
9
|
+
import { isClassNameKey } from './key';
|
|
10
|
+
|
|
11
|
+
describe('ast-property utils', () => {
|
|
12
|
+
describe('isObjectExpression', () => {
|
|
13
|
+
it('returns true for ObjectExpression nodes', () => {
|
|
14
|
+
const node = parseExpression('{ className: "flex" }');
|
|
15
|
+
expect(isObjectExpression(node)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns false for other node types', () => {
|
|
19
|
+
const node = parseExpression('foo');
|
|
20
|
+
expect(isObjectExpression(node)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('isProperty', () => {
|
|
25
|
+
it('returns true for Property nodes', () => {
|
|
26
|
+
const node = parseExpression(`{ className: "flex" }`);
|
|
27
|
+
assert(isObjectExpression(node));
|
|
28
|
+
expect(isProperty(node.properties[0])).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns false for non-Property nodes', () => {
|
|
32
|
+
const node = parseExpression('foo');
|
|
33
|
+
expect(isProperty(node)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('isStaticProperty', () => {
|
|
38
|
+
it('returns true for non-computed Identifier or Literal keys without key filter', () => {
|
|
39
|
+
const objIdent = parseExpression<ObjectExpression>(
|
|
40
|
+
'{ className: "flex" }',
|
|
41
|
+
);
|
|
42
|
+
const objStr = parseExpression<ObjectExpression>(
|
|
43
|
+
'{ "className": "flex" }',
|
|
44
|
+
);
|
|
45
|
+
const objNum = parseExpression<ObjectExpression>('{ 123: "flex" }');
|
|
46
|
+
|
|
47
|
+
expect(isStaticProperty(objIdent.properties[0])).toBe(true);
|
|
48
|
+
expect(isStaticProperty(objStr.properties[0])).toBe(true);
|
|
49
|
+
expect(isStaticProperty(objNum.properties[0])).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('filters by string keyName', () => {
|
|
53
|
+
const objVariants = parseExpression<ObjectExpression>('{ variants: {} }');
|
|
54
|
+
const objNum = parseExpression<ObjectExpression>('{ 123: "flex" }');
|
|
55
|
+
|
|
56
|
+
expect(isStaticProperty(objVariants.properties[0], 'variants')).toBe(
|
|
57
|
+
true,
|
|
58
|
+
);
|
|
59
|
+
expect(
|
|
60
|
+
isStaticProperty(objVariants.properties[0], 'compoundVariants'),
|
|
61
|
+
).toBe(false);
|
|
62
|
+
expect(isStaticProperty(objNum.properties[0], 'variants')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('filters by callback keyName', () => {
|
|
66
|
+
const objWrapper = parseExpression<ObjectExpression>(
|
|
67
|
+
'{ wrapperClassName: "flex" }',
|
|
68
|
+
);
|
|
69
|
+
const objStyle = parseExpression<ObjectExpression>('{ style: "flex" }');
|
|
70
|
+
const objNum = parseExpression<ObjectExpression>('{ 123: "flex" }');
|
|
71
|
+
|
|
72
|
+
expect(isStaticProperty(objWrapper.properties[0], isClassNameKey)).toBe(
|
|
73
|
+
true,
|
|
74
|
+
);
|
|
75
|
+
expect(isStaticProperty(objStyle.properties[0], isClassNameKey)).toBe(
|
|
76
|
+
false,
|
|
77
|
+
);
|
|
78
|
+
expect(isStaticProperty(objNum.properties[0], isClassNameKey)).toBe(
|
|
79
|
+
false,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns false for computed or non-static keys', () => {
|
|
84
|
+
const objComputed = parseExpression<ObjectExpression>(
|
|
85
|
+
'{ [className]: "flex" }',
|
|
86
|
+
);
|
|
87
|
+
const nodeIdent = parseExpression('foo');
|
|
88
|
+
|
|
89
|
+
expect(isStaticProperty(objComputed.properties[0])).toBe(false);
|
|
90
|
+
expect(isStaticProperty(objComputed.properties[0], 'className')).toBe(
|
|
91
|
+
false,
|
|
92
|
+
);
|
|
93
|
+
expect(isStaticProperty(nodeIdent)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Node, ObjectExpression, Property } from 'estree-jsx';
|
|
2
|
+
import { isIdentifier } from './ast-common';
|
|
3
|
+
import { isLiteral, isStringLiteral } from './ast-literal';
|
|
4
|
+
|
|
5
|
+
export function isObjectExpression(node: Node): node is ObjectExpression {
|
|
6
|
+
return node.type === 'ObjectExpression';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isProperty(node: Node): node is Property {
|
|
10
|
+
return node.type === 'Property';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isStaticProperty(
|
|
14
|
+
node: Node,
|
|
15
|
+
keyName?: string | ((key: string) => boolean),
|
|
16
|
+
): node is Property {
|
|
17
|
+
if (!isProperty(node) || node.computed) return false;
|
|
18
|
+
|
|
19
|
+
const key = isIdentifier(node.key)
|
|
20
|
+
? node.key.name
|
|
21
|
+
: isStringLiteral(node.key)
|
|
22
|
+
? node.key.value
|
|
23
|
+
: null;
|
|
24
|
+
|
|
25
|
+
if (key === null) {
|
|
26
|
+
return keyName === undefined && isLiteral(node.key);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (keyName === undefined) return true;
|
|
30
|
+
if (typeof keyName === 'function') return keyName(key);
|
|
31
|
+
return key === keyName;
|
|
32
|
+
}
|
package/src/ast/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './ast-array-expression';
|
|
2
|
+
export * from './ast-call-expression';
|
|
3
|
+
export * from './ast-comment';
|
|
4
|
+
export * from './ast-common';
|
|
5
|
+
export * from './ast-jsx';
|
|
6
|
+
export * from './ast-literal';
|
|
7
|
+
export * from './ast-property';
|
|
8
|
+
export * from './key';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isClassNameKey } from './key';
|
|
3
|
+
|
|
4
|
+
describe('isClassNameKeyName', () => {
|
|
5
|
+
it('matches exact className, suffix ClassName, and suffix className', () => {
|
|
6
|
+
expect(isClassNameKey('className')).toBe(true);
|
|
7
|
+
expect(isClassNameKey('wrapperClassName')).toBe(true);
|
|
8
|
+
expect(isClassNameKey('wrapperclassName')).toBe(true);
|
|
9
|
+
expect(isClassNameKey('containerClassName')).toBe(true);
|
|
10
|
+
expect(isClassNameKey('$prefixClassName')).toBe(true);
|
|
11
|
+
expect(isClassNameKey('_privateClassName')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('rejects names that do not match', () => {
|
|
15
|
+
expect(isClassNameKey('classname')).toBe(false);
|
|
16
|
+
expect(isClassNameKey('CLASSNAME')).toBe(false);
|
|
17
|
+
expect(isClassNameKey('id')).toBe(false);
|
|
18
|
+
expect(isClassNameKey('style')).toBe(false);
|
|
19
|
+
expect(isClassNameKey('')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
package/src/ast/key.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ClassGroupBucket } from './group-classes.types';
|
|
2
|
+
import { classGroups, classGroupsCount } from '../orders/class-groups';
|
|
3
|
+
import { configUtils } from '../orders/class-order.tailwind-merge';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fast lookup map from tailwind-merge groupId to class group index (0-based).
|
|
7
|
+
* Built with null prototype to avoid prototype pollution and enable O(1) lookups.
|
|
8
|
+
*/
|
|
9
|
+
const groupIdToGroupIndex: Record<string, number> = Object.create(null);
|
|
10
|
+
|
|
11
|
+
for (let groupIndex = 0; groupIndex < classGroups.length; groupIndex++) {
|
|
12
|
+
const groupList = classGroups[groupIndex];
|
|
13
|
+
for (let i = 0; i < groupList.length; i++) {
|
|
14
|
+
groupIdToGroupIndex[groupList[i]] = groupIndex;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createClassBuckets(): ClassGroupBucket[] {
|
|
19
|
+
return Array.from({ length: classGroupsCount }, () => ({
|
|
20
|
+
baseClasses: [],
|
|
21
|
+
modifierGroups: new Map(),
|
|
22
|
+
totalModifierCount: 0,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveGroupIndex(
|
|
27
|
+
parsed: ReturnType<typeof configUtils.parseClassName>,
|
|
28
|
+
unknownGroupIndex: number,
|
|
29
|
+
): number {
|
|
30
|
+
// Strip postfix modifier (e.g. '/item' or '/50') to properly resolve base class groupId
|
|
31
|
+
const baseClassName = parsed.maybePostfixModifierPosition
|
|
32
|
+
? parsed.baseClassName.slice(0, parsed.maybePostfixModifierPosition)
|
|
33
|
+
: parsed.baseClassName;
|
|
34
|
+
|
|
35
|
+
const groupId = configUtils.getClassGroupId(baseClassName);
|
|
36
|
+
|
|
37
|
+
return groupId !== undefined && groupIdToGroupIndex[groupId] !== undefined
|
|
38
|
+
? groupIdToGroupIndex[groupId]
|
|
39
|
+
: unknownGroupIndex;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function addClassToBucket(
|
|
43
|
+
bucket: ClassGroupBucket,
|
|
44
|
+
cls: string,
|
|
45
|
+
modifiers: string[],
|
|
46
|
+
): void {
|
|
47
|
+
if (modifiers.length === 0) {
|
|
48
|
+
bucket.baseClasses.push(cls);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const modifierKey = modifiers.join(':');
|
|
53
|
+
const group = bucket.modifierGroups.get(modifierKey);
|
|
54
|
+
|
|
55
|
+
if (group) {
|
|
56
|
+
group.push(cls);
|
|
57
|
+
} else {
|
|
58
|
+
bucket.modifierGroups.set(modifierKey, [cls]);
|
|
59
|
+
}
|
|
60
|
+
bucket.totalModifierCount++;
|
|
61
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ClassGroupBucket } from './group-classes.types';
|
|
2
|
+
import { sortModifierKeys } from '../orders/modifier-order.prettier-plugin-tailwindcss';
|
|
3
|
+
|
|
4
|
+
export function formatBucketLines(
|
|
5
|
+
bucket: ClassGroupBucket,
|
|
6
|
+
modifierThreshold: number,
|
|
7
|
+
): string[] {
|
|
8
|
+
const { baseClasses, modifierGroups, totalModifierCount } = bucket;
|
|
9
|
+
|
|
10
|
+
if (baseClasses.length === 0 && totalModifierCount === 0) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sortedModifierKeys = sortModifierKeys(
|
|
15
|
+
Array.from(modifierGroups.keys()),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (totalModifierCount <= modifierThreshold) {
|
|
19
|
+
// Threshold not met (<= modifierThreshold): Merge all classes into 1 line
|
|
20
|
+
const lineClasses = [...baseClasses];
|
|
21
|
+
for (let i = 0; i < sortedModifierKeys.length; i++) {
|
|
22
|
+
lineClasses.push(...modifierGroups.get(sortedModifierKeys[i])!);
|
|
23
|
+
}
|
|
24
|
+
return [lineClasses.join(' ')];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Threshold exceeded (> modifierThreshold): Separate base classes and each modifier cluster into distinct lines
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
if (baseClasses.length > 0) {
|
|
30
|
+
lines.push(baseClasses.join(' '));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < sortedModifierKeys.length; i++) {
|
|
34
|
+
const modClasses = modifierGroups.get(sortedModifierKeys[i])!;
|
|
35
|
+
lines.push(modClasses.join(' '));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return lines;
|
|
39
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { groupClasses } from './group-classes';
|
|
3
|
+
|
|
4
|
+
describe('groupClasses with category-first and modifier threshold', () => {
|
|
5
|
+
it('should group classes without modifiers according to categories', () => {
|
|
6
|
+
const input = ['flex', 'items-center', 'p-4', 'text-sm', 'bg-white'];
|
|
7
|
+
const result = groupClasses(input);
|
|
8
|
+
|
|
9
|
+
expect(result).toEqual([
|
|
10
|
+
'flex items-center',
|
|
11
|
+
'p-4',
|
|
12
|
+
'text-sm',
|
|
13
|
+
'bg-white',
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should merge modifier classes into the base class line when modifier count <= 2 (threshold)', () => {
|
|
18
|
+
const input = [
|
|
19
|
+
'text-sm',
|
|
20
|
+
'font-medium',
|
|
21
|
+
'hover:text-blue-600',
|
|
22
|
+
'dark:text-white',
|
|
23
|
+
];
|
|
24
|
+
const result = groupClasses(input);
|
|
25
|
+
|
|
26
|
+
expect(result).toEqual([
|
|
27
|
+
'text-sm font-medium hover:text-blue-600 dark:text-white',
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should split modifier classes into separate lines when total modifier count >= 3', () => {
|
|
32
|
+
const input = [
|
|
33
|
+
'bg-white',
|
|
34
|
+
'hover:bg-gray-100',
|
|
35
|
+
'dark:bg-gray-900',
|
|
36
|
+
'dark:hover:bg-gray-800',
|
|
37
|
+
'md:bg-transparent',
|
|
38
|
+
];
|
|
39
|
+
const result = groupClasses(input);
|
|
40
|
+
|
|
41
|
+
expect(result).toEqual([
|
|
42
|
+
'bg-white',
|
|
43
|
+
'hover:bg-gray-100',
|
|
44
|
+
'md:bg-transparent',
|
|
45
|
+
'dark:bg-gray-900',
|
|
46
|
+
'dark:hover:bg-gray-800',
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should respect custom modifierThreshold option', () => {
|
|
51
|
+
const input = [
|
|
52
|
+
'bg-white',
|
|
53
|
+
'hover:bg-gray-100',
|
|
54
|
+
'dark:bg-gray-900',
|
|
55
|
+
'dark:hover:bg-gray-800',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// With modifierThreshold = 4, 3 modifiers should remain merged
|
|
59
|
+
const mergedResult = groupClasses(input, 4);
|
|
60
|
+
expect(mergedResult).toEqual([
|
|
61
|
+
'bg-white hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-800',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// With modifierThreshold = 1, 3 modifiers should split
|
|
65
|
+
const splitResult = groupClasses(input, 1);
|
|
66
|
+
expect(splitResult).toEqual([
|
|
67
|
+
'bg-white',
|
|
68
|
+
'hover:bg-gray-100',
|
|
69
|
+
'dark:bg-gray-900',
|
|
70
|
+
'dark:hover:bg-gray-800',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// With modifierThreshold = 0, even a single modifier splits from base classes
|
|
74
|
+
const zeroResult = groupClasses(['flex', 'hover:flex'], 0);
|
|
75
|
+
expect(zeroResult).toEqual(['flex', 'hover:flex']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should handle postfix opacity modifier such as /50 and group by base class category', () => {
|
|
79
|
+
const input = ['bg-red-500/50', 'bg-white', 'text-white/80', 'text-sm'];
|
|
80
|
+
const result = groupClasses(input);
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual([
|
|
83
|
+
'text-white/80 text-sm',
|
|
84
|
+
'bg-red-500/50 bg-white',
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle category without base classes when total modifiers >= 3', () => {
|
|
89
|
+
const input = [
|
|
90
|
+
'focus:ring-2',
|
|
91
|
+
'focus:ring-blue-500',
|
|
92
|
+
'dark:focus:ring-blue-400',
|
|
93
|
+
];
|
|
94
|
+
const result = groupClasses(input);
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual([
|
|
97
|
+
'focus:ring-2 focus:ring-blue-500',
|
|
98
|
+
'dark:focus:ring-blue-400',
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle named group, peer and container classes in category 1', () => {
|
|
103
|
+
const input = [
|
|
104
|
+
'group/button',
|
|
105
|
+
'peer/draft',
|
|
106
|
+
'@container/sidebar',
|
|
107
|
+
'text-sm',
|
|
108
|
+
];
|
|
109
|
+
const result = groupClasses(input);
|
|
110
|
+
|
|
111
|
+
expect(result).toEqual([
|
|
112
|
+
'group/button peer/draft @container/sidebar',
|
|
113
|
+
'text-sm',
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should handle unknown custom classes and put them at the end', () => {
|
|
118
|
+
const input = ['flex', 'custom-component-class', 'another-unknown'];
|
|
119
|
+
const result = groupClasses(input);
|
|
120
|
+
|
|
121
|
+
expect(result).toEqual([
|
|
122
|
+
'flex',
|
|
123
|
+
'custom-component-class another-unknown',
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { classGroupsCount } from '../orders/class-groups';
|
|
2
|
+
import { configUtils } from '../orders/class-order.tailwind-merge';
|
|
3
|
+
import {
|
|
4
|
+
addClassToBucket,
|
|
5
|
+
createClassBuckets,
|
|
6
|
+
resolveGroupIndex,
|
|
7
|
+
} from './group-classes.bucket';
|
|
8
|
+
import { formatBucketLines } from './group-classes.format';
|
|
9
|
+
|
|
10
|
+
export function groupClasses(
|
|
11
|
+
classes: string[],
|
|
12
|
+
modifierThreshold = 2,
|
|
13
|
+
): string[] {
|
|
14
|
+
const unknownGroupIndex = classGroupsCount - 1;
|
|
15
|
+
const buckets = createClassBuckets();
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < classes.length; i++) {
|
|
18
|
+
const cls = classes[i];
|
|
19
|
+
const parsed = configUtils.parseClassName(cls);
|
|
20
|
+
const groupIndex = resolveGroupIndex(parsed, unknownGroupIndex);
|
|
21
|
+
addClassToBucket(buckets[groupIndex], cls, parsed.modifiers);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result: string[] = [];
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < classGroupsCount; i++) {
|
|
27
|
+
const lines = formatBucketLines(buckets[i], modifierThreshold);
|
|
28
|
+
for (let j = 0; j < lines.length; j++) {
|
|
29
|
+
result.push(lines[j]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import dedent from 'dedent';
|
|
2
|
+
import { describe } from 'vitest';
|
|
3
|
+
import { testFormat } from '../__tests__/utils/test-format';
|
|
4
|
+
|
|
5
|
+
describe('handle-cva-class-value: case 1: stringLiteral expression', () => {
|
|
6
|
+
testFormat(
|
|
7
|
+
'converts StringLiteral to StringArrayExpression when class count > threshold',
|
|
8
|
+
{ classNameThreshold: 4 },
|
|
9
|
+
dedent /* ts */ `
|
|
10
|
+
const b = cva(
|
|
11
|
+
'flex items-center justify-center text-sm font-bold bg-white',
|
|
12
|
+
);
|
|
13
|
+
`,
|
|
14
|
+
dedent /* ts */ `
|
|
15
|
+
const b = cva([
|
|
16
|
+
'flex items-center justify-center',
|
|
17
|
+
'text-sm font-bold',
|
|
18
|
+
'bg-white',
|
|
19
|
+
]);
|
|
20
|
+
`,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
testFormat(
|
|
24
|
+
'leaves StringLiteral unchanged when class count <= threshold',
|
|
25
|
+
{ classNameThreshold: 5 },
|
|
26
|
+
dedent /* ts */ ` const b = cva('flex items-center text-sm'); `,
|
|
27
|
+
dedent /* ts */ ` const b = cva('flex items-center text-sm'); `,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('handle-cva-class-value: case 2: callExpression with tailwind function', () => {
|
|
32
|
+
testFormat(
|
|
33
|
+
'simplifies CallExpression to StringLiteral when class count <= threshold',
|
|
34
|
+
{ classNameThreshold: 5 },
|
|
35
|
+
dedent /* ts */ ` const b = cva(cn('flex', 'items-center')); `,
|
|
36
|
+
dedent /* ts */ ` const b = cva('flex items-center'); `,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
testFormat(
|
|
40
|
+
'groups CallExpression arguments when class count > threshold',
|
|
41
|
+
{ classNameThreshold: 4 },
|
|
42
|
+
dedent /* ts */ `
|
|
43
|
+
const b = cva(
|
|
44
|
+
cn('flex items-center justify-center text-sm font-bold bg-white'),
|
|
45
|
+
);
|
|
46
|
+
`,
|
|
47
|
+
dedent /* ts */ `
|
|
48
|
+
const b = cva(
|
|
49
|
+
cn('flex items-center justify-center', 'text-sm font-bold', 'bg-white'),
|
|
50
|
+
);
|
|
51
|
+
`,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
testFormat(
|
|
55
|
+
'supports custom tailwindFunctions option',
|
|
56
|
+
{ classNameThreshold: 2, tailwindFunctions: ['clsx'] },
|
|
57
|
+
dedent /* ts */ `
|
|
58
|
+
const b = cva(clsx('flex items-center text-sm font-bold'));
|
|
59
|
+
`,
|
|
60
|
+
dedent /* ts */ `
|
|
61
|
+
const b = cva(clsx('flex items-center', 'text-sm font-bold'));
|
|
62
|
+
`,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('handle-cva-class-value: case 3: arrayExpression', () => {
|
|
67
|
+
testFormat(
|
|
68
|
+
'simplifies ArrayExpression to StringLiteral when class count <= threshold',
|
|
69
|
+
{ classNameThreshold: 10 },
|
|
70
|
+
dedent /* ts */ ` const b = cva(['flex', 'items-center', 'text-sm']); `,
|
|
71
|
+
dedent /* ts */ ` const b = cva('flex items-center text-sm'); `,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
testFormat(
|
|
75
|
+
'flattens and regroups ArrayExpression when class count > threshold',
|
|
76
|
+
{ classNameThreshold: 4 },
|
|
77
|
+
dedent /* ts */ `
|
|
78
|
+
const b = cva([
|
|
79
|
+
'flex items-center justify-center',
|
|
80
|
+
'text-sm font-bold bg-white',
|
|
81
|
+
]);
|
|
82
|
+
`,
|
|
83
|
+
dedent /* ts */ `
|
|
84
|
+
const b = cva([
|
|
85
|
+
'flex items-center justify-center',
|
|
86
|
+
'text-sm font-bold',
|
|
87
|
+
'bg-white',
|
|
88
|
+
]);
|
|
89
|
+
`,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
testFormat(
|
|
93
|
+
'preserves dynamic elements within ArrayExpression',
|
|
94
|
+
{ classNameThreshold: 1 },
|
|
95
|
+
dedent /* ts */ ` const b = cva([isActive && 'bg-blue-500', 'flex']); `,
|
|
96
|
+
dedent /* ts */ ` const b = cva(['flex', isActive && 'bg-blue-500']); `,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
testFormat(
|
|
100
|
+
'preserves sparse array holes (empty slots) when regrouping',
|
|
101
|
+
{ classNameThreshold: 4 },
|
|
102
|
+
dedent /* ts */ `
|
|
103
|
+
const b = cva([
|
|
104
|
+
,
|
|
105
|
+
'flex items-center justify-center text-sm font-bold bg-white',
|
|
106
|
+
]);
|
|
107
|
+
`,
|
|
108
|
+
dedent /* ts */ `
|
|
109
|
+
const b = cva([
|
|
110
|
+
'flex items-center justify-center',
|
|
111
|
+
'text-sm font-bold',
|
|
112
|
+
'bg-white',
|
|
113
|
+
,
|
|
114
|
+
]);
|
|
115
|
+
`,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('handle-cva-class-value: fallback: unrecognized or other expressions', () => {
|
|
120
|
+
testFormat(
|
|
121
|
+
'ignores CallExpression with callee not in tailwindFunctions',
|
|
122
|
+
{ classNameThreshold: 1, tailwindFunctions: ['cn'] },
|
|
123
|
+
dedent /* ts */ ` const b = cva(customHelper('flex items-center')); `,
|
|
124
|
+
dedent /* ts */ ` const b = cva(customHelper('flex items-center')); `,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
testFormat(
|
|
128
|
+
'ignores Identifier class value',
|
|
129
|
+
{ classNameThreshold: 1 },
|
|
130
|
+
dedent /* ts */ ` const b = cva(baseStyles); `,
|
|
131
|
+
dedent /* ts */ ` const b = cva(baseStyles); `,
|
|
132
|
+
);
|
|
133
|
+
});
|