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

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,51 @@
#!/usr/bin/env node
/**
* CLI entry: parse argv and dispatch install / uninstall.
*/
import { main as installMain } from './install.js';
import { main as uninstallMain } from './uninstall.js';
const command = process.argv[2];
switch (command) {
case 'install':
installMain();
break;
case 'uninstall':
uninstallMain();
break;
case undefined:
case '--help':
case '-h':
console.log(`
@xagi/vite-plugin-design-mode CLI
Usage:
npx @xagi/vite-plugin-design-mode <command>
pnpm dlx @xagi/vite-plugin-design-mode <command>
Commands:
install Add the plugin to package.json and vite.config
uninstall Remove the plugin from package.json and vite.config
Notes:
- Only edits config files; it does not run your package manager.
- After install/uninstall, run install/remove yourself to sync node_modules.
Examples:
pnpm dlx @xagi/vite-plugin-design-mode install
npx @xagi/vite-plugin-design-mode install
pnpm dlx @xagi/vite-plugin-design-mode uninstall
npx @xagi/vite-plugin-design-mode uninstall
More info: https://www.npmjs.com/package/@xagi/vite-plugin-design-mode
`);
break;
default:
console.error(`✗ Unknown command: ${command}`);
console.error('Run with --help to see available commands.');
process.exit(1);
}

View File

