"添加前端模板和运行代码模块"

This commit is contained in:
Codex
2026-06-01 13:43:09 +08:00
parent 24045274ad
commit 8092c4b1f8
587 changed files with 99761 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
import * as babel from '@babel/standalone';
import { createSourceMappingPlugin } from './sourceMapper.js';
import type { DesignModeOptions } from '../types';
export function transformSourceCode(
code: string,
id: string,
options: Required<DesignModeOptions>
): string {
try {
// Handle ESM/CJS interop
const babelApi = (babel as any).default || babel;
// Parse source into AST (via Babel transform)
// Use a single transform call to ensure our plugin runs before presets compile JSX
const result = babelApi.transform(code, {
ast: false, // We don't need the AST output
code: true,
sourceType: 'module',
filename: id,
presets: [
'typescript',
['react', {
runtime: 'automatic', // Use automatic JSX runtime (React 17+)
development: false, // Use production runtime for better performance
}]
],
plugins: [
createSourceMappingPlugin(id, options)
],
parserOpts: {
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
createParenthesizedExpressions: true
}
});
return (result && result.code) || code;
} catch (error) {
if (options.verbose) {
console.warn(`[appdev-design-mode] AST transformation failed for ${id}:`, error);
}
return code;
}
}

View File

@@ -0,0 +1,101 @@
import { IncomingMessage, ServerResponse } from 'http';
import { performUpdate, UpdateRequest } from './codeUpdater.js';
export interface BatchUpdateRequest {
updates: UpdateRequest[];
}
export interface BatchUpdateResult {
results: Array<{
success: boolean;
message?: string;
filePath: string;
type: 'style' | 'content';
}>;
summary: {
total: number;
success: number;
failed: number;
};
}
export async function handleBatchUpdate(req: IncomingMessage, res: ServerResponse, root: string) {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end('Method Not Allowed');
return;
}
const body = await readBody(req);
try {
const data = JSON.parse(body) as BatchUpdateRequest;
const { updates } = data;
if (!Array.isArray(updates)) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid request: updates must be an array' }));
return;
}
const results = [];
let successCount = 0;
let failedCount = 0;
for (const update of updates) {
try {
const result = performUpdate(root, update);
if (result.success) {
successCount++;
} else {
failedCount++;
}
results.push({
success: result.success,
message: result.message,
filePath: update.filePath,
type: update.type
});
} catch (error) {
failedCount++;
results.push({
success: false,
message: String(error),
filePath: update.filePath,
type: update.type
});
}
}
const response: BatchUpdateResult = {
results,
summary: {
total: updates.length,
success: successCount,
failed: failedCount
}
};
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(response));
} catch (error) {
console.error('[appdev-design-mode] Error handling batch update:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: String(error) }));
}
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
resolve(body);
});
req.on('error', reject);
});
}

View File

@@ -0,0 +1,160 @@
import * as fs from 'fs';
import path from 'path';
import { IncomingMessage, ServerResponse } from 'http';
export interface UpdateRequest {
filePath: string;
line: number;
column: number;
newValue: string;
type: 'style' | 'content';
originalValue?: string; // Original value for matching/replacement
}
export function performUpdate(root: string, data: UpdateRequest): { success: boolean, message: string } {
const { filePath, newValue, type, originalValue } = data;
// Resolve absolute path
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath);
// Security: Validate path is within project root (prevent path traversal)
if (!isPathWithinRoot(root, absolutePath)) {
console.error('[appdev-design-mode] Security: Path traversal attempt blocked:', absolutePath);
return { success: false, message: 'Access denied: path outside project root' };
}
if (!fs.existsSync(absolutePath)) {
console.error('[appdev-design-mode] File not found:', absolutePath);
return { success: false, message: 'File not found' };
}
// Security: Check file size limit (10MB)
const stats = fs.statSync(absolutePath);
const maxFileSize = 10 * 1024 * 1024; // 10MB
if (stats.size > maxFileSize) {
console.error('[appdev-design-mode] File too large:', absolutePath, stats.size);
return { success: false, message: 'File too large (max 10MB)' };
}
// Security: Validate file extension (only allow source files)
const allowedExtensions = ['.js', '.jsx', '.ts', '.tsx', '.vue'];
const ext = path.extname(absolutePath).toLowerCase();
if (!allowedExtensions.includes(ext)) {
console.error('[appdev-design-mode] Invalid file type:', absolutePath);
return { success: false, message: 'Invalid file type' };
}
// Security: Validate newValue length (prevent DoS)
const maxValueLength = 100000; // 100KB
if (newValue.length > maxValueLength) {
console.error('[appdev-design-mode] Value too large:', newValue.length);
return { success: false, message: 'Value too large (max 100KB)' };
}
let sourceCode = fs.readFileSync(absolutePath, 'utf-8');
let updated = false;
if (type === 'content') {
if (originalValue && originalValue !== newValue) {
// Try to match with original value first
const escapedOriginal = originalValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(>\\s*)${escapedOriginal}(\\s*<)`, 'g');
const newSourceCode = sourceCode.replace(regex, `$1${newValue}$2`);
if (newSourceCode !== sourceCode) {
fs.writeFileSync(absolutePath, newSourceCode, 'utf-8');
updated = true;
} else {
// Fallback: If original value not found, try to find any similar content
// This handles cases where the file was already updated by HMR
console.warn('[appdev-design-mode] Original value not found, trying fallback match');
// Try to find the new value in the file (maybe it's already there)
if (sourceCode.includes(newValue)) {
updated = true; // Consider it successful since the desired state is already there
}
}
} else if (originalValue === newValue) {
// No change needed
updated = true;
} else {
console.warn('[appdev-design-mode] Missing originalValue for content update');
}
} else if (type === 'style') {
// TODO: Full style update pipeline
// Simple implementation: find className="..." and replace (AST would be more robust)
if (originalValue) {
// Try className="originalValue"
const escapedOriginal = originalValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Matches className="...", class="...", className={...}, etc.; simplified to className="value"
const regex = new RegExp(`(className=["'])${escapedOriginal}(["'])`, 'g');
const newSourceCode = sourceCode.replace(regex, `$1${newValue}$2`);
if (newSourceCode !== sourceCode) {
fs.writeFileSync(absolutePath, newSourceCode, 'utf-8');
updated = true;
}
} else {
// Style update requires originalValue (current implementation)
}
}
if (updated) {
return { success: true, message: 'File updated successfully' };
} else {
console.warn('[appdev-design-mode] Could not update file - no match found');
return { success: false, message: 'Could not locate content to update' };
}
}
export async function handleUpdate(req: IncomingMessage, res: ServerResponse, root: string) {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end('Method Not Allowed');
return;
}
const body = await readBody(req);
try {
const data = JSON.parse(body) as UpdateRequest;
const result = performUpdate(root, data);
if (result.success) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(result));
} else {
res.statusCode = 400;
res.end(JSON.stringify(result));
}
} catch (error) {
console.error('[appdev-design-mode] Error handling update:', error);
res.statusCode = 500;
res.end(JSON.stringify({ success: false, error: String(error) }));
}
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
resolve(body);
});
req.on('error', reject);
});
}
function isPathWithinRoot(rootDir: string, targetPath: string): boolean {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(targetPath);
if (resolvedRoot === resolvedTarget) {
return true;
}
const relativePath = path.relative(resolvedRoot, resolvedTarget);
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
}

