@codedesignai/nextjs-live-edit-plugin 1.0.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 +144 -0
- package/index.js +63 -0
- package/live-edit-handler.js +512 -0
- package/package.json +49 -0
- package/source-mapper-loader.js +234 -0
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# @codedesignai/nextjs-live-edit-plugin
|
|
2
|
+
|
|
3
|
+
A Next.js plugin for live editing React components with AST-powered source mapping. Enables precise, character-level updates to your source files directly from the browser.
|
|
4
|
+
|
|
5
|
+
This is the Next.js port of `@codedesignai/vite-live-edit-plugin` with 100% feature parity.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- 🎯 **AST-Powered Source Mapping** — Injects precise location data into JSX elements via a custom webpack loader
|
|
10
|
+
- 📝 **Text Content Editing** — Edit text content directly from the browser
|
|
11
|
+
- 🖼️ **Image Source Editing** — Update image sources with validation
|
|
12
|
+
- ✅ **Full Validation** — Pre and post-update validation with rollback capability
|
|
13
|
+
- 🔒 **Security** — Validates URLs and content before applying changes
|
|
14
|
+
- ⚡ **Fast Refresh Integration** — Changes trigger Next.js Fast Refresh automatically
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @codedesignai/nextjs-live-edit-plugin
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or from Git:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install git+https://github.com/codedesignapp/ai-companion-live-edit-plugin.git#nextjs
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
### 1. Update `next.config.js`
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
const { withLiveEdit } = require('@codedesignai/nextjs-live-edit-plugin');
|
|
34
|
+
|
|
35
|
+
module.exports = withLiveEdit({
|
|
36
|
+
// your existing Next.js config options
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or with TypeScript/ESM (`next.config.ts`):
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { withLiveEdit } from '@codedesignai/nextjs-live-edit-plugin';
|
|
44
|
+
|
|
45
|
+
export default withLiveEdit({
|
|
46
|
+
// your existing Next.js config options
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
#### Custom Source Directories
|
|
51
|
+
|
|
52
|
+
By default, the plugin processes `.jsx` and `.tsx` files in `app/`, `components/`, and `src/`. To customize:
|
|
53
|
+
|
|
54
|
+
```javascript
|
|
55
|
+
module.exports = withLiveEdit(
|
|
56
|
+
{ /* next config */ },
|
|
57
|
+
{ sourceDirs: ['app', 'components', 'src', 'features'] }
|
|
58
|
+
);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Add the API Route
|
|
62
|
+
|
|
63
|
+
#### App Router (`app/api/live-edit/route.js`)
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
const { createLiveEditHandler } = require('@codedesignai/nextjs-live-edit-plugin/live-edit-handler');
|
|
67
|
+
|
|
68
|
+
const { POST, OPTIONS } = createLiveEditHandler();
|
|
69
|
+
|
|
70
|
+
module.exports = { POST, OPTIONS };
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or with TypeScript/ESM:
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
import { createLiveEditHandler } from '@codedesignai/nextjs-live-edit-plugin/live-edit-handler';
|
|
77
|
+
|
|
78
|
+
export const { POST, OPTIONS } = createLiveEditHandler();
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
#### Pages Router (`pages/api/live-edit.js`)
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
const { createPagesApiHandler } = require('@codedesignai/nextjs-live-edit-plugin/live-edit-handler');
|
|
85
|
+
|
|
86
|
+
module.exports = createPagesApiHandler();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## How It Works
|
|
90
|
+
|
|
91
|
+
1. **Source Mapping** (build time): The webpack loader parses `.jsx`/`.tsx` files with `@babel/parser`, walks the AST, and injects `data-element-id` and `data-source-loc` attributes into JSX elements that contain text or image sources.
|
|
92
|
+
|
|
93
|
+
2. **Live Editing** (runtime): The `/api/live-edit` endpoint receives update requests from the browser, validates the location data against the actual source file (including AST-level verification), applies the text/image change, and lets Next.js Fast Refresh handle the reload.
|
|
94
|
+
|
|
95
|
+
### API Endpoint
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
POST /api/live-edit
|
|
99
|
+
Content-Type: application/json
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
"element": {
|
|
103
|
+
"tagName": "P",
|
|
104
|
+
"elementId": "Home-p-L5-0",
|
|
105
|
+
"sourceLoc": {
|
|
106
|
+
"file": "page.tsx",
|
|
107
|
+
"start": 123,
|
|
108
|
+
"end": 145,
|
|
109
|
+
"text": "Original text",
|
|
110
|
+
"type": "text-content"
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"content": "New text content"
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Requirements
|
|
118
|
+
|
|
119
|
+
- Node.js >= 18.0.0
|
|
120
|
+
- Next.js >= 13.0.0
|
|
121
|
+
- React (for JSX support)
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
The plugin automatically:
|
|
126
|
+
- Only runs in development mode
|
|
127
|
+
- Only processes `.jsx` and `.tsx` files in configured source directories
|
|
128
|
+
- Only adds the webpack loader for client-side builds (not server-side)
|
|
129
|
+
- Injects source mapping data into JSX elements for browser interaction
|
|
130
|
+
|
|
131
|
+
No additional configuration is required beyond the two setup steps above.
|
|
132
|
+
|
|
133
|
+
## Comparison with Vite Plugin
|
|
134
|
+
|
|
135
|
+
| Concept | Vite Plugin | Next.js Plugin |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| Source mapping | `sourceMapperPlugin()` (Vite transform hook) | Custom webpack loader |
|
|
138
|
+
| Live edit API | `enhancedLiveEditPlugin()` (dev server middleware) | Next.js API route |
|
|
139
|
+
| Hot reload | `server.reloadModule()` | Automatic Fast Refresh |
|
|
140
|
+
| File filter | `src/` directory | `app/`, `components/`, `src/` (configurable) |
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Next.js config wrapper that enables the live edit plugin.
|
|
5
|
+
*
|
|
6
|
+
* Usage in next.config.js (CommonJS):
|
|
7
|
+
*
|
|
8
|
+
* const { withLiveEdit } = require('@codedesignai/nextjs-live-edit-plugin');
|
|
9
|
+
* module.exports = withLiveEdit({
|
|
10
|
+
* // your normal Next.js config
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* Usage in next.config.ts (ESM):
|
|
14
|
+
*
|
|
15
|
+
* import { withLiveEdit } from '@codedesignai/nextjs-live-edit-plugin';
|
|
16
|
+
* export default withLiveEdit({
|
|
17
|
+
* // your normal Next.js config
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} nextConfig - Your Next.js configuration object
|
|
21
|
+
* @param {Object} pluginOptions - Plugin-specific options
|
|
22
|
+
* @param {string[]} pluginOptions.sourceDirs - Directories to process (default: ['app', 'components', 'src'])
|
|
23
|
+
* @returns {Object} Modified Next.js configuration
|
|
24
|
+
*/
|
|
25
|
+
function withLiveEdit(nextConfig = {}, pluginOptions = {}) {
|
|
26
|
+
const sourceDirs = pluginOptions.sourceDirs || ['app', 'components', 'src'];
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
...nextConfig,
|
|
30
|
+
webpack(config, context) {
|
|
31
|
+
const { dev, isServer } = context;
|
|
32
|
+
|
|
33
|
+
// Only add the source mapper in development mode and for client builds
|
|
34
|
+
// (source mapping attributes are only needed in the browser)
|
|
35
|
+
if (dev && !isServer) {
|
|
36
|
+
// Add our source mapper loader for JSX/TSX files
|
|
37
|
+
config.module.rules.push({
|
|
38
|
+
test: /\.(jsx|tsx)$/,
|
|
39
|
+
// Only process files in the configured source directories
|
|
40
|
+
include: sourceDirs.map(dir => path.resolve(process.cwd(), dir)),
|
|
41
|
+
use: [
|
|
42
|
+
{
|
|
43
|
+
loader: path.resolve(__dirname, 'source-mapper-loader.js'),
|
|
44
|
+
options: {
|
|
45
|
+
sourceDirs,
|
|
46
|
+
projectRoot: process.cwd(),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Call the user's webpack config if they provided one
|
|
54
|
+
if (typeof nextConfig.webpack === 'function') {
|
|
55
|
+
return nextConfig.webpack(config, context);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return config;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { withLiveEdit };
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const parser = require('@babel/parser');
|
|
4
|
+
const _traverse = require('@babel/traverse');
|
|
5
|
+
|
|
6
|
+
// Handle both ES module and CommonJS exports
|
|
7
|
+
const traverse = _traverse.default || _traverse;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert character position to line number and character position within line
|
|
11
|
+
* Returns 0-based indexing (line 0 = first line, char 0 = first character in line)
|
|
12
|
+
*/
|
|
13
|
+
function getLineAndCharPosition(content, charPosition) {
|
|
14
|
+
const lines = content.split('\n');
|
|
15
|
+
let currentPos = 0;
|
|
16
|
+
|
|
17
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
18
|
+
const lineLength = lines[lineIndex].length + 1; // +1 for newline character
|
|
19
|
+
|
|
20
|
+
if (charPosition < currentPos + lineLength) {
|
|
21
|
+
const charInLine = charPosition - currentPos;
|
|
22
|
+
return {
|
|
23
|
+
line: lineIndex,
|
|
24
|
+
character: charInLine
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
currentPos += lineLength;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
line: lines.length - 1,
|
|
33
|
+
character: lines[lines.length - 1].length
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate that the location data is correct in the current source file
|
|
39
|
+
* Returns validation result with details
|
|
40
|
+
*/
|
|
41
|
+
function validateLocationInSource(originalContent, location) {
|
|
42
|
+
// 1. Basic bounds checking
|
|
43
|
+
if (location.start < 0 || location.end < 0) {
|
|
44
|
+
return { valid: false, error: 'Invalid location: negative positions' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (location.start >= location.end) {
|
|
48
|
+
return { valid: false, error: 'Invalid location: start >= end' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (location.end > originalContent.length) {
|
|
52
|
+
return { valid: false, error: `Invalid location: end position ${location.end} exceeds file length ${originalContent.length}` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Verify the text at the location matches what we expect
|
|
56
|
+
const actualText = originalContent.substring(location.start, location.end);
|
|
57
|
+
const expectedText = location.text;
|
|
58
|
+
|
|
59
|
+
if (actualText !== expectedText) {
|
|
60
|
+
return {
|
|
61
|
+
valid: false,
|
|
62
|
+
error: `Location mismatch: expected "${expectedText}" but found "${actualText}". Source file may have changed.`
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. AST verification based on type
|
|
67
|
+
const locationType = location.type || 'text-content';
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const ast = parser.parse(originalContent, {
|
|
71
|
+
sourceType: 'module',
|
|
72
|
+
plugins: ['jsx', 'typescript'],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
let foundMatchingNode = false;
|
|
76
|
+
|
|
77
|
+
if (locationType === 'text-content') {
|
|
78
|
+
// Validate JSXText node
|
|
79
|
+
traverse(ast, {
|
|
80
|
+
JSXText(astPath) {
|
|
81
|
+
const { node } = astPath;
|
|
82
|
+
if (node.start === location.start && node.end === location.end) {
|
|
83
|
+
foundMatchingNode = true;
|
|
84
|
+
astPath.stop();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!foundMatchingNode) {
|
|
90
|
+
return {
|
|
91
|
+
valid: false,
|
|
92
|
+
error: `AST structure mismatch: no JSXText node found at position ${location.start}-${location.end}. Source structure may have changed.`
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
} else if (locationType === 'image-src') {
|
|
96
|
+
// Validate img src attribute
|
|
97
|
+
traverse(ast, {
|
|
98
|
+
JSXAttribute(astPath) {
|
|
99
|
+
const { node } = astPath;
|
|
100
|
+
if (node.name.name === 'src' && node.value) {
|
|
101
|
+
let valueNode = node.value;
|
|
102
|
+
|
|
103
|
+
// Handle string literals and expression containers
|
|
104
|
+
if (valueNode.type === 'JSXExpressionContainer') {
|
|
105
|
+
valueNode = valueNode.expression;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (valueNode.type === 'StringLiteral') {
|
|
109
|
+
const actualStart = valueNode.start + 1;
|
|
110
|
+
const actualEnd = valueNode.end - 1;
|
|
111
|
+
|
|
112
|
+
if (actualStart === location.start && actualEnd === location.end) {
|
|
113
|
+
foundMatchingNode = true;
|
|
114
|
+
astPath.stop();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!foundMatchingNode) {
|
|
122
|
+
return {
|
|
123
|
+
valid: false,
|
|
124
|
+
error: `AST structure mismatch: no img src attribute found at position ${location.start}-${location.end}. Source structure may have changed.`
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (parseError) {
|
|
129
|
+
return {
|
|
130
|
+
valid: false,
|
|
131
|
+
error: `Source file has syntax errors: ${parseError.message}`
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { valid: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Validate that the updated content is syntactically valid
|
|
140
|
+
*/
|
|
141
|
+
function validateUpdatedContent(content, filePath) {
|
|
142
|
+
try {
|
|
143
|
+
parser.parse(content, {
|
|
144
|
+
sourceType: 'module',
|
|
145
|
+
plugins: ['jsx', 'typescript'],
|
|
146
|
+
});
|
|
147
|
+
return { valid: true };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return {
|
|
150
|
+
valid: false,
|
|
151
|
+
error: `Syntax error in updated content: ${error.message}`,
|
|
152
|
+
details: error
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate image URL for security and correctness
|
|
159
|
+
*/
|
|
160
|
+
function validateImageUrl(url) {
|
|
161
|
+
// Reject potentially harmful protocols
|
|
162
|
+
const dangerousProtocols = ['javascript:', 'data:text/html', 'vbscript:'];
|
|
163
|
+
const lowerUrl = url.toLowerCase();
|
|
164
|
+
|
|
165
|
+
for (const protocol of dangerousProtocols) {
|
|
166
|
+
if (lowerUrl.startsWith(protocol)) {
|
|
167
|
+
return { valid: false, error: `Dangerous protocol detected: ${protocol}` };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Basic length check
|
|
172
|
+
if (url.length > 2000) {
|
|
173
|
+
return { valid: false, error: 'Image URL too long (max 2000 characters)' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Allow relative paths, http(s), data:image, and common protocols
|
|
177
|
+
const validPatterns = [
|
|
178
|
+
/^https?:\/\//, // http:// or https://
|
|
179
|
+
/^\/[^\/]/, // Absolute path (starts with single /)
|
|
180
|
+
/^\.\.?\//, // Relative path (starts with ./ or ../)
|
|
181
|
+
/^[a-zA-Z0-9]/, // Relative path (no protocol)
|
|
182
|
+
/^data:image\//, // Data URI for images only
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const isValid = validPatterns.some(pattern => pattern.test(url));
|
|
186
|
+
|
|
187
|
+
if (!isValid) {
|
|
188
|
+
return { valid: false, error: 'Invalid image URL format' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { valid: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Handle text and image updates using AST-injected location data
|
|
196
|
+
* PRODUCTION-READY with full validation and rollback capability
|
|
197
|
+
*
|
|
198
|
+
* @param {Object} data - The update data from the client
|
|
199
|
+
* @param {Object} options - Configuration options
|
|
200
|
+
* @param {string[]} options.sourceDirs - Source directories to search (default: ['app', 'components', 'src'])
|
|
201
|
+
* @param {string} options.projectRoot - Project root directory (default: process.cwd())
|
|
202
|
+
*/
|
|
203
|
+
async function handleEnhancedTextUpdate(data, options = {}) {
|
|
204
|
+
const { element, content, imageUrl, updateType } = data;
|
|
205
|
+
const { sourceDirs = ['app', 'components', 'src'], projectRoot = process.cwd() } = options;
|
|
206
|
+
|
|
207
|
+
// Normalize content field - handle both 'content' and 'imageUrl'
|
|
208
|
+
const actualContent = content || imageUrl;
|
|
209
|
+
|
|
210
|
+
// === INPUT VALIDATION ===
|
|
211
|
+
if (!element || !actualContent) {
|
|
212
|
+
console.error('❌ Live edit failed: Missing element or content data');
|
|
213
|
+
console.error(' Received data:', { hasElement: !!element, hasContent: !!content, hasImageUrl: !!imageUrl, updateType });
|
|
214
|
+
return { success: false, error: 'Missing element or content data' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (typeof actualContent !== 'string' || actualContent.length === 0) {
|
|
218
|
+
console.error('❌ Live edit failed: Invalid content type or empty');
|
|
219
|
+
return { success: false, error: 'Invalid content: must be a non-empty string' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Extract location data from element
|
|
223
|
+
const sourceLocAttr = element.sourceLoc || element['data-source-loc'];
|
|
224
|
+
const elementId = element.elementId || element['data-element-id'];
|
|
225
|
+
|
|
226
|
+
console.log(`📥 Live edit request for element: ${elementId}, tagName: ${element.tagName}`);
|
|
227
|
+
|
|
228
|
+
if (!sourceLocAttr) {
|
|
229
|
+
console.error(`❌ Live edit failed: No source location data for element ${elementId}`);
|
|
230
|
+
console.error(' Element data:', JSON.stringify(element, null, 2));
|
|
231
|
+
return { success: false, error: 'No source location data available - element may not have been compiled with source mapping' };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let originalContent = null; // Backup for rollback
|
|
235
|
+
let fullFilePath = null;
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Parse the location data (it's a JSON string)
|
|
239
|
+
const location = typeof sourceLocAttr === 'string'
|
|
240
|
+
? JSON.parse(sourceLocAttr.replace(/'/g, "'"))
|
|
241
|
+
: sourceLocAttr;
|
|
242
|
+
|
|
243
|
+
// Validate location object structure
|
|
244
|
+
if (!location || !location.file || typeof location.start !== 'number' || typeof location.end !== 'number') {
|
|
245
|
+
return { success: false, error: 'Invalid location data structure' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const locationType = location.type || 'text-content';
|
|
249
|
+
|
|
250
|
+
console.log(`🎯 Updating ${location.file} [${locationType}] at characters ${location.start}-${location.end}`);
|
|
251
|
+
|
|
252
|
+
// Try to find the file in any of the source directories
|
|
253
|
+
fullFilePath = null;
|
|
254
|
+
for (const dir of sourceDirs) {
|
|
255
|
+
const candidate = path.resolve(projectRoot, dir, location.file);
|
|
256
|
+
if (fs.existsSync(candidate)) {
|
|
257
|
+
fullFilePath = candidate;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Also try resolving directly from project root
|
|
263
|
+
if (!fullFilePath) {
|
|
264
|
+
const directPath = path.resolve(projectRoot, location.file);
|
|
265
|
+
if (fs.existsSync(directPath)) {
|
|
266
|
+
fullFilePath = directPath;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!fullFilePath) {
|
|
271
|
+
return { success: false, error: `Source file not found: ${location.file}` };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// === READ & BACKUP ORIGINAL ===
|
|
275
|
+
originalContent = fs.readFileSync(fullFilePath, 'utf-8');
|
|
276
|
+
|
|
277
|
+
// === PRE-FLIGHT VALIDATION ===
|
|
278
|
+
const preValidation = validateLocationInSource(originalContent, location);
|
|
279
|
+
if (!preValidation.valid) {
|
|
280
|
+
console.error(`❌ Pre-flight validation failed: ${preValidation.error}`);
|
|
281
|
+
return { success: false, error: `Validation failed: ${preValidation.error}` };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
console.log('✓ Pre-flight validation passed');
|
|
285
|
+
|
|
286
|
+
// === EXTRACT NEW CONTENT ===
|
|
287
|
+
let newContent;
|
|
288
|
+
|
|
289
|
+
if (locationType === 'image-src') {
|
|
290
|
+
// For images, extract the src value
|
|
291
|
+
if (actualContent.startsWith('<img')) {
|
|
292
|
+
const srcMatch = actualContent.match(/src=["']([^"']+)["']/);
|
|
293
|
+
if (srcMatch) {
|
|
294
|
+
newContent = srcMatch[1];
|
|
295
|
+
} else {
|
|
296
|
+
return { success: false, error: 'Could not extract src from img tag' };
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
newContent = actualContent;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Validate image URL
|
|
303
|
+
const urlValidation = validateImageUrl(newContent);
|
|
304
|
+
if (!urlValidation.valid) {
|
|
305
|
+
return { success: false, error: `Image URL validation failed: ${urlValidation.error}` };
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
// For text content, extract text from HTML if needed
|
|
309
|
+
newContent = actualContent;
|
|
310
|
+
if (actualContent.startsWith('<') && actualContent.endsWith('>')) {
|
|
311
|
+
newContent = actualContent.replace(/<[^>]*>/g, '');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Sanitize text content
|
|
315
|
+
if (newContent.length > 10000) {
|
|
316
|
+
return { success: false, error: 'Content too large (max 10000 characters)' };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// === APPLY UPDATE ===
|
|
321
|
+
const updatedContent =
|
|
322
|
+
originalContent.substring(0, location.start) +
|
|
323
|
+
newContent +
|
|
324
|
+
originalContent.substring(location.end);
|
|
325
|
+
|
|
326
|
+
// === POST-UPDATE VALIDATION ===
|
|
327
|
+
const postValidation = validateUpdatedContent(updatedContent, fullFilePath);
|
|
328
|
+
if (!postValidation.valid) {
|
|
329
|
+
console.error(`❌ Post-update validation failed: ${postValidation.error}`);
|
|
330
|
+
return {
|
|
331
|
+
success: false,
|
|
332
|
+
error: `Updated content has syntax errors: ${postValidation.error}. Changes not applied.`
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
console.log('✓ Post-update validation passed');
|
|
337
|
+
|
|
338
|
+
// === WRITE CHANGES (only after all validation passes) ===
|
|
339
|
+
fs.writeFileSync(fullFilePath, updatedContent, 'utf-8');
|
|
340
|
+
|
|
341
|
+
// Next.js Fast Refresh will automatically detect the file change
|
|
342
|
+
// No need to manually trigger HMR like in Vite
|
|
343
|
+
|
|
344
|
+
const updateLabel = locationType === 'image-src' ? '🖼️ Image' : '📝 Text';
|
|
345
|
+
console.log(`✅ ${updateLabel} updated in ${location.file}`);
|
|
346
|
+
console.log(` "${location.text}" → "${newContent}"`);
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
success: true,
|
|
350
|
+
message: `Updated ${path.basename(fullFilePath)}`,
|
|
351
|
+
file: fullFilePath,
|
|
352
|
+
type: locationType
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
} catch (error) {
|
|
356
|
+
console.error('❌ Error updating source file:', error);
|
|
357
|
+
|
|
358
|
+
// === ROLLBACK ON ERROR ===
|
|
359
|
+
if (originalContent && fullFilePath) {
|
|
360
|
+
try {
|
|
361
|
+
fs.writeFileSync(fullFilePath, originalContent, 'utf-8');
|
|
362
|
+
console.log('↩️ Rolled back to original content');
|
|
363
|
+
} catch (rollbackError) {
|
|
364
|
+
console.error('❌ Failed to rollback:', rollbackError);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { success: false, error: error.message };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* CORS headers for the live-edit API
|
|
374
|
+
*/
|
|
375
|
+
const corsHeaders = {
|
|
376
|
+
'Access-Control-Allow-Origin': '*',
|
|
377
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
378
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Create a Next.js App Router API route handler for live editing.
|
|
383
|
+
*
|
|
384
|
+
* Usage in app/api/live-edit/route.js:
|
|
385
|
+
*
|
|
386
|
+
* const { createLiveEditHandler } = require('@codedesignai/nextjs-live-edit-plugin/live-edit-handler');
|
|
387
|
+
* const { POST, OPTIONS } = createLiveEditHandler();
|
|
388
|
+
* module.exports = { POST, OPTIONS };
|
|
389
|
+
*
|
|
390
|
+
* Or for ES modules (app/api/live-edit/route.ts):
|
|
391
|
+
*
|
|
392
|
+
* import { createLiveEditHandler } from '@codedesignai/nextjs-live-edit-plugin/live-edit-handler';
|
|
393
|
+
* export const { POST, OPTIONS } = createLiveEditHandler();
|
|
394
|
+
*
|
|
395
|
+
* @param {Object} options
|
|
396
|
+
* @param {string[]} options.sourceDirs - Directories to search for source files
|
|
397
|
+
* @param {string} options.projectRoot - Project root directory
|
|
398
|
+
*/
|
|
399
|
+
function createLiveEditHandler(options = {}) {
|
|
400
|
+
const handlerOptions = {
|
|
401
|
+
sourceDirs: options.sourceDirs || ['app', 'components', 'src'],
|
|
402
|
+
projectRoot: options.projectRoot || process.cwd(),
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
async function POST(request) {
|
|
406
|
+
// Only allow in development
|
|
407
|
+
if (process.env.NODE_ENV === 'production') {
|
|
408
|
+
return new Response(
|
|
409
|
+
JSON.stringify({ success: false, error: 'Live editing is only available in development mode' }),
|
|
410
|
+
{ status: 403, headers: { 'Content-Type': 'application/json', ...corsHeaders } }
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const data = await request.json();
|
|
416
|
+
console.log('🔄 Received enhanced live edit request:', JSON.stringify(data, null, 2));
|
|
417
|
+
|
|
418
|
+
const result = await handleEnhancedTextUpdate(data, handlerOptions);
|
|
419
|
+
|
|
420
|
+
if (result.success) {
|
|
421
|
+
return new Response(
|
|
422
|
+
JSON.stringify({ success: true, message: result.message }),
|
|
423
|
+
{ status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } }
|
|
424
|
+
);
|
|
425
|
+
} else {
|
|
426
|
+
return new Response(
|
|
427
|
+
JSON.stringify({ success: false, error: result.error }),
|
|
428
|
+
{ status: 400, headers: { 'Content-Type': 'application/json', ...corsHeaders } }
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
} catch (error) {
|
|
432
|
+
console.error('❌ Error processing enhanced live edit request:', error);
|
|
433
|
+
return new Response(
|
|
434
|
+
JSON.stringify({ success: false, error: error.message }),
|
|
435
|
+
{ status: 500, headers: { 'Content-Type': 'application/json', ...corsHeaders } }
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function OPTIONS() {
|
|
441
|
+
return new Response(null, {
|
|
442
|
+
status: 200,
|
|
443
|
+
headers: corsHeaders,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return { POST, OPTIONS };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Create a Pages Router API handler (for pages/api/live-edit.js)
|
|
452
|
+
*
|
|
453
|
+
* Usage:
|
|
454
|
+
* const { createPagesApiHandler } = require('@codedesignai/nextjs-live-edit-plugin/live-edit-handler');
|
|
455
|
+
* module.exports = createPagesApiHandler();
|
|
456
|
+
*/
|
|
457
|
+
function createPagesApiHandler(options = {}) {
|
|
458
|
+
const handlerOptions = {
|
|
459
|
+
sourceDirs: options.sourceDirs || ['app', 'components', 'src'],
|
|
460
|
+
projectRoot: options.projectRoot || process.cwd(),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
return async function handler(req, res) {
|
|
464
|
+
// Set CORS headers
|
|
465
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
466
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
467
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
468
|
+
|
|
469
|
+
// Handle OPTIONS preflight
|
|
470
|
+
if (req.method === 'OPTIONS') {
|
|
471
|
+
res.status(200).end();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Only allow POST
|
|
476
|
+
if (req.method !== 'POST') {
|
|
477
|
+
res.status(405).json({ success: false, error: 'Method Not Allowed' });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Only allow in development
|
|
482
|
+
if (process.env.NODE_ENV === 'production') {
|
|
483
|
+
res.status(403).json({ success: false, error: 'Live editing is only available in development mode' });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
const data = req.body;
|
|
489
|
+
console.log('🔄 Received enhanced live edit request:', JSON.stringify(data, null, 2));
|
|
490
|
+
|
|
491
|
+
const result = await handleEnhancedTextUpdate(data, handlerOptions);
|
|
492
|
+
|
|
493
|
+
if (result.success) {
|
|
494
|
+
res.status(200).json({ success: true, message: result.message });
|
|
495
|
+
} else {
|
|
496
|
+
res.status(400).json({ success: false, error: result.error });
|
|
497
|
+
}
|
|
498
|
+
} catch (error) {
|
|
499
|
+
console.error('❌ Error processing enhanced live edit request:', error);
|
|
500
|
+
res.status(500).json({ success: false, error: error.message });
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
module.exports = {
|
|
506
|
+
handleEnhancedTextUpdate,
|
|
507
|
+
validateLocationInSource,
|
|
508
|
+
validateUpdatedContent,
|
|
509
|
+
validateImageUrl,
|
|
510
|
+
createLiveEditHandler,
|
|
511
|
+
createPagesApiHandler,
|
|
512
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codedesignai/nextjs-live-edit-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Next.js plugin for live editing React components with AST-powered source mapping",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./live-edit-handler": "./live-edit-handler.js",
|
|
9
|
+
"./source-mapper-loader": "./source-mapper-loader.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"source-mapper-loader.js",
|
|
14
|
+
"live-edit-handler.js",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"nextjs",
|
|
20
|
+
"next",
|
|
21
|
+
"plugin",
|
|
22
|
+
"live-edit",
|
|
23
|
+
"react",
|
|
24
|
+
"ast",
|
|
25
|
+
"source-mapping",
|
|
26
|
+
"code-editing",
|
|
27
|
+
"webpack-loader"
|
|
28
|
+
],
|
|
29
|
+
"author": "",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/codedesignapp/ai-companion-live-edit-plugin.git",
|
|
34
|
+
"directory": "nextjs"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"next": ">=13.0.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@babel/parser": "^7.28.4",
|
|
47
|
+
"@babel/traverse": "^7.28.4"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const parser = require('@babel/parser');
|
|
3
|
+
const _traverse = require('@babel/traverse');
|
|
4
|
+
|
|
5
|
+
// Handle both ES module and CommonJS exports
|
|
6
|
+
const traverse = _traverse.default || _traverse;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convert character position to line number and character position within line
|
|
10
|
+
* Returns 0-based indexing (line 0 = first line, char 0 = first character in line)
|
|
11
|
+
*/
|
|
12
|
+
function getLineAndCharPosition(content, charPosition) {
|
|
13
|
+
const lines = content.split('\n');
|
|
14
|
+
let currentPos = 0;
|
|
15
|
+
|
|
16
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
17
|
+
const lineLength = lines[lineIndex].length + 1; // +1 for newline character
|
|
18
|
+
|
|
19
|
+
if (charPosition < currentPos + lineLength) {
|
|
20
|
+
const charInLine = charPosition - currentPos;
|
|
21
|
+
return {
|
|
22
|
+
line: lineIndex, // 0-based line number
|
|
23
|
+
character: charInLine // 0-based character position within line
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
currentPos += lineLength;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fallback - shouldn't happen with valid positions
|
|
31
|
+
return {
|
|
32
|
+
line: lines.length - 1,
|
|
33
|
+
character: lines[lines.length - 1].length
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate a unique element ID
|
|
39
|
+
*/
|
|
40
|
+
function generateElementId(filePath, lineNumber, elementName, index = 0) {
|
|
41
|
+
const fileName = path.basename(filePath, path.extname(filePath));
|
|
42
|
+
const cleanFileName = fileName.replace(/[^a-zA-Z0-9]/g, '');
|
|
43
|
+
return `${cleanFileName}-${elementName}-L${lineNumber}-${index}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Custom webpack loader that injects AST-powered source mapping data
|
|
48
|
+
* into JSX elements. This is the Next.js equivalent of the Vite
|
|
49
|
+
* sourceMapperPlugin() transform hook.
|
|
50
|
+
*
|
|
51
|
+
* Injects `data-element-id` and `data-source-loc` attributes into:
|
|
52
|
+
* - Text content elements (JSXText nodes)
|
|
53
|
+
* - Image elements (<img> and <Image>) with src attributes
|
|
54
|
+
*/
|
|
55
|
+
module.exports = function sourceMapperLoader(source) {
|
|
56
|
+
// Only run in development mode
|
|
57
|
+
if (process.env.NODE_ENV === 'production') {
|
|
58
|
+
return source;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const resourcePath = this.resourcePath;
|
|
62
|
+
const options = this.getOptions() || {};
|
|
63
|
+
|
|
64
|
+
// Configurable source directories (default: app, components, src)
|
|
65
|
+
const sourceDirs = options.sourceDirs || ['app', 'components', 'src'];
|
|
66
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
67
|
+
|
|
68
|
+
// Only process JSX/TSX files
|
|
69
|
+
if (!/\.(jsx|tsx)$/.test(resourcePath)) {
|
|
70
|
+
return source;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check if the file is in one of the configured source directories
|
|
74
|
+
const relativePath = path.relative(projectRoot, resourcePath);
|
|
75
|
+
const isInSourceDir = sourceDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath.startsWith(dir + '/'));
|
|
76
|
+
|
|
77
|
+
if (!isInSourceDir) {
|
|
78
|
+
return source;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Use the first matching source dir to compute the relative path for element IDs
|
|
82
|
+
const matchingDir = sourceDirs.find(dir => relativePath.startsWith(dir + path.sep) || relativePath.startsWith(dir + '/'));
|
|
83
|
+
const relativeToSrcDir = matchingDir ? path.relative(matchingDir, relativePath) : relativePath;
|
|
84
|
+
|
|
85
|
+
const modifications = [];
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const ast = parser.parse(source, {
|
|
89
|
+
sourceType: 'module',
|
|
90
|
+
plugins: ['jsx', 'typescript'],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
traverse(ast, {
|
|
94
|
+
JSXElement(astPath) {
|
|
95
|
+
const { node } = astPath;
|
|
96
|
+
const openingElement = node.openingElement;
|
|
97
|
+
|
|
98
|
+
// Handle JSXIdentifier and JSXMemberExpression
|
|
99
|
+
let tagName;
|
|
100
|
+
if (openingElement.name.type === 'JSXIdentifier') {
|
|
101
|
+
tagName = openingElement.name.name;
|
|
102
|
+
} else if (openingElement.name.type === 'JSXMemberExpression') {
|
|
103
|
+
// e.g., motion.div → use the property name
|
|
104
|
+
tagName = openingElement.name.property.name;
|
|
105
|
+
} else {
|
|
106
|
+
return; // Skip unsupported tag name types
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { line } = openingElement.loc.start;
|
|
110
|
+
|
|
111
|
+
// === HANDLE IMAGE ELEMENTS (both <img> and <Image>) ===
|
|
112
|
+
const isImageElement = tagName === 'img' || tagName === 'Image';
|
|
113
|
+
|
|
114
|
+
if (isImageElement) {
|
|
115
|
+
// Find the src attribute
|
|
116
|
+
const srcAttribute = openingElement.attributes.find(attr =>
|
|
117
|
+
attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'src'
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (srcAttribute && srcAttribute.value) {
|
|
121
|
+
let srcValue, srcStart, srcEnd;
|
|
122
|
+
|
|
123
|
+
// Handle different src value types
|
|
124
|
+
if (srcAttribute.value.type === 'StringLiteral') {
|
|
125
|
+
// src="image.jpg"
|
|
126
|
+
srcValue = srcAttribute.value.value;
|
|
127
|
+
srcStart = srcAttribute.value.start + 1; // Skip opening quote
|
|
128
|
+
srcEnd = srcAttribute.value.end - 1; // Skip closing quote
|
|
129
|
+
} else if (srcAttribute.value.type === 'JSXExpressionContainer') {
|
|
130
|
+
// src={variable} or src={`template`}
|
|
131
|
+
const expression = srcAttribute.value.expression;
|
|
132
|
+
if (expression.type === 'StringLiteral') {
|
|
133
|
+
srcValue = expression.value;
|
|
134
|
+
srcStart = expression.start + 1;
|
|
135
|
+
srcEnd = expression.end - 1;
|
|
136
|
+
} else if (expression.type === 'TemplateLiteral') {
|
|
137
|
+
// For template literals, we'll skip for now as they're dynamic
|
|
138
|
+
return;
|
|
139
|
+
} else {
|
|
140
|
+
// Skip dynamic expressions (variables, etc.)
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (srcValue !== undefined) {
|
|
146
|
+
// Get line-based positions for chat AI mode
|
|
147
|
+
const startLinePos = getLineAndCharPosition(source, srcStart);
|
|
148
|
+
const endLinePos = getLineAndCharPosition(source, srcEnd);
|
|
149
|
+
|
|
150
|
+
const locationData = {
|
|
151
|
+
file: relativeToSrcDir,
|
|
152
|
+
start: srcStart,
|
|
153
|
+
end: srcEnd,
|
|
154
|
+
text: srcValue,
|
|
155
|
+
type: 'image-src', // Mark this as an image source
|
|
156
|
+
// Line-based information for chat AI mode (0-based indexing)
|
|
157
|
+
line: startLinePos.line,
|
|
158
|
+
character: startLinePos.character,
|
|
159
|
+
endLine: endLinePos.line,
|
|
160
|
+
endCharacter: endLinePos.character
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const elementId = generateElementId(relativeToSrcDir, line, tagName, 0);
|
|
164
|
+
|
|
165
|
+
const insertPosition = openingElement.selfClosing
|
|
166
|
+
? openingElement.end - 2 // Before '/>'
|
|
167
|
+
: openingElement.end - 1; // Before '>'
|
|
168
|
+
|
|
169
|
+
const attributes = ` data-element-id="${elementId}" data-source-loc='${JSON.stringify(locationData).replace(/'/g, "'")}'`;
|
|
170
|
+
|
|
171
|
+
modifications.push({ position: insertPosition, text: attributes });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return; // Done processing this image element
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// === HANDLE TEXT CONTENT ELEMENTS ===
|
|
178
|
+
// Find text content children
|
|
179
|
+
const textChildren = node.children.filter(child =>
|
|
180
|
+
child.type === 'JSXText' && child.value.trim().length > 0
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Skip if no text content
|
|
184
|
+
if (textChildren.length === 0) return;
|
|
185
|
+
|
|
186
|
+
// Only inject once per element, using the FIRST text child
|
|
187
|
+
const firstTextNode = textChildren[0];
|
|
188
|
+
|
|
189
|
+
// Get line-based positions for chat AI mode
|
|
190
|
+
const startLinePos = getLineAndCharPosition(source, firstTextNode.start);
|
|
191
|
+
const endLinePos = getLineAndCharPosition(source, firstTextNode.end);
|
|
192
|
+
|
|
193
|
+
const locationData = {
|
|
194
|
+
file: relativeToSrcDir,
|
|
195
|
+
start: firstTextNode.start,
|
|
196
|
+
end: firstTextNode.end,
|
|
197
|
+
text: firstTextNode.value.trim(),
|
|
198
|
+
type: 'text-content', // Mark this as text content
|
|
199
|
+
// Line-based information for chat AI mode (0-based indexing)
|
|
200
|
+
line: startLinePos.line,
|
|
201
|
+
character: startLinePos.character,
|
|
202
|
+
endLine: endLinePos.line,
|
|
203
|
+
endCharacter: endLinePos.character
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const elementId = generateElementId(relativeToSrcDir, line, tagName, 0);
|
|
207
|
+
|
|
208
|
+
// Inject attributes into the opening tag
|
|
209
|
+
const insertPosition = openingElement.selfClosing
|
|
210
|
+
? openingElement.end - 2 // Before '/>'
|
|
211
|
+
: openingElement.end - 1; // Before '>'
|
|
212
|
+
|
|
213
|
+
const attributes = ` data-element-id="${elementId}" data-source-loc='${JSON.stringify(locationData).replace(/'/g, "'")}'`;
|
|
214
|
+
|
|
215
|
+
modifications.push({ position: insertPosition, text: attributes });
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Apply modifications from end to start to preserve positions
|
|
220
|
+
if (modifications.length > 0) {
|
|
221
|
+
let transformedCode = source.split('');
|
|
222
|
+
modifications.sort((a, b) => b.position - a.position).forEach(mod => {
|
|
223
|
+
transformedCode.splice(mod.position, 0, mod.text);
|
|
224
|
+
});
|
|
225
|
+
return transformedCode.join('');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
} catch (e) {
|
|
229
|
+
// Don't break the build on parse errors - just skip transformation
|
|
230
|
+
console.error(`❌ [nextjs-live-edit] Failed to parse ${relativePath}:`, e.message);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return source;
|
|
234
|
+
};
|