@@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* Install flow: add devDependency + wire `appdevDesignMode()` in Vite config.
*/
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const PLUGIN_NAME = '@xagi/vite-plugin-design-mode';
const VITE_CONFIG_FILES = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'];
/**
* Read plugin version by walking up from this file until the package named PLUGIN_NAME is found.
*/
function getPluginVersion(): string {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let currentDir = resolve(__dirname);
const root = resolve('/');
let depth = 0;
const maxDepth = 5;
while (currentDir !== root && depth < maxDepth) {
const packageJsonPath = join(currentDir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
if (packageJson.name === PLUGIN_NAME && packageJson.version) {
return packageJson.version;
}
} catch (_error) {
// Invalid JSON — keep walking
}
}
currentDir = dirname(currentDir);
depth++;
}
} catch (_error) {
// Fallback to latest below
}
return 'latest';
}
/** Walk up from startDir until package.json exists. */
function findProjectRoot(startDir: string = process.cwd()): string {
let currentDir = resolve(startDir);
const root = resolve('/');
while (currentDir !== root) {
const packageJsonPath = join(currentDir, 'package.json');
if (existsSync(packageJsonPath)) {
return currentDir;
}
currentDir = dirname(currentDir);
}
return startDir;
}
interface PackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
packageManager?: string;
}
function getDependencyVersion(packageJson: PackageJson, depName: string): string | undefined {
return packageJson.dependencies?.[depName] || packageJson.devDependencies?.[depName];
}
/** package.json lists vite */
function hasVite(packageJson: PackageJson): boolean {
return !!packageJson.dependencies?.vite || !!packageJson.devDependencies?.vite;
}
/** package.json lists react */
function hasReact(packageJson: PackageJson): boolean {
return !!packageJson.dependencies?.react || !!packageJson.devDependencies?.react;
}
/** package.json lists vue */
function hasVue(packageJson: PackageJson): boolean {
return !!getDependencyVersion(packageJson, 'vue');
}
/**
* Returns false only when we can confidently identify a Vue 2 range.
* Unknown or non-semver formats (workspace:, github:, file:) are treated as supported.
*/
function isVue3Version(version: string): boolean {
const normalized = version.trim();
if (!normalized) return true;
if (/(^|[^\d])2\./.test(normalized) || /(^|[^\d])\^?~?2(\D|$)/.test(normalized)) {
return false;
}
if (/(^|[^\d])3\./.test(normalized) || /(^|[^\d])\^?~?3(\D|$)/.test(normalized)) {
return true;
}
return true;
}
/** PLUGIN_NAME present in deps */
function isPluginInstalled(packageJson: PackageJson): boolean {
return !!packageJson.dependencies?.[PLUGIN_NAME] || !!packageJson.devDependencies?.[PLUGIN_NAME];
}
function addPluginToPackageJson(packageJson: PackageJson, version: string): PackageJson {
if (!packageJson.devDependencies) {
packageJson.devDependencies = {};
}
packageJson.devDependencies[PLUGIN_NAME] = `^${version}`;
return packageJson;
}
/** First existing vite.config.{ts,js,mjs} */
function findViteConfig(projectRoot: string): string | null {
for (const file of VITE_CONFIG_FILES) {
const configPath = join(projectRoot, file);
if (existsSync(configPath)) {
return configPath;
}
}
return null;
}
function hasImport(content: string): boolean {
const importPatterns = [
/import\s+appdevDesignMode\s+from\s+['"]@xagi\/vite-plugin-design-mode['"]/,
/import\s+\{\s*default\s+as\s+appdevDesignMode\s*\}\s+from\s+['"]@xagi\/vite-plugin-design-mode['"]/,
];
return importPatterns.some(pattern => pattern.test(content));
}
function hasPluginConfig(content: string): boolean {
const pluginPatterns = [/appdevDesignMode\s*\(/, /appdevDesignMode\s*\(\s*\{/];
return pluginPatterns.some(pattern => pattern.test(content));
}
function addImport(content: string): string {
if (hasImport(content)) {
return content;
}
const importRegex = /^import\s+.*?from\s+['"].*?['"];?$/gm;
const imports = content.match(importRegex);
if (imports && imports.length > 0) {
const lastImport = imports[imports.length - 1];
const lastImportIndex = content.lastIndexOf(lastImport);
const insertIndex = lastImportIndex + lastImport.length;
const useSingleQuote = lastImport.includes("'");
const quote = useSingleQuote ? "'" : '"';
const newImport = `\nimport appdevDesignMode from ${quote}@xagi/vite-plugin-design-mode${quote};`;
return content.slice(0, insertIndex) + newImport + content.slice(insertIndex);
}
const useSingleQuote = content.includes("'");
const quote = useSingleQuote ? "'" : '"';
return `import appdevDesignMode from ${quote}@xagi/vite-plugin-design-mode${quote};\n${content}`;
}
function addPluginConfig(content: string): string {
if (hasPluginConfig(content)) {
return content.replace(/appdevDesignMode\s*\([^)]*\)/g, 'appdevDesignMode()');
}
const pluginsArrayRegex = /plugins\s*:\s*\[/;
const match = content.match(pluginsArrayRegex);
if (match) {
const startIndex = match.index! + match[0].length;
let depth = 1;
let i = startIndex;
let inString = false;
let stringChar = '';
let inTemplateString = false;
while (i < content.length && depth > 0) {
const char = content[i];
const prevChar = i > 0 ? content[i - 1] : '';
if ((char === '"' || char === "'") && prevChar !== '\\') {
if (!inString && !inTemplateString) {
inString = true;
stringChar = char;
} else if (inString && char === stringChar) {
inString = false;
stringChar = '';
}
}
if (char === '`' && prevChar !== '\\') {
inTemplateString = !inTemplateString;
}
if (!inString && !inTemplateString) {
if (char === '[') {
depth++;
} else if (char === ']') {
depth--;
if (depth === 0) {
const beforeClosing = content.substring(startIndex, i);
const afterClosing = content.substring(i);
const cleanedBefore = beforeClosing
.replace(/\/\/.*$/gm, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
const hasOtherPlugins = cleanedBefore.length > 0;
const beforePlugins = content.substring(0, match.index!);
const lastNewlineIndex = beforePlugins.lastIndexOf('\n');
const pluginsLine = beforePlugins.substring(lastNewlineIndex + 1);
const indent = pluginsLine.match(/^(\s*)/)?.[1] || ' ';
if (hasOtherPlugins) {
let lastNonWhitespace = beforeClosing.length - 1;
while (lastNonWhitespace >= 0 && /\s/.test(beforeClosing[lastNonWhitespace])) {
lastNonWhitespace--;
}
const lastChar = lastNonWhitespace >= 0 ? beforeClosing[lastNonWhitespace] : '';
const needsComma = lastChar !== ',';
const insertText = (needsComma ? ',' : '') + '\n' + indent + ' appdevDesignMode()';
return (
content.substring(0, startIndex + lastNonWhitespace + 1) +
insertText +
'\n' +
indent +
afterClosing
);
}
return (
content.substring(0, startIndex) +
'\n' +
indent +
' appdevDesignMode()\n' +
indent +
afterClosing
);
}
}
}
i++;
}
}
const defineConfigRegex = /defineConfig\s*\(\s*\{([\s\S]*?)\}\s*\)/;
const configMatch = content.match(defineConfigRegex);
if (configMatch) {
const configObject = configMatch[0];
const pluginsConfig = `\n plugins: [\n appdevDesignMode()\n ],`;
const newConfigObject = configObject.replace(/\}\s*\)$/, `${pluginsConfig}\n}`);
return content.replace(defineConfigRegex, newConfigObject);
}
return `${content}\n\n// appdevDesignMode plugin\nplugins: [appdevDesignMode()],`;
}
function main() {
console.log('Installing @xagi/vite-plugin-design-mode...\n');
const installerVersion = getPluginVersion();
console.log(`[vite-plugin-design-mode] installer version: ${installerVersion}`);
const projectRoot = findProjectRoot();
console.log(`Project root: ${projectRoot}\n`);
const packageJsonPath = join(projectRoot, 'package.json');
if (!existsSync(packageJsonPath)) {
console.error('Error: package.json not found');
console.error(`Directory: ${projectRoot}`);
console.error('Run this command from your project root.');
process.exit(1);
}
const packageJson: PackageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const hasViteDep = hasVite(packageJson);
const hasReactDep = hasReact(packageJson);
const hasVueDep = hasVue(packageJson);
const vueVersion = getDependencyVersion(packageJson, 'vue');
const hasVue3Dep = hasVueDep && isVue3Version(vueVersion || '');
if (!hasViteDep) {
console.error('Error: Vite not found in package.json');
process.exit(1);
}
if (!hasReactDep && !hasVueDep) {
console.error('Error: neither React nor Vue found in package.json');
process.exit(1);
}
if (!hasReactDep && hasVueDep && !hasVue3Dep) {
console.error(`Error: unsupported Vue version: ${vueVersion}`);
process.exit(1);
}
const pluginVersion = installerVersion;
const isInstalled = isPluginInstalled(packageJson);
const updatedPackageJson = addPluginToPackageJson(packageJson, pluginVersion);
const versionString = `^${pluginVersion}`;
const currentVersion =
packageJson.devDependencies?.[PLUGIN_NAME] || packageJson.dependencies?.[PLUGIN_NAME];
if (!isInstalled || currentVersion !== versionString) {
writeFileSync(packageJsonPath, JSON.stringify(updatedPackageJson, null, 2) + '\n', 'utf-8');
}
const viteConfigPath = findViteConfig(projectRoot);
if (!viteConfigPath) {
console.log('Done. Run your package manager install command.');
return;
}
let configContent = readFileSync(viteConfigPath, 'utf-8');
const originalContent = configContent;
configContent = addImport(configContent);
configContent = addPluginConfig(configContent);
if (configContent !== originalContent) {
writeFileSync(viteConfigPath, configContent, 'utf-8');
}
console.log('Done.');
}
export { main };

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* Uninstall: remove devDependency and strip plugin from vite.config.
*/
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join, resolve, dirname } from 'path';
const PLUGIN_NAME = '@xagi/vite-plugin-design-mode';
const VITE_CONFIG_FILES = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'];
/** Walk up from startDir until package.json exists. */
function findProjectRoot(startDir: string = process.cwd()): string {
let currentDir = resolve(startDir);
const root = resolve('/');
while (currentDir !== root) {
const packageJsonPath = join(currentDir, 'package.json');
if (existsSync(packageJsonPath)) {
return currentDir;
}
currentDir = dirname(currentDir);
}
return startDir;
}
interface PackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
packageManager?: string;
}
function isPluginInstalled(packageJson: PackageJson): boolean {
return !!packageJson.dependencies?.[PLUGIN_NAME] || !!packageJson.devDependencies?.[PLUGIN_NAME];
}
function removePluginFromPackageJson(packageJson: PackageJson): PackageJson {
if (!isPluginInstalled(packageJson)) {
return packageJson;
}
if (packageJson.dependencies?.[PLUGIN_NAME]) {
delete packageJson.dependencies[PLUGIN_NAME];
if (Object.keys(packageJson.dependencies).length === 0) {
delete packageJson.dependencies;
}
}
if (packageJson.devDependencies?.[PLUGIN_NAME]) {
delete packageJson.devDependencies[PLUGIN_NAME];
if (Object.keys(packageJson.devDependencies).length === 0) {
delete packageJson.devDependencies;
}
}
return packageJson;
}
function findViteConfig(projectRoot: string): string | null {
for (const file of VITE_CONFIG_FILES) {
const configPath = join(projectRoot, file);
if (existsSync(configPath)) {
return configPath;
}
}
return null;
}
function removeImport(content: string): string {
const importPatterns = [
/import\s+appdevDesignMode\s+from\s+['"]@xagi\/vite-plugin-design-mode['"];?\s*\n?/g,
/import\s+\{\s*default\s+as\s+appdevDesignMode\s*\}\s+from\s+['"]@xagi\/vite-plugin-design-mode['"];?\s*\n?/g,
];
let result = content;
for (const pattern of importPatterns) {
result = result.replace(pattern, '');
}
return result;
}
function removePluginConfig(content: string): string {
const pluginCallPattern = /appdevDesignMode\s*\([^)]*\)/g;
let result = content.replace(pluginCallPattern, '');
result = result.replace(/,\s*,/g, ',');
result = result.replace(/,\s*\]/g, ']');
result = result.replace(/\[\s*,/g, '[');
result = result.replace(/,\s*}/g, '}');
result = result.replace(/\/\/\s*appdevDesignMode.*?\n/g, '');
result = result.replace(/\/\*\s*appdevDesignMode.*?\*\//g, '');
result = result.replace(/,\s*plugins\s*:\s*\[\s*\]/g, '');
result = result.replace(/plugins\s*:\s*\[\s*\]\s*,?/g, '');
result = result.replace(/\n{3,}/g, '\n\n');
result = result.replace(/[ \t]+$/gm, '');
return result;
}
function main() {
console.log('Removing @xagi/vite-plugin-design-mode...\n');
const projectRoot = findProjectRoot();
const packageJsonPath = join(projectRoot, 'package.json');
if (!existsSync(packageJsonPath)) {
console.error('Error: package.json not found');
process.exit(1);
}
const packageJson: PackageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
if (!isPluginInstalled(packageJson)) {
console.log('Plugin not listed in package.json, nothing to remove.');
return;
}
const updatedPackageJson = removePluginFromPackageJson(packageJson);
if (updatedPackageJson !== packageJson) {
writeFileSync(packageJsonPath, JSON.stringify(updatedPackageJson, null, 2) + '\n', 'utf-8');
}
const viteConfigPath = findViteConfig(projectRoot);
if (!viteConfigPath) {
console.log('Done.');
return;
}
let configContent = readFileSync(viteConfigPath, 'utf-8');
const originalContent = configContent;
configContent = removeImport(configContent);
configContent = removePluginConfig(configContent);
if (configContent !== originalContent) {
writeFileSync(viteConfigPath, configContent, 'utf-8');
}
console.log('Done.');
}
export { main };

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, '\\$&');
}

View File

@@ -0,0 +1,569 @@
import type { Plugin } from 'vite';
import { createServerMiddleware } from './core/serverMiddleware.js';
import { transformSourceCode } from './core/astTransformer.js';
import { transformVueSfcTemplate } from './core/vueSfcTransformer.js';
export interface DesignModeOptions {
/**
* Whether design mode is enabled.
* @default true
*/
enabled?: boolean;
/**
* Whether to enable in production builds (usually false).
* @default false
*/
enableInProduction?: boolean;
/**
* Prefix for injected source-mapping attributes.
* @default 'data-source'
*/
attributePrefix?: string;
/**
* Verbose logging.
* @default false
*/
verbose?: boolean;
/**
* Glob/path patterns to exclude from transformation.
*/
exclude?: string[];
/**
* Glob/path patterns to include.
*/
include?: string[];
/**
* Enable backup copies before writes.
* @default false
*/
enableBackup?: boolean;
/**
* Enable update history / batch session tracking.
* @default false
*/
enableHistory?: boolean;
/**
* Target framework mode.
* - auto: detect from project dependencies
* - react: force React client injection
* - vue: disable React client injection for Vue projects
* @default 'auto'
*/
framework?: 'auto' | 'react' | 'vue';
}
const DEFAULT_OPTIONS: Required<DesignModeOptions> = {
enabled: true,
enableInProduction: false,
attributePrefix: 'data-xagi',
verbose: true,
exclude: ['node_modules', 'dist'],
include: ['src/**/*.{ts,js,tsx,jsx,vue}'],
enableBackup: false,
enableHistory: false,
framework: 'auto',
};
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
import { existsSync, readFileSync } from 'fs';
import { createRequire } from 'module';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
// Virtual module id for loading client code in Vite
const VIRTUAL_CLIENT_MODULE_ID = 'virtual:appdev-design-mode-client';
const RESOLVED_VIRTUAL_CLIENT_MODULE_ID = '\0' + VIRTUAL_CLIENT_MODULE_ID + '.tsx';
type ClientRuntimeMode = 'react' | 'vue';
function appdevDesignModePlugin(userOptions: DesignModeOptions = {}): Plugin {
const options = { ...DEFAULT_OPTIONS, ...userOptions };
// Resolved base URL for client script injection
let basePath = '/';
// Current Vite command (serve vs build)
let currentCommand: 'serve' | 'build' = 'serve';
// Framework runtime resolution
let resolvedFramework: 'react' | 'vue' = 'react';
let hasReactRuntime = true;
let clientRuntimeMode: ClientRuntimeMode = 'react';
let hasWarnedVueLiteRuntime = false;
// Whether the plugin should run (dev by default; prod only if allowed)
const isPluginEnabled = () => {
if (!options.enabled) {
return false;
}
// Disable during build unless production is explicitly allowed
if (currentCommand === 'build' && !options.enableInProduction) {
return false;
}
return true;
};
return {
name: '@xagi/vite-plugin-design-mode',
enforce: 'pre',
resolveId(id) {
// Skip virtual module when plugin is off
if (!isPluginEnabled()) {
return null;
}
if (id === VIRTUAL_CLIENT_MODULE_ID) {
return RESOLVED_VIRTUAL_CLIENT_MODULE_ID;
}
return null;
},
load(id) {
// Skip when plugin is off
if (!isPluginEnabled()) {
return null;
}
if (id === RESOLVED_VIRTUAL_CLIENT_MODULE_ID) {
const clientEntryPath = resolveClientEntryPath(clientRuntimeMode);
if (!existsSync(clientEntryPath)) {
throw new Error(
`[appdev-design-mode] Client entry not found: ${clientEntryPath}`
);
}
// Rewrite relative imports to absolute paths so the virtual module resolves deps
const code = readFileSync(clientEntryPath, 'utf-8');
const clientDir = dirname(clientEntryPath);
// Rewrite relative imports to absolute paths
const rewrittenCode = code.replace(
/from ['"]\.\/([^'"]+)['"]/g,
(match, moduleName) => {
const absolutePath = resolve(clientDir, moduleName);
return `from '${absolutePath}'`;
}
);
return rewrittenCode;
}
// **NEW: Process tsx/jsx files in load hook BEFORE React plugin**
// This allows us to add attributes to JSX elements before they are compiled
const shouldProcess = shouldProcessFile(id, options);
if (shouldProcess) {
try {
// Read the original file content
const code = readFileSync(id, 'utf-8');
// Transform source to add design-mode attributes before framework plugins run.
const transformedCode = id.endsWith('.vue')
? transformVueSfcTemplate(code, id, options)
: transformSourceCode(code, id, options);
// Return the transformed code
// React plugin will then process this code to compile JSX
return transformedCode;
} catch (error) {
// Log error in verbose mode for debugging
if (options.verbose) {
console.error('[appdev-design-mode] Transform error for', id, ':', error);
}
// Return null to let other plugins handle it
return null;
}
}
return null;
},
config(config, { command }) {
currentCommand = command;
const projectRoot = config.root ?? process.cwd();
const runtime = resolveFrameworkMode(projectRoot, options.framework);
resolvedFramework = runtime.framework;
hasReactRuntime = runtime.hasReactRuntime;
clientRuntimeMode = hasReactRuntime ? 'react' : 'vue';
basePath = config.base || '/';
// Normalize base: leading slash; trailing slash unless root
if (!basePath.startsWith('/')) {
basePath = '/' + basePath;
}
if (basePath !== '/' && !basePath.endsWith('/')) {
basePath = basePath + '/';
}
// No extra config when plugin disabled
if (!isPluginEnabled()) {
return {};
}
// Merge with user fs.allow
const existingAllow = config.server?.fs?.allow || [];
const pluginDistPath = resolve(__dirname, '..');
const allowedPaths = new Set<string>();
existingAllow.forEach((path: string) => {
// Normalize to absolute paths
const normalizedPath = resolve(path);
allowedPaths.add(normalizedPath);
});
allowedPaths.add(resolve(projectRoot));
// node_modules (dependency resolution)
const nodeModulesPath = resolve(projectRoot, 'node_modules');
allowedPaths.add(nodeModulesPath);
// Plugin package root (client bundle)
allowedPaths.add(pluginDistPath);
if (options.verbose && !hasReactRuntime && !hasWarnedVueLiteRuntime) {
hasWarnedVueLiteRuntime = true;
console.warn(
'[appdev-design-mode] React runtime not detected; using Vue3 client runtime.'
);
}
const pluginConfig: Record<string, unknown> = {
define: {
__APPDEV_DESIGN_MODE__: true,
__APPDEV_DESIGN_MODE_VERBOSE__: options.verbose,
},
server: {
fs: {
// User entries plus required plugin paths
allow: Array.from(allowedPaths),
},
},
};
if (hasReactRuntime) {
pluginConfig.esbuild = {
logOverride: { 'this-is-undefined-in-esm': 'silent' },
jsx: 'automatic',
jsxDev: false,
};
pluginConfig.optimizeDeps = {
include: ['react', 'react-dom'],
};
}
return pluginConfig;
},
configureServer(server) {
// Skip middleware when plugin off
if (!isPluginEnabled()) {
return;
}
// Register client code middleware
server.middlewares.use(async (req, res, next) => {
// Match client script URL with or without base prefix
const url = req.url || '';
const isClientRequest =
url === '/@appdev-design-mode/client.js' ||
url.endsWith('/@appdev-design-mode/client.js') ||
url.includes('@appdev-design-mode/client.js');
if (isClientRequest) {
try {
// Load bundled client via virtual module (ssr: false = browser)
const result = await server.transformRequest(
VIRTUAL_CLIENT_MODULE_ID,
{ ssr: false }
);
if (result && result.code) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'no-cache');
res.end(result.code);
} else {
throw new Error('transformRequest returned no code');
}
} catch (error: any) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain');
res.end(
`[appdev-design-mode] Failed to load client bundle: ${error.message}`
);
if (options.verbose) {
console.error(
'[appdev-design-mode] Client bundle error:',
error
);
}
}
return;
}
next();
});
// Register the main API middleware - this handles all /__appdev_design_mode/* endpoints
server.middlewares.use(
'/__appdev_design_mode',
createServerMiddleware(options, server.config.root)
);
},
transformIndexHtml(html, ctx) {
if (!isPluginEnabled()) {
return html;
}
// Client script URL respects Vite base (subpath deploys)
const clientScriptPath = `${basePath}@appdev-design-mode/client.js`.replace(/\/+/g, '/');
// HTTP endpoint avoids /@fs and some node_modules access edge cases
return {
html,
tags: [
// Inject prefix config for runtime client
{
tag: 'script',
attrs: {
type: 'text/javascript',
},
injectTo: 'head',
children: `window.__APPDEV_DESIGN_MODE_CONFIG__ = ${JSON.stringify({
attributePrefix: options.attributePrefix,
framework: resolvedFramework,
})};`,
},
{
tag: 'script',
attrs: {
type: 'module',
src: clientScriptPath,
},
injectTo: 'body',
},
],
};
},
async transform(code, id, transformOptions) {
if (!isPluginEnabled()) {
return code;
}
// Virtual client module: compile TSX with esbuild (not user's React plugin)
if (id === RESOLVED_VIRTUAL_CLIENT_MODULE_ID) {
const { transformWithEsbuild } = await import('vite');
const result = await transformWithEsbuild(code, id, {
loader: clientRuntimeMode === 'react' ? 'tsx' : 'ts',
jsx: 'automatic',
format: 'esm',
});
return result.code;
}
// Plugin package sources: esbuild only (avoids duplicate Babel runs in pnpm, etc.)
const pluginDistPath = resolve(__dirname, '..');
if (id.startsWith(pluginDistPath) && (id.endsWith('.tsx') || id.endsWith('.ts'))) {
const { transformWithEsbuild } = await import('vite');
const result = await transformWithEsbuild(code, id, {
loader: id.endsWith('.tsx') ? 'tsx' : 'ts',
jsx: 'automatic',
format: 'esm',
});
return result.code;
}
// User source files are already processed in load hook, skip here to avoid double processing
return code;
},
buildStart() {
if (options.verbose) {
console.log('[appdev-design-mode] Plugin started');
}
},
buildEnd() {
if (options.verbose) {
console.log('[appdev-design-mode] Plugin ended');
}
},
};
}
/** Whether `filePath` matches include globs and not exclude patterns. */
function shouldProcessFile(
filePath: string,
options: Required<DesignModeOptions>
): boolean {
if (options.exclude.some(pattern => filePath.includes(pattern))) {
return false;
}
// Include globs
const isIncluded = options.include.some(pattern => {
// Ignore negation patterns in include (rely on exclude option)
if (pattern.startsWith('!')) {
return false;
}
// Convert glob to regex using placeholders to avoid escaping issues
let regex = pattern;
// 1) Placeholders for glob tokens
// IMPORTANT: Replace **/ as a unit first to avoid double slashes
regex = regex.replace(/\*\*\//g, '%%GLOBSTAR_SLASH%%');
regex = regex.replace(/\*\*/g, '%%GLOBSTAR%%');
regex = regex.replace(/\*/g, '%%WILDCARD%%');
regex = regex.replace(/\{([^}]+)\}/g, (_, group) =>
`%%BRACE_START%%${group.replace(/,/g, '%%COMMA%%')}%%BRACE_END%%`
);
// 2. Escape special regex characters
regex = regex.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
// 3. Restore placeholders with regex equivalents
// **/ matches zero or more path segments (e.g., "", "foo/", "foo/bar/")
regex = regex.replace(/%%GLOBSTAR_SLASH%%/g, '(([^/]+/)*)');
// ** alone (rare) matches any characters
regex = regex.replace(/%%GLOBSTAR%%/g, '.*');
regex = regex.replace(/%%WILDCARD%%/g, '[^/]*');
regex = regex.replace(/%%BRACE_START%%/g, '(');
regex = regex.replace(/%%BRACE_END%%/g, ')');
regex = regex.replace(/%%COMMA%%/g, '|');
// 4. Create RegExp
// Allow partial match for absolute paths by prepending .* if not already there
// and not anchored
if (!regex.startsWith('.*') && !regex.startsWith('^')) {
regex = '.*' + regex;
}
const re = new RegExp(regex + '$'); // Anchor to end
return re.test(filePath);
});
return isIncluded;
}
export default appdevDesignModePlugin;
function resolveFrameworkMode(
projectRoot: string,
configured: 'auto' | 'react' | 'vue'
): { framework: 'react' | 'vue'; hasReactRuntime: boolean } {
const packageMeta = readProjectPackageMeta(projectRoot);
const vueMajor = getVueMajorVersion(packageMeta.deps.vue);
const hasVue2Plugin = Boolean(packageMeta.deps['@vitejs/plugin-vue2']);
if (configured === 'react') {
return { framework: 'react', hasReactRuntime: true };
}
if (configured === 'vue') {
// Explicit vue mode means the user expects Vue support; fail fast if Vue 2 is detected.
if (hasVue2Plugin || vueMajor === 2) {
throw new Error(
'[appdev-design-mode] Vue 2 is not supported. Please upgrade to Vue 3 and use @vitejs/plugin-vue.'
);
}
return { framework: 'vue', hasReactRuntime: false };
}
const hasReact = Boolean(packageMeta.deps.react && packageMeta.deps['react-dom']);
if (hasReact) {
return { framework: 'react', hasReactRuntime: true };
}
// Auto mode should still hard-reject Vue 2 projects to avoid silent partial behavior.
if (hasVue2Plugin || vueMajor === 2) {
throw new Error(
'[appdev-design-mode] Detected Vue 2 project. This plugin supports Vue 3 only.'
);
}
if (vueMajor === 3) {
return { framework: 'vue', hasReactRuntime: false };
}
return { framework: 'react', hasReactRuntime: true };
}
function readProjectPackageMeta(
projectRoot: string
): { deps: Record<string, string> } {
const packageJsonPath = resolve(projectRoot, 'package.json');
if (!existsSync(packageJsonPath)) {
return { deps: {} };
}
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
return { deps: { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) } };
} catch {
return { deps: {} };
}
}
function getVueMajorVersion(versionRange?: string): 2 | 3 | null {
if (!versionRange) {
return null;
}
// Strip leading range operators/spaces and read the first semver-like number.
const match = versionRange.trim().match(/(\d+)/);
if (!match) {
return null;
}
const major = Number(match[1]);
if (major === 2 || major === 3) {
return major;
}
return null;
}
function resolveClientEntryPath(mode: ClientRuntimeMode): string {
const packageName =
mode === 'react'
? '@xagi/design-mode-client-react'
: '@xagi/design-mode-client-vue';
const packageEntry = resolvePackageEntry(packageName);
const candidates =
mode === 'react'
? [packageEntry, resolve(__dirname, '../../client-react/src/index.tsx')]
: [packageEntry, resolve(__dirname, '../../client-vue/src/index.ts')];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
throw new Error(
`[appdev-design-mode] Cannot resolve client entry for runtime mode: ${mode}`
);
}
function resolvePackageEntry(packageName: string): string {
try {
return require.resolve(packageName);
} catch {
return '';
}
}

