@codedesignai/nextjs-live-edit-plugin 1.0.3 → 1.0.5
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/index.js +9 -6
- package/live-edit-handler.js +265 -126
- package/package.json +1 -1
- package/source-mapper-loader.js +302 -135
package/index.js
CHANGED
|
@@ -3,21 +3,25 @@ const path = require('path');
|
|
|
3
3
|
/**
|
|
4
4
|
* Next.js config wrapper that enables the live edit plugin.
|
|
5
5
|
* Uses Turbopack's built-in webpack loader support via turbopack.rules.
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* NOTE: Only Turbopack is supported. Adding a `webpack` key to the config
|
|
8
|
+
* causes hydration mismatches in Next.js 16 because the loader would only
|
|
9
|
+
* run on client-side builds, while server-rendered HTML has no data attributes.
|
|
10
|
+
*
|
|
7
11
|
* Usage in next.config.js (CommonJS):
|
|
8
|
-
*
|
|
12
|
+
*
|
|
9
13
|
* const { withLiveEdit } = require('@codedesignai/nextjs-live-edit-plugin');
|
|
10
14
|
* module.exports = withLiveEdit({
|
|
11
15
|
* // your normal Next.js config
|
|
12
16
|
* });
|
|
13
|
-
*
|
|
17
|
+
*
|
|
14
18
|
* Usage in next.config.ts (ESM):
|
|
15
|
-
*
|
|
19
|
+
*
|
|
16
20
|
* import { withLiveEdit } from '@codedesignai/nextjs-live-edit-plugin';
|
|
17
21
|
* export default withLiveEdit({
|
|
18
22
|
* // your normal Next.js config
|
|
19
23
|
* });
|
|
20
|
-
*
|
|
24
|
+
*
|
|
21
25
|
* @param {Object} nextConfig - Your Next.js configuration object
|
|
22
26
|
* @param {Object} pluginOptions - Plugin-specific options
|
|
23
27
|
* @param {string[]} pluginOptions.sourceDirs - Directories to process (default: ['app', 'components', 'src'])
|
|
@@ -50,7 +54,6 @@ function withLiveEdit(nextConfig = {}, pluginOptions = {}) {
|
|
|
50
54
|
...existingTurbopack,
|
|
51
55
|
rules: {
|
|
52
56
|
...existingRules,
|
|
53
|
-
// Apply our source mapper loader to JSX and TSX files
|
|
54
57
|
'*.jsx': {
|
|
55
58
|
loaders: [loaderConfig],
|
|
56
59
|
},
|
package/live-edit-handler.js
CHANGED
|
@@ -6,66 +6,72 @@ const _traverse = require('@babel/traverse');
|
|
|
6
6
|
// Handle both ES module and CommonJS exports
|
|
7
7
|
const traverse = _traverse.default || _traverse;
|
|
8
8
|
|
|
9
|
+
// ─── Utility Functions ──────────────────────────────────────────────────────
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
|
-
* Convert character position to line number and character position within line
|
|
11
|
-
* Returns 0-based indexing
|
|
12
|
+
* Convert character position to line number and character position within line.
|
|
13
|
+
* Returns 0-based indexing.
|
|
12
14
|
*/
|
|
13
15
|
function getLineAndCharPosition(content, charPosition) {
|
|
14
16
|
const lines = content.split('\n');
|
|
15
17
|
let currentPos = 0;
|
|
16
|
-
|
|
18
|
+
|
|
17
19
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
18
|
-
const lineLength = lines[lineIndex].length + 1;
|
|
19
|
-
|
|
20
|
+
const lineLength = lines[lineIndex].length + 1;
|
|
21
|
+
|
|
20
22
|
if (charPosition < currentPos + lineLength) {
|
|
21
|
-
const charInLine = charPosition - currentPos;
|
|
22
23
|
return {
|
|
23
24
|
line: lineIndex,
|
|
24
|
-
character:
|
|
25
|
+
character: charPosition - currentPos,
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
+
|
|
28
29
|
currentPos += lineLength;
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
+
|
|
31
32
|
return {
|
|
32
33
|
line: lines.length - 1,
|
|
33
|
-
character: lines[lines.length - 1].length
|
|
34
|
+
character: lines[lines.length - 1].length,
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
// ─── Validation ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
37
40
|
/**
|
|
38
|
-
* Validate that the location data is correct in the current source file
|
|
39
|
-
*
|
|
41
|
+
* Validate that the location data is correct in the current source file.
|
|
42
|
+
* Performs bounds checking, text matching, and AST-level verification.
|
|
40
43
|
*/
|
|
41
44
|
function validateLocationInSource(originalContent, location) {
|
|
42
45
|
// 1. Basic bounds checking
|
|
43
46
|
if (location.start < 0 || location.end < 0) {
|
|
44
47
|
return { valid: false, error: 'Invalid location: negative positions' };
|
|
45
48
|
}
|
|
46
|
-
|
|
49
|
+
|
|
47
50
|
if (location.start >= location.end) {
|
|
48
51
|
return { valid: false, error: 'Invalid location: start >= end' };
|
|
49
52
|
}
|
|
50
|
-
|
|
53
|
+
|
|
51
54
|
if (location.end > originalContent.length) {
|
|
52
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
valid: false,
|
|
57
|
+
error: `Invalid location: end position ${location.end} exceeds file length ${originalContent.length}`,
|
|
58
|
+
};
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
// 2. Verify the text at the location matches what we expect
|
|
56
62
|
const actualText = originalContent.substring(location.start, location.end);
|
|
57
63
|
const expectedText = location.text;
|
|
58
|
-
|
|
64
|
+
|
|
59
65
|
if (actualText !== expectedText) {
|
|
60
|
-
return {
|
|
61
|
-
valid: false,
|
|
62
|
-
error: `Location mismatch: expected "${expectedText}" but found "${actualText}". Source file may have changed
|
|
66
|
+
return {
|
|
67
|
+
valid: false,
|
|
68
|
+
error: `Location mismatch: expected "${expectedText}" but found "${actualText}". Source file may have changed.`,
|
|
63
69
|
};
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
// 3. AST verification based on type
|
|
67
73
|
const locationType = location.type || 'text-content';
|
|
68
|
-
|
|
74
|
+
|
|
69
75
|
try {
|
|
70
76
|
const ast = parser.parse(originalContent, {
|
|
71
77
|
sourceType: 'module',
|
|
@@ -75,7 +81,7 @@ function validateLocationInSource(originalContent, location) {
|
|
|
75
81
|
let foundMatchingNode = false;
|
|
76
82
|
|
|
77
83
|
if (locationType === 'text-content') {
|
|
78
|
-
// Validate JSXText node
|
|
84
|
+
// Validate JSXText node at the expected position
|
|
79
85
|
traverse(ast, {
|
|
80
86
|
JSXText(astPath) {
|
|
81
87
|
const { node } = astPath;
|
|
@@ -83,52 +89,134 @@ function validateLocationInSource(originalContent, location) {
|
|
|
83
89
|
foundMatchingNode = true;
|
|
84
90
|
astPath.stop();
|
|
85
91
|
}
|
|
86
|
-
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!foundMatchingNode) {
|
|
96
|
+
return {
|
|
97
|
+
valid: false,
|
|
98
|
+
error: `AST mismatch: no JSXText node at position ${location.start}-${location.end}.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
} else if (locationType === 'string-expression') {
|
|
102
|
+
// Validate StringLiteral inside a JSXExpressionContainer
|
|
103
|
+
traverse(ast, {
|
|
104
|
+
JSXExpressionContainer(astPath) {
|
|
105
|
+
const expr = astPath.node.expression;
|
|
106
|
+
if (
|
|
107
|
+
expr.type === 'StringLiteral' &&
|
|
108
|
+
expr.start + 1 === location.start &&
|
|
109
|
+
expr.end - 1 === location.end
|
|
110
|
+
) {
|
|
111
|
+
foundMatchingNode = true;
|
|
112
|
+
astPath.stop();
|
|
113
|
+
}
|
|
114
|
+
},
|
|
87
115
|
});
|
|
88
116
|
|
|
89
117
|
if (!foundMatchingNode) {
|
|
90
118
|
return {
|
|
91
119
|
valid: false,
|
|
92
|
-
error: `AST
|
|
120
|
+
error: `AST mismatch: no StringLiteral expression at position ${location.start}-${location.end}.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
} else if (locationType === 'template-literal') {
|
|
124
|
+
// Validate static TemplateLiteral inside a JSXExpressionContainer
|
|
125
|
+
traverse(ast, {
|
|
126
|
+
JSXExpressionContainer(astPath) {
|
|
127
|
+
const expr = astPath.node.expression;
|
|
128
|
+
if (
|
|
129
|
+
expr.type === 'TemplateLiteral' &&
|
|
130
|
+
expr.expressions.length === 0 &&
|
|
131
|
+
expr.quasis.length === 1
|
|
132
|
+
) {
|
|
133
|
+
const quasi = expr.quasis[0];
|
|
134
|
+
if (quasi.start === location.start && quasi.end === location.end) {
|
|
135
|
+
foundMatchingNode = true;
|
|
136
|
+
astPath.stop();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!foundMatchingNode) {
|
|
143
|
+
return {
|
|
144
|
+
valid: false,
|
|
145
|
+
error: `AST mismatch: no static TemplateLiteral at position ${location.start}-${location.end}.`,
|
|
93
146
|
};
|
|
94
147
|
}
|
|
95
148
|
} else if (locationType === 'image-src') {
|
|
96
|
-
// Validate img src attribute
|
|
149
|
+
// Validate img/Image src attribute StringLiteral
|
|
97
150
|
traverse(ast, {
|
|
98
151
|
JSXAttribute(astPath) {
|
|
99
152
|
const { node } = astPath;
|
|
100
153
|
if (node.name.name === 'src' && node.value) {
|
|
101
154
|
let valueNode = node.value;
|
|
102
|
-
|
|
103
|
-
// Handle string literals and expression containers
|
|
155
|
+
|
|
104
156
|
if (valueNode.type === 'JSXExpressionContainer') {
|
|
105
157
|
valueNode = valueNode.expression;
|
|
106
158
|
}
|
|
107
|
-
|
|
159
|
+
|
|
108
160
|
if (valueNode.type === 'StringLiteral') {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
161
|
+
if (
|
|
162
|
+
valueNode.start + 1 === location.start &&
|
|
163
|
+
valueNode.end - 1 === location.end
|
|
164
|
+
) {
|
|
113
165
|
foundMatchingNode = true;
|
|
114
166
|
astPath.stop();
|
|
115
167
|
}
|
|
116
168
|
}
|
|
117
169
|
}
|
|
118
|
-
}
|
|
170
|
+
},
|
|
119
171
|
});
|
|
120
172
|
|
|
121
173
|
if (!foundMatchingNode) {
|
|
122
174
|
return {
|
|
123
175
|
valid: false,
|
|
124
|
-
error: `AST
|
|
176
|
+
error: `AST mismatch: no src attribute at position ${location.start}-${location.end}.`,
|
|
125
177
|
};
|
|
126
178
|
}
|
|
179
|
+
} else if (locationType.startsWith('attribute:')) {
|
|
180
|
+
// Validate a named attribute with StringLiteral value
|
|
181
|
+
const attrName = locationType.split(':')[1];
|
|
182
|
+
traverse(ast, {
|
|
183
|
+
JSXAttribute(astPath) {
|
|
184
|
+
const { node } = astPath;
|
|
185
|
+
if (node.name.name === attrName && node.value) {
|
|
186
|
+
let valueNode = node.value;
|
|
187
|
+
|
|
188
|
+
if (valueNode.type === 'JSXExpressionContainer') {
|
|
189
|
+
valueNode = valueNode.expression;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (valueNode.type === 'StringLiteral') {
|
|
193
|
+
if (
|
|
194
|
+
valueNode.start + 1 === location.start &&
|
|
195
|
+
valueNode.end - 1 === location.end
|
|
196
|
+
) {
|
|
197
|
+
foundMatchingNode = true;
|
|
198
|
+
astPath.stop();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (!foundMatchingNode) {
|
|
206
|
+
return {
|
|
207
|
+
valid: false,
|
|
208
|
+
error: `AST mismatch: no ${attrName} attribute at position ${location.start}-${location.end}.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
} else if (locationType === 'style-background-image') {
|
|
212
|
+
// For background images, we do positional text verification only (already done above).
|
|
213
|
+
// Full AST verification of nested style objects is complex and the text match is sufficient.
|
|
214
|
+
foundMatchingNode = true;
|
|
127
215
|
}
|
|
128
216
|
} catch (parseError) {
|
|
129
217
|
return {
|
|
130
218
|
valid: false,
|
|
131
|
-
error: `Source file has syntax errors: ${parseError.message}
|
|
219
|
+
error: `Source file has syntax errors: ${parseError.message}`,
|
|
132
220
|
};
|
|
133
221
|
}
|
|
134
222
|
|
|
@@ -136,7 +224,7 @@ function validateLocationInSource(originalContent, location) {
|
|
|
136
224
|
}
|
|
137
225
|
|
|
138
226
|
/**
|
|
139
|
-
* Validate that the updated content is syntactically valid
|
|
227
|
+
* Validate that the updated content is syntactically valid JSX/TSX.
|
|
140
228
|
*/
|
|
141
229
|
function validateUpdatedContent(content, filePath) {
|
|
142
230
|
try {
|
|
@@ -146,71 +234,77 @@ function validateUpdatedContent(content, filePath) {
|
|
|
146
234
|
});
|
|
147
235
|
return { valid: true };
|
|
148
236
|
} catch (error) {
|
|
149
|
-
return {
|
|
150
|
-
valid: false,
|
|
237
|
+
return {
|
|
238
|
+
valid: false,
|
|
151
239
|
error: `Syntax error in updated content: ${error.message}`,
|
|
152
|
-
details: error
|
|
240
|
+
details: error,
|
|
153
241
|
};
|
|
154
242
|
}
|
|
155
243
|
}
|
|
156
244
|
|
|
157
245
|
/**
|
|
158
|
-
* Validate image URL for security and correctness
|
|
246
|
+
* Validate image URL for security and correctness.
|
|
159
247
|
*/
|
|
160
248
|
function validateImageUrl(url) {
|
|
161
|
-
// Reject potentially harmful protocols
|
|
162
249
|
const dangerousProtocols = ['javascript:', 'data:text/html', 'vbscript:'];
|
|
163
250
|
const lowerUrl = url.toLowerCase();
|
|
164
|
-
|
|
251
|
+
|
|
165
252
|
for (const protocol of dangerousProtocols) {
|
|
166
253
|
if (lowerUrl.startsWith(protocol)) {
|
|
167
254
|
return { valid: false, error: `Dangerous protocol detected: ${protocol}` };
|
|
168
255
|
}
|
|
169
256
|
}
|
|
170
257
|
|
|
171
|
-
// Basic length check
|
|
172
258
|
if (url.length > 2000) {
|
|
173
259
|
return { valid: false, error: 'Image URL too long (max 2000 characters)' };
|
|
174
260
|
}
|
|
175
261
|
|
|
176
|
-
// Allow relative paths, http(s), data:image, and common protocols
|
|
177
262
|
const validPatterns = [
|
|
178
|
-
/^https?:\/\//,
|
|
179
|
-
/^\/[^\/]/,
|
|
180
|
-
/^\.\.?\//,
|
|
181
|
-
/^[a-zA-Z0-9]/,
|
|
182
|
-
/^data:image\//,
|
|
263
|
+
/^https?:\/\//, // http:// or https://
|
|
264
|
+
/^\/[^\/]/, // Absolute path (starts with single /)
|
|
265
|
+
/^\.\.?\//, // Relative path (starts with ./ or ../)
|
|
266
|
+
/^[a-zA-Z0-9]/, // Relative path (no protocol)
|
|
267
|
+
/^data:image\//, // Data URI for images only
|
|
183
268
|
];
|
|
184
269
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (!isValid) {
|
|
270
|
+
if (!validPatterns.some(pattern => pattern.test(url))) {
|
|
188
271
|
return { valid: false, error: 'Invalid image URL format' };
|
|
189
272
|
}
|
|
190
273
|
|
|
191
274
|
return { valid: true };
|
|
192
275
|
}
|
|
193
276
|
|
|
277
|
+
// ─── Core Update Logic ──────────────────────────────────────────────────────
|
|
278
|
+
|
|
194
279
|
/**
|
|
195
|
-
* Handle text and image updates using AST-injected location data
|
|
196
|
-
*
|
|
197
|
-
*
|
|
280
|
+
* Handle text and image updates using AST-injected location data.
|
|
281
|
+
* Supports all region types: text-content, string-expression, template-literal,
|
|
282
|
+
* image-src, attribute:*, and style-background-image.
|
|
283
|
+
*
|
|
198
284
|
* @param {Object} data - The update data from the client
|
|
199
285
|
* @param {Object} options - Configuration options
|
|
200
|
-
* @param {string[]} options.sourceDirs - Source directories to search
|
|
201
|
-
* @param {string} options.projectRoot - Project root directory
|
|
286
|
+
* @param {string[]} options.sourceDirs - Source directories to search
|
|
287
|
+
* @param {string} options.projectRoot - Project root directory
|
|
202
288
|
*/
|
|
203
289
|
async function handleEnhancedTextUpdate(data, options = {}) {
|
|
204
290
|
const { element, content, imageUrl, updateType } = data;
|
|
205
|
-
const {
|
|
206
|
-
|
|
207
|
-
|
|
291
|
+
const {
|
|
292
|
+
sourceDirs = ['app', 'components', 'src'],
|
|
293
|
+
projectRoot = process.cwd(),
|
|
294
|
+
} = options;
|
|
295
|
+
|
|
296
|
+
// Normalize content field
|
|
208
297
|
const actualContent = content || imageUrl;
|
|
209
|
-
|
|
298
|
+
|
|
210
299
|
// === INPUT VALIDATION ===
|
|
211
300
|
if (!element || !actualContent) {
|
|
212
301
|
console.error('❌ Live edit failed: Missing element or content data');
|
|
213
|
-
console.error(' Received data:', {
|
|
302
|
+
console.error(' Received data:', {
|
|
303
|
+
hasElement: !!element,
|
|
304
|
+
hasContent: !!content,
|
|
305
|
+
hasImageUrl: !!imageUrl,
|
|
306
|
+
updateType,
|
|
307
|
+
});
|
|
214
308
|
return { success: false, error: 'Missing element or content data' };
|
|
215
309
|
}
|
|
216
310
|
|
|
@@ -222,33 +316,68 @@ async function handleEnhancedTextUpdate(data, options = {}) {
|
|
|
222
316
|
// Extract location data from element
|
|
223
317
|
const sourceLocAttr = element.sourceLoc || element['data-source-loc'];
|
|
224
318
|
const elementId = element.elementId || element['data-element-id'];
|
|
225
|
-
|
|
319
|
+
|
|
226
320
|
console.log(`📥 Live edit request for element: ${elementId}, tagName: ${element.tagName}`);
|
|
227
|
-
|
|
321
|
+
|
|
228
322
|
if (!sourceLocAttr) {
|
|
229
323
|
console.error(`❌ Live edit failed: No source location data for element ${elementId}`);
|
|
230
324
|
console.error(' Element data:', JSON.stringify(element, null, 2));
|
|
231
|
-
return {
|
|
325
|
+
return {
|
|
326
|
+
success: false,
|
|
327
|
+
error: 'No source location data available - element may not have been compiled with source mapping',
|
|
328
|
+
};
|
|
232
329
|
}
|
|
233
330
|
|
|
234
|
-
let originalContent = null;
|
|
331
|
+
let originalContent = null;
|
|
235
332
|
let fullFilePath = null;
|
|
236
333
|
|
|
237
334
|
try {
|
|
238
|
-
// Parse the location data
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
335
|
+
// Parse the location data — handle both legacy single-object and new array format
|
|
336
|
+
let parsed =
|
|
337
|
+
typeof sourceLocAttr === 'string'
|
|
338
|
+
? JSON.parse(sourceLocAttr.replace(/'/g, "'"))
|
|
339
|
+
: sourceLocAttr;
|
|
340
|
+
|
|
341
|
+
// Backward compatibility: if it's a single object (legacy format), wrap in array
|
|
342
|
+
// Then pick the region the client wants to edit.
|
|
343
|
+
// The client can specify `regionIndex` to target a specific region,
|
|
344
|
+
// or `regionType` to target by type. Default: first region.
|
|
345
|
+
let location;
|
|
346
|
+
|
|
347
|
+
if (Array.isArray(parsed)) {
|
|
348
|
+
const regionIndex = data.regionIndex;
|
|
349
|
+
const regionType = data.regionType;
|
|
350
|
+
|
|
351
|
+
if (typeof regionIndex === 'number' && regionIndex < parsed.length) {
|
|
352
|
+
location = parsed[regionIndex];
|
|
353
|
+
} else if (regionType) {
|
|
354
|
+
location = parsed.find(r => r.type === regionType);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!location) {
|
|
358
|
+
location = parsed[0]; // default to first region
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
// Legacy single-object format
|
|
362
|
+
location = parsed;
|
|
363
|
+
}
|
|
364
|
+
|
|
243
365
|
// Validate location object structure
|
|
244
|
-
if (
|
|
366
|
+
if (
|
|
367
|
+
!location ||
|
|
368
|
+
!location.file ||
|
|
369
|
+
typeof location.start !== 'number' ||
|
|
370
|
+
typeof location.end !== 'number'
|
|
371
|
+
) {
|
|
245
372
|
return { success: false, error: 'Invalid location data structure' };
|
|
246
373
|
}
|
|
247
374
|
|
|
248
375
|
const locationType = location.type || 'text-content';
|
|
249
|
-
|
|
250
|
-
console.log(
|
|
251
|
-
|
|
376
|
+
|
|
377
|
+
console.log(
|
|
378
|
+
`🎯 Updating ${location.file} [${locationType}] at characters ${location.start}-${location.end}`
|
|
379
|
+
);
|
|
380
|
+
|
|
252
381
|
// Try to find the file in any of the source directories
|
|
253
382
|
fullFilePath = null;
|
|
254
383
|
for (const dir of sourceDirs) {
|
|
@@ -273,21 +402,21 @@ async function handleEnhancedTextUpdate(data, options = {}) {
|
|
|
273
402
|
|
|
274
403
|
// === READ & BACKUP ORIGINAL ===
|
|
275
404
|
originalContent = fs.readFileSync(fullFilePath, 'utf-8');
|
|
276
|
-
|
|
405
|
+
|
|
277
406
|
// === PRE-FLIGHT VALIDATION ===
|
|
278
407
|
const preValidation = validateLocationInSource(originalContent, location);
|
|
279
408
|
if (!preValidation.valid) {
|
|
280
409
|
console.error(`❌ Pre-flight validation failed: ${preValidation.error}`);
|
|
281
410
|
return { success: false, error: `Validation failed: ${preValidation.error}` };
|
|
282
411
|
}
|
|
283
|
-
|
|
412
|
+
|
|
284
413
|
console.log('✓ Pre-flight validation passed');
|
|
285
|
-
|
|
414
|
+
|
|
286
415
|
// === EXTRACT NEW CONTENT ===
|
|
287
416
|
let newContent;
|
|
288
|
-
|
|
289
|
-
if (locationType === 'image-src') {
|
|
290
|
-
// For images
|
|
417
|
+
|
|
418
|
+
if (locationType === 'image-src' || locationType === 'style-background-image') {
|
|
419
|
+
// For images / background images — extract the URL
|
|
291
420
|
if (actualContent.startsWith('<img')) {
|
|
292
421
|
const srcMatch = actualContent.match(/src=["']([^"']+)["']/);
|
|
293
422
|
if (srcMatch) {
|
|
@@ -305,8 +434,10 @@ async function handleEnhancedTextUpdate(data, options = {}) {
|
|
|
305
434
|
return { success: false, error: `Image URL validation failed: ${urlValidation.error}` };
|
|
306
435
|
}
|
|
307
436
|
} else {
|
|
308
|
-
// For text content
|
|
437
|
+
// For text content (text-content, string-expression, template-literal, attribute:*)
|
|
309
438
|
newContent = actualContent;
|
|
439
|
+
|
|
440
|
+
// Strip HTML tags if the content looks like HTML
|
|
310
441
|
if (actualContent.startsWith('<') && actualContent.endsWith('>')) {
|
|
311
442
|
newContent = actualContent.replace(/<[^>]*>/g, '');
|
|
312
443
|
}
|
|
@@ -318,43 +449,45 @@ async function handleEnhancedTextUpdate(data, options = {}) {
|
|
|
318
449
|
}
|
|
319
450
|
|
|
320
451
|
// === APPLY UPDATE ===
|
|
321
|
-
const updatedContent =
|
|
322
|
-
originalContent.substring(0, location.start) +
|
|
323
|
-
newContent +
|
|
452
|
+
const updatedContent =
|
|
453
|
+
originalContent.substring(0, location.start) +
|
|
454
|
+
newContent +
|
|
324
455
|
originalContent.substring(location.end);
|
|
325
|
-
|
|
456
|
+
|
|
326
457
|
// === POST-UPDATE VALIDATION ===
|
|
327
458
|
const postValidation = validateUpdatedContent(updatedContent, fullFilePath);
|
|
328
459
|
if (!postValidation.valid) {
|
|
329
460
|
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
|
|
461
|
+
return {
|
|
462
|
+
success: false,
|
|
463
|
+
error: `Updated content has syntax errors: ${postValidation.error}. Changes not applied.`,
|
|
333
464
|
};
|
|
334
465
|
}
|
|
335
|
-
|
|
466
|
+
|
|
336
467
|
console.log('✓ Post-update validation passed');
|
|
337
|
-
|
|
468
|
+
|
|
338
469
|
// === WRITE CHANGES (only after all validation passes) ===
|
|
339
470
|
fs.writeFileSync(fullFilePath, updatedContent, 'utf-8');
|
|
340
471
|
|
|
341
472
|
// Next.js Fast Refresh will automatically detect the file change
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
473
|
+
const updateLabel =
|
|
474
|
+
locationType === 'image-src'
|
|
475
|
+
? '🖼️ Image'
|
|
476
|
+
: locationType === 'style-background-image'
|
|
477
|
+
? '🖼️ Background Image'
|
|
478
|
+
: '📝 Text';
|
|
345
479
|
console.log(`✅ ${updateLabel} updated in ${location.file}`);
|
|
346
480
|
console.log(` "${location.text}" → "${newContent}"`);
|
|
347
|
-
|
|
348
|
-
return {
|
|
349
|
-
success: true,
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
success: true,
|
|
350
484
|
message: `Updated ${path.basename(fullFilePath)}`,
|
|
351
485
|
file: fullFilePath,
|
|
352
|
-
type: locationType
|
|
486
|
+
type: locationType,
|
|
353
487
|
};
|
|
354
|
-
|
|
355
488
|
} catch (error) {
|
|
356
489
|
console.error('❌ Error updating source file:', error);
|
|
357
|
-
|
|
490
|
+
|
|
358
491
|
// === ROLLBACK ON ERROR ===
|
|
359
492
|
if (originalContent && fullFilePath) {
|
|
360
493
|
try {
|
|
@@ -364,13 +497,15 @@ async function handleEnhancedTextUpdate(data, options = {}) {
|
|
|
364
497
|
console.error('❌ Failed to rollback:', rollbackError);
|
|
365
498
|
}
|
|
366
499
|
}
|
|
367
|
-
|
|
500
|
+
|
|
368
501
|
return { success: false, error: error.message };
|
|
369
502
|
}
|
|
370
503
|
}
|
|
371
504
|
|
|
505
|
+
// ─── API Route Handlers ─────────────────────────────────────────────────────
|
|
506
|
+
|
|
372
507
|
/**
|
|
373
|
-
* CORS headers for the live-edit API
|
|
508
|
+
* CORS headers for the live-edit API.
|
|
374
509
|
*/
|
|
375
510
|
const corsHeaders = {
|
|
376
511
|
'Access-Control-Allow-Origin': '*',
|
|
@@ -380,18 +515,18 @@ const corsHeaders = {
|
|
|
380
515
|
|
|
381
516
|
/**
|
|
382
517
|
* Create a Next.js App Router API route handler for live editing.
|
|
383
|
-
*
|
|
518
|
+
*
|
|
384
519
|
* Usage in app/api/live-edit/route.js:
|
|
385
|
-
*
|
|
520
|
+
*
|
|
386
521
|
* const { createLiveEditHandler } = require('@codedesignai/nextjs-live-edit-plugin/live-edit-handler');
|
|
387
522
|
* const { POST, OPTIONS } = createLiveEditHandler();
|
|
388
523
|
* module.exports = { POST, OPTIONS };
|
|
389
|
-
*
|
|
524
|
+
*
|
|
390
525
|
* Or for ES modules (app/api/live-edit/route.ts):
|
|
391
|
-
*
|
|
526
|
+
*
|
|
392
527
|
* import { createLiveEditHandler } from '@codedesignai/nextjs-live-edit-plugin/live-edit-handler';
|
|
393
528
|
* export const { POST, OPTIONS } = createLiveEditHandler();
|
|
394
|
-
*
|
|
529
|
+
*
|
|
395
530
|
* @param {Object} options
|
|
396
531
|
* @param {string[]} options.sourceDirs - Directories to search for source files
|
|
397
532
|
* @param {string} options.projectRoot - Project root directory
|
|
@@ -406,7 +541,10 @@ function createLiveEditHandler(options = {}) {
|
|
|
406
541
|
// Only allow in development
|
|
407
542
|
if (process.env.NODE_ENV === 'production') {
|
|
408
543
|
return new Response(
|
|
409
|
-
JSON.stringify({
|
|
544
|
+
JSON.stringify({
|
|
545
|
+
success: false,
|
|
546
|
+
error: 'Live editing is only available in development mode',
|
|
547
|
+
}),
|
|
410
548
|
{ status: 403, headers: { 'Content-Type': 'application/json', ...corsHeaders } }
|
|
411
549
|
);
|
|
412
550
|
}
|
|
@@ -416,24 +554,24 @@ function createLiveEditHandler(options = {}) {
|
|
|
416
554
|
console.log('🔄 Received enhanced live edit request:', JSON.stringify(data, null, 2));
|
|
417
555
|
|
|
418
556
|
const result = await handleEnhancedTextUpdate(data, handlerOptions);
|
|
419
|
-
|
|
557
|
+
|
|
420
558
|
if (result.success) {
|
|
421
|
-
return new Response(
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
);
|
|
559
|
+
return new Response(JSON.stringify({ success: true, message: result.message }), {
|
|
560
|
+
status: 200,
|
|
561
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
562
|
+
});
|
|
425
563
|
} else {
|
|
426
|
-
return new Response(
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
);
|
|
564
|
+
return new Response(JSON.stringify({ success: false, error: result.error }), {
|
|
565
|
+
status: 400,
|
|
566
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
567
|
+
});
|
|
430
568
|
}
|
|
431
569
|
} catch (error) {
|
|
432
570
|
console.error('❌ Error processing enhanced live edit request:', error);
|
|
433
|
-
return new Response(
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
);
|
|
571
|
+
return new Response(JSON.stringify({ success: false, error: error.message }), {
|
|
572
|
+
status: 500,
|
|
573
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
574
|
+
});
|
|
437
575
|
}
|
|
438
576
|
}
|
|
439
577
|
|
|
@@ -449,7 +587,7 @@ function createLiveEditHandler(options = {}) {
|
|
|
449
587
|
|
|
450
588
|
/**
|
|
451
589
|
* Create a Pages Router API handler (for pages/api/live-edit.js)
|
|
452
|
-
*
|
|
590
|
+
*
|
|
453
591
|
* Usage:
|
|
454
592
|
* const { createPagesApiHandler } = require('@codedesignai/nextjs-live-edit-plugin/live-edit-handler');
|
|
455
593
|
* module.exports = createPagesApiHandler();
|
|
@@ -466,21 +604,20 @@ function createPagesApiHandler(options = {}) {
|
|
|
466
604
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
467
605
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
468
606
|
|
|
469
|
-
// Handle OPTIONS preflight
|
|
470
607
|
if (req.method === 'OPTIONS') {
|
|
471
608
|
res.status(200).end();
|
|
472
609
|
return;
|
|
473
610
|
}
|
|
474
611
|
|
|
475
|
-
// Only allow POST
|
|
476
612
|
if (req.method !== 'POST') {
|
|
477
613
|
res.status(405).json({ success: false, error: 'Method Not Allowed' });
|
|
478
614
|
return;
|
|
479
615
|
}
|
|
480
616
|
|
|
481
|
-
// Only allow in development
|
|
482
617
|
if (process.env.NODE_ENV === 'production') {
|
|
483
|
-
res
|
|
618
|
+
res
|
|
619
|
+
.status(403)
|
|
620
|
+
.json({ success: false, error: 'Live editing is only available in development mode' });
|
|
484
621
|
return;
|
|
485
622
|
}
|
|
486
623
|
|
|
@@ -489,7 +626,7 @@ function createPagesApiHandler(options = {}) {
|
|
|
489
626
|
console.log('🔄 Received enhanced live edit request:', JSON.stringify(data, null, 2));
|
|
490
627
|
|
|
491
628
|
const result = await handleEnhancedTextUpdate(data, handlerOptions);
|
|
492
|
-
|
|
629
|
+
|
|
493
630
|
if (result.success) {
|
|
494
631
|
res.status(200).json({ success: true, message: result.message });
|
|
495
632
|
} else {
|
|
@@ -502,6 +639,8 @@ function createPagesApiHandler(options = {}) {
|
|
|
502
639
|
};
|
|
503
640
|
}
|
|
504
641
|
|
|
642
|
+
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
643
|
+
|
|
505
644
|
module.exports = {
|
|
506
645
|
handleEnhancedTextUpdate,
|
|
507
646
|
validateLocationInSource,
|
package/package.json
CHANGED
package/source-mapper-loader.js
CHANGED
|
@@ -5,17 +5,41 @@ const _traverse = require('@babel/traverse');
|
|
|
5
5
|
// Handle both ES module and CommonJS exports
|
|
6
6
|
const traverse = _traverse.default || _traverse;
|
|
7
7
|
|
|
8
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tags whose content is never visible text on the page.
|
|
12
|
+
* We skip these entirely to avoid injecting data attributes into non-visible elements.
|
|
13
|
+
*/
|
|
14
|
+
const EXCLUDED_TAGS = new Set([
|
|
15
|
+
'script', 'style', 'meta', 'link', 'head', 'noscript',
|
|
16
|
+
'title', 'html', 'base', 'template',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Elements whose `src` attribute is an editable image source.
|
|
21
|
+
*/
|
|
22
|
+
const IMAGE_ELEMENTS = new Set(['img', 'Image']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Attributes that contain user-visible text worth making editable.
|
|
26
|
+
* Scoped to `placeholder` per the current requirements.
|
|
27
|
+
*/
|
|
28
|
+
const TEXT_ATTRIBUTES = new Set(['placeholder']);
|
|
29
|
+
|
|
30
|
+
// ─── Utility Functions ──────────────────────────────────────────────────────
|
|
31
|
+
|
|
8
32
|
/**
|
|
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)
|
|
33
|
+
* Convert character position to line number and character position within line.
|
|
34
|
+
* Returns 0-based indexing (line 0 = first line, char 0 = first character in line).
|
|
11
35
|
*/
|
|
12
36
|
function getLineAndCharPosition(content, charPosition) {
|
|
13
37
|
const lines = content.split('\n');
|
|
14
38
|
let currentPos = 0;
|
|
15
|
-
|
|
39
|
+
|
|
16
40
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
17
41
|
const lineLength = lines[lineIndex].length + 1; // +1 for newline character
|
|
18
|
-
|
|
42
|
+
|
|
19
43
|
if (charPosition < currentPos + lineLength) {
|
|
20
44
|
const charInLine = charPosition - currentPos;
|
|
21
45
|
return {
|
|
@@ -23,11 +47,11 @@ function getLineAndCharPosition(content, charPosition) {
|
|
|
23
47
|
character: charInLine // 0-based character position within line
|
|
24
48
|
};
|
|
25
49
|
}
|
|
26
|
-
|
|
50
|
+
|
|
27
51
|
currentPos += lineLength;
|
|
28
52
|
}
|
|
29
|
-
|
|
30
|
-
// Fallback
|
|
53
|
+
|
|
54
|
+
// Fallback — shouldn't happen with valid positions
|
|
31
55
|
return {
|
|
32
56
|
line: lines.length - 1,
|
|
33
57
|
character: lines[lines.length - 1].length
|
|
@@ -35,22 +59,91 @@ function getLineAndCharPosition(content, charPosition) {
|
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
/**
|
|
38
|
-
* Generate a unique element ID
|
|
62
|
+
* Generate a unique element ID.
|
|
63
|
+
* Uses a file-wide counter to avoid collisions when multiple same-tag
|
|
64
|
+
* elements appear on the same line.
|
|
39
65
|
*/
|
|
40
|
-
function generateElementId(filePath, lineNumber, elementName,
|
|
66
|
+
function generateElementId(filePath, lineNumber, elementName, counter) {
|
|
41
67
|
const fileName = path.basename(filePath, path.extname(filePath));
|
|
42
68
|
const cleanFileName = fileName.replace(/[^a-zA-Z0-9]/g, '');
|
|
43
|
-
return `${cleanFileName}-${elementName}-L${lineNumber}-${
|
|
69
|
+
return `${cleanFileName}-${elementName}-L${lineNumber}-${counter}`;
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
/**
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
73
|
+
* Build a region descriptor for injection into data-source-loc.
|
|
74
|
+
*/
|
|
75
|
+
function makeRegion(source, file, start, end, text, type) {
|
|
76
|
+
const startPos = getLineAndCharPosition(source, start);
|
|
77
|
+
const endPos = getLineAndCharPosition(source, end);
|
|
78
|
+
return {
|
|
79
|
+
file,
|
|
80
|
+
start,
|
|
81
|
+
end,
|
|
82
|
+
text,
|
|
83
|
+
type,
|
|
84
|
+
line: startPos.line,
|
|
85
|
+
character: startPos.character,
|
|
86
|
+
endLine: endPos.line,
|
|
87
|
+
endCharacter: endPos.character,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the tag name string from an opening element's name node.
|
|
93
|
+
* Handles JSXIdentifier (`div`) and JSXMemberExpression (`motion.div`).
|
|
94
|
+
* Returns null for unsupported name types (JSXNamespacedName, etc.).
|
|
95
|
+
*/
|
|
96
|
+
function resolveTagName(nameNode) {
|
|
97
|
+
if (nameNode.type === 'JSXIdentifier') {
|
|
98
|
+
return nameNode.name;
|
|
99
|
+
}
|
|
100
|
+
if (nameNode.type === 'JSXMemberExpression') {
|
|
101
|
+
return nameNode.property.name;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Extract a string literal value from a JSXAttribute's value node.
|
|
108
|
+
* Handles both bare StringLiterals and JSXExpressionContainer wrapping.
|
|
109
|
+
* Returns { value, start, end } or null.
|
|
110
|
+
*/
|
|
111
|
+
function extractStringFromAttrValue(valueNode) {
|
|
112
|
+
if (!valueNode) return null;
|
|
113
|
+
|
|
114
|
+
if (valueNode.type === 'StringLiteral') {
|
|
115
|
+
return {
|
|
116
|
+
value: valueNode.value,
|
|
117
|
+
start: valueNode.start + 1, // skip opening quote
|
|
118
|
+
end: valueNode.end - 1, // skip closing quote
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (valueNode.type === 'JSXExpressionContainer') {
|
|
123
|
+
const expr = valueNode.expression;
|
|
124
|
+
if (expr.type === 'StringLiteral') {
|
|
125
|
+
return {
|
|
126
|
+
value: expr.value,
|
|
127
|
+
start: expr.start + 1,
|
|
128
|
+
end: expr.end - 1,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Webpack Loader ─────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Custom webpack / Turbopack loader that injects AST-powered source mapping
|
|
140
|
+
* data into JSX elements for live editing.
|
|
50
141
|
*
|
|
51
|
-
* Injects `data-element-id` and `data-source-loc` attributes
|
|
52
|
-
*
|
|
53
|
-
*
|
|
142
|
+
* Injects `data-element-id` and `data-source-loc` attributes for:
|
|
143
|
+
* - Text content (JSXText, string expressions, static template literals)
|
|
144
|
+
* - Image sources (<img>, <Image> src)
|
|
145
|
+
* - Text-bearing attributes (placeholder)
|
|
146
|
+
* - Inline backgroundImage styles
|
|
54
147
|
*/
|
|
55
148
|
module.exports = function sourceMapperLoader(source) {
|
|
56
149
|
// Only run in development mode
|
|
@@ -60,7 +153,7 @@ module.exports = function sourceMapperLoader(source) {
|
|
|
60
153
|
|
|
61
154
|
const resourcePath = this.resourcePath;
|
|
62
155
|
const options = this.getOptions() || {};
|
|
63
|
-
|
|
156
|
+
|
|
64
157
|
// Configurable source directories (default: app, components, src)
|
|
65
158
|
const sourceDirs = options.sourceDirs || ['app', 'components', 'src'];
|
|
66
159
|
const projectRoot = options.projectRoot || process.cwd();
|
|
@@ -72,17 +165,24 @@ module.exports = function sourceMapperLoader(source) {
|
|
|
72
165
|
|
|
73
166
|
// Check if the file is in one of the configured source directories
|
|
74
167
|
const relativePath = path.relative(projectRoot, resourcePath);
|
|
75
|
-
const isInSourceDir = sourceDirs.some(
|
|
76
|
-
|
|
168
|
+
const isInSourceDir = sourceDirs.some(
|
|
169
|
+
dir => relativePath.startsWith(dir + path.sep) || relativePath.startsWith(dir + '/')
|
|
170
|
+
);
|
|
171
|
+
|
|
77
172
|
if (!isInSourceDir) {
|
|
78
173
|
return source;
|
|
79
174
|
}
|
|
80
175
|
|
|
81
|
-
//
|
|
82
|
-
const matchingDir = sourceDirs.find(
|
|
83
|
-
|
|
176
|
+
// Compute the relative path from the matching source dir (for element IDs / location data)
|
|
177
|
+
const matchingDir = sourceDirs.find(
|
|
178
|
+
dir => relativePath.startsWith(dir + path.sep) || relativePath.startsWith(dir + '/')
|
|
179
|
+
);
|
|
180
|
+
const relativeToSrcDir = matchingDir
|
|
181
|
+
? path.relative(matchingDir, relativePath)
|
|
182
|
+
: relativePath;
|
|
84
183
|
|
|
85
184
|
const modifications = [];
|
|
185
|
+
let elementCounter = 0; // file-wide counter to avoid ID collisions
|
|
86
186
|
|
|
87
187
|
try {
|
|
88
188
|
const ast = parser.parse(source, {
|
|
@@ -94,139 +194,206 @@ module.exports = function sourceMapperLoader(source) {
|
|
|
94
194
|
JSXElement(astPath) {
|
|
95
195
|
const { node } = astPath;
|
|
96
196
|
const openingElement = node.openingElement;
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
} else {
|
|
106
|
-
return; // Skip unsupported tag name types
|
|
107
|
-
}
|
|
108
|
-
|
|
197
|
+
|
|
198
|
+
// ── Resolve tag name ─────────────────────────────────────────────
|
|
199
|
+
const tagName = resolveTagName(openingElement.name);
|
|
200
|
+
if (!tagName) return; // unsupported name type
|
|
201
|
+
|
|
202
|
+
// ── Skip non-visible elements ────────────────────────────────────
|
|
203
|
+
if (EXCLUDED_TAGS.has(tagName.toLowerCase())) return;
|
|
204
|
+
|
|
109
205
|
const { line } = openingElement.loc.start;
|
|
110
206
|
|
|
111
|
-
//
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'src'
|
|
207
|
+
// Collect all editable regions for this element
|
|
208
|
+
const regions = [];
|
|
209
|
+
|
|
210
|
+
// ── 1. IMAGE SOURCES ─────────────────────────────────────────────
|
|
211
|
+
if (IMAGE_ELEMENTS.has(tagName)) {
|
|
212
|
+
const srcAttr = openingElement.attributes.find(
|
|
213
|
+
attr => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'src'
|
|
118
214
|
);
|
|
119
215
|
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
}
|
|
216
|
+
if (srcAttr && srcAttr.value) {
|
|
217
|
+
const extracted = extractStringFromAttrValue(srcAttr.value);
|
|
218
|
+
|
|
219
|
+
if (extracted) {
|
|
220
|
+
regions.push(
|
|
221
|
+
makeRegion(source, relativeToSrcDir, extracted.start, extracted.end, extracted.value, 'image-src')
|
|
222
|
+
);
|
|
143
223
|
}
|
|
224
|
+
// If src is a dynamic expression (variable, template literal with expressions), skip it
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── 2. TEXT CHILDREN ─────────────────────────────────────────────
|
|
229
|
+
// Process ALL children (not just the first) to capture every editable text segment.
|
|
230
|
+
for (const child of node.children) {
|
|
231
|
+
// 2a. Direct JSXText with non-whitespace content
|
|
232
|
+
if (child.type === 'JSXText') {
|
|
233
|
+
const trimmed = child.value.trim();
|
|
234
|
+
if (trimmed.length > 0) {
|
|
235
|
+
regions.push(
|
|
236
|
+
makeRegion(source, relativeToSrcDir, child.start, child.end, trimmed, 'text-content')
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
144
240
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
241
|
+
// 2b. String literal inside expression container: {"Hello"}
|
|
242
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
243
|
+
const expr = child.expression;
|
|
244
|
+
|
|
245
|
+
if (expr.type === 'StringLiteral') {
|
|
246
|
+
regions.push(
|
|
247
|
+
makeRegion(
|
|
248
|
+
source, relativeToSrcDir,
|
|
249
|
+
expr.start + 1, // skip opening quote
|
|
250
|
+
expr.end - 1, // skip closing quote
|
|
251
|
+
expr.value,
|
|
252
|
+
'string-expression'
|
|
253
|
+
)
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 2c. Static template literal (no expressions): {`Hello`}
|
|
258
|
+
if (
|
|
259
|
+
expr.type === 'TemplateLiteral' &&
|
|
260
|
+
expr.expressions.length === 0 &&
|
|
261
|
+
expr.quasis.length === 1
|
|
262
|
+
) {
|
|
263
|
+
const quasi = expr.quasis[0];
|
|
264
|
+
const text = quasi.value.cooked || quasi.value.raw;
|
|
265
|
+
if (text.trim().length > 0) {
|
|
266
|
+
regions.push(
|
|
267
|
+
makeRegion(
|
|
268
|
+
source, relativeToSrcDir,
|
|
269
|
+
quasi.start, // template quasi start is after the backtick
|
|
270
|
+
quasi.end, // template quasi end is before the closing backtick
|
|
271
|
+
text,
|
|
272
|
+
'template-literal'
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
}
|
|
172
276
|
}
|
|
173
277
|
}
|
|
174
|
-
return; // Done processing this image element
|
|
175
278
|
}
|
|
176
279
|
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
280
|
+
// ── 3. TEXT-BEARING ATTRIBUTES ───────────────────────────────────
|
|
281
|
+
for (const attr of openingElement.attributes) {
|
|
282
|
+
if (attr.type !== 'JSXAttribute' || !attr.name) continue;
|
|
283
|
+
|
|
284
|
+
const attrName = attr.name.name;
|
|
285
|
+
if (!TEXT_ATTRIBUTES.has(attrName)) continue;
|
|
286
|
+
|
|
287
|
+
const extracted = extractStringFromAttrValue(attr.value);
|
|
288
|
+
if (extracted && extracted.value.trim().length > 0) {
|
|
289
|
+
regions.push(
|
|
290
|
+
makeRegion(
|
|
291
|
+
source, relativeToSrcDir,
|
|
292
|
+
extracted.start, extracted.end,
|
|
293
|
+
extracted.value,
|
|
294
|
+
`attribute:${attrName}`
|
|
295
|
+
)
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── 4. INLINE backgroundImage STYLE ──────────────────────────────
|
|
301
|
+
const styleAttr = openingElement.attributes.find(
|
|
302
|
+
attr => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'style'
|
|
181
303
|
);
|
|
182
304
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
305
|
+
if (styleAttr && styleAttr.value && styleAttr.value.type === 'JSXExpressionContainer') {
|
|
306
|
+
const styleExpr = styleAttr.value.expression;
|
|
307
|
+
|
|
308
|
+
// style={{ backgroundImage: 'url(...)' }} or style={{ backgroundImage: "url(...)" }}
|
|
309
|
+
if (styleExpr.type === 'ObjectExpression') {
|
|
310
|
+
for (const prop of styleExpr.properties) {
|
|
311
|
+
if (
|
|
312
|
+
prop.type === 'ObjectProperty' &&
|
|
313
|
+
prop.key &&
|
|
314
|
+
(prop.key.name === 'backgroundImage' || prop.key.value === 'backgroundImage')
|
|
315
|
+
) {
|
|
316
|
+
if (prop.value.type === 'StringLiteral') {
|
|
317
|
+
const bgValue = prop.value.value;
|
|
318
|
+
// Extract URL from url('...') or url("...") or url(...)
|
|
319
|
+
const urlMatch = bgValue.match(/url\(\s*['"]?([^'")\s]+)['"]?\s*\)/);
|
|
320
|
+
if (urlMatch) {
|
|
321
|
+
const url = urlMatch[1];
|
|
322
|
+
// Find the position of the URL within the full string literal value
|
|
323
|
+
const urlIndexInValue = bgValue.indexOf(url);
|
|
324
|
+
// prop.value.start + 1 skips the opening quote of the string literal
|
|
325
|
+
const urlStart = prop.value.start + 1 + urlIndexInValue;
|
|
326
|
+
const urlEnd = urlStart + url.length;
|
|
327
|
+
|
|
328
|
+
regions.push(
|
|
329
|
+
makeRegion(
|
|
330
|
+
source, relativeToSrcDir,
|
|
331
|
+
urlStart, urlEnd,
|
|
332
|
+
url,
|
|
333
|
+
'style-background-image'
|
|
334
|
+
)
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// style={{ backgroundImage: `url(...)` }} — static template literal
|
|
340
|
+
if (
|
|
341
|
+
prop.value.type === 'TemplateLiteral' &&
|
|
342
|
+
prop.value.expressions.length === 0 &&
|
|
343
|
+
prop.value.quasis.length === 1
|
|
344
|
+
) {
|
|
345
|
+
const quasi = prop.value.quasis[0];
|
|
346
|
+
const bgValue = quasi.value.cooked || quasi.value.raw;
|
|
347
|
+
const urlMatch = bgValue.match(/url\(\s*['"]?([^'")\s]+)['"]?\s*\)/);
|
|
348
|
+
if (urlMatch) {
|
|
349
|
+
const url = urlMatch[1];
|
|
350
|
+
const urlIndexInValue = bgValue.indexOf(url);
|
|
351
|
+
const urlStart = quasi.start + urlIndexInValue;
|
|
352
|
+
const urlEnd = urlStart + url.length;
|
|
353
|
+
|
|
354
|
+
regions.push(
|
|
355
|
+
makeRegion(
|
|
356
|
+
source, relativeToSrcDir,
|
|
357
|
+
urlStart, urlEnd,
|
|
358
|
+
url,
|
|
359
|
+
'style-background-image'
|
|
360
|
+
)
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── INJECT ───────────────────────────────────────────────────────
|
|
370
|
+
if (regions.length > 0) {
|
|
371
|
+
const currentId = generateElementId(relativeToSrcDir, line, tagName, elementCounter++);
|
|
372
|
+
|
|
373
|
+
const insertPosition = openingElement.selfClosing
|
|
374
|
+
? openingElement.end - 2 // Before '/>'
|
|
375
|
+
: openingElement.end - 1; // Before '>'
|
|
376
|
+
|
|
377
|
+
const locJson = JSON.stringify(regions).replace(/'/g, ''');
|
|
378
|
+
const attributes = ` data-element-id="${currentId}" data-source-loc='${locJson}'`;
|
|
379
|
+
|
|
380
|
+
modifications.push({ position: insertPosition, text: attributes });
|
|
381
|
+
}
|
|
216
382
|
},
|
|
217
383
|
});
|
|
218
384
|
|
|
219
|
-
// Apply modifications from end to start to preserve positions
|
|
385
|
+
// Apply modifications from end to start to preserve character positions
|
|
220
386
|
if (modifications.length > 0) {
|
|
221
387
|
let transformedCode = source.split('');
|
|
222
|
-
modifications
|
|
223
|
-
|
|
224
|
-
|
|
388
|
+
modifications
|
|
389
|
+
.sort((a, b) => b.position - a.position)
|
|
390
|
+
.forEach(mod => {
|
|
391
|
+
transformedCode.splice(mod.position, 0, mod.text);
|
|
392
|
+
});
|
|
225
393
|
return transformedCode.join('');
|
|
226
394
|
}
|
|
227
|
-
|
|
228
395
|
} catch (e) {
|
|
229
|
-
// Don't break the build on parse errors
|
|
396
|
+
// Don't break the build on parse errors — just skip transformation
|
|
230
397
|
console.error(`❌ [nextjs-live-edit] Failed to parse ${relativePath}:`, e.message);
|
|
231
398
|
}
|
|
232
399
|
|