View File

@@ -0,0 +1,974 @@
import type { ViteDevServer } from 'vite';
import type { DesignModeOptions } from '../types';
import * as fs from 'fs';
import * as path from 'path';
import { applyVueSfcTemplateUpdate } from './vueSfcUpdater.js';
export function createServerMiddleware(
options: Required<DesignModeOptions>,
rootDir: string
) {
const enableBackup = options.enableBackup === true;
const enableHistory = options.enableHistory === true;
return async (req: any, res: any) => {
const url = new URL(req.url, 'http://localhost');
try {
switch (url.pathname) {
// Core endpoints
case '/get-source':
await handleGetSource(url, res, rootDir);
break;
case '/modify-source':
await handleModifySource(req, res, rootDir);
break;
case '/health':
await handleHealthCheck(res);
break;
// Extended endpoints
case '/update':
await handleUpdate(req, res, rootDir, enableBackup);
break;
case '/batch-update':
await handleBatchUpdate(req, res, rootDir, enableBackup, enableHistory);
break;
case '/batch-update-status':
await handleBatchUpdateStatus(req, res, rootDir, enableHistory);
break;
case '/undo':
await handleUndo(req, res, rootDir, enableBackup);
break;
case '/redo':
await handleRedo(req, res, rootDir);
break;
case '/get-history':
await handleGetHistory(req, res, rootDir, enableHistory);
break;
case '/validate-update':
await handleValidateUpdate(req, res, rootDir);
break;
default:
res.statusCode = 404;
res.end(JSON.stringify({
error: 'Not found',
requestedPath: url.pathname,
availableEndpoints: [
'/get-source',
'/modify-source',
'/update',
'/batch-update',
'/batch-update-status',
'/undo',
'/redo',
'/get-history',
'/validate-update',
'/health'
]
}));
}
} catch (error) {
console.error('[DesignMode] Server middleware error:', error);
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error',
stack: process.env.NODE_ENV === 'development' ? (error instanceof Error ? error.stack : undefined) : undefined
})
);
}
};
}
/**
* GET /get-source — enriched source snapshot for an element id.
*/
async function handleGetSource(url: URL, res: any, rootDir: string) {
const elementId = url.searchParams.get('elementId');
if (!elementId) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing elementId parameter' }));
return;
}
try {
// Parse elementId → file position
const sourceInfo = parseElementId(elementId);
if (!sourceInfo) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid elementId format' }));
return;
}
const filePath = path.resolve(rootDir, sourceInfo.fileName);
// Security: Validate path is within project root (prevent path traversal)
if (!isPathWithinRoot(rootDir, filePath)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Access denied: path outside project root' }));
return;
}
try {
await fs.promises.access(filePath, fs.constants.F_OK);
} catch {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Source file not found', filePath }));
return;
}
const fileContent = await fs.promises.readFile(filePath, 'utf-8');
// Target line + context window
const lines = fileContent.split('\n');
const targetLine = Math.max(0, sourceInfo.lineNumber - 1);
const contextLines = lines.slice(
Math.max(0, targetLine - 5),
Math.min(lines.length, targetLine + 5)
);
// Response payload
const response = {
sourceInfo: {
...sourceInfo,
fileContent: fileContent,
contextLines: contextLines,
targetLineContent: lines[targetLine] || '',
totalLines: lines.length,
fileExists: true
},
elementMetadata: {
tagName: extractTagNameFromLine(lines[targetLine]),
estimatedClassName: extractClassNameFromLine(lines[targetLine]),
lineContext: getLineContext(lines, targetLine)
},
fileStats: {
size: fileContent.length,
lastModified: (await fs.promises.stat(filePath)).mtime.getTime()
}
};
res.statusCode = 200;
res.end(JSON.stringify(response));
} catch (error) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error'
})
);
}
}
/**
* POST /modify-source — legacy style update.
*/
async function handleModifySource(req: any, res: any, rootDir: string) {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const { elementId, newStyles, oldStyles } = JSON.parse(body);
if (!elementId || newStyles === undefined) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing required parameters' }));
return;
}
// Parse elementId
const sourceInfo = parseElementId(elementId);
if (!sourceInfo) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid elementId format' }));
return;
}
const filePath = path.resolve(rootDir, sourceInfo.fileName);
// Security: Validate path is within project root (prevent path traversal)
if (!isPathWithinRoot(rootDir, filePath)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Access denied: path outside project root' }));
return;
}
const fileContent = await fs.promises.readFile(filePath, 'utf-8');
const updatedContent = await smartReplaceInSource(
fileContent,
{
lineNumber: sourceInfo.lineNumber,
columnNumber: sourceInfo.columnNumber,
newValue: newStyles,
originalValue: oldStyles || '',
type: 'style',
},
rootDir,
filePath
);
// Persist
await fs.promises.writeFile(filePath, updatedContent, 'utf-8');
res.statusCode = 200;
res.end(
JSON.stringify({
success: true,
message: 'Source modified successfully',
sourceInfo,
})
);
} catch (error) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error',
})
);
}
});
}
/**
* GET /health
*/
async function handleHealthCheck(res: any) {
res.statusCode = 200;
res.end(
JSON.stringify({
status: 'ok',
timestamp: Date.now(),
plugin: '@xagi/vite-plugin-design-mode',
})
);
}
/**
* POST /update — style, content, or attribute.
*/
async function handleUpdate(req: any, res: any, rootDir: string, enableBackup: boolean = false) {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const updateData = JSON.parse(body);
const { filePath, line, column, newValue, originalValue, type } = updateData;
// Validate body
if (!filePath || line === undefined || column === undefined || newValue === undefined || !type) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing required parameters' }));
return;
}
if (!['style', 'content', 'attribute'].includes(type)) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid update type' }));
return;
}
const fullFilePath = path.resolve(rootDir, filePath);
try {
await fs.promises.access(fullFilePath, fs.constants.F_OK);
} catch {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Source file not found', filePath }));
return;
}
const fileContent = await fs.promises.readFile(fullFilePath, 'utf-8');
const lines = fileContent.split('\n');
const targetLine = Math.max(0, line - 1);
if (targetLine >= lines.length) {
res.statusCode = 400;
res.end(JSON.stringify({ error: `Line ${line} exceeds file length (${lines.length} lines)` }));
return;
}
// Line-aware replace
const updatedContent = await smartReplaceInSource(
fileContent,
{
lineNumber: line,
columnNumber: column,
newValue,
originalValue,
type
},
rootDir,
fullFilePath
);
// Optional .backup copy
let backupPath: string | undefined;
if (enableBackup) {
backupPath = `${fullFilePath}.backup.${Date.now()}`;
await fs.promises.writeFile(backupPath, fileContent, 'utf-8');
}
// Persist
await fs.promises.writeFile(fullFilePath, updatedContent, 'utf-8');
// Audit record (in-memory response)
const updateRecord = {
id: `update_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
filePath,
line,
column,
type,
newValue,
originalValue,
timestamp: Date.now(),
backupPath: backupPath || null
};
res.statusCode = 200;
res.end(
JSON.stringify({
success: true,
message: 'Update completed successfully',
updateRecord,
affectedLines: {
before: lines[targetLine],
after: updatedContent.split('\n')[targetLine]
}
})
);
} catch (error) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error',
details: error instanceof Error ? error.stack : undefined
})
);
}
});
}
/**
* POST /batch-update
*/
async function handleBatchUpdate(req: any, res: any, rootDir: string, enableBackup: boolean = false, enableHistory: boolean = false) {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const { updates } = JSON.parse(body);
if (!Array.isArray(updates) || updates.length === 0) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'No updates provided or invalid format' }));
return;
}
// Validate each item
const validationResults = await Promise.allSettled(
updates.map(update => validateUpdateRequest(update, rootDir))
);
// Batch session id
const batchId = `batch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const batchSession: {
id: string;
timestamp: number;
totalUpdates: number;
successfulUpdates: number;
failedUpdates: number;
updates: any[];
} = {
id: batchId,
timestamp: Date.now(),
totalUpdates: updates.length,
successfulUpdates: 0,
failedUpdates: 0,
updates: []
};
// Apply updates sequentially
const results = [];
for (let i = 0; i < updates.length; i++) {
const update = updates[i];
const validation = validationResults[i];
if (validation.status === 'rejected') {
results.push({
success: false,
error: validation.reason,
update
});
batchSession.failedUpdates++;
continue;
}
try {
const result = await processSingleUpdate(update, rootDir, batchId, enableBackup);
results.push(result);
if (result.success) {
batchSession.successfulUpdates++;
} else {
batchSession.failedUpdates++;
}
batchSession.updates.push(result);
} catch (error) {
results.push({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
update
});
batchSession.failedUpdates++;
batchSession.updates.push(results[results.length - 1]);
}
}
// Persist session JSON when history enabled
if (enableHistory) {
const sessionFile = path.join(rootDir, '.appdev_batch_sessions.json');
let sessions = {};
try {
const existingSessionsContent = await fs.promises.readFile(sessionFile, 'utf-8');
sessions = JSON.parse(existingSessionsContent);
} catch {
// Missing or unreadable → start empty
}
(sessions as any)[batchId] = batchSession;
await fs.promises.writeFile(sessionFile, JSON.stringify(sessions, null, 2), 'utf-8');
}
res.statusCode = 200;
res.end(
JSON.stringify({
batchId,
success: batchSession.failedUpdates === 0,
summary: {
total: updates.length,
successful: batchSession.successfulUpdates,
failed: batchSession.failedUpdates
},
results
})
);
} catch (error) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error'
})
);
}
});
}
/**
* GET /batch-update-status
*/
async function handleBatchUpdateStatus(req: any, res: any, rootDir: string, enableHistory: boolean = false) {
const batchId = req.url.split('?')[1]?.split('=')[1];
if (!batchId) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing batchId' }));
return;
}
if (!enableHistory) {
res.statusCode = 400;
res.end(JSON.stringify({
error: 'History is disabled. Cannot query batch status without history enabled.',
enableHistory: false
}));
return;
}
try {
const sessionFile = path.join(rootDir, '.appdev_batch_sessions.json');
let sessions = {};
try {
const existingSessionsContent = await fs.promises.readFile(sessionFile, 'utf-8');
sessions = JSON.parse(existingSessionsContent);
} catch {
// Session file missing
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Batch session not found' }));
return;
}
const session = (sessions as any)[batchId];
if (!session) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Batch session not found' }));
return;
}
res.statusCode = 200;
res.end(JSON.stringify({
success: true,
session
}));
} catch (error) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error'
})
);
}
}
/**
* POST /undo — restore from backup when backups enabled.
*/
async function handleUndo(req: any, res: any, rootDir: string, enableBackup: boolean = false) {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const { batchId } = JSON.parse(body);
if (!batchId) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing batchId' }));
return;
}
if (!enableBackup) {
res.statusCode = 400;
res.end(JSON.stringify({
error: 'Backup is disabled. Cannot undo without backup files.',
enableBackup: false
}));
return;
}
// Find backup sidecar files
const backupFiles = await fs.promises.readdir(rootDir);
const matchingBackups = backupFiles
.filter(file => file.startsWith('.') && file.includes('.backup.') && file.includes(batchId));
if (matchingBackups.length === 0) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'No backup files found for this batch' }));
return;
}
// Pick lexicographically latest (timestamp suffix)
const latestBackup = matchingBackups.sort().pop()!;
const backupPath = path.join(rootDir, latestBackup);
const originalFile = backupPath.replace(/\.backup\.\d+$/, '');
// Restore from backup
const backupContent = await fs.promises.readFile(backupPath, 'utf-8');
await fs.promises.writeFile(originalFile, backupContent, 'utf-8');
await fs.promises.unlink(backupPath);
res.statusCode = 200;
res.end(
JSON.stringify({
success: true,
message: 'Undo completed successfully',
restoredFile: originalFile,
backupFile: backupPath
})
);
} catch (error) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error'
})
);
}
});
}
/**
* POST /redo — not implemented.
*/
async function handleRedo(req: any, res: any, rootDir: string) {
// Could re-apply updates from `.appdev_batch_sessions.json`
res.statusCode = 501;
res.end(JSON.stringify({ error: 'Redo operation not yet implemented' }));
}
/**
* GET /get-history
*/
async function handleGetHistory(req: any, res: any, rootDir: string, enableHistory: boolean = false) {
if (!enableHistory) {
res.statusCode = 400;
res.end(JSON.stringify({
error: 'History is disabled. Cannot get history without history enabled.',
enableHistory: false
}));
return;
}
try {
const sessionFile = path.join(rootDir, '.appdev_batch_sessions.json');
let sessions = {};
try {
const existingSessionsContent = await fs.promises.readFile(sessionFile, 'utf-8');
sessions = JSON.parse(existingSessionsContent);
} catch {
// No session file → empty history
}
res.statusCode = 200;
res.end(JSON.stringify({
success: true,
history: sessions
}));
} catch (error) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error'
})
);
}
}
/**
* POST /validate-update
*/
async function handleValidateUpdate(req: any, res: any, rootDir: string) {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const updateData = JSON.parse(body);
const validation = await validateUpdateRequest(updateData, rootDir);
res.statusCode = 200;
res.end(JSON.stringify({
success: true,
validation
}));
} catch (error) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error'
})
);
}
});
}
/**
* Helpers
*/
// Parse `elementId` → file + line + column
function parseElementId(
elementId: string
): { fileName: string; lineNumber: number; columnNumber: number } | null {
// Support IDs shaped like "<file>:<line>:<column>_<elementType>[_<index>]".
const match = elementId.match(/^(.*):(\d+):(\d+)_/);
if (!match) return null;
const fileName = match[1];
const lineNumber = parseInt(match[2], 10);
const columnNumber = parseInt(match[3], 10);
if (!fileName || isNaN(lineNumber) || isNaN(columnNumber)) return null;
return {
fileName,
lineNumber,
columnNumber,
};
}
// Line-oriented smart replace
async function smartReplaceInSource(
content: string,
options: {
lineNumber: number;
columnNumber: number;
newValue: string;
originalValue?: string;
type: 'style' | 'content' | 'attribute';
},
rootDir: string,
filePath?: string
): Promise<string> {
const lines = content.split('\n');
const targetLine = Math.max(0, options.lineNumber - 1);
if (targetLine >= lines.length) {
throw new Error(`Line ${options.lineNumber} exceeds file length`);
}
const line = lines[targetLine];
let newLine = line;
const isVueFile = Boolean(filePath && filePath.endsWith('.vue'));
if (isVueFile) {
return applyVueSfcTemplateUpdate(content, {
lineNumber: options.lineNumber,
columnNumber: options.columnNumber,
newValue: options.newValue,
originalValue: options.originalValue,
type: options.type,
});
}
try {
switch (options.type) {
case 'style':
newLine = await smartReplaceStyle(line, options);
break;
case 'content':
newLine = await smartReplaceContent(line, options);
break;
case 'attribute':
newLine = await smartReplaceAttribute(line, options);
break;
}
lines[targetLine] = newLine;
return lines.join('\n');
} catch (error) {
console.error('[DesignMode] Smart replace failed:', error);
return content; // Fallback to original content
}
}
async function smartReplaceStyle(line: string, options: any): Promise<string> {
const { newValue } = options;
// 1) className="..." or className='...'
const classNameRegex = /className=(["'])(.*?)\1/;
if (classNameRegex.test(line)) {
return line.replace(classNameRegex, `className=$1${newValue}$1`);
}
// 2) className={...} → coerce to static string (design mode output)
const classNameExpressionRegex = /className=\{([^}]*)\}/;
if (classNameExpressionRegex.test(line)) {
return line.replace(classNameExpressionRegex, `className="${newValue}"`);
}
// 3) No className: insert after opening tag name (<Foo or <div)
const tagMatch = line.match(/<([A-Z][a-zA-Z0-9.]*|[a-z][a-z0-9-]*)/);
if (tagMatch) {
const tagName = tagMatch[1];
return line.replace(tagName, `${tagName} className="${newValue}"`);
}
// 4) No safe match — never replace the whole line with `newValue`
console.warn('[DesignMode] Failed to match className or tag in line:', line);
throw new Error('Cannot find a valid location to insert className. The component might not support styling or has complex syntax.');
}
async function smartReplaceContent(line: string, options: any): Promise<string> {
if (options.originalValue && line.includes(options.originalValue)) {
return line.replace(
new RegExp(escapeRegExp(options.originalValue), 'g'),
options.newValue
);
}
// Between `>` and `<` when it matches original
const contentMatch = line.match(/>([^<]*)</);
if (contentMatch && contentMatch[1] === options.originalValue) {
return line.replace(contentMatch[0], `>${options.newValue}<`);
}
return options.newValue;
}
async function smartReplaceAttribute(line: string, options: any): Promise<string> {
return line.replace(
new RegExp(`${options.attributeName}="[^"]*"`),
`${options.attributeName}="${options.newValue}"`
);
}
async function validateUpdateRequest(update: any, rootDir: string): Promise<{ valid: boolean; errors: string[] }> {
const errors: string[] = [];
if (!update.filePath) errors.push('Missing filePath');
if (update.line === undefined) errors.push('Missing line');
if (update.column === undefined) errors.push('Missing column');
if (update.newValue === undefined) errors.push('Missing newValue');
if (!update.type) errors.push('Missing type');
// File must exist under root
if (update.filePath) {
const fullPath = path.resolve(rootDir, update.filePath);
try {
await fs.promises.access(fullPath, fs.constants.F_OK);
} catch {
errors.push(`File not found: ${update.filePath}`);
}
}
// type ∈ style | content | attribute
if (update.type && !['style', 'content', 'attribute'].includes(update.type)) {
errors.push(`Invalid update type: ${update.type}`);
}
return {
valid: errors.length === 0,
errors
};
}
// Single item inside batch
async function processSingleUpdate(update: any, rootDir: string, batchId: string, enableBackup: boolean = false): Promise<any> {
try {
const validation = await validateUpdateRequest(update, rootDir);
if (!validation.valid) {
return {
success: false,
error: validation.errors.join(', '),
update
};
}
const fullFilePath = path.resolve(rootDir, update.filePath);
const fileContent = await fs.promises.readFile(fullFilePath, 'utf-8');
const updatedContent = await smartReplaceInSource(
fileContent,
{
lineNumber: update.line,
columnNumber: update.column,
newValue: update.newValue,
originalValue: update.originalValue,
type: update.type
},
rootDir,
fullFilePath
);
if (enableBackup) {
const backupPath = `${fullFilePath}.backup.${Date.now()}`;
await fs.promises.writeFile(backupPath, fileContent, 'utf-8');
}
await fs.promises.writeFile(fullFilePath, updatedContent, 'utf-8');
return {
success: true,
update,
affectedLines: {
before: fileContent.split('\n')[update.line - 1],
after: updatedContent.split('\n')[update.line - 1]
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
update
};
}
}
// Heuristic: file touched in last 5 minutes
async function checkForModifications(filePath: string): Promise<boolean> {
try {
const stat = await fs.promises.stat(filePath);
const lastModified = stat.mtime.getTime();
const now = Date.now();
return (now - lastModified) < 5 * 60 * 1000;
} catch {
return false;
}
}
// Best-effort tag from a source line
function extractTagNameFromLine(line: string): string {
const tagMatch = line.match(/<(\w+)/);
return tagMatch ? tagMatch[1] : 'unknown';
}
// className="..." substring
function extractClassNameFromLine(line: string): string {
const classMatch = line.match(/className\s*=\s*["']([^"']+)["']/);
return classMatch ? classMatch[1] : '';
}
// Neighbor lines around index
function getLineContext(lines: string[], targetLine: number): { before: string; current: string; after: string } {
return {
before: lines[Math.max(0, targetLine - 1)] || '',
current: lines[targetLine] || '',
after: lines[Math.min(lines.length - 1, targetLine + 1)] || ''
};
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isPathWithinRoot(rootDir: string, targetPath: string): boolean {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(targetPath);
const relativePath = path.relative(resolvedRoot, resolvedTarget);
if (resolvedRoot === resolvedTarget) {
return true;
}
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
}

View File

@@ -0,0 +1,444 @@
import * as babel from '@babel/standalone';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
import type { DesignModeOptions } from '../types';
export interface SourceMappingInfo {
fileName: string;
lineNumber: number;
columnNumber: number;
elementType: string;
componentName?: string;
functionName?: string;
elementId: string;
attributePrefix: string;
importPath?: string; // Resolved import path for the component
isUIComponent?: boolean; // True when file is under components/ui (UI kit)
}
export interface JSXElementWithLoc extends t.JSXOpeningElement {
loc: t.SourceLocation;
}
// PluginItem type from Babel
type PluginItem = any;
type NodePath = any;
/**
* Whether the source file lives under a UI-kit folder (components/ui).
* Runtime selection prefers usage site over definition for these.
*/
function isUIComponentFile(filePath: string): boolean {
// Match /components/ui/ or \components\ui\
return /[/\\]components[/\\]ui[/\\]/.test(filePath);
}
/**
* Babel plugin factory: inject source-mapping data attributes.
*/
export function createSourceMappingPlugin(
fileName: string,
options: Required<DesignModeOptions>
): PluginItem {
const { attributePrefix } = options;
const imports: Record<string, string> = {}; // local name -> module specifier
return {
visitor: {
// 1) Collect import bindings
ImportDeclaration(path: NodePath) {
const { node } = path;
const sourceValue = node.source.value; // e.g. "@/components/ui/button"
node.specifiers.forEach((specifier: any) => {
if (t.isImportSpecifier(specifier)) {
// import { Button } from ...
imports[specifier.local.name] = sourceValue;
// console.log(`[DesignMode] Found import: ${specifier.local.name} from ${sourceValue}`);
} else if (t.isImportDefaultSpecifier(specifier)) {
// import Button from ...
imports[specifier.local.name] = sourceValue;
// console.log(`[DesignMode] Found default import: ${specifier.local.name} from ${sourceValue}`);
} else if (t.isImportNamespaceSpecifier(specifier)) {
// import * as UI from ...
imports[specifier.local.name] = sourceValue;
}
});
},
JSXOpeningElement(path: NodePath) {
const { node } = path;
const location = node.loc;
if (!location) return;
const componentInfo = extractComponentInfo(path);
const elementType = getJSXElementName(node.name);
// Resolve import path for JSX name (member expr uses object part only)
let importPath: string | undefined;
if (t.isJSXIdentifier(node.name)) {
importPath = imports[node.name.name];
} else if (t.isJSXMemberExpression(node.name) && t.isJSXIdentifier(node.name.object)) {
importPath = imports[node.name.object.name];
}
// if (importPath) {
// console.log(`[DesignMode] Injecting importPath for ${elementType}: ${importPath}`);
// }
const isUIComponent = isUIComponentFile(fileName);
const sourceInfo: SourceMappingInfo = {
fileName: fileName,
lineNumber: location.start.line,
columnNumber: location.start.column,
elementType: elementType,
componentName: componentInfo.componentName,
functionName: componentInfo.functionName,
elementId: generateElementId(node, fileName, location),
attributePrefix,
importPath,
isUIComponent
};
addSourceInfoAttribute(node, sourceInfo, options);
addPositionAttribute(node, location, options);
addElementIdAttribute(node, sourceInfo, options);
// Optional: extra per-field attrs (disabled to keep DOM small; info JSON holds all)
// addIndividualAttributes(node, sourceInfo, options);
// Check if content is static and add attribute
if (isStaticContent(path)) {
addStaticContentAttribute(node, path, options);
}
// Check if className is static (pure string literal) and add attribute
if (isStaticClassName(node)) {
addStaticClassAttribute(node, options);
}
},
JSXElement(path: NodePath) {
const { node } = path;
const { openingElement, children } = node;
if (!children || children.length === 0) return;
const hasStaticText = children.some((child: any) => t.isJSXText(child) && child.value.trim() !== '');
if (hasStaticText) {
const textChild = children.find((child: any) => t.isJSXText(child) && child.value.trim() !== '');
if (textChild && textChild.loc) {
// children-source: where static text came from (for pass-through components)
const childrenSourceValue = `${fileName}:${textChild.loc.start.line}:${textChild.loc.start.column}`;
const attributeName = `${attributePrefix}-children-source`;
openingElement.attributes.push(
t.jsxAttribute(
t.jsxIdentifier(attributeName),
t.stringLiteral(childrenSourceValue)
)
);
}
}
}
}
};
}
/**
* Best-effort enclosing React component / function name.
*/
function extractComponentInfo(path: NodePath): { componentName?: string; functionName?: string } {
const componentInfo: { componentName?: string; functionName?: string } = {};
// Nearest function or class
const functionParent = path.findParent((p: NodePath) =>
t.isFunctionDeclaration(p.node) ||
t.isArrowFunctionExpression(p.node) ||
t.isClassDeclaration(p.node)
);
if (functionParent) {
const node = functionParent.node;
if (t.isFunctionDeclaration(node) && node.id?.name) {
componentInfo.functionName = node.id.name;
// React: components are PascalCase
if (/^[A-Z]/.test(node.id.name)) {
componentInfo.componentName = node.id.name;
}
} else if (t.isArrowFunctionExpression(node)) {
componentInfo.functionName = 'anonymous-arrow-function';
} else if (t.isClassDeclaration(node) && node.id?.name) {
componentInfo.functionName = node.id.name;
componentInfo.componentName = node.id.name;
}
}
// Fallback: const Foo = ...
const variableParent = path.findParent((p: NodePath) =>
t.isVariableDeclarator(p.node)
);
if (variableParent && t.isVariableDeclarator(variableParent.node)) {
const id = variableParent.node.id;
if (t.isIdentifier(id)) {
componentInfo.functionName = id.name;
if (/^[A-Z]/.test(id.name)) {
componentInfo.componentName = id.name;
}
}
}
return componentInfo;
}
/**
* JSX tag name as string (identifier or member root).
*/
function getJSXElementName(name: any): string {
if (t.isJSXIdentifier(name)) {
return name.name;
} else if (t.isJSXMemberExpression(name)) {
return `${getJSXElementName(name.object)}`;
}
return 'unknown';
}
/**
* Stable id: file:line:col_tag#id
*/
function generateElementId(node: t.JSXOpeningElement, fileName: string, location: t.SourceLocation): string {
const tagName = getJSXElementName(node.name);
// const className = extractStringAttribute(node, 'className'); // omitted to shorten id
const id = extractStringAttribute(node, 'id');
const baseId = `${fileName}:${location.start.line}:${location.start.column}`;
const tag = tagName.toLowerCase();
// const cls = className ? className.replace(/\s+/g, '-') : '';
const elementId = id ? `#${id}` : '';
// return `${baseId}_${tag}${cls ? '_' + cls : ''}${elementId}`;
return `${baseId}_${tag}${elementId}`;
}
/**
* String literal JSX attribute value, if any.
*/
function extractStringAttribute(node: t.JSXOpeningElement, attributeName: string): string | null {
const attr = node.attributes.find(a =>
t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === attributeName
) as t.JSXAttribute;
if (attr && t.isStringLiteral(attr.value)) {
return attr.value.value;
}
return null;
}
/**
* Inject compact JSON on `{prefix}-info`.
*/
function addSourceInfoAttribute(node: t.JSXOpeningElement, sourceInfo: SourceMappingInfo, options: Required<DesignModeOptions>) {
const { attributePrefix } = options;
// Remove previous info attr
node.attributes = node.attributes.filter(a =>
!(t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === `${attributePrefix}-info`)
);
// Push new JSON attr
const attr = t.jSXAttribute(
t.jSXIdentifier(`${attributePrefix}-info`),
t.stringLiteral(JSON.stringify({
fileName: sourceInfo.fileName,
lineNumber: sourceInfo.lineNumber,
columnNumber: sourceInfo.columnNumber,
elementType: sourceInfo.elementType,
componentName: sourceInfo.componentName,
functionName: sourceInfo.functionName,
elementId: sourceInfo.elementId,
importPath: sourceInfo.importPath,
isUIComponent: sourceInfo.isUIComponent
}))
);
node.attributes.unshift(attr);
}
/**
* `{prefix}-position` shorthand.
*/
function addPositionAttribute(node: t.JSXOpeningElement, location: t.SourceLocation, options: Required<DesignModeOptions>) {
const { attributePrefix } = options;
node.attributes = node.attributes.filter(a =>
!(t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === `${attributePrefix}-position`)
);
const attr = t.jSXAttribute(
t.jSXIdentifier(`${attributePrefix}-position`),
t.stringLiteral(`${location.start.line}:${location.start.column}`)
);
node.attributes.unshift(attr);
}
/**
* `{prefix}-element-id`.
*/
function addElementIdAttribute(node: t.JSXOpeningElement, sourceInfo: SourceMappingInfo, options: Required<DesignModeOptions>) {
const { attributePrefix } = options;
node.attributes = node.attributes.filter(a =>
!(t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === `${attributePrefix}-element-id`)
);
const attr = t.jSXAttribute(
t.jSXIdentifier(`${attributePrefix}-element-id`),
t.stringLiteral(sourceInfo.elementId)
);
node.attributes.unshift(attr);
}
/**
* Legacy: one attribute per field (debug / queries).
*/
function addIndividualAttributes(node: t.JSXOpeningElement, sourceInfo: SourceMappingInfo, options: Required<DesignModeOptions>) {
const { attributePrefix } = options;
// Helper to add attribute if it doesn't exist
const addAttr = (name: string, value: string | number | undefined) => {
if (value === undefined) return;
node.attributes = node.attributes.filter(a =>
!(t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === name)
);
node.attributes.unshift(t.jSXAttribute(
t.jSXIdentifier(name),
t.stringLiteral(String(value))
));
};
addAttr(`${attributePrefix}-file`, sourceInfo.fileName);
addAttr(`${attributePrefix}-line`, sourceInfo.lineNumber);
addAttr(`${attributePrefix}-column`, sourceInfo.columnNumber);
addAttr(`${attributePrefix}-component`, sourceInfo.componentName);
addAttr(`${attributePrefix}-function`, sourceInfo.functionName);
addAttr(`${attributePrefix}-import`, sourceInfo.importPath);
}
/**
* True only when children are exclusively JSXText (no expr containers, nested tags, etc.).
*/
function isStaticContent(path: NodePath): boolean {
const { node } = path;
const element = path.parent; // JSXElement
if (!t.isJSXElement(element)) return false;
if (element.children.length === 0) return false;
return element.children.every(child => {
if (t.isJSXText(child)) {
return true;
}
return false;
});
}
/**
* `{prefix}-static-content="true"` when children are pure JSXText.
*/
function addStaticContentAttribute(node: t.JSXOpeningElement, path: NodePath, options: Required<DesignModeOptions>) {
const { attributePrefix } = options;
const attributeName = `${attributePrefix}-static-content`;
if (!isStaticContent(path)) {
return;
}
// Check if attribute already exists
const hasAttr = node.attributes.some(a =>
t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === attributeName
);
if (!hasAttr) {
node.attributes.unshift(t.jSXAttribute(
t.jSXIdentifier(attributeName),
t.stringLiteral('true')
));
}
}
/**
* Static className: absent, string literal, or expr with only static string/template without expressions.
* Dynamic: variables, cn(), conditional expr, template with ${}, etc.
*/
function isStaticClassName(node: t.JSXOpeningElement): boolean {
const classNameAttr = node.attributes.find(attr =>
t.isJSXAttribute(attr) &&
t.isJSXIdentifier(attr.name) &&
attr.name.name === 'className'
) as t.JSXAttribute | undefined;
if (!classNameAttr) {
return true;
}
const value = classNameAttr.value;
if (t.isStringLiteral(value)) {
return true;
}
if (t.isJSXExpressionContainer(value)) {
const expression = value.expression;
if (t.isStringLiteral(expression)) {
return true;
}
if (t.isTemplateLiteral(expression)) {
if (expression.expressions.length === 0) {
return true;
}
}
return false;
}
return true;
}
/**
* `{prefix}-static-class="true"` when className is static per `isStaticClassName`.
*/
function addStaticClassAttribute(node: t.JSXOpeningElement, options: Required<DesignModeOptions>) {
const { attributePrefix } = options;
const attributeName = `${attributePrefix}-static-class`;
// Check if attribute already exists
const hasAttr = node.attributes.some(a =>
t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === attributeName
);
if (!hasAttr) {
node.attributes.unshift(t.jSXAttribute(
t.jSXIdentifier(attributeName),
t.stringLiteral('true')
));
}
}

View File

@@ -0,0 +1,184 @@
import { NodeTypes, parse as parseTemplate } from '@vue/compiler-dom';
import { parse as parseSfc } from '@vue/compiler-sfc';
import type { DesignModeOptions } from '../types';
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function getLineAndColumnFromIndex(source: string, index: number): { line: number; column: number } {
const safeIndex = Math.max(0, Math.min(index, source.length));
const prefix = source.slice(0, safeIndex);
const lines = prefix.split('\n');
return {
line: lines.length,
column: (lines[lines.length - 1] ?? '').length + 1,
};
}
export function transformVueSfcTemplate(
code: string,
id: string,
options: Required<DesignModeOptions>
): string {
const sfc = parseSfc(code, { filename: id });
const templateBlock = sfc.descriptor.template;
if (!templateBlock) {
return code;
}
const templateContent = templateBlock.content;
const attrPrefix = options.attributePrefix;
const existingAttrPattern = new RegExp(
`\\s${attrPrefix}-[\\w-]+=(?:"[^"]*"|'[^']*')`,
'g'
);
const cleanedTemplate = templateContent.replace(existingAttrPattern, '');
const templateBaseOffset = templateBlock.loc.start.offset;
const ast = parseTemplate(cleanedTemplate, { comments: true });
const insertions: Array<{ offset: number; text: string }> = [];
let elementIndex = 0;
const visit = (node: any) => {
if (!node || node.type !== NodeTypes.ELEMENT) {
if (Array.isArray(node?.children)) {
node.children.forEach(visit);
}
return;
}
const tagName = String(node.tag);
const startTagEndInTemplate = findStartTagEndOffset(cleanedTemplate, node.loc.start.offset);
if (startTagEndInTemplate < 0) {
if (Array.isArray(node.children)) {
node.children.forEach(visit);
}
return;
}
const absoluteStartOffset = templateBaseOffset + node.loc.start.offset;
const { line, column } = getLineAndColumnFromIndex(code, absoluteStartOffset);
const elementType = tagName.toLowerCase();
const elementId = `${id}:${line}:${column}_${elementType}_${elementIndex++}`;
const sourceInfo = {
fileName: id,
lineNumber: line,
columnNumber: column,
// Backward compatibility for old clients that still read `line`/`column`.
line,
column,
elementId,
elementType,
};
const infoAttr = escapeHtmlAttr(JSON.stringify(sourceInfo));
let attrsToInject =
` ${attrPrefix}-info="${infoAttr}"` +
` ${attrPrefix}-position="${line}:${column}"` +
` ${attrPrefix}-element-id="${elementId}"`;
// Add static-content attribute if children are pure text
if (isStaticContent(node)) {
attrsToInject += ` ${attrPrefix}-static-content="true"`;
}
// Add static-class attribute if class is static
if (isStaticClass(node)) {
attrsToInject += ` ${attrPrefix}-static-class="true"`;
}
insertions.push({
offset: templateBaseOffset + startTagEndInTemplate,
text: attrsToInject,
});
if (Array.isArray(node.children)) {
node.children.forEach(visit);
}
};
ast.children.forEach(visit);
if (insertions.length === 0 && cleanedTemplate === templateContent) {
return code;
}
let rebuilt = code.slice(0, templateBaseOffset) + cleanedTemplate + code.slice(templateBaseOffset + templateContent.length);
insertions.sort((a, b) => b.offset - a.offset).forEach((entry) => {
rebuilt = rebuilt.slice(0, entry.offset) + entry.text + rebuilt.slice(entry.offset);
});
return rebuilt;
}
function findStartTagEndOffset(source: string, startOffset: number): number {
let index = startOffset;
let quote: '"' | "'" | null = null;
while (index < source.length) {
const char = source[index];
if (!quote && (char === '"' || char === "'")) {
quote = char;
index += 1;
continue;
}
if (quote && char === quote) {
quote = null;
index += 1;
continue;
}
if (!quote && char === '>') {
if (index > startOffset && source[index - 1] === '/') {
return index - 1;
}
return index;
}
index += 1;
}
return -1;
}
/**
* Check if element has only static text content (no interpolations, no nested elements)
*/
function isStaticContent(node: any): boolean {
if (!node.children || node.children.length === 0) {
return false;
}
// All children must be TEXT nodes (type 2)
return node.children.every((child: any) => {
return child.type === NodeTypes.TEXT;
});
}
/**
* Check if element has static class attribute (no v-bind:class, no :class)
* Static means: absent, or a plain string attribute
*/
function isStaticClass(node: any): boolean {
if (!node.props || node.props.length === 0) {
return true; // No class at all = static
}
// Look for class-related props
for (const prop of node.props) {
// v-bind:class or :class (type 7 = DIRECTIVE)
if (prop.type === NodeTypes.DIRECTIVE) {
if (prop.name === 'bind' && prop.arg?.content === 'class') {
return false; // Dynamic binding
}
}
// Plain class attribute (type 6 = ATTRIBUTE)
if (prop.type === NodeTypes.ATTRIBUTE && prop.name === 'class') {
// Static string class
return true;
}
}
return true; // No class attribute found = static
}

View File

@@ -0,0 +1,176 @@
import { parse as parseSfc } from '@vue/compiler-sfc';
import { NodeTypes, parse as parseTemplate } from '@vue/compiler-dom';
type UpdateKind = 'style' | 'content' | 'attribute';
export interface VueSfcUpdateOptions {
lineNumber: number;
columnNumber: number;
newValue: string;
originalValue?: string;
type: UpdateKind;
}
interface SourceSpan {
start: number;
end: number;
}
export function applyVueSfcTemplateUpdate(
source: string,
options: VueSfcUpdateOptions
): string {
const sfc = parseSfc(source, { filename: 'component.vue' });
const templateBlock = sfc.descriptor.template;
if (!templateBlock) {
throw new Error('Vue SFC missing <template> block.');
}
const templateStartOffset = templateBlock.loc.start.offset;
const templateSource = templateBlock.content;
const targetOffsetInFile = getOffsetFromLineColumn(source, options.lineNumber, options.columnNumber);
const targetOffsetInTemplate = targetOffsetInFile - templateStartOffset;
if (targetOffsetInTemplate < 0 || targetOffsetInTemplate > templateSource.length) {
throw new Error('Target position is outside <template> block.');
}
const ast = parseTemplate(templateSource, { comments: true });
const targetElement = findElementByPosition(ast, targetOffsetInTemplate);
if (!targetElement) {
throw new Error('Unable to locate target element in Vue template AST.');
}
switch (options.type) {
case 'style':
return updateElementClass(source, templateStartOffset, targetElement, options.newValue);
case 'content':
return updateElementTextContent(source, templateStartOffset, targetElement, options.newValue, options.originalValue);
case 'attribute':
throw new Error('Vue attribute update is not supported in AST mode yet.');
default:
return source;
}
}
function updateElementClass(
fullSource: string,
templateStartOffset: number,
elementNode: any,
newClassValue: string
): string {
const classAttr = elementNode.props.find(
(prop: any) => prop.type === NodeTypes.ATTRIBUTE && prop.name === 'class'
);
if (classAttr?.value?.loc) {
const valueLoc = toFullFileSpan(templateStartOffset, classAttr.value.loc.start.offset, classAttr.value.loc.end.offset);
const originalValueSource = fullSource.slice(valueLoc.start, valueLoc.end);
const quoteChar = originalValueSource.startsWith("'") ? "'" : '"';
const escaped = escapeAttributeValue(newClassValue, quoteChar);
return replaceSpan(fullSource, valueLoc, `${quoteChar}${escaped}${quoteChar}`);
}
const classBind = elementNode.props.find(
(prop: any) => prop.type === NodeTypes.DIRECTIVE && prop.name === 'bind' && prop.arg?.type === NodeTypes.SIMPLE_EXPRESSION && prop.arg.content === 'class'
);
if (classBind?.exp?.type === NodeTypes.SIMPLE_EXPRESSION && classBind.exp.isStatic) {
const bindLoc = toFullFileSpan(templateStartOffset, classBind.loc.start.offset, classBind.loc.end.offset);
return replaceSpan(fullSource, bindLoc, `class="${escapeAttributeValue(newClassValue)}"`);
}
if (classBind) {
throw new Error('Dynamic :class expression is not safely editable in Vue AST mode.');
}
const insertOffset = templateStartOffset + elementNode.loc.start.offset + `<${elementNode.tag}`.length;
return `${fullSource.slice(0, insertOffset)} class="${escapeAttributeValue(newClassValue)}"${fullSource.slice(insertOffset)}`;
}
function updateElementTextContent(
fullSource: string,
templateStartOffset: number,
elementNode: any,
newText: string,
originalValue?: string
): string {
const textChild = elementNode.children.find(
(child: any) => child.type === NodeTypes.TEXT && child.content.trim().length > 0
);
if (!textChild?.loc) {
throw new Error('Target Vue element has no editable static text node.');
}
const currentText = textChild.content;
if (originalValue && !currentText.includes(originalValue)) {
throw new Error('Original text does not match current Vue template text.');
}
const replacement = originalValue
? currentText.replace(new RegExp(escapeRegExp(originalValue), 'g'), newText)
: newText;
const textLoc = toFullFileSpan(templateStartOffset, textChild.loc.start.offset, textChild.loc.end.offset);
return replaceSpan(fullSource, textLoc, replacement);
}
function findElementByPosition(ast: any, targetOffset: number): any | null {
let matched: any | null = null;
const visit = (node: any) => {
if (!node?.loc?.start || !node?.loc?.end) {
return;
}
if (targetOffset < node.loc.start.offset || targetOffset > node.loc.end.offset) {
return;
}
if (node.type === NodeTypes.ELEMENT) {
matched = node;
}
if (Array.isArray(node.children)) {
for (const child of node.children) {
visit(child);
}
}
};
for (const child of ast.children ?? []) {
visit(child);
}
return matched;
}
function getOffsetFromLineColumn(source: string, line: number, column: number): number {
const lines = source.split('\n');
if (line < 1 || line > lines.length) {
throw new Error(`Line ${line} exceeds source length.`);
}
const targetLine = lines[line - 1] ?? '';
if (column < 1 || column > targetLine.length + 1) {
throw new Error(`Column ${column} is invalid for line ${line}.`);
}
let offset = 0;
for (let i = 0; i < line - 1; i++) {
offset += (lines[i] ?? '').length + 1;
}
return offset + (column - 1);
}
function toFullFileSpan(templateStart: number, start: number, end: number): SourceSpan {
return {
start: templateStart + start,
end: templateStart + end,
};
}
function replaceSpan(source: string, span: SourceSpan, replacement: string): string {
return `${source.slice(0, span.start)}${replacement}${source.slice(span.end)}`;
}
function escapeAttributeValue(value: string, quoteChar: '"' | "'" = '"'): string {
if (quoteChar === "'") {
return value.replace(/'/g, '&#39;');
}
return value.replace(/"/g, '&quot;');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}