@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 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
  },
@@ -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 (line 0 = first line, char 0 = first character in line)
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; // +1 for newline character
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: charInLine
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
- * Returns validation result with details
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 { valid: false, error: `Invalid location: end position ${location.end} exceeds file length ${originalContent.length}` };
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 structure mismatch: no JSXText node found at position ${location.start}-${location.end}. Source structure may have changed.`
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
- const actualStart = valueNode.start + 1;
110
- const actualEnd = valueNode.end - 1;
111
-
112
- if (actualStart === location.start && actualEnd === location.end) {
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 structure mismatch: no img src attribute found at position ${location.start}-${location.end}. Source structure may have changed.`
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?:\/\//, // 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
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
- const isValid = validPatterns.some(pattern => pattern.test(url));
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
- * PRODUCTION-READY with full validation and rollback capability
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 (default: ['app', 'components', 'src'])
201
- * @param {string} options.projectRoot - Project root directory (default: process.cwd())
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 { sourceDirs = ['app', 'components', 'src'], projectRoot = process.cwd() } = options;
206
-
207
- // Normalize content field - handle both 'content' and 'imageUrl'
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:', { hasElement: !!element, hasContent: !!content, hasImageUrl: !!imageUrl, updateType });
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 { success: false, error: 'No source location data available - element may not have been compiled with source mapping' };
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; // Backup for rollback
331
+ let originalContent = null;
235
332
  let fullFilePath = null;
236
333
 
237
334
  try {
238
- // Parse the location data (it's a JSON string)
239
- const location = typeof sourceLocAttr === 'string'
240
- ? JSON.parse(sourceLocAttr.replace(/&apos;/g, "'"))
241
- : sourceLocAttr;
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(/&apos;/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 (!location || !location.file || typeof location.start !== 'number' || typeof location.end !== 'number') {
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(`🎯 Updating ${location.file} [${locationType}] at characters ${location.start}-${location.end}`);
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, extract the src value
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, extract text from HTML if needed
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
- // No need to manually trigger HMR like in Vite
343
-
344
- const updateLabel = locationType === 'image-src' ? '🖼️ Image' : '📝 Text';
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({ success: false, error: 'Live editing is only available in development mode' }),
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
- JSON.stringify({ success: true, message: result.message }),
423
- { status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } }
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
- JSON.stringify({ success: false, error: result.error }),
428
- { status: 400, headers: { 'Content-Type': 'application/json', ...corsHeaders } }
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
- JSON.stringify({ success: false, error: error.message }),
435
- { status: 500, headers: { 'Content-Type': 'application/json', ...corsHeaders } }
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.status(403).json({ success: false, error: 'Live editing is only available in development mode' });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedesignai/nextjs-live-edit-plugin",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Next.js plugin for live editing React components with AST-powered source mapping",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -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 - shouldn't happen with valid positions
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, index = 0) {
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}-${index}`;
69
+ return `${cleanFileName}-${elementName}-L${lineNumber}-${counter}`;
44
70
  }
45
71
 
46
72
  /**
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.
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 into:
52
- * - Text content elements (JSXText nodes)
53
- * - Image elements (<img> and <Image>) with src attributes
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(dir => relativePath.startsWith(dir + path.sep) || relativePath.startsWith(dir + '/'));
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
- // 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;
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
- // 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
-
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
- // === 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'
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 (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
- }
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
- 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, "&apos;")}'`;
170
-
171
- modifications.push({ position: insertPosition, text: attributes });
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
- // === 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
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
- // 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, "&apos;")}'`;
214
-
215
- modifications.push({ position: insertPosition, text: attributes });
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, '&apos;');
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.sort((a, b) => b.position - a.position).forEach(mod => {
223
- transformedCode.splice(mod.position, 0, mod.text);
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 - just skip transformation
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