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 = { 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(); 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 = { 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 ): 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 } { const packageJsonPath = resolve(projectRoot, 'package.json'); if (!existsSync(packageJsonPath)) { return { deps: {} }; } try { const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { dependencies?: Record; devDependencies?: Record; }; 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 ''; } }