"添加前端模板和运行代码模块"
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as babel from '@babel/standalone';
|
||||
import { createSourceMappingPlugin } from './sourceMapper.js';
|
||||
import type { DesignModeOptions } from '../types';
|
||||
|
||||
export function transformSourceCode(
|
||||
code: string,
|
||||
id: string,
|
||||
options: Required<DesignModeOptions>
|
||||
): string {
|
||||
try {
|
||||
// Handle ESM/CJS interop
|
||||
const babelApi = (babel as any).default || babel;
|
||||
|
||||
|
||||
|
||||
// Parse source into AST (via Babel transform)
|
||||
// Use a single transform call to ensure our plugin runs before presets compile JSX
|
||||
const result = babelApi.transform(code, {
|
||||
ast: false, // We don't need the AST output
|
||||
code: true,
|
||||
sourceType: 'module',
|
||||
filename: id,
|
||||
presets: [
|
||||
'typescript',
|
||||
['react', {
|
||||
runtime: 'automatic', // Use automatic JSX runtime (React 17+)
|
||||
development: false, // Use production runtime for better performance
|
||||
}]
|
||||
],
|
||||
plugins: [
|
||||
createSourceMappingPlugin(id, options)
|
||||
],
|
||||
parserOpts: {
|
||||
allowImportExportEverywhere: true,
|
||||
allowReturnOutsideFunction: true,
|
||||
createParenthesizedExpressions: true
|
||||
}
|
||||
});
|
||||
|
||||
return (result && result.code) || code;
|
||||
} catch (error) {
|
||||
if (options.verbose) {
|
||||
console.warn(`[appdev-design-mode] AST transformation failed for ${id}:`, error);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { performUpdate, UpdateRequest } from './codeUpdater.js';
|
||||
|
||||
export interface BatchUpdateRequest {
|
||||
updates: UpdateRequest[];
|
||||
}
|
||||
|
||||
export interface BatchUpdateResult {
|
||||
results: Array<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
filePath: string;
|
||||
type: 'style' | 'content';
|
||||
}>;
|
||||
summary: {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleBatchUpdate(req: IncomingMessage, res: ServerResponse, root: string) {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readBody(req);
|
||||
try {
|
||||
const data = JSON.parse(body) as BatchUpdateRequest;
|
||||
const { updates } = data;
|
||||
|
||||
if (!Array.isArray(updates)) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid request: updates must be an array' }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const results = [];
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const update of updates) {
|
||||
try {
|
||||
const result = performUpdate(root, update);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
results.push({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
filePath: update.filePath,
|
||||
type: update.type
|
||||
});
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
results.push({
|
||||
success: false,
|
||||
message: String(error),
|
||||
filePath: update.filePath,
|
||||
type: update.type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response: BatchUpdateResult = {
|
||||
results,
|
||||
summary: {
|
||||
total: updates.length,
|
||||
success: successCount,
|
||||
failed: failedCount
|
||||
}
|
||||
};
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(response));
|
||||
|
||||
} catch (error) {
|
||||
console.error('[appdev-design-mode] Error handling batch update:', error);
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({ error: String(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
resolve(body);
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'path';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
export interface UpdateRequest {
|
||||
filePath: string;
|
||||
line: number;
|
||||
column: number;
|
||||
newValue: string;
|
||||
type: 'style' | 'content';
|
||||
originalValue?: string; // Original value for matching/replacement
|
||||
}
|
||||
|
||||
export function performUpdate(root: string, data: UpdateRequest): { success: boolean, message: string } {
|
||||
const { filePath, newValue, type, originalValue } = data;
|
||||
|
||||
// Resolve absolute path
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath);
|
||||
|
||||
// Security: Validate path is within project root (prevent path traversal)
|
||||
if (!isPathWithinRoot(root, absolutePath)) {
|
||||
console.error('[appdev-design-mode] Security: Path traversal attempt blocked:', absolutePath);
|
||||
return { success: false, message: 'Access denied: path outside project root' };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
console.error('[appdev-design-mode] File not found:', absolutePath);
|
||||
return { success: false, message: 'File not found' };
|
||||
}
|
||||
|
||||
// Security: Check file size limit (10MB)
|
||||
const stats = fs.statSync(absolutePath);
|
||||
const maxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
if (stats.size > maxFileSize) {
|
||||
console.error('[appdev-design-mode] File too large:', absolutePath, stats.size);
|
||||
return { success: false, message: 'File too large (max 10MB)' };
|
||||
}
|
||||
|
||||
// Security: Validate file extension (only allow source files)
|
||||
const allowedExtensions = ['.js', '.jsx', '.ts', '.tsx', '.vue'];
|
||||
const ext = path.extname(absolutePath).toLowerCase();
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
console.error('[appdev-design-mode] Invalid file type:', absolutePath);
|
||||
return { success: false, message: 'Invalid file type' };
|
||||
}
|
||||
|
||||
// Security: Validate newValue length (prevent DoS)
|
||||
const maxValueLength = 100000; // 100KB
|
||||
if (newValue.length > maxValueLength) {
|
||||
console.error('[appdev-design-mode] Value too large:', newValue.length);
|
||||
return { success: false, message: 'Value too large (max 100KB)' };
|
||||
}
|
||||
|
||||
let sourceCode = fs.readFileSync(absolutePath, 'utf-8');
|
||||
let updated = false;
|
||||
|
||||
if (type === 'content') {
|
||||
if (originalValue && originalValue !== newValue) {
|
||||
// Try to match with original value first
|
||||
const escapedOriginal = originalValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(>\\s*)${escapedOriginal}(\\s*<)`, 'g');
|
||||
|
||||
const newSourceCode = sourceCode.replace(regex, `$1${newValue}$2`);
|
||||
|
||||
if (newSourceCode !== sourceCode) {
|
||||
fs.writeFileSync(absolutePath, newSourceCode, 'utf-8');
|
||||
updated = true;
|
||||
} else {
|
||||
// Fallback: If original value not found, try to find any similar content
|
||||
// This handles cases where the file was already updated by HMR
|
||||
console.warn('[appdev-design-mode] Original value not found, trying fallback match');
|
||||
|
||||
// Try to find the new value in the file (maybe it's already there)
|
||||
if (sourceCode.includes(newValue)) {
|
||||
updated = true; // Consider it successful since the desired state is already there
|
||||
}
|
||||
}
|
||||
} else if (originalValue === newValue) {
|
||||
// No change needed
|
||||
updated = true;
|
||||
} else {
|
||||
console.warn('[appdev-design-mode] Missing originalValue for content update');
|
||||
}
|
||||
} else if (type === 'style') {
|
||||
// TODO: Full style update pipeline
|
||||
// Simple implementation: find className="..." and replace (AST would be more robust)
|
||||
if (originalValue) {
|
||||
// Try className="originalValue"
|
||||
const escapedOriginal = originalValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// Matches className="...", class="...", className={...}, etc.; simplified to className="value"
|
||||
const regex = new RegExp(`(className=["'])${escapedOriginal}(["'])`, 'g');
|
||||
const newSourceCode = sourceCode.replace(regex, `$1${newValue}$2`);
|
||||
|
||||
if (newSourceCode !== sourceCode) {
|
||||
fs.writeFileSync(absolutePath, newSourceCode, 'utf-8');
|
||||
updated = true;
|
||||
}
|
||||
} else {
|
||||
// Style update requires originalValue (current implementation)
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
return { success: true, message: 'File updated successfully' };
|
||||
} else {
|
||||
console.warn('[appdev-design-mode] Could not update file - no match found');
|
||||
return { success: false, message: 'Could not locate content to update' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleUpdate(req: IncomingMessage, res: ServerResponse, root: string) {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readBody(req);
|
||||
try {
|
||||
const data = JSON.parse(body) as UpdateRequest;
|
||||
const result = performUpdate(root, data);
|
||||
|
||||
if (result.success) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(result));
|
||||
} else {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify(result));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[appdev-design-mode] Error handling update:', error);
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
resolve(body);
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function isPathWithinRoot(rootDir: string, targetPath: string): boolean {
|
||||
const resolvedRoot = path.resolve(rootDir);
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
if (resolvedRoot === resolvedTarget) {
|
||||
return true;
|
||||
}
|
||||
const relativePath = path.relative(resolvedRoot, resolvedTarget);
|
||||
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
import type { ViteDevServer } from 'vite';
|
||||
import type { DesignModeOptions } from '../types';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { applyVueSfcTemplateUpdate } from './vueSfcUpdater.js';
|
||||
|
||||
export function createServerMiddleware(
|
||||
options: Required<DesignModeOptions>,
|
||||
rootDir: string
|
||||
) {
|
||||
const enableBackup = options.enableBackup === true;
|
||||
const enableHistory = options.enableHistory === true;
|
||||
|
||||
return async (req: any, res: any) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
|
||||
try {
|
||||
switch (url.pathname) {
|
||||
// Core endpoints
|
||||
case '/get-source':
|
||||
await handleGetSource(url, res, rootDir);
|
||||
break;
|
||||
case '/modify-source':
|
||||
await handleModifySource(req, res, rootDir);
|
||||
break;
|
||||
case '/health':
|
||||
await handleHealthCheck(res);
|
||||
break;
|
||||
|
||||
// Extended endpoints
|
||||
case '/update':
|
||||
await handleUpdate(req, res, rootDir, enableBackup);
|
||||
break;
|
||||
case '/batch-update':
|
||||
await handleBatchUpdate(req, res, rootDir, enableBackup, enableHistory);
|
||||
break;
|
||||
case '/batch-update-status':
|
||||
await handleBatchUpdateStatus(req, res, rootDir, enableHistory);
|
||||
break;
|
||||
case '/undo':
|
||||
await handleUndo(req, res, rootDir, enableBackup);
|
||||
break;
|
||||
case '/redo':
|
||||
await handleRedo(req, res, rootDir);
|
||||
break;
|
||||
case '/get-history':
|
||||
await handleGetHistory(req, res, rootDir, enableHistory);
|
||||
break;
|
||||
case '/validate-update':
|
||||
await handleValidateUpdate(req, res, rootDir);
|
||||
break;
|
||||
|
||||
default:
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({
|
||||
error: 'Not found',
|
||||
requestedPath: url.pathname,
|
||||
availableEndpoints: [
|
||||
'/get-source',
|
||||
'/modify-source',
|
||||
'/update',
|
||||
'/batch-update',
|
||||
'/batch-update-status',
|
||||
'/undo',
|
||||
'/redo',
|
||||
'/get-history',
|
||||
'/validate-update',
|
||||
'/health'
|
||||
]
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DesignMode] Server middleware error:', error);
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: process.env.NODE_ENV === 'development' ? (error instanceof Error ? error.stack : undefined) : undefined
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /get-source — enriched source snapshot for an element id.
|
||||
*/
|
||||
async function handleGetSource(url: URL, res: any, rootDir: string) {
|
||||
const elementId = url.searchParams.get('elementId');
|
||||
|
||||
if (!elementId) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Missing elementId parameter' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse elementId → file position
|
||||
const sourceInfo = parseElementId(elementId);
|
||||
|
||||
if (!sourceInfo) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid elementId format' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.resolve(rootDir, sourceInfo.fileName);
|
||||
|
||||
// Security: Validate path is within project root (prevent path traversal)
|
||||
if (!isPathWithinRoot(rootDir, filePath)) {
|
||||
res.statusCode = 403;
|
||||
res.end(JSON.stringify({ error: 'Access denied: path outside project root' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
} catch {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Source file not found', filePath }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = await fs.promises.readFile(filePath, 'utf-8');
|
||||
|
||||
// Target line + context window
|
||||
const lines = fileContent.split('\n');
|
||||
const targetLine = Math.max(0, sourceInfo.lineNumber - 1);
|
||||
const contextLines = lines.slice(
|
||||
Math.max(0, targetLine - 5),
|
||||
Math.min(lines.length, targetLine + 5)
|
||||
);
|
||||
|
||||
// Response payload
|
||||
const response = {
|
||||
sourceInfo: {
|
||||
...sourceInfo,
|
||||
fileContent: fileContent,
|
||||
contextLines: contextLines,
|
||||
targetLineContent: lines[targetLine] || '',
|
||||
totalLines: lines.length,
|
||||
fileExists: true
|
||||
},
|
||||
elementMetadata: {
|
||||
tagName: extractTagNameFromLine(lines[targetLine]),
|
||||
estimatedClassName: extractClassNameFromLine(lines[targetLine]),
|
||||
lineContext: getLineContext(lines, targetLine)
|
||||
},
|
||||
fileStats: {
|
||||
size: fileContent.length,
|
||||
lastModified: (await fs.promises.stat(filePath)).mtime.getTime()
|
||||
}
|
||||
};
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify(response));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modify-source — legacy style update.
|
||||
*/
|
||||
async function handleModifySource(req: any, res: any, rootDir: string) {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', (chunk: any) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { elementId, newStyles, oldStyles } = JSON.parse(body);
|
||||
|
||||
if (!elementId || newStyles === undefined) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Missing required parameters' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse elementId
|
||||
const sourceInfo = parseElementId(elementId);
|
||||
if (!sourceInfo) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid elementId format' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.resolve(rootDir, sourceInfo.fileName);
|
||||
|
||||
// Security: Validate path is within project root (prevent path traversal)
|
||||
if (!isPathWithinRoot(rootDir, filePath)) {
|
||||
res.statusCode = 403;
|
||||
res.end(JSON.stringify({ error: 'Access denied: path outside project root' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = await fs.promises.readFile(filePath, 'utf-8');
|
||||
|
||||
const updatedContent = await smartReplaceInSource(
|
||||
fileContent,
|
||||
{
|
||||
lineNumber: sourceInfo.lineNumber,
|
||||
columnNumber: sourceInfo.columnNumber,
|
||||
newValue: newStyles,
|
||||
originalValue: oldStyles || '',
|
||||
type: 'style',
|
||||
},
|
||||
rootDir,
|
||||
filePath
|
||||
);
|
||||
|
||||
// Persist
|
||||
await fs.promises.writeFile(filePath, updatedContent, 'utf-8');
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Source modified successfully',
|
||||
sourceInfo,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
*/
|
||||
async function handleHealthCheck(res: any) {
|
||||
res.statusCode = 200;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
plugin: '@xagi/vite-plugin-design-mode',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /update — style, content, or attribute.
|
||||
*/
|
||||
async function handleUpdate(req: any, res: any, rootDir: string, enableBackup: boolean = false) {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', (chunk: any) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const updateData = JSON.parse(body);
|
||||
const { filePath, line, column, newValue, originalValue, type } = updateData;
|
||||
|
||||
// Validate body
|
||||
if (!filePath || line === undefined || column === undefined || newValue === undefined || !type) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Missing required parameters' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['style', 'content', 'attribute'].includes(type)) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid update type' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullFilePath = path.resolve(rootDir, filePath);
|
||||
|
||||
try {
|
||||
await fs.promises.access(fullFilePath, fs.constants.F_OK);
|
||||
} catch {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Source file not found', filePath }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = await fs.promises.readFile(fullFilePath, 'utf-8');
|
||||
const lines = fileContent.split('\n');
|
||||
const targetLine = Math.max(0, line - 1);
|
||||
|
||||
if (targetLine >= lines.length) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: `Line ${line} exceeds file length (${lines.length} lines)` }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Line-aware replace
|
||||
const updatedContent = await smartReplaceInSource(
|
||||
fileContent,
|
||||
{
|
||||
lineNumber: line,
|
||||
columnNumber: column,
|
||||
newValue,
|
||||
originalValue,
|
||||
type
|
||||
},
|
||||
rootDir,
|
||||
fullFilePath
|
||||
);
|
||||
|
||||
// Optional .backup copy
|
||||
let backupPath: string | undefined;
|
||||
if (enableBackup) {
|
||||
backupPath = `${fullFilePath}.backup.${Date.now()}`;
|
||||
await fs.promises.writeFile(backupPath, fileContent, 'utf-8');
|
||||
}
|
||||
|
||||
// Persist
|
||||
await fs.promises.writeFile(fullFilePath, updatedContent, 'utf-8');
|
||||
|
||||
// Audit record (in-memory response)
|
||||
const updateRecord = {
|
||||
id: `update_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
filePath,
|
||||
line,
|
||||
column,
|
||||
type,
|
||||
newValue,
|
||||
originalValue,
|
||||
timestamp: Date.now(),
|
||||
backupPath: backupPath || null
|
||||
};
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Update completed successfully',
|
||||
updateRecord,
|
||||
affectedLines: {
|
||||
before: lines[targetLine],
|
||||
after: updatedContent.split('\n')[targetLine]
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
details: error instanceof Error ? error.stack : undefined
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /batch-update
|
||||
*/
|
||||
async function handleBatchUpdate(req: any, res: any, rootDir: string, enableBackup: boolean = false, enableHistory: boolean = false) {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', (chunk: any) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { updates } = JSON.parse(body);
|
||||
|
||||
if (!Array.isArray(updates) || updates.length === 0) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'No updates provided or invalid format' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each item
|
||||
const validationResults = await Promise.allSettled(
|
||||
updates.map(update => validateUpdateRequest(update, rootDir))
|
||||
);
|
||||
|
||||
// Batch session id
|
||||
const batchId = `batch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const batchSession: {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
totalUpdates: number;
|
||||
successfulUpdates: number;
|
||||
failedUpdates: number;
|
||||
updates: any[];
|
||||
} = {
|
||||
id: batchId,
|
||||
timestamp: Date.now(),
|
||||
totalUpdates: updates.length,
|
||||
successfulUpdates: 0,
|
||||
failedUpdates: 0,
|
||||
updates: []
|
||||
};
|
||||
|
||||
// Apply updates sequentially
|
||||
const results = [];
|
||||
for (let i = 0; i < updates.length; i++) {
|
||||
const update = updates[i];
|
||||
const validation = validationResults[i];
|
||||
|
||||
if (validation.status === 'rejected') {
|
||||
results.push({
|
||||
success: false,
|
||||
error: validation.reason,
|
||||
update
|
||||
});
|
||||
batchSession.failedUpdates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processSingleUpdate(update, rootDir, batchId, enableBackup);
|
||||
results.push(result);
|
||||
if (result.success) {
|
||||
batchSession.successfulUpdates++;
|
||||
} else {
|
||||
batchSession.failedUpdates++;
|
||||
}
|
||||
batchSession.updates.push(result);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
update
|
||||
});
|
||||
batchSession.failedUpdates++;
|
||||
batchSession.updates.push(results[results.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist session JSON when history enabled
|
||||
if (enableHistory) {
|
||||
const sessionFile = path.join(rootDir, '.appdev_batch_sessions.json');
|
||||
let sessions = {};
|
||||
try {
|
||||
const existingSessionsContent = await fs.promises.readFile(sessionFile, 'utf-8');
|
||||
sessions = JSON.parse(existingSessionsContent);
|
||||
} catch {
|
||||
// Missing or unreadable → start empty
|
||||
}
|
||||
|
||||
(sessions as any)[batchId] = batchSession;
|
||||
await fs.promises.writeFile(sessionFile, JSON.stringify(sessions, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
batchId,
|
||||
success: batchSession.failedUpdates === 0,
|
||||
summary: {
|
||||
total: updates.length,
|
||||
successful: batchSession.successfulUpdates,
|
||||
failed: batchSession.failedUpdates
|
||||
},
|
||||
results
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /batch-update-status
|
||||
*/
|
||||
async function handleBatchUpdateStatus(req: any, res: any, rootDir: string, enableHistory: boolean = false) {
|
||||
const batchId = req.url.split('?')[1]?.split('=')[1];
|
||||
|
||||
if (!batchId) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Missing batchId' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enableHistory) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({
|
||||
error: 'History is disabled. Cannot query batch status without history enabled.',
|
||||
enableHistory: false
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionFile = path.join(rootDir, '.appdev_batch_sessions.json');
|
||||
let sessions = {};
|
||||
|
||||
try {
|
||||
const existingSessionsContent = await fs.promises.readFile(sessionFile, 'utf-8');
|
||||
sessions = JSON.parse(existingSessionsContent);
|
||||
} catch {
|
||||
// Session file missing
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Batch session not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const session = (sessions as any)[batchId];
|
||||
if (!session) {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Batch session not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
session
|
||||
}));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /undo — restore from backup when backups enabled.
|
||||
*/
|
||||
async function handleUndo(req: any, res: any, rootDir: string, enableBackup: boolean = false) {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', (chunk: any) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { batchId } = JSON.parse(body);
|
||||
|
||||
if (!batchId) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Missing batchId' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enableBackup) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({
|
||||
error: 'Backup is disabled. Cannot undo without backup files.',
|
||||
enableBackup: false
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Find backup sidecar files
|
||||
const backupFiles = await fs.promises.readdir(rootDir);
|
||||
const matchingBackups = backupFiles
|
||||
.filter(file => file.startsWith('.') && file.includes('.backup.') && file.includes(batchId));
|
||||
|
||||
if (matchingBackups.length === 0) {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'No backup files found for this batch' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick lexicographically latest (timestamp suffix)
|
||||
const latestBackup = matchingBackups.sort().pop()!;
|
||||
const backupPath = path.join(rootDir, latestBackup);
|
||||
const originalFile = backupPath.replace(/\.backup\.\d+$/, '');
|
||||
|
||||
// Restore from backup
|
||||
const backupContent = await fs.promises.readFile(backupPath, 'utf-8');
|
||||
await fs.promises.writeFile(originalFile, backupContent, 'utf-8');
|
||||
|
||||
await fs.promises.unlink(backupPath);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Undo completed successfully',
|
||||
restoredFile: originalFile,
|
||||
backupFile: backupPath
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /redo — not implemented.
|
||||
*/
|
||||
async function handleRedo(req: any, res: any, rootDir: string) {
|
||||
// Could re-apply updates from `.appdev_batch_sessions.json`
|
||||
res.statusCode = 501;
|
||||
res.end(JSON.stringify({ error: 'Redo operation not yet implemented' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /get-history
|
||||
*/
|
||||
async function handleGetHistory(req: any, res: any, rootDir: string, enableHistory: boolean = false) {
|
||||
if (!enableHistory) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({
|
||||
error: 'History is disabled. Cannot get history without history enabled.',
|
||||
enableHistory: false
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionFile = path.join(rootDir, '.appdev_batch_sessions.json');
|
||||
let sessions = {};
|
||||
|
||||
try {
|
||||
const existingSessionsContent = await fs.promises.readFile(sessionFile, 'utf-8');
|
||||
sessions = JSON.parse(existingSessionsContent);
|
||||
} catch {
|
||||
// No session file → empty history
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
history: sessions
|
||||
}));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /validate-update
|
||||
*/
|
||||
async function handleValidateUpdate(req: any, res: any, rootDir: string) {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', (chunk: any) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const updateData = JSON.parse(body);
|
||||
const validation = await validateUpdateRequest(updateData, rootDir);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
validation
|
||||
}));
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helpers
|
||||
*/
|
||||
|
||||
// Parse `elementId` → file + line + column
|
||||
function parseElementId(
|
||||
elementId: string
|
||||
): { fileName: string; lineNumber: number; columnNumber: number } | null {
|
||||
// Support IDs shaped like "<file>:<line>:<column>_<elementType>[_<index>]".
|
||||
const match = elementId.match(/^(.*):(\d+):(\d+)_/);
|
||||
if (!match) return null;
|
||||
|
||||
const fileName = match[1];
|
||||
const lineNumber = parseInt(match[2], 10);
|
||||
const columnNumber = parseInt(match[3], 10);
|
||||
|
||||
if (!fileName || isNaN(lineNumber) || isNaN(columnNumber)) return null;
|
||||
|
||||
return {
|
||||
fileName,
|
||||
lineNumber,
|
||||
columnNumber,
|
||||
};
|
||||
}
|
||||
|
||||
// Line-oriented smart replace
|
||||
async function smartReplaceInSource(
|
||||
content: string,
|
||||
options: {
|
||||
lineNumber: number;
|
||||
columnNumber: number;
|
||||
newValue: string;
|
||||
originalValue?: string;
|
||||
type: 'style' | 'content' | 'attribute';
|
||||
},
|
||||
rootDir: string,
|
||||
filePath?: string
|
||||
): Promise<string> {
|
||||
const lines = content.split('\n');
|
||||
const targetLine = Math.max(0, options.lineNumber - 1);
|
||||
|
||||
if (targetLine >= lines.length) {
|
||||
throw new Error(`Line ${options.lineNumber} exceeds file length`);
|
||||
}
|
||||
|
||||
const line = lines[targetLine];
|
||||
let newLine = line;
|
||||
const isVueFile = Boolean(filePath && filePath.endsWith('.vue'));
|
||||
|
||||
if (isVueFile) {
|
||||
return applyVueSfcTemplateUpdate(content, {
|
||||
lineNumber: options.lineNumber,
|
||||
columnNumber: options.columnNumber,
|
||||
newValue: options.newValue,
|
||||
originalValue: options.originalValue,
|
||||
type: options.type,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
switch (options.type) {
|
||||
case 'style':
|
||||
newLine = await smartReplaceStyle(line, options);
|
||||
break;
|
||||
case 'content':
|
||||
newLine = await smartReplaceContent(line, options);
|
||||
break;
|
||||
case 'attribute':
|
||||
newLine = await smartReplaceAttribute(line, options);
|
||||
break;
|
||||
}
|
||||
|
||||
lines[targetLine] = newLine;
|
||||
return lines.join('\n');
|
||||
} catch (error) {
|
||||
console.error('[DesignMode] Smart replace failed:', error);
|
||||
return content; // Fallback to original content
|
||||
}
|
||||
}
|
||||
|
||||
async function smartReplaceStyle(line: string, options: any): Promise<string> {
|
||||
const { newValue } = options;
|
||||
|
||||
// 1) className="..." or className='...'
|
||||
const classNameRegex = /className=(["'])(.*?)\1/;
|
||||
if (classNameRegex.test(line)) {
|
||||
return line.replace(classNameRegex, `className=$1${newValue}$1`);
|
||||
}
|
||||
|
||||
// 2) className={...} → coerce to static string (design mode output)
|
||||
const classNameExpressionRegex = /className=\{([^}]*)\}/;
|
||||
if (classNameExpressionRegex.test(line)) {
|
||||
return line.replace(classNameExpressionRegex, `className="${newValue}"`);
|
||||
}
|
||||
|
||||
// 3) No className: insert after opening tag name (<Foo or <div)
|
||||
const tagMatch = line.match(/<([A-Z][a-zA-Z0-9.]*|[a-z][a-z0-9-]*)/);
|
||||
if (tagMatch) {
|
||||
const tagName = tagMatch[1];
|
||||
return line.replace(tagName, `${tagName} className="${newValue}"`);
|
||||
}
|
||||
|
||||
// 4) No safe match — never replace the whole line with `newValue`
|
||||
console.warn('[DesignMode] Failed to match className or tag in line:', line);
|
||||
throw new Error('Cannot find a valid location to insert className. The component might not support styling or has complex syntax.');
|
||||
}
|
||||
|
||||
async function smartReplaceContent(line: string, options: any): Promise<string> {
|
||||
if (options.originalValue && line.includes(options.originalValue)) {
|
||||
return line.replace(
|
||||
new RegExp(escapeRegExp(options.originalValue), 'g'),
|
||||
options.newValue
|
||||
);
|
||||
}
|
||||
|
||||
// Between `>` and `<` when it matches original
|
||||
const contentMatch = line.match(/>([^<]*)</);
|
||||
if (contentMatch && contentMatch[1] === options.originalValue) {
|
||||
return line.replace(contentMatch[0], `>${options.newValue}<`);
|
||||
}
|
||||
|
||||
return options.newValue;
|
||||
}
|
||||
|
||||
async function smartReplaceAttribute(line: string, options: any): Promise<string> {
|
||||
return line.replace(
|
||||
new RegExp(`${options.attributeName}="[^"]*"`),
|
||||
`${options.attributeName}="${options.newValue}"`
|
||||
);
|
||||
}
|
||||
|
||||
async function validateUpdateRequest(update: any, rootDir: string): Promise<{ valid: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!update.filePath) errors.push('Missing filePath');
|
||||
if (update.line === undefined) errors.push('Missing line');
|
||||
if (update.column === undefined) errors.push('Missing column');
|
||||
if (update.newValue === undefined) errors.push('Missing newValue');
|
||||
if (!update.type) errors.push('Missing type');
|
||||
|
||||
// File must exist under root
|
||||
if (update.filePath) {
|
||||
const fullPath = path.resolve(rootDir, update.filePath);
|
||||
try {
|
||||
await fs.promises.access(fullPath, fs.constants.F_OK);
|
||||
} catch {
|
||||
errors.push(`File not found: ${update.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// type ∈ style | content | attribute
|
||||
if (update.type && !['style', 'content', 'attribute'].includes(update.type)) {
|
||||
errors.push(`Invalid update type: ${update.type}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
// Single item inside batch
|
||||
async function processSingleUpdate(update: any, rootDir: string, batchId: string, enableBackup: boolean = false): Promise<any> {
|
||||
try {
|
||||
const validation = await validateUpdateRequest(update, rootDir);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: validation.errors.join(', '),
|
||||
update
|
||||
};
|
||||
}
|
||||
|
||||
const fullFilePath = path.resolve(rootDir, update.filePath);
|
||||
const fileContent = await fs.promises.readFile(fullFilePath, 'utf-8');
|
||||
|
||||
const updatedContent = await smartReplaceInSource(
|
||||
fileContent,
|
||||
{
|
||||
lineNumber: update.line,
|
||||
columnNumber: update.column,
|
||||
newValue: update.newValue,
|
||||
originalValue: update.originalValue,
|
||||
type: update.type
|
||||
},
|
||||
rootDir,
|
||||
fullFilePath
|
||||
);
|
||||
|
||||
if (enableBackup) {
|
||||
const backupPath = `${fullFilePath}.backup.${Date.now()}`;
|
||||
await fs.promises.writeFile(backupPath, fileContent, 'utf-8');
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(fullFilePath, updatedContent, 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
update,
|
||||
affectedLines: {
|
||||
before: fileContent.split('\n')[update.line - 1],
|
||||
after: updatedContent.split('\n')[update.line - 1]
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
update
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic: file touched in last 5 minutes
|
||||
async function checkForModifications(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const lastModified = stat.mtime.getTime();
|
||||
const now = Date.now();
|
||||
|
||||
return (now - lastModified) < 5 * 60 * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort tag from a source line
|
||||
function extractTagNameFromLine(line: string): string {
|
||||
const tagMatch = line.match(/<(\w+)/);
|
||||
return tagMatch ? tagMatch[1] : 'unknown';
|
||||
}
|
||||
|
||||
// className="..." substring
|
||||
function extractClassNameFromLine(line: string): string {
|
||||
const classMatch = line.match(/className\s*=\s*["']([^"']+)["']/);
|
||||
return classMatch ? classMatch[1] : '';
|
||||
}
|
||||
|
||||
// Neighbor lines around index
|
||||
function getLineContext(lines: string[], targetLine: number): { before: string; current: string; after: string } {
|
||||
return {
|
||||
before: lines[Math.max(0, targetLine - 1)] || '',
|
||||
current: lines[targetLine] || '',
|
||||
after: lines[Math.min(lines.length - 1, targetLine + 1)] || ''
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isPathWithinRoot(rootDir: string, targetPath: string): boolean {
|
||||
const resolvedRoot = path.resolve(rootDir);
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
const relativePath = path.relative(resolvedRoot, resolvedTarget);
|
||||
if (resolvedRoot === resolvedTarget) {
|
||||
return true;
|
||||
}
|
||||
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import * as babel from '@babel/standalone';
|
||||
import traverse from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
import type { DesignModeOptions } from '../types';
|
||||
|
||||
export interface SourceMappingInfo {
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
columnNumber: number;
|
||||
elementType: string;
|
||||
componentName?: string;
|
||||
functionName?: string;
|
||||
elementId: string;
|
||||
attributePrefix: string;
|
||||
importPath?: string; // Resolved import path for the component
|
||||
isUIComponent?: boolean; // True when file is under components/ui (UI kit)
|
||||
}
|
||||
|
||||
|
||||
export interface JSXElementWithLoc extends t.JSXOpeningElement {
|
||||
loc: t.SourceLocation;
|
||||
}
|
||||
|
||||
// PluginItem type from Babel
|
||||
type PluginItem = any;
|
||||
type NodePath = any;
|
||||
|
||||
/**
|
||||
* Whether the source file lives under a UI-kit folder (components/ui).
|
||||
* Runtime selection prefers usage site over definition for these.
|
||||
*/
|
||||
function isUIComponentFile(filePath: string): boolean {
|
||||
// Match /components/ui/ or \components\ui\
|
||||
return /[/\\]components[/\\]ui[/\\]/.test(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Babel plugin factory: inject source-mapping data attributes.
|
||||
*/
|
||||
export function createSourceMappingPlugin(
|
||||
fileName: string,
|
||||
options: Required<DesignModeOptions>
|
||||
): PluginItem {
|
||||
const { attributePrefix } = options;
|
||||
const imports: Record<string, string> = {}; // local name -> module specifier
|
||||
|
||||
return {
|
||||
visitor: {
|
||||
// 1) Collect import bindings
|
||||
ImportDeclaration(path: NodePath) {
|
||||
const { node } = path;
|
||||
const sourceValue = node.source.value; // e.g. "@/components/ui/button"
|
||||
|
||||
node.specifiers.forEach((specifier: any) => {
|
||||
if (t.isImportSpecifier(specifier)) {
|
||||
// import { Button } from ...
|
||||
imports[specifier.local.name] = sourceValue;
|
||||
// console.log(`[DesignMode] Found import: ${specifier.local.name} from ${sourceValue}`);
|
||||
} else if (t.isImportDefaultSpecifier(specifier)) {
|
||||
// import Button from ...
|
||||
imports[specifier.local.name] = sourceValue;
|
||||
// console.log(`[DesignMode] Found default import: ${specifier.local.name} from ${sourceValue}`);
|
||||
} else if (t.isImportNamespaceSpecifier(specifier)) {
|
||||
// import * as UI from ...
|
||||
imports[specifier.local.name] = sourceValue;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
JSXOpeningElement(path: NodePath) {
|
||||
const { node } = path;
|
||||
|
||||
const location = node.loc;
|
||||
if (!location) return;
|
||||
|
||||
const componentInfo = extractComponentInfo(path);
|
||||
const elementType = getJSXElementName(node.name);
|
||||
|
||||
// Resolve import path for JSX name (member expr uses object part only)
|
||||
let importPath: string | undefined;
|
||||
if (t.isJSXIdentifier(node.name)) {
|
||||
importPath = imports[node.name.name];
|
||||
} else if (t.isJSXMemberExpression(node.name) && t.isJSXIdentifier(node.name.object)) {
|
||||
importPath = imports[node.name.object.name];
|
||||
}
|
||||
|
||||
// if (importPath) {
|
||||
// console.log(`[DesignMode] Injecting importPath for ${elementType}: ${importPath}`);
|
||||
// }
|
||||
|
||||
const isUIComponent = isUIComponentFile(fileName);
|
||||
|
||||
const sourceInfo: SourceMappingInfo = {
|
||||
fileName: fileName,
|
||||
lineNumber: location.start.line,
|
||||
columnNumber: location.start.column,
|
||||
elementType: elementType,
|
||||
componentName: componentInfo.componentName,
|
||||
functionName: componentInfo.functionName,
|
||||
elementId: generateElementId(node, fileName, location),
|
||||
attributePrefix,
|
||||
importPath,
|
||||
isUIComponent
|
||||
};
|
||||
|
||||
|
||||
addSourceInfoAttribute(node, sourceInfo, options);
|
||||
|
||||
addPositionAttribute(node, location, options);
|
||||
|
||||
addElementIdAttribute(node, sourceInfo, options);
|
||||
|
||||
// Optional: extra per-field attrs (disabled to keep DOM small; info JSON holds all)
|
||||
// addIndividualAttributes(node, sourceInfo, options);
|
||||
|
||||
// Check if content is static and add attribute
|
||||
if (isStaticContent(path)) {
|
||||
addStaticContentAttribute(node, path, options);
|
||||
}
|
||||
|
||||
// Check if className is static (pure string literal) and add attribute
|
||||
if (isStaticClassName(node)) {
|
||||
addStaticClassAttribute(node, options);
|
||||
}
|
||||
},
|
||||
|
||||
JSXElement(path: NodePath) {
|
||||
const { node } = path;
|
||||
const { openingElement, children } = node;
|
||||
|
||||
if (!children || children.length === 0) return;
|
||||
|
||||
const hasStaticText = children.some((child: any) => t.isJSXText(child) && child.value.trim() !== '');
|
||||
|
||||
if (hasStaticText) {
|
||||
const textChild = children.find((child: any) => t.isJSXText(child) && child.value.trim() !== '');
|
||||
if (textChild && textChild.loc) {
|
||||
// children-source: where static text came from (for pass-through components)
|
||||
const childrenSourceValue = `${fileName}:${textChild.loc.start.line}:${textChild.loc.start.column}`;
|
||||
const attributeName = `${attributePrefix}-children-source`;
|
||||
|
||||
openingElement.attributes.push(
|
||||
t.jsxAttribute(
|
||||
t.jsxIdentifier(attributeName),
|
||||
t.stringLiteral(childrenSourceValue)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort enclosing React component / function name.
|
||||
*/
|
||||
function extractComponentInfo(path: NodePath): { componentName?: string; functionName?: string } {
|
||||
const componentInfo: { componentName?: string; functionName?: string } = {};
|
||||
|
||||
// Nearest function or class
|
||||
const functionParent = path.findParent((p: NodePath) =>
|
||||
t.isFunctionDeclaration(p.node) ||
|
||||
t.isArrowFunctionExpression(p.node) ||
|
||||
t.isClassDeclaration(p.node)
|
||||
);
|
||||
|
||||
if (functionParent) {
|
||||
const node = functionParent.node;
|
||||
|
||||
if (t.isFunctionDeclaration(node) && node.id?.name) {
|
||||
componentInfo.functionName = node.id.name;
|
||||
// React: components are PascalCase
|
||||
if (/^[A-Z]/.test(node.id.name)) {
|
||||
componentInfo.componentName = node.id.name;
|
||||
}
|
||||
} else if (t.isArrowFunctionExpression(node)) {
|
||||
componentInfo.functionName = 'anonymous-arrow-function';
|
||||
} else if (t.isClassDeclaration(node) && node.id?.name) {
|
||||
componentInfo.functionName = node.id.name;
|
||||
componentInfo.componentName = node.id.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: const Foo = ...
|
||||
const variableParent = path.findParent((p: NodePath) =>
|
||||
t.isVariableDeclarator(p.node)
|
||||
);
|
||||
|
||||
if (variableParent && t.isVariableDeclarator(variableParent.node)) {
|
||||
const id = variableParent.node.id;
|
||||
if (t.isIdentifier(id)) {
|
||||
componentInfo.functionName = id.name;
|
||||
if (/^[A-Z]/.test(id.name)) {
|
||||
componentInfo.componentName = id.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return componentInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSX tag name as string (identifier or member root).
|
||||
*/
|
||||
function getJSXElementName(name: any): string {
|
||||
if (t.isJSXIdentifier(name)) {
|
||||
return name.name;
|
||||
} else if (t.isJSXMemberExpression(name)) {
|
||||
return `${getJSXElementName(name.object)}`;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable id: file:line:col_tag#id
|
||||
*/
|
||||
function generateElementId(node: t.JSXOpeningElement, fileName: string, location: t.SourceLocation): string {
|
||||
const tagName = getJSXElementName(node.name);
|
||||
|
||||
// const className = extractStringAttribute(node, 'className'); // omitted to shorten id
|
||||
const id = extractStringAttribute(node, 'id');
|
||||
|
||||
const baseId = `${fileName}:${location.start.line}:${location.start.column}`;
|
||||
const tag = tagName.toLowerCase();
|
||||
// const cls = className ? className.replace(/\s+/g, '-') : '';
|
||||
const elementId = id ? `#${id}` : '';
|
||||
|
||||
// return `${baseId}_${tag}${cls ? '_' + cls : ''}${elementId}`;
|
||||
return `${baseId}_${tag}${elementId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* String literal JSX attribute value, if any.
|
||||
*/
|
||||
function extractStringAttribute(node: t.JSXOpeningElement, attributeName: string): string | null {
|
||||
const attr = node.attributes.find(a =>
|
||||
t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === attributeName
|
||||
) as t.JSXAttribute;
|
||||
|
||||
if (attr && t.isStringLiteral(attr.value)) {
|
||||
return attr.value.value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject compact JSON on `{prefix}-info`.
|
||||
*/
|
||||
function addSourceInfoAttribute(node: t.JSXOpeningElement, sourceInfo: SourceMappingInfo, options: Required<DesignModeOptions>) {
|
||||
const { attributePrefix } = options;
|
||||
|
||||
// Remove previous info attr
|
||||
node.attributes = node.attributes.filter(a =>
|
||||
!(t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === `${attributePrefix}-info`)
|
||||
);
|
||||
|
||||
// Push new JSON attr
|
||||
const attr = t.jSXAttribute(
|
||||
t.jSXIdentifier(`${attributePrefix}-info`),
|
||||
t.stringLiteral(JSON.stringify({
|
||||
fileName: sourceInfo.fileName,
|
||||
lineNumber: sourceInfo.lineNumber,
|
||||
columnNumber: sourceInfo.columnNumber,
|
||||
elementType: sourceInfo.elementType,
|
||||
componentName: sourceInfo.componentName,
|
||||
functionName: sourceInfo.functionName,
|
||||
elementId: sourceInfo.elementId,
|
||||
importPath: sourceInfo.importPath,
|
||||
isUIComponent: sourceInfo.isUIComponent
|
||||
}))
|
||||
);
|
||||
|
||||
node.attributes.unshift(attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* `{prefix}-position` shorthand.
|
||||
*/
|
||||
function addPositionAttribute(node: t.JSXOpeningElement, location: t.SourceLocation, options: Required<DesignModeOptions>) {
|
||||
const { attributePrefix } = options;
|
||||
|
||||
node.attributes = node.attributes.filter(a =>
|
||||
!(t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === `${attributePrefix}-position`)
|
||||
);
|
||||
|
||||
const attr = t.jSXAttribute(
|
||||
t.jSXIdentifier(`${attributePrefix}-position`),
|
||||
t.stringLiteral(`${location.start.line}:${location.start.column}`)
|
||||
);
|
||||
|
||||
node.attributes.unshift(attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* `{prefix}-element-id`.
|
||||
*/
|
||||
function addElementIdAttribute(node: t.JSXOpeningElement, sourceInfo: SourceMappingInfo, options: Required<DesignModeOptions>) {
|
||||
const { attributePrefix } = options;
|
||||
|
||||
node.attributes = node.attributes.filter(a =>
|
||||
!(t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === `${attributePrefix}-element-id`)
|
||||
);
|
||||
|
||||
const attr = t.jSXAttribute(
|
||||
t.jSXIdentifier(`${attributePrefix}-element-id`),
|
||||
t.stringLiteral(sourceInfo.elementId)
|
||||
);
|
||||
|
||||
node.attributes.unshift(attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy: one attribute per field (debug / queries).
|
||||
*/
|
||||
function addIndividualAttributes(node: t.JSXOpeningElement, sourceInfo: SourceMappingInfo, options: Required<DesignModeOptions>) {
|
||||
const { attributePrefix } = options;
|
||||
|
||||
// Helper to add attribute if it doesn't exist
|
||||
const addAttr = (name: string, value: string | number | undefined) => {
|
||||
if (value === undefined) return;
|
||||
|
||||
node.attributes = node.attributes.filter(a =>
|
||||
!(t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === name)
|
||||
);
|
||||
|
||||
node.attributes.unshift(t.jSXAttribute(
|
||||
t.jSXIdentifier(name),
|
||||
t.stringLiteral(String(value))
|
||||
));
|
||||
};
|
||||
|
||||
addAttr(`${attributePrefix}-file`, sourceInfo.fileName);
|
||||
addAttr(`${attributePrefix}-line`, sourceInfo.lineNumber);
|
||||
addAttr(`${attributePrefix}-column`, sourceInfo.columnNumber);
|
||||
addAttr(`${attributePrefix}-component`, sourceInfo.componentName);
|
||||
addAttr(`${attributePrefix}-function`, sourceInfo.functionName);
|
||||
addAttr(`${attributePrefix}-import`, sourceInfo.importPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when children are exclusively JSXText (no expr containers, nested tags, etc.).
|
||||
*/
|
||||
function isStaticContent(path: NodePath): boolean {
|
||||
const { node } = path;
|
||||
const element = path.parent; // JSXElement
|
||||
|
||||
if (!t.isJSXElement(element)) return false;
|
||||
|
||||
if (element.children.length === 0) return false;
|
||||
|
||||
return element.children.every(child => {
|
||||
if (t.isJSXText(child)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `{prefix}-static-content="true"` when children are pure JSXText.
|
||||
*/
|
||||
function addStaticContentAttribute(node: t.JSXOpeningElement, path: NodePath, options: Required<DesignModeOptions>) {
|
||||
const { attributePrefix } = options;
|
||||
const attributeName = `${attributePrefix}-static-content`;
|
||||
|
||||
if (!isStaticContent(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if attribute already exists
|
||||
const hasAttr = node.attributes.some(a =>
|
||||
t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === attributeName
|
||||
);
|
||||
|
||||
if (!hasAttr) {
|
||||
node.attributes.unshift(t.jSXAttribute(
|
||||
t.jSXIdentifier(attributeName),
|
||||
t.stringLiteral('true')
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static className: absent, string literal, or expr with only static string/template without expressions.
|
||||
* Dynamic: variables, cn(), conditional expr, template with ${}, etc.
|
||||
*/
|
||||
function isStaticClassName(node: t.JSXOpeningElement): boolean {
|
||||
const classNameAttr = node.attributes.find(attr =>
|
||||
t.isJSXAttribute(attr) &&
|
||||
t.isJSXIdentifier(attr.name) &&
|
||||
attr.name.name === 'className'
|
||||
) as t.JSXAttribute | undefined;
|
||||
|
||||
if (!classNameAttr) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const value = classNameAttr.value;
|
||||
|
||||
if (t.isStringLiteral(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (t.isJSXExpressionContainer(value)) {
|
||||
const expression = value.expression;
|
||||
|
||||
if (t.isStringLiteral(expression)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (t.isTemplateLiteral(expression)) {
|
||||
if (expression.expressions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{prefix}-static-class="true"` when className is static per `isStaticClassName`.
|
||||
*/
|
||||
function addStaticClassAttribute(node: t.JSXOpeningElement, options: Required<DesignModeOptions>) {
|
||||
const { attributePrefix } = options;
|
||||
const attributeName = `${attributePrefix}-static-class`;
|
||||
|
||||
// Check if attribute already exists
|
||||
const hasAttr = node.attributes.some(a =>
|
||||
t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === attributeName
|
||||
);
|
||||
|
||||
if (!hasAttr) {
|
||||
node.attributes.unshift(t.jSXAttribute(
|
||||
t.jSXIdentifier(attributeName),
|
||||
t.stringLiteral('true')
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { NodeTypes, parse as parseTemplate } from '@vue/compiler-dom';
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc';
|
||||
import type { DesignModeOptions } from '../types';
|
||||
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function getLineAndColumnFromIndex(source: string, index: number): { line: number; column: number } {
|
||||
const safeIndex = Math.max(0, Math.min(index, source.length));
|
||||
const prefix = source.slice(0, safeIndex);
|
||||
const lines = prefix.split('\n');
|
||||
return {
|
||||
line: lines.length,
|
||||
column: (lines[lines.length - 1] ?? '').length + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformVueSfcTemplate(
|
||||
code: string,
|
||||
id: string,
|
||||
options: Required<DesignModeOptions>
|
||||
): string {
|
||||
const sfc = parseSfc(code, { filename: id });
|
||||
const templateBlock = sfc.descriptor.template;
|
||||
if (!templateBlock) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const templateContent = templateBlock.content;
|
||||
const attrPrefix = options.attributePrefix;
|
||||
const existingAttrPattern = new RegExp(
|
||||
`\\s${attrPrefix}-[\\w-]+=(?:"[^"]*"|'[^']*')`,
|
||||
'g'
|
||||
);
|
||||
const cleanedTemplate = templateContent.replace(existingAttrPattern, '');
|
||||
const templateBaseOffset = templateBlock.loc.start.offset;
|
||||
const ast = parseTemplate(cleanedTemplate, { comments: true });
|
||||
|
||||
const insertions: Array<{ offset: number; text: string }> = [];
|
||||
let elementIndex = 0;
|
||||
|
||||
const visit = (node: any) => {
|
||||
if (!node || node.type !== NodeTypes.ELEMENT) {
|
||||
if (Array.isArray(node?.children)) {
|
||||
node.children.forEach(visit);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = String(node.tag);
|
||||
const startTagEndInTemplate = findStartTagEndOffset(cleanedTemplate, node.loc.start.offset);
|
||||
if (startTagEndInTemplate < 0) {
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach(visit);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const absoluteStartOffset = templateBaseOffset + node.loc.start.offset;
|
||||
const { line, column } = getLineAndColumnFromIndex(code, absoluteStartOffset);
|
||||
const elementType = tagName.toLowerCase();
|
||||
const elementId = `${id}:${line}:${column}_${elementType}_${elementIndex++}`;
|
||||
const sourceInfo = {
|
||||
fileName: id,
|
||||
lineNumber: line,
|
||||
columnNumber: column,
|
||||
// Backward compatibility for old clients that still read `line`/`column`.
|
||||
line,
|
||||
column,
|
||||
elementId,
|
||||
elementType,
|
||||
};
|
||||
const infoAttr = escapeHtmlAttr(JSON.stringify(sourceInfo));
|
||||
let attrsToInject =
|
||||
` ${attrPrefix}-info="${infoAttr}"` +
|
||||
` ${attrPrefix}-position="${line}:${column}"` +
|
||||
` ${attrPrefix}-element-id="${elementId}"`;
|
||||
|
||||
// Add static-content attribute if children are pure text
|
||||
if (isStaticContent(node)) {
|
||||
attrsToInject += ` ${attrPrefix}-static-content="true"`;
|
||||
}
|
||||
|
||||
// Add static-class attribute if class is static
|
||||
if (isStaticClass(node)) {
|
||||
attrsToInject += ` ${attrPrefix}-static-class="true"`;
|
||||
}
|
||||
|
||||
insertions.push({
|
||||
offset: templateBaseOffset + startTagEndInTemplate,
|
||||
text: attrsToInject,
|
||||
});
|
||||
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach(visit);
|
||||
}
|
||||
};
|
||||
|
||||
ast.children.forEach(visit);
|
||||
|
||||
if (insertions.length === 0 && cleanedTemplate === templateContent) {
|
||||
return code;
|
||||
}
|
||||
|
||||
let rebuilt = code.slice(0, templateBaseOffset) + cleanedTemplate + code.slice(templateBaseOffset + templateContent.length);
|
||||
insertions.sort((a, b) => b.offset - a.offset).forEach((entry) => {
|
||||
rebuilt = rebuilt.slice(0, entry.offset) + entry.text + rebuilt.slice(entry.offset);
|
||||
});
|
||||
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
function findStartTagEndOffset(source: string, startOffset: number): number {
|
||||
let index = startOffset;
|
||||
let quote: '"' | "'" | null = null;
|
||||
while (index < source.length) {
|
||||
const char = source[index];
|
||||
if (!quote && (char === '"' || char === "'")) {
|
||||
quote = char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote && char === quote) {
|
||||
quote = null;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!quote && char === '>') {
|
||||
if (index > startOffset && source[index - 1] === '/') {
|
||||
return index - 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has only static text content (no interpolations, no nested elements)
|
||||
*/
|
||||
function isStaticContent(node: any): boolean {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All children must be TEXT nodes (type 2)
|
||||
return node.children.every((child: any) => {
|
||||
return child.type === NodeTypes.TEXT;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has static class attribute (no v-bind:class, no :class)
|
||||
* Static means: absent, or a plain string attribute
|
||||
*/
|
||||
function isStaticClass(node: any): boolean {
|
||||
if (!node.props || node.props.length === 0) {
|
||||
return true; // No class at all = static
|
||||
}
|
||||
|
||||
// Look for class-related props
|
||||
for (const prop of node.props) {
|
||||
// v-bind:class or :class (type 7 = DIRECTIVE)
|
||||
if (prop.type === NodeTypes.DIRECTIVE) {
|
||||
if (prop.name === 'bind' && prop.arg?.content === 'class') {
|
||||
return false; // Dynamic binding
|
||||
}
|
||||
}
|
||||
|
||||
// Plain class attribute (type 6 = ATTRIBUTE)
|
||||
if (prop.type === NodeTypes.ATTRIBUTE && prop.name === 'class') {
|
||||
// Static string class
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return true; // No class attribute found = static
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { parse as parseSfc } from '@vue/compiler-sfc';
|
||||
import { NodeTypes, parse as parseTemplate } from '@vue/compiler-dom';
|
||||
|
||||
type UpdateKind = 'style' | 'content' | 'attribute';
|
||||
|
||||
export interface VueSfcUpdateOptions {
|
||||
lineNumber: number;
|
||||
columnNumber: number;
|
||||
newValue: string;
|
||||
originalValue?: string;
|
||||
type: UpdateKind;
|
||||
}
|
||||
|
||||
interface SourceSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function applyVueSfcTemplateUpdate(
|
||||
source: string,
|
||||
options: VueSfcUpdateOptions
|
||||
): string {
|
||||
const sfc = parseSfc(source, { filename: 'component.vue' });
|
||||
const templateBlock = sfc.descriptor.template;
|
||||
if (!templateBlock) {
|
||||
throw new Error('Vue SFC missing <template> block.');
|
||||
}
|
||||
|
||||
const templateStartOffset = templateBlock.loc.start.offset;
|
||||
const templateSource = templateBlock.content;
|
||||
const targetOffsetInFile = getOffsetFromLineColumn(source, options.lineNumber, options.columnNumber);
|
||||
const targetOffsetInTemplate = targetOffsetInFile - templateStartOffset;
|
||||
if (targetOffsetInTemplate < 0 || targetOffsetInTemplate > templateSource.length) {
|
||||
throw new Error('Target position is outside <template> block.');
|
||||
}
|
||||
|
||||
const ast = parseTemplate(templateSource, { comments: true });
|
||||
const targetElement = findElementByPosition(ast, targetOffsetInTemplate);
|
||||
if (!targetElement) {
|
||||
throw new Error('Unable to locate target element in Vue template AST.');
|
||||
}
|
||||
|
||||
switch (options.type) {
|
||||
case 'style':
|
||||
return updateElementClass(source, templateStartOffset, targetElement, options.newValue);
|
||||
case 'content':
|
||||
return updateElementTextContent(source, templateStartOffset, targetElement, options.newValue, options.originalValue);
|
||||
case 'attribute':
|
||||
throw new Error('Vue attribute update is not supported in AST mode yet.');
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
function updateElementClass(
|
||||
fullSource: string,
|
||||
templateStartOffset: number,
|
||||
elementNode: any,
|
||||
newClassValue: string
|
||||
): string {
|
||||
const classAttr = elementNode.props.find(
|
||||
(prop: any) => prop.type === NodeTypes.ATTRIBUTE && prop.name === 'class'
|
||||
);
|
||||
if (classAttr?.value?.loc) {
|
||||
const valueLoc = toFullFileSpan(templateStartOffset, classAttr.value.loc.start.offset, classAttr.value.loc.end.offset);
|
||||
const originalValueSource = fullSource.slice(valueLoc.start, valueLoc.end);
|
||||
const quoteChar = originalValueSource.startsWith("'") ? "'" : '"';
|
||||
const escaped = escapeAttributeValue(newClassValue, quoteChar);
|
||||
return replaceSpan(fullSource, valueLoc, `${quoteChar}${escaped}${quoteChar}`);
|
||||
}
|
||||
|
||||
const classBind = elementNode.props.find(
|
||||
(prop: any) => prop.type === NodeTypes.DIRECTIVE && prop.name === 'bind' && prop.arg?.type === NodeTypes.SIMPLE_EXPRESSION && prop.arg.content === 'class'
|
||||
);
|
||||
if (classBind?.exp?.type === NodeTypes.SIMPLE_EXPRESSION && classBind.exp.isStatic) {
|
||||
const bindLoc = toFullFileSpan(templateStartOffset, classBind.loc.start.offset, classBind.loc.end.offset);
|
||||
return replaceSpan(fullSource, bindLoc, `class="${escapeAttributeValue(newClassValue)}"`);
|
||||
}
|
||||
if (classBind) {
|
||||
throw new Error('Dynamic :class expression is not safely editable in Vue AST mode.');
|
||||
}
|
||||
|
||||
const insertOffset = templateStartOffset + elementNode.loc.start.offset + `<${elementNode.tag}`.length;
|
||||
return `${fullSource.slice(0, insertOffset)} class="${escapeAttributeValue(newClassValue)}"${fullSource.slice(insertOffset)}`;
|
||||
}
|
||||
|
||||
function updateElementTextContent(
|
||||
fullSource: string,
|
||||
templateStartOffset: number,
|
||||
elementNode: any,
|
||||
newText: string,
|
||||
originalValue?: string
|
||||
): string {
|
||||
const textChild = elementNode.children.find(
|
||||
(child: any) => child.type === NodeTypes.TEXT && child.content.trim().length > 0
|
||||
);
|
||||
if (!textChild?.loc) {
|
||||
throw new Error('Target Vue element has no editable static text node.');
|
||||
}
|
||||
|
||||
const currentText = textChild.content;
|
||||
if (originalValue && !currentText.includes(originalValue)) {
|
||||
throw new Error('Original text does not match current Vue template text.');
|
||||
}
|
||||
|
||||
const replacement = originalValue
|
||||
? currentText.replace(new RegExp(escapeRegExp(originalValue), 'g'), newText)
|
||||
: newText;
|
||||
const textLoc = toFullFileSpan(templateStartOffset, textChild.loc.start.offset, textChild.loc.end.offset);
|
||||
return replaceSpan(fullSource, textLoc, replacement);
|
||||
}
|
||||
|
||||
function findElementByPosition(ast: any, targetOffset: number): any | null {
|
||||
let matched: any | null = null;
|
||||
|
||||
const visit = (node: any) => {
|
||||
if (!node?.loc?.start || !node?.loc?.end) {
|
||||
return;
|
||||
}
|
||||
if (targetOffset < node.loc.start.offset || targetOffset > node.loc.end.offset) {
|
||||
return;
|
||||
}
|
||||
if (node.type === NodeTypes.ELEMENT) {
|
||||
matched = node;
|
||||
}
|
||||
if (Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const child of ast.children ?? []) {
|
||||
visit(child);
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
function getOffsetFromLineColumn(source: string, line: number, column: number): number {
|
||||
const lines = source.split('\n');
|
||||
if (line < 1 || line > lines.length) {
|
||||
throw new Error(`Line ${line} exceeds source length.`);
|
||||
}
|
||||
const targetLine = lines[line - 1] ?? '';
|
||||
if (column < 1 || column > targetLine.length + 1) {
|
||||
throw new Error(`Column ${column} is invalid for line ${line}.`);
|
||||
}
|
||||
let offset = 0;
|
||||
for (let i = 0; i < line - 1; i++) {
|
||||
offset += (lines[i] ?? '').length + 1;
|
||||
}
|
||||
return offset + (column - 1);
|
||||
}
|
||||
|
||||
function toFullFileSpan(templateStart: number, start: number, end: number): SourceSpan {
|
||||
return {
|
||||
start: templateStart + start,
|
||||
end: templateStart + end,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceSpan(source: string, span: SourceSpan, replacement: string): string {
|
||||
return `${source.slice(0, span.start)}${replacement}${source.slice(span.end)}`;
|
||||
}
|
||||
|
||||
function escapeAttributeValue(value: string, quoteChar: '"' | "'" = '"'): string {
|
||||
if (quoteChar === "'") {
|
||||
return value.replace(/'/g, ''');
|
||||
}
|
||||
return value.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
569
qiming-vite-plugin-design-mode/packages/plugin/src/index.ts
Normal file
569
qiming-vite-plugin-design-mode/packages/plugin/src/index.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user