View File

@@ -0,0 +1,53 @@
import type { Plugin } from 'vite';
export interface DesignModeOptions {
enabled?: boolean;
enableInProduction?: boolean;
attributePrefix?: string;
verbose?: boolean;
exclude?: string[];
include?: string[];
enableBackup?: boolean; // Enable file backups (default false)
enableHistory?: boolean; // Enable update history (default false)
framework?: 'auto' | 'react' | 'vue';
}
export interface SourceMappingInfo {
fileName: string;
lineNumber: number;
columnNumber: number;
elementType: string;
componentName?: string;
functionName?: string;
elementId: string;
attributePrefix: string;
}
export interface ElementSourceRequest {
elementId: string;
}
export interface ElementSourceResponse {
sourceInfo: SourceMappingInfo & {
fileContent: string;
contextLines: string;
targetLineContent: string;
};
}
export interface ElementModifyRequest {
elementId: string;
newStyles: string;
oldStyles?: string;
}
export interface ElementModifyResponse {
success: boolean;
message: string;
sourceInfo: SourceMappingInfo;
}
// Type declaration for module exports
declare const _default: (options?: DesignModeOptions) => Plugin;
export default _default;

View File

@@ -0,0 +1,340 @@
// Message types for iframe ↔ parent window communication
export interface SourceInfo {
fileName: string;
lineNumber: number;
columnNumber: number;
elementType?: string;
componentName?: string;
functionName?: string;
elementId?: string;
importPath?: string;
isUIComponent?: boolean; // Whether this is a UI-kit component (e.g. under components/ui)
}
export interface ElementInfo {
tagName: string;
className: string;
textContent: string;
sourceInfo: SourceInfo;
isStaticText: boolean;
isStaticClass?: boolean; // Whether className is a pure static string (editable)
componentName?: string;
componentPath?: string;
props?: Record<string, string>;
hierarchy?: {
tagName: string;
componentName?: string;
fileName?: string;
}[];
}
// Message validation types
export interface MessageValidationResult {
isValid: boolean;
errors?: string[];
}
export interface MessageValidator {
validate: (message: any) => MessageValidationResult;
}
// Bridge Ready Message
export interface BridgeReadyMessage {
type: 'BRIDGE_READY';
payload: {
timestamp: number;
environment: 'iframe' | 'main';
};
timestamp?: number;
requestId?: string;
}
// iframe → Parent messages
export interface ElementSelectedMessage {
type: 'ELEMENT_SELECTED';
payload: {
elementInfo: ElementInfo;
};
requestId?: string;
timestamp?: number;
}
export interface ElementDeselectedMessage {
type: 'ELEMENT_DESELECTED';
requestId?: string;
timestamp?: number;
payload?: null; // Added optional payload to match usage
}
export interface ContentUpdatedMessage {
type: 'CONTENT_UPDATED';
payload: {
sourceInfo: SourceInfo;
oldValue: string;
newValue: string;
realtime?: boolean; // Whether this is a realtime (throttled) update
};
requestId?: string;
timestamp?: number;
}
export interface ContentUpdatedCallbackMessage {
type: 'CONTENT_UPDATED_CALLBACK';
payload: {
sourceInfo: SourceInfo;
oldValue: string;
newValue: string;
realtime?: boolean; // Whether this is a realtime (throttled) update
};
requestId?: string;
timestamp?: number;
}
export interface StyleUpdatedMessage {
type: 'STYLE_UPDATED';
payload: {
sourceInfo: SourceInfo;
oldClass: string;
newClass: string;
};
requestId?: string;
timestamp?: number;
}
export interface DesignModeChangedMessage {
type: 'DESIGN_MODE_CHANGED';
enabled: boolean;
requestId?: string;
timestamp?: number;
}
// Parent → iframe messages
export interface ToggleDesignModeMessage {
type: 'TOGGLE_DESIGN_MODE';
enabled: boolean;
requestId?: string;
timestamp?: number;
}
export interface UpdateStyleMessage {
type: 'UPDATE_STYLE';
payload: {
sourceInfo: SourceInfo;
newClass: string;
persist?: boolean;
};
requestId?: string;
timestamp?: number;
enabled?: boolean; // Added to fix TS error where enabled was accessed on this type (though likely a bug in usage, adding optional property avoids strict error if discriminated union fails)
}
export interface UpdateContentMessage {
type: 'UPDATE_CONTENT';
payload: {
sourceInfo: SourceInfo;
newContent: string;
persist?: boolean;
};
requestId?: string;
timestamp?: number;
}
export interface BatchUpdateItem {
type: 'style' | 'content';
sourceInfo: SourceInfo;
newValue: string;
originalValue?: string;
}
export interface BatchUpdateMessage {
type: 'BATCH_UPDATE';
payload: {
updates: BatchUpdateItem[];
};
requestId?: string;
timestamp?: number;
}
// Add to Chat Message
export interface AddToChatMessage {
type: 'ADD_TO_CHAT';
payload: {
content: string;
context?: {
elementInfo?: ElementInfo;
sourceInfo?: SourceInfo;
};
};
requestId?: string;
timestamp?: number;
}
// Copy Element Message
export interface CopyElementMessage {
type: 'COPY_ELEMENT';
payload: {
elementInfo: {
tagName: string;
className: string;
content: string;
sourceInfo?: SourceInfo;
};
textContent: string; // JSON string for clipboard
success: boolean; // Whether copy succeeded
error?: string; // Error message when copy failed
};
requestId?: string;
timestamp?: number;
}
// Error message
export interface ErrorMessage {
type: 'ERROR';
payload: {
code: string;
message: string;
details?: any;
};
requestId?: string;
timestamp?: number;
}
// Acknowledgement message
export interface AcknowledgementMessage {
type: 'ACKNOWLEDGEMENT';
payload: {
messageType: string;
};
requestId: string; // Response MUST have requestId
timestamp?: number;
}
// Heartbeat and health-check messages
export interface HeartbeatMessage {
type: 'HEARTBEAT';
payload?: {
timestamp: number;
};
timestamp?: number;
}
export interface HealthCheckMessage {
type: 'HEALTH_CHECK';
requestId?: string; // Made optional for sender
timestamp?: number; // Made optional for sender
}
export interface HealthCheckResponseMessage {
type: 'HEALTH_CHECK_RESPONSE';
payload: {
status: 'healthy' | 'unhealthy' | 'degraded' | 'connecting' | 'unnecessary';
version?: string;
uptime?: number;
message?: string;
response?: any;
error?: string;
isIframe?: boolean;
isConnected?: boolean;
origin?: string;
userAgent?: string;
location?: string;
};
requestId: string; // Response MUST have requestId
timestamp?: number;
}
// Union types
export type IframeToParentMessage =
| ElementSelectedMessage
| ElementDeselectedMessage
| ContentUpdatedMessage
| StyleUpdatedMessage
| DesignModeChangedMessage
| ErrorMessage
| AcknowledgementMessage
| HeartbeatMessage
| HealthCheckResponseMessage
| BridgeReadyMessage
| AddToChatMessage
| CopyElementMessage
| ContentUpdatedCallbackMessage;
export type ParentToIframeMessage =
| ToggleDesignModeMessage
| UpdateStyleMessage
| UpdateContentMessage
| BatchUpdateMessage
| HealthCheckMessage
| HeartbeatMessage; // Added HeartbeatMessage here too for bidirectional support
export type DesignModeMessage = IframeToParentMessage | ParentToIframeMessage;
// Promise-based request/response types
export type RequestMessage = ParentToIframeMessage & {
requestId: string;
timestamp: number;
};
export type ResponseMessage = IframeToParentMessage & {
requestId: string;
success?: boolean;
error?: string;
timestamp: number;
};
export type RequestResponseMessage = RequestMessage | ResponseMessage | AcknowledgementMessage;
// Message handler type
export interface MessageHandler<T extends DesignModeMessage = DesignModeMessage> {
type: T['type'];
handler: (message: T) => Promise<any> | any;
validator?: MessageValidator;
}
// Config-related types
export interface IframeModeConfig {
enabled: boolean;
hideUI: boolean;
enableSelection: boolean;
enableDirectEdit: boolean;
}
export interface BatchUpdateConfig {
enabled: boolean;
debounceMs: number;
}
export interface BridgeConfig {
timeout: number;
retryAttempts: number;
retryDelay: number;
heartbeatInterval: number;
debug: boolean;
}
// Message utility helpers
export interface MessageUtils {
generateRequestId: () => string;
createTimestamp: () => number;
isValidMessage: (message: any) => boolean;
createResponse: <T extends IframeToParentMessage>(
originalMessage: ParentToIframeMessage,
payload: T extends { payload: infer P } ? P : never,
success?: boolean,
error?: string
) => T;
}
// Bridge interface
export interface BridgeInterface {
send: <T extends DesignModeMessage>(message: T) => Promise<void>;
sendWithResponse: <T extends DesignModeMessage, R extends DesignModeMessage>(
message: T,
responseType: R['type']
) => Promise<R>;
on: <T extends DesignModeMessage>(type: T['type'], handler: (message: T) => void) => void;
off: (type: string, handler: Function) => void;
isConnected: () => boolean;
disconnect: () => void;
}

