Skip to content

Commit 74d4637

Browse files
committed
Add tests for babel plugin
1 parent c4ab31a commit 74d4637

File tree

2 files changed

+190
-21
lines changed

2 files changed

+190
-21
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { transformSync, PluginObj } from '@babel/core';
2+
import { describe, it, expect } from 'vitest';
3+
import { babelPrefixReactClassNames } from '../babel-prefix-react-classnames';
4+
5+
// Helper function to transform code using the plugin
6+
function transform(
7+
code: string,
8+
options: { prefix: string; cnUtil?: string | false } = { prefix: 'prefix-' },
9+
): string {
10+
const result = transformSync(code, {
11+
plugins: [
12+
[babelPrefixReactClassNames(options) as unknown as PluginObj, {}],
13+
],
14+
presets: ['@babel/preset-react'],
15+
configFile: false,
16+
babelrc: false,
17+
});
18+
19+
return result?.code || '';
20+
}
21+
22+
describe('babel-prefix-react-classnames', () => {
23+
it('should add prefix to string literal className', () => {
24+
const code = '<div className="foo">Hello</div>';
25+
const result = transform(code);
26+
expect(result).toContain('className: "prefix-foo"');
27+
});
28+
29+
it('should handle multiple classes in string literal className', () => {
30+
const code = '<div className="foo bar">Hello</div>';
31+
const result = transform(code);
32+
expect(result).toContain('className: "prefix-foo prefix-bar"');
33+
});
34+
35+
it('should not add prefix if already has the prefix', () => {
36+
const code = '<div className="prefix-foo">Hello</div>';
37+
const result = transform(code);
38+
expect(result).toContain('className: "prefix-foo"');
39+
});
40+
41+
it('should handle mixed prefixed and non-prefixed classes', () => {
42+
const code = '<div className="prefix-foo bar">Hello</div>';
43+
const result = transform(code);
44+
expect(result).toContain('className: "prefix-foo prefix-bar"');
45+
});
46+
47+
it('should handle template literals', () => {
48+
const code = '<div className={`foo ${dynamic} bar`}>Hello</div>';
49+
const result = transform(code);
50+
expect(result).toContain('className: `prefix-foo ${dynamic} prefix-bar`');
51+
});
52+
53+
it('should handle identifiers by using the helper function', () => {
54+
const code = '<div className={classes}>Hello</div>';
55+
const result = transform(code);
56+
expect(result).toContain('className: __prefixClassNames(classes)');
57+
});
58+
59+
it('should handle member expressions by using the helper function', () => {
60+
const code = '<div className={styles.container}>Hello</div>';
61+
const result = transform(code);
62+
expect(result).toContain('className: __prefixClassNames(styles.container)');
63+
});
64+
65+
it('should handle cn utility function calls with string arguments', () => {
66+
const code = '<div className={cn("foo", "bar")}>Hello</div>';
67+
const result = transform(code);
68+
expect(result).toContain('cn("prefix-foo"');
69+
expect(result).toContain('"prefix-bar"');
70+
});
71+
72+
it('should handle cn utility function calls with template literals', () => {
73+
const code = '<div className={cn(`foo ${dynamic}`, "bar")}>Hello</div>';
74+
const result = transform(code);
75+
expect(result).toContain('cn(`prefix-foo ${dynamic}`');
76+
expect(result).toContain('"prefix-bar"');
77+
});
78+
79+
it('should handle cn utility function calls with variables', () => {
80+
const code = '<div className={cn(classes, "bar")}>Hello</div>';
81+
const result = transform(code);
82+
expect(result).toContain('cn(__prefixClassNames(classes)');
83+
expect(result).toContain('"prefix-bar"');
84+
});
85+
86+
it('should use custom cn utility name if provided', () => {
87+
const code = '<div className={classNames("foo", "bar")}>Hello</div>';
88+
const result = transform(code, { prefix: 'prefix-', cnUtil: 'classNames' });
89+
expect(result).toContain('classNames("prefix-foo"');
90+
expect(result).toContain('"prefix-bar"');
91+
});
92+
93+
it('should handle multiple JSX elements with className', () => {
94+
const code = `
95+
<div>
96+
<span className="foo">Span</span>
97+
<p className="bar">Paragraph</p>
98+
</div>
99+
`;
100+
const result = transform(code);
101+
expect(result).toMatch(/className: "prefix-foo"/);
102+
expect(result).toMatch(/className: "prefix-bar"/);
103+
});
104+
105+
it('should handle complex nested JSX with mixed className types', () => {
106+
const code = `
107+
<div className="container">
108+
<span className={styles.text}>Text</span>
109+
<button className={cn('btn', isActive && 'active')}>Click</button>
110+
</div>
111+
`;
112+
const result = transform(code);
113+
expect(result).toMatch(/className: "prefix-container"/);
114+
expect(result).toMatch(/className: __prefixClassNames\(styles\.text\)/);
115+
expect(result).toMatch(/cn\("prefix-btn"/);
116+
expect(result).toMatch(/isActive && "prefix-active"/);
117+
});
118+
119+
it('should handle JSX attributes other than className', () => {
120+
const code = '<div id="main" className="foo" data-test="value">Hello</div>';
121+
const result = transform(code);
122+
expect(result).toContain('id: "main"');
123+
expect(result).toContain('className: "prefix-foo"');
124+
expect(result).toContain('"data-test": "value"');
125+
});
126+
127+
it('should handle JSX elements without className', () => {
128+
const code = '<div>Hello</div>';
129+
const result = transform(code);
130+
expect(result).toContain('React.createElement("div"');
131+
});
132+
133+
it('should only transform className attributes', () => {
134+
const code = '<div className="foo" style={{ color: "red" }}>Hello</div>';
135+
const result = transform(code);
136+
expect(result).toContain('className: "prefix-foo"');
137+
expect(result).toContain('style: {');
138+
expect(result).toContain('color: "red"');
139+
});
140+
141+
it('should add the helper function when needed', () => {
142+
const code = '<div className={styles.container}>Hello</div>';
143+
const result = transform(code);
144+
expect(result).toContain('function __prefixClassNames(value)');
145+
expect(result).toMatch(/typeof value === "string"/);
146+
});
147+
148+
it('should not modify non-JSX code', () => {
149+
const code = 'const foo = "bar"; function test() { return 42; }';
150+
const result = transform(code);
151+
expect(result).toContain('const foo = "bar"');
152+
expect(result).toContain('function test()');
153+
expect(result).toContain('return 42');
154+
});
155+
});

packages/onchainkit/plugins/babel-prefix-react-classnames.ts

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ function prefixStringLiteral(stringLiteral: string, prefix: string) {
1010
// 1. Are at the start of the string (^) OR preceded by whitespace (\s)
1111
// 2. Don't already start with the prefix
1212
new RegExp(`(^|\\s)(?!${prefix})(\\S+)`, 'g'),
13-
`$1${prefix}$2 `,
13+
`$1${prefix}$2`,
1414
);
1515
}
1616

