"添加前端模板和运行代码模块"
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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, ''');
|
||||
}
|
||||
return value.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
Reference in New Issue
Block a user