nextjs-ide-helper 1.1.3 → 1.2.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/README.md +145 -8
- package/package.json +8 -2
- package/src/__tests__/loader.test.js +429 -0
- package/src/loader.js +194 -28
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ A Next.js plugin that automatically adds IDE buttons to React components in deve
|
|
|
12
12
|
- ⚡ **Hydration Safe**: No SSR/client hydration mismatches
|
|
13
13
|
- 📱 **TypeScript Support**: Full TypeScript definitions included
|
|
14
14
|
- 🔌 **Multi-IDE Support**: Supports Cursor, VS Code, WebStorm, and Atom
|
|
15
|
+
- 🎭 **Multiple Component Patterns**: Supports all React component export patterns
|
|
16
|
+
- 🔍 **AST-Based Processing**: Uses robust Abstract Syntax Tree parsing for accurate code transformation
|
|
15
17
|
|
|
16
18
|
## Installation
|
|
17
19
|
|
|
@@ -91,16 +93,95 @@ export default withIdeButton(MyComponent, 'src/components/MyComponent.tsx', {
|
|
|
91
93
|
});
|
|
92
94
|
```
|
|
93
95
|
|
|
96
|
+
## Supported Component Patterns
|
|
97
|
+
|
|
98
|
+
The plugin automatically detects and wraps all types of React component export patterns:
|
|
99
|
+
|
|
100
|
+
### Named Components
|
|
101
|
+
```tsx
|
|
102
|
+
// Standard pattern
|
|
103
|
+
const MyComponent = () => <div>Hello</div>;
|
|
104
|
+
export default MyComponent;
|
|
105
|
+
|
|
106
|
+
// Variable declaration
|
|
107
|
+
const MyComponent = function() {
|
|
108
|
+
return <div>Hello</div>;
|
|
109
|
+
};
|
|
110
|
+
export default MyComponent;
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Direct Export Function Components
|
|
114
|
+
```tsx
|
|
115
|
+
// Named function
|
|
116
|
+
export default function MyComponent() {
|
|
117
|
+
return <div>Hello</div>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Anonymous function
|
|
121
|
+
export default function() {
|
|
122
|
+
return <div>Hello</div>;
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Arrow Function Components
|
|
127
|
+
```tsx
|
|
128
|
+
// Anonymous arrow function
|
|
129
|
+
export default () => {
|
|
130
|
+
return <div>Hello</div>;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Arrow function with parameters
|
|
134
|
+
export default (props) => {
|
|
135
|
+
return <div>Hello {props.name}</div>;
|
|
136
|
+
};
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Class Components
|
|
140
|
+
```tsx
|
|
141
|
+
// Named class
|
|
142
|
+
export default class MyComponent extends React.Component {
|
|
143
|
+
render() {
|
|
144
|
+
return <div>Hello</div>;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// TypeScript class
|
|
149
|
+
export default class MyComponent extends Component<Props> {
|
|
150
|
+
render() {
|
|
151
|
+
return <div>Hello</div>;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### TypeScript Components
|
|
157
|
+
```tsx
|
|
158
|
+
// Function with TypeScript
|
|
159
|
+
interface Props {
|
|
160
|
+
title: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default function MyComponent(props: Props) {
|
|
164
|
+
return <div>{props.title}</div>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Arrow function with TypeScript
|
|
168
|
+
export default (props: Props) => {
|
|
169
|
+
return <div>{props.title}</div>;
|
|
170
|
+
};
|
|
171
|
+
```
|
|
172
|
+
|
|
94
173
|
## How It Works
|
|
95
174
|
|
|
96
|
-
1. **
|
|
97
|
-
2. **
|
|
98
|
-
3. **
|
|
99
|
-
4. **
|
|
100
|
-
5. **
|
|
175
|
+
1. **AST-Based Processing**: Uses Babel's Abstract Syntax Tree parser for robust code analysis
|
|
176
|
+
2. **Webpack Loader**: The plugin uses a custom webpack loader to transform your React components at build time
|
|
177
|
+
3. **Automatic Detection**: It scans specified directories for `.tsx` and `.jsx` files
|
|
178
|
+
4. **Smart Wrapping**: Only wraps components that export a default React component (PascalCase names)
|
|
179
|
+
5. **Development Only**: The IDE buttons only appear in development mode
|
|
180
|
+
6. **Hydration Safe**: Uses client-side state to prevent SSR/hydration mismatches
|
|
101
181
|
|
|
102
|
-
##
|
|
182
|
+
## Examples
|
|
103
183
|
|
|
184
|
+
### Standard Component Pattern
|
|
104
185
|
Before (your original component):
|
|
105
186
|
```tsx
|
|
106
187
|
// src/components/Button.tsx
|
|
@@ -133,7 +214,53 @@ const Button = ({ children, onClick }) => {
|
|
|
133
214
|
|
|
134
215
|
export default withIdeButton(Button, 'src/components/Button.tsx', {
|
|
135
216
|
projectRoot: '/path/to/project',
|
|
136
|
-
ideType: 'cursor'
|
|
217
|
+
ideType: 'cursor'
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Direct Export Function Component
|
|
222
|
+
Before:
|
|
223
|
+
```tsx
|
|
224
|
+
// src/components/Header.tsx
|
|
225
|
+
export default function Header() {
|
|
226
|
+
return <header>My App Header</header>;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
After (automatically transformed):
|
|
231
|
+
```tsx
|
|
232
|
+
// src/components/Header.tsx (transformed by the plugin)
|
|
233
|
+
import { withIdeButton } from 'nextjs-ide-plugin/withIdeButton';
|
|
234
|
+
|
|
235
|
+
function Header() {
|
|
236
|
+
return <header>My App Header</header>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export default withIdeButton(Header, 'src/components/Header.tsx', {
|
|
240
|
+
projectRoot: '/path/to/project',
|
|
241
|
+
ideType: 'cursor'
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Anonymous Component
|
|
246
|
+
Before:
|
|
247
|
+
```tsx
|
|
248
|
+
// src/components/Footer.tsx
|
|
249
|
+
export default () => {
|
|
250
|
+
return <footer>© 2025 My App</footer>;
|
|
251
|
+
};
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
After (automatically transformed):
|
|
255
|
+
```tsx
|
|
256
|
+
// src/components/Footer.tsx (transformed by the plugin)
|
|
257
|
+
import { withIdeButton } from 'nextjs-ide-plugin/withIdeButton';
|
|
258
|
+
|
|
259
|
+
export default withIdeButton(() => {
|
|
260
|
+
return <footer>© 2025 My App</footer>;
|
|
261
|
+
}, 'src/components/Footer.tsx', {
|
|
262
|
+
projectRoot: '/path/to/project',
|
|
263
|
+
ideType: 'cursor'
|
|
137
264
|
});
|
|
138
265
|
```
|
|
139
266
|
|
|
@@ -174,8 +301,18 @@ MIT
|
|
|
174
301
|
|
|
175
302
|
## Changelog
|
|
176
303
|
|
|
304
|
+
See [CHANGELOG.md](./CHANGELOG.md) for detailed release notes.
|
|
305
|
+
|
|
306
|
+
### Recent Releases
|
|
307
|
+
|
|
308
|
+
### 1.2.0 - Enhanced Component Support
|
|
309
|
+
- Added support for all React component export patterns
|
|
310
|
+
- AST-based code transformation for better reliability
|
|
311
|
+
- Anonymous component support
|
|
312
|
+
- Enhanced TypeScript compatibility
|
|
313
|
+
|
|
177
314
|
### 1.1.3
|
|
178
|
-
|
|
315
|
+
- Updated readme
|
|
179
316
|
|
|
180
317
|
### 1.1.2
|
|
181
318
|
- Added support for multiple IDEs (Cursor, VS Code, WebStorm, Atom)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nextjs-ide-helper",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A Next.js plugin that automatically adds IDE buttons to React components for seamless IDE integration. Supports Cursor, VS Code, WebStorm, and Atom.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"prepare": "npm run build",
|
|
14
|
-
"test": "
|
|
14
|
+
"test": "jest"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"nextjs",
|
|
@@ -33,8 +33,14 @@
|
|
|
33
33
|
"react": ">=18.0.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
+
"@babel/generator": "^7.28.0",
|
|
37
|
+
"@babel/parser": "^7.28.0",
|
|
38
|
+
"@babel/traverse": "^7.28.0",
|
|
39
|
+
"@babel/types": "^7.28.2",
|
|
40
|
+
"@types/jest": "^30.0.0",
|
|
36
41
|
"@types/node": "^20.19.9",
|
|
37
42
|
"@types/react": "^18.3.23",
|
|
43
|
+
"jest": "^30.0.5",
|
|
38
44
|
"typescript": "^5.8.3"
|
|
39
45
|
},
|
|
40
46
|
"repository": {
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const loader = require('../loader.js');
|
|
3
|
+
|
|
4
|
+
describe('cursorButtonLoader', function() {
|
|
5
|
+
let mockContext;
|
|
6
|
+
|
|
7
|
+
beforeEach(function() {
|
|
8
|
+
mockContext = {
|
|
9
|
+
resourcePath: process.cwd() + '/src/components/Button.tsx',
|
|
10
|
+
getOptions: jest.fn(() => ({}))
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
process.env.NODE_ENV = 'development';
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(function() {
|
|
17
|
+
jest.clearAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('when enabled', function() {
|
|
21
|
+
beforeEach(function() {
|
|
22
|
+
mockContext.getOptions.mockReturnValue({ enabled: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should wrap a React component with withIdeButton', function() {
|
|
26
|
+
const source = `import React from 'react';
|
|
27
|
+
|
|
28
|
+
const Button = () => {
|
|
29
|
+
return <button>Click me</button>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default Button;`;
|
|
33
|
+
|
|
34
|
+
const result = loader.call(mockContext, source);
|
|
35
|
+
|
|
36
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
37
|
+
expect(result).toContain("export default withIdeButton(Button,");
|
|
38
|
+
expect(result).toMatch(/projectRoot: ['"][^'"]*['"]/); // Accept both single and double quotes and any valid path
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should handle JSX files', function() {
|
|
42
|
+
mockContext.resourcePath = process.cwd() + '/src/components/Button.jsx';
|
|
43
|
+
const source = `const Button = () => <button>Click</button>;
|
|
44
|
+
export default Button;`;
|
|
45
|
+
|
|
46
|
+
const result = loader.call(mockContext, source);
|
|
47
|
+
|
|
48
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
49
|
+
expect(result).toContain("export default withIdeButton(Button,");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should handle components with existing imports', function() {
|
|
53
|
+
const source = `import React from 'react';
|
|
54
|
+
import { useState } from 'react';
|
|
55
|
+
|
|
56
|
+
const Counter = () => {
|
|
57
|
+
const [count, setCount] = useState(0);
|
|
58
|
+
return <div>{count}</div>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export default Counter;`;
|
|
62
|
+
|
|
63
|
+
const result = loader.call(mockContext, source);
|
|
64
|
+
|
|
65
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
66
|
+
expect(result).toContain("export default withIdeButton(Counter,");
|
|
67
|
+
|
|
68
|
+
const lines = result.split('\n');
|
|
69
|
+
const importIndex = lines.findIndex(line => line.includes("import { withIdeButton }"));
|
|
70
|
+
const useStateIndex = lines.findIndex(line => line.includes("import { useState }"));
|
|
71
|
+
expect(importIndex).toBeGreaterThan(useStateIndex);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should handle components with no imports', function() {
|
|
75
|
+
const source = `const SimpleButton = () => {
|
|
76
|
+
return <button>Simple</button>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export default SimpleButton;`;
|
|
80
|
+
|
|
81
|
+
const result = loader.call(mockContext, source);
|
|
82
|
+
|
|
83
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
84
|
+
expect(result).toContain("export default withIdeButton(SimpleButton,");
|
|
85
|
+
expect(result.indexOf("import { withIdeButton }")).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should include relative path in the wrapper', function() {
|
|
89
|
+
mockContext.resourcePath = process.cwd() + '/src/components/ui/Button.tsx';
|
|
90
|
+
mockContext.getOptions.mockReturnValue({
|
|
91
|
+
enabled: true,
|
|
92
|
+
projectRoot: process.cwd()
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const source = `const Button = () => <button>Click</button>;
|
|
96
|
+
export default Button;`;
|
|
97
|
+
|
|
98
|
+
const result = loader.call(mockContext, source);
|
|
99
|
+
|
|
100
|
+
expect(result).toContain("'src/components/ui/Button.tsx'");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should use custom import path when specified', function() {
|
|
104
|
+
mockContext.getOptions.mockReturnValue({
|
|
105
|
+
enabled: true,
|
|
106
|
+
importPath: 'custom-ide-helper'
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const source = `const Button = () => <button>Click</button>;
|
|
110
|
+
export default Button;`;
|
|
111
|
+
|
|
112
|
+
const result = loader.call(mockContext, source);
|
|
113
|
+
|
|
114
|
+
expect(result).toContain("import { withIdeButton } from 'custom-ide-helper';");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('when disabled', function() {
|
|
119
|
+
it('should return source unchanged when enabled is false', function() {
|
|
120
|
+
mockContext.getOptions.mockReturnValue({ enabled: false });
|
|
121
|
+
const source = `const Button = () => <button>Click</button>;
|
|
122
|
+
export default Button;`;
|
|
123
|
+
|
|
124
|
+
const result = loader.call(mockContext, source);
|
|
125
|
+
|
|
126
|
+
expect(result).toBe(source);
|
|
127
|
+
expect(result).not.toContain('withIdeButton');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should return source unchanged when NODE_ENV is production and enabled not explicitly set', function() {
|
|
131
|
+
process.env.NODE_ENV = 'production';
|
|
132
|
+
const source = `const Button = () => <button>Click</button>;
|
|
133
|
+
export default Button;`;
|
|
134
|
+
|
|
135
|
+
const result = loader.call(mockContext, source);
|
|
136
|
+
|
|
137
|
+
expect(result).toBe(source);
|
|
138
|
+
expect(result).not.toContain('withIdeButton');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('file filtering', function() {
|
|
143
|
+
beforeEach(function() {
|
|
144
|
+
mockContext.getOptions.mockReturnValue({ enabled: true });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should not process files outside component directories', function() {
|
|
148
|
+
mockContext.resourcePath = process.cwd() + '/src/utils/helper.tsx';
|
|
149
|
+
const source = `const Helper = () => <div>Helper</div>;
|
|
150
|
+
export default Helper;`;
|
|
151
|
+
|
|
152
|
+
const result = loader.call(mockContext, source);
|
|
153
|
+
|
|
154
|
+
expect(result).toBe(source);
|
|
155
|
+
expect(result).not.toContain('withIdeButton');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should process files in custom component directories', function() {
|
|
159
|
+
mockContext.resourcePath = process.cwd() + '/src/widgets/MyWidget.tsx';
|
|
160
|
+
mockContext.getOptions.mockReturnValue({
|
|
161
|
+
enabled: true,
|
|
162
|
+
componentPaths: ['src/widgets'],
|
|
163
|
+
projectRoot: process.cwd()
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const source = `const MyWidget = () => <div>Widget</div>;
|
|
167
|
+
export default MyWidget;`;
|
|
168
|
+
|
|
169
|
+
const result = loader.call(mockContext, source);
|
|
170
|
+
|
|
171
|
+
expect(result).toContain('withIdeButton');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should not process non-JSX/TSX files', function() {
|
|
175
|
+
mockContext.resourcePath = process.cwd() + '/src/components/Button.js';
|
|
176
|
+
const source = `const Button = () => <button>Click</button>;
|
|
177
|
+
export default Button;`;
|
|
178
|
+
|
|
179
|
+
const result = loader.call(mockContext, source);
|
|
180
|
+
|
|
181
|
+
expect(result).toBe(source);
|
|
182
|
+
expect(result).not.toContain('withIdeButton');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('component detection', function() {
|
|
187
|
+
beforeEach(function() {
|
|
188
|
+
mockContext.getOptions.mockReturnValue({ enabled: true });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should not process files without default export', function() {
|
|
192
|
+
const source = `export const Button = () => <button>Click</button>;`;
|
|
193
|
+
|
|
194
|
+
const result = loader.call(mockContext, source);
|
|
195
|
+
|
|
196
|
+
expect(result).toBe(source);
|
|
197
|
+
expect(result).not.toContain('withIdeButton');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should not process files with lowercase default export', function() {
|
|
201
|
+
const source = `const button = () => <button>Click</button>;
|
|
202
|
+
export default button;`;
|
|
203
|
+
|
|
204
|
+
const result = loader.call(mockContext, source);
|
|
205
|
+
|
|
206
|
+
expect(result).toBe(source);
|
|
207
|
+
expect(result).not.toContain('withIdeButton');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should process files with PascalCase default export', function() {
|
|
211
|
+
const source = `const MyButton = () => <button>Click</button>;
|
|
212
|
+
export default MyButton;`;
|
|
213
|
+
|
|
214
|
+
const result = loader.call(mockContext, source);
|
|
215
|
+
|
|
216
|
+
expect(result).toContain('withIdeButton');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should process direct export default function declarations', function() {
|
|
220
|
+
const source = `export default function MyCoolComponent() {
|
|
221
|
+
return <div>My Cool Component</div>;
|
|
222
|
+
}`;
|
|
223
|
+
|
|
224
|
+
const result = loader.call(mockContext, source);
|
|
225
|
+
|
|
226
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
227
|
+
expect(result).toContain("export default withIdeButton(MyCoolComponent,");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should process direct export default arrow function expressions', function() {
|
|
231
|
+
const source = `export default () => {
|
|
232
|
+
return <div>Anonymous Component</div>;
|
|
233
|
+
};`;
|
|
234
|
+
|
|
235
|
+
const result = loader.call(mockContext, source);
|
|
236
|
+
|
|
237
|
+
// Should process anonymous components using file path
|
|
238
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
239
|
+
expect(result).toContain("export default withIdeButton(");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should process anonymous function expressions', function() {
|
|
243
|
+
const source = `export default function() {
|
|
244
|
+
return <div>Anonymous Function Component</div>;
|
|
245
|
+
}`;
|
|
246
|
+
|
|
247
|
+
const result = loader.call(mockContext, source);
|
|
248
|
+
|
|
249
|
+
// Should process anonymous function components
|
|
250
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
251
|
+
expect(result).toContain("export default withIdeButton(");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should process direct export default class components', function() {
|
|
255
|
+
const source = `import React from 'react';
|
|
256
|
+
|
|
257
|
+
export default class MyClassComponent extends React.Component {
|
|
258
|
+
render() {
|
|
259
|
+
return <div>Class Component</div>;
|
|
260
|
+
}
|
|
261
|
+
}`;
|
|
262
|
+
|
|
263
|
+
const result = loader.call(mockContext, source);
|
|
264
|
+
|
|
265
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
266
|
+
expect(result).toContain("export default withIdeButton(MyClassComponent,");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should not process lowercase direct export default functions', function() {
|
|
270
|
+
const source = `export default function myLowercaseComponent() {
|
|
271
|
+
return <div>Lowercase Component</div>;
|
|
272
|
+
}`;
|
|
273
|
+
|
|
274
|
+
const result = loader.call(mockContext, source);
|
|
275
|
+
|
|
276
|
+
expect(result).toBe(source);
|
|
277
|
+
expect(result).not.toContain('withIdeButton');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should handle direct export default with complex function names', function() {
|
|
281
|
+
const source = `export default function MyComplexComponent123WithNumbers() {
|
|
282
|
+
return <div>Complex Named Component</div>;
|
|
283
|
+
}`;
|
|
284
|
+
|
|
285
|
+
const result = loader.call(mockContext, source);
|
|
286
|
+
|
|
287
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
288
|
+
expect(result).toContain("export default withIdeButton(MyComplexComponent123WithNumbers,");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should handle function components with TypeScript', function() {
|
|
292
|
+
const source = `interface Props {
|
|
293
|
+
title: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export default function TypeScriptComponent(props: Props) {
|
|
297
|
+
return <div>{props.title}</div>;
|
|
298
|
+
}`;
|
|
299
|
+
|
|
300
|
+
const result = loader.call(mockContext, source);
|
|
301
|
+
|
|
302
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
303
|
+
expect(result).toContain("export default withIdeButton(TypeScriptComponent,");
|
|
304
|
+
expect(result).toContain("function TypeScriptComponent(props: Props)");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should handle class components with TypeScript', function() {
|
|
308
|
+
const source = `import React, { Component } from 'react';
|
|
309
|
+
|
|
310
|
+
interface Props {
|
|
311
|
+
name: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export default class TSClassComponent extends Component<Props> {
|
|
315
|
+
render() {
|
|
316
|
+
return <div>Hello {this.props.name}</div>;
|
|
317
|
+
}
|
|
318
|
+
}`;
|
|
319
|
+
|
|
320
|
+
const result = loader.call(mockContext, source);
|
|
321
|
+
|
|
322
|
+
expect(result).toContain("import { withIdeButton } from 'nextjs-ide-helper';");
|
|
323
|
+
expect(result).toContain("export default withIdeButton(TSClassComponent,");
|
|
324
|
+
expect(result).toContain("class TSClassComponent extends Component<Props>");
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('already wrapped components', function() {
|
|
329
|
+
beforeEach(function() {
|
|
330
|
+
mockContext.getOptions.mockReturnValue({ enabled: true });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should not wrap components that are already wrapped', function() {
|
|
334
|
+
const source = `import { withIdeButton } from 'nextjs-ide-helper';
|
|
335
|
+
|
|
336
|
+
const Button = () => <button>Click</button>;
|
|
337
|
+
|
|
338
|
+
export default withIdeButton(Button, 'src/components/Button.tsx');`;
|
|
339
|
+
|
|
340
|
+
const result = loader.call(mockContext, source);
|
|
341
|
+
|
|
342
|
+
expect(result).toBe(source);
|
|
343
|
+
expect((result.match(/withIdeButton/g) || []).length).toBe(2);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('edge cases', function() {
|
|
348
|
+
beforeEach(function() {
|
|
349
|
+
mockContext.getOptions.mockReturnValue({ enabled: true });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should handle export default with semicolon', function() {
|
|
353
|
+
const source = `const Button = () => <button>Click</button>;
|
|
354
|
+
export default Button;`;
|
|
355
|
+
|
|
356
|
+
const result = loader.call(mockContext, source);
|
|
357
|
+
|
|
358
|
+
expect(result).toContain("export default withIdeButton(Button,");
|
|
359
|
+
expect(result).not.toContain("export default Button;");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should handle export default without semicolon', function() {
|
|
363
|
+
const source = `const Button = () => <button>Click</button>
|
|
364
|
+
export default Button`;
|
|
365
|
+
|
|
366
|
+
const result = loader.call(mockContext, source);
|
|
367
|
+
|
|
368
|
+
expect(result).toContain("export default withIdeButton(Button,");
|
|
369
|
+
expect(result).not.toContain("export default Button");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should handle complex component names', function() {
|
|
373
|
+
const source = `const MyComplexComponent123 = () => <div>Complex</div>;
|
|
374
|
+
export default MyComplexComponent123;`;
|
|
375
|
+
|
|
376
|
+
const result = loader.call(mockContext, source);
|
|
377
|
+
|
|
378
|
+
expect(result).toContain("export default withIdeButton(MyComplexComponent123,");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should handle files with multiple component paths', function() {
|
|
382
|
+
mockContext.resourcePath = process.cwd() + '/lib/components/Button.tsx';
|
|
383
|
+
mockContext.getOptions.mockReturnValue({
|
|
384
|
+
enabled: true,
|
|
385
|
+
componentPaths: ['src/components', 'lib/components'],
|
|
386
|
+
projectRoot: process.cwd()
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const source = `const Button = () => <button>Click</button>;
|
|
390
|
+
export default Button;`;
|
|
391
|
+
|
|
392
|
+
const result = loader.call(mockContext, source);
|
|
393
|
+
|
|
394
|
+
expect(result).toContain('withIdeButton');
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('options handling', function() {
|
|
399
|
+
it('should use default options when none provided', function() {
|
|
400
|
+
mockContext.getOptions.mockReturnValue(null);
|
|
401
|
+
process.env.NODE_ENV = 'development';
|
|
402
|
+
|
|
403
|
+
const source = `const Button = () => <button>Click</button>;
|
|
404
|
+
export default Button;`;
|
|
405
|
+
|
|
406
|
+
const result = loader.call(mockContext, source);
|
|
407
|
+
|
|
408
|
+
expect(result).toContain('withIdeButton');
|
|
409
|
+
expect(result).toContain("'nextjs-ide-helper'");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should handle custom project root', function() {
|
|
413
|
+
const customRoot = '/custom/project';
|
|
414
|
+
mockContext.resourcePath = customRoot + '/src/components/Button.tsx';
|
|
415
|
+
mockContext.getOptions.mockReturnValue({
|
|
416
|
+
enabled: true,
|
|
417
|
+
projectRoot: customRoot
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const source = `const Button = () => <button>Click</button>;
|
|
421
|
+
export default Button;`;
|
|
422
|
+
|
|
423
|
+
const result = loader.call(mockContext, source);
|
|
424
|
+
|
|
425
|
+
expect(result).toMatch(new RegExp("projectRoot:\\s*['\"]" + customRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "['\"]"));
|
|
426
|
+
expect(result).toContain("'src/components/Button.tsx'");
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
});
|
package/src/loader.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
+
const { parse } = require('@babel/parser');
|
|
3
|
+
const traverse = require('@babel/traverse').default;
|
|
4
|
+
const generate = require('@babel/generator').default;
|
|
5
|
+
const t = require('@babel/types');
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* Webpack loader that automatically wraps React components with cursor buttons
|
|
@@ -30,50 +34,212 @@ module.exports = function cursorButtonLoader(source) {
|
|
|
30
34
|
return source;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
// Check if it's already wrapped
|
|
37
|
+
// Check if it's already wrapped using simple string check for performance
|
|
34
38
|
if (source.includes('withIdeButton')) {
|
|
35
39
|
return source;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
let ast;
|
|
43
|
+
try {
|
|
44
|
+
// Parse the source code into an AST
|
|
45
|
+
ast = parse(source, {
|
|
46
|
+
sourceType: 'module',
|
|
47
|
+
plugins: [
|
|
48
|
+
'jsx',
|
|
49
|
+
'typescript',
|
|
50
|
+
'decorators-legacy',
|
|
51
|
+
'classProperties',
|
|
52
|
+
'objectRestSpread',
|
|
53
|
+
'asyncGenerators',
|
|
54
|
+
'functionBind',
|
|
55
|
+
'exportDefaultFrom',
|
|
56
|
+
'exportNamespaceFrom',
|
|
57
|
+
'dynamicImport',
|
|
58
|
+
'nullishCoalescingOperator',
|
|
59
|
+
'optionalChaining'
|
|
60
|
+
]
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// If parsing fails, return original source
|
|
64
|
+
return source;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let hasDefaultExport = false;
|
|
68
|
+
let defaultExportName = null;
|
|
69
|
+
let isAnonymousComponent = false;
|
|
70
|
+
let hasWithIdeButtonImport = false;
|
|
71
|
+
let lastImportPath = null;
|
|
72
|
+
let defaultExportPath = null;
|
|
73
|
+
|
|
74
|
+
// Traverse the AST to analyze the code
|
|
75
|
+
traverse(ast, {
|
|
76
|
+
ImportDeclaration(path) {
|
|
77
|
+
lastImportPath = path;
|
|
78
|
+
|
|
79
|
+
// Check if withIdeButton is already imported
|
|
80
|
+
if (path.node.source.value === importPath) {
|
|
81
|
+
path.node.specifiers.forEach(spec => {
|
|
82
|
+
if (t.isImportSpecifier(spec) && spec.imported.name === 'withIdeButton') {
|
|
83
|
+
hasWithIdeButtonImport = true;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
ExportDefaultDeclaration(path) {
|
|
90
|
+
hasDefaultExport = true;
|
|
91
|
+
defaultExportPath = path;
|
|
92
|
+
|
|
93
|
+
if (t.isIdentifier(path.node.declaration)) {
|
|
94
|
+
// export default ComponentName
|
|
95
|
+
defaultExportName = path.node.declaration.name;
|
|
96
|
+
} else if (t.isFunctionDeclaration(path.node.declaration)) {
|
|
97
|
+
if (path.node.declaration.id) {
|
|
98
|
+
// export default function ComponentName() {}
|
|
99
|
+
defaultExportName = path.node.declaration.id.name;
|
|
100
|
+
} else {
|
|
101
|
+
// export default function() {} - anonymous function
|
|
102
|
+
isAnonymousComponent = true;
|
|
103
|
+
}
|
|
104
|
+
} else if (t.isClassDeclaration(path.node.declaration) && path.node.declaration.id) {
|
|
105
|
+
// export default class ComponentName extends ... {}
|
|
106
|
+
defaultExportName = path.node.declaration.id.name;
|
|
107
|
+
} else if (t.isArrowFunctionExpression(path.node.declaration)) {
|
|
108
|
+
// export default () => {} - anonymous arrow function
|
|
109
|
+
isAnonymousComponent = true;
|
|
110
|
+
} else if (t.isVariableDeclaration(path.node.declaration)) {
|
|
111
|
+
// export default const ComponentName = ...
|
|
112
|
+
const declarator = path.node.declaration.declarations[0];
|
|
113
|
+
if (t.isIdentifier(declarator.id)) {
|
|
114
|
+
defaultExportName = declarator.id.name;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Stop traversal once we find the default export
|
|
119
|
+
path.stop();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Check if we should process this file
|
|
124
|
+
if (!hasDefaultExport || (!defaultExportName && !isAnonymousComponent)) {
|
|
41
125
|
return source;
|
|
42
126
|
}
|
|
43
127
|
|
|
44
|
-
const componentName = defaultExportMatch[1];
|
|
45
|
-
|
|
46
128
|
// Check if component name starts with uppercase (React component convention)
|
|
47
|
-
|
|
129
|
+
// Skip this check for anonymous components
|
|
130
|
+
if (defaultExportName && defaultExportName[0] !== defaultExportName[0].toUpperCase()) {
|
|
131
|
+
return source;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// If withIdeButton is already imported, don't process
|
|
135
|
+
if (hasWithIdeButtonImport) {
|
|
48
136
|
return source;
|
|
49
137
|
}
|
|
50
138
|
|
|
51
139
|
// Get relative path from project root
|
|
52
140
|
const relativePath = path.relative(projectRoot, filename);
|
|
53
141
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
142
|
+
// Transform the AST
|
|
143
|
+
let modified = false;
|
|
144
|
+
|
|
145
|
+
// Add the withIdeButton import
|
|
146
|
+
const withIdeButtonImport = t.importDeclaration(
|
|
147
|
+
[t.importSpecifier(t.identifier('withIdeButton'), t.identifier('withIdeButton'))],
|
|
148
|
+
t.stringLiteral(importPath)
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Insert import after last existing import or at the beginning
|
|
152
|
+
if (lastImportPath) {
|
|
153
|
+
lastImportPath.insertAfter(withIdeButtonImport);
|
|
154
|
+
modified = true;
|
|
64
155
|
} else {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
156
|
+
// No imports found, add at the beginning
|
|
157
|
+
if (ast.program && ast.program.body && Array.isArray(ast.program.body)) {
|
|
158
|
+
ast.program.body.unshift(withIdeButtonImport);
|
|
159
|
+
modified = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Replace the default export with wrapped version
|
|
164
|
+
if (defaultExportPath && modified) {
|
|
165
|
+
let wrappedCall;
|
|
72
166
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
167
|
+
if (isAnonymousComponent) {
|
|
168
|
+
// For anonymous components, wrap the original function/arrow function
|
|
169
|
+
let componentExpression;
|
|
170
|
+
|
|
171
|
+
if (t.isFunctionDeclaration(defaultExportPath.node.declaration)) {
|
|
172
|
+
// Convert anonymous function declaration to function expression
|
|
173
|
+
componentExpression = t.functionExpression(
|
|
174
|
+
null, // id is null for anonymous
|
|
175
|
+
defaultExportPath.node.declaration.params,
|
|
176
|
+
defaultExportPath.node.declaration.body,
|
|
177
|
+
defaultExportPath.node.declaration.generator,
|
|
178
|
+
defaultExportPath.node.declaration.async
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
// For arrow functions and other expressions, use directly
|
|
182
|
+
componentExpression = defaultExportPath.node.declaration;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
wrappedCall = t.callExpression(
|
|
186
|
+
t.identifier('withIdeButton'),
|
|
187
|
+
[
|
|
188
|
+
componentExpression,
|
|
189
|
+
t.stringLiteral(relativePath),
|
|
190
|
+
t.objectExpression([
|
|
191
|
+
t.objectProperty(t.identifier('projectRoot'), t.stringLiteral(projectRoot))
|
|
192
|
+
])
|
|
193
|
+
]
|
|
194
|
+
);
|
|
195
|
+
} else {
|
|
196
|
+
// For named components, wrap with the component name identifier
|
|
197
|
+
wrappedCall = t.callExpression(
|
|
198
|
+
t.identifier('withIdeButton'),
|
|
199
|
+
[
|
|
200
|
+
t.identifier(defaultExportName),
|
|
201
|
+
t.stringLiteral(relativePath),
|
|
202
|
+
t.objectExpression([
|
|
203
|
+
t.objectProperty(t.identifier('projectRoot'), t.stringLiteral(projectRoot))
|
|
204
|
+
])
|
|
205
|
+
]
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// If the export is a named function or class declaration, we need to handle it differently
|
|
210
|
+
if (!isAnonymousComponent &&
|
|
211
|
+
(t.isFunctionDeclaration(defaultExportPath.node.declaration) ||
|
|
212
|
+
t.isClassDeclaration(defaultExportPath.node.declaration))) {
|
|
213
|
+
// Insert the function/class declaration before the export
|
|
214
|
+
const declaration = defaultExportPath.node.declaration;
|
|
215
|
+
defaultExportPath.insertBefore(declaration);
|
|
216
|
+
|
|
217
|
+
// Replace the export declaration with the wrapped call
|
|
218
|
+
defaultExportPath.node.declaration = wrappedCall;
|
|
219
|
+
} else {
|
|
220
|
+
// For other cases (identifier, variable declaration, anonymous functions), just replace the declaration
|
|
221
|
+
defaultExportPath.node.declaration = wrappedCall;
|
|
222
|
+
}
|
|
76
223
|
}
|
|
77
224
|
|
|
78
|
-
|
|
225
|
+
// Generate the transformed code
|
|
226
|
+
if (modified) {
|
|
227
|
+
try {
|
|
228
|
+
const result = generate(ast, {
|
|
229
|
+
retainLines: false,
|
|
230
|
+
compact: false,
|
|
231
|
+
jsescOption: {
|
|
232
|
+
quotes: 'single'
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
return result.code;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
// If generation fails, return original source
|
|
238
|
+
return source;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// If we reach here, nothing was modified
|
|
243
|
+
|
|
244
|
+
return source;
|
|
79
245
|
};
|