@@ -26,28 +26,29 @@ function processTemplateLiteral(
2626
const prevQuasiString = templateLiteral.quasis[index - 1]?.value.raw;
2727
const prevEndsInWhitespace = /^\s$/.test(prevQuasiString?.at(-1) ?? '');
2828

29-
// ...split the quasi into classes, filter out empty strings, and map each class to a prefixed class.
30-
const prefixed = quasi.value.raw
31-
.split(/\s+/)
32-
.filter(Boolean)
33-
.map((cls, i) => {
34-
const shouldPrefix =
35-
// If we're not at the first class in this quasi, we prefix.
36-
i !== 0 ||
37-
// If we're at the first quasi, we prefix.
38-
isFirstQuasi ||
39-
// If the previous quasi ends in whitespace, we prefix.
40-
prevEndsInWhitespace;
41-
42-
// But only if the class doesn't already start with the prefix.
43-
if (shouldPrefix && !cls.startsWith(prefix)) {
44-
return `${prefix}${cls}`;
29+
const prefixed = quasi.value.raw.replace(
30+
/(?:^\S)|(?:\s\S)/g,
31+
(match, index, str) => {
32+
const rest = str.substring(index).trim();
33+
34+
// If the rest of the string starts with the prefix, we don't need to prefix.
35+
if (rest.startsWith(prefix)) return match;
36+
37+
const startsWithWhitespace = /^\s/.test(match);
38+
39+
// If we're not at the first quasi,
40+
// and we're starting with a non-whitespace character,
41+
// we want to check if the previous quasi ended in whitespace.
42+
// If it didn't, we don't want to prefix since we're part of the same class.
43+
if (!isFirstQuasi && !startsWithWhitespace && !prevEndsInWhitespace) {
44+
return match;
4545
}
4646

47-
// Otherwise, we don't prefix.
48-
return cls;
49-
})
50-
.join(' ');
47+
const prefixed = prefix + match.trim();
48+
49+
return startsWithWhitespace ? ` ${prefixed}` : prefixed;
50+
},
51+
);
5152

5253
// Update the quasi with the prefixed classes.
5354
quasi.value.raw = prefixed;
@@ -182,6 +183,19 @@ export function babelPrefixReactClassNames({
182183
return createHelperCall(arg, path);
183184
}
184185

186+
// Handle conditional classes such as `isActive && "some-class"`
187+
if (types.isLogicalExpression(arg)) {
188+
if (types.isStringLiteral(arg.right)) {
189+
return types.logicalExpression(
190+
arg.operator,
191+
arg.left,
192+
types.stringLiteral(
193+
prefixStringLiteral(arg.right.value, prefix),
194+
),
195+
);
196+
}
197+
}
198+
185199
return arg;
186200
});
187201
}

0 commit comments

Comments
 (0)