570 lines
17 KiB
TypeScript
570 lines
17 KiB
TypeScript
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 '';
|
|
}
|
|
}
|
|
|