"添加前端模板和运行代码模块"
This commit is contained in:
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user