@codedesignai/nextjs-live-edit-plugin 1.0.0

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