View File

@@ -0,0 +1,87 @@
import * as t from '@babel/types';
/**
* Whether the name looks like a React component (PascalCase).
*/
export function isReactComponentName(name: string): boolean {
return /^[A-Z]/.test(name);
}
/**
* Base name of a JSX element (identifier or member property).
*/
export function getJSXElementBaseName(name: any): string {
if (t.isJSXIdentifier(name)) {
return name.name;
} else if (t.isJSXMemberExpression(name)) {
return name.property.name;
}
return 'unknown';
}
/**
* Whether a JSX attribute is a string literal for the given name.
*/
export function isStringLiteralAttribute(
attr: t.JSXAttribute,
name: string
): attr is t.JSXAttribute & { value: t.StringLiteral } {
return (
t.isJSXIdentifier(attr.name) &&
attr.name.name === name &&
t.isStringLiteral(attr.value)
);
}
/**
* Read a string literal attribute value from a JSX opening element.
*/
export function extractStringAttributeValue(
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;
}
/**
* Serialize file:line:column into a single string.
*/
export function createSourcePositionString(
fileName: string,
lineNumber: number,
columnNumber: number
): string {
return `${fileName}:${lineNumber}:${columnNumber}`;
}
/**
* Parse a file:line:column string back into parts.
*/
export function parseSourcePositionString(position: string): {
fileName: string;
lineNumber: number;
columnNumber: number;
} | null {
const parts = position.split(':');
if (parts.length !== 3) return null;
const [fileName, lineNumberStr, columnNumberStr] = parts;
const lineNumber = parseInt(lineNumberStr);
const columnNumber = parseInt(columnNumberStr);
if (isNaN(lineNumber) || isNaN(columnNumber)) return null;
return {
fileName,
lineNumber,
columnNumber
};
}