feat: sync full workspace including web modules, docs, and configurations to Gitea
Optimized the root .gitignore to exclude virtual environments, node modules, and temp folders to ensure clean and lightweight version tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function hasVersionQuery(requestUrl: string) {
|
||||
return /[?&]v=/.test(requestUrl);
|
||||
}
|
||||
|
||||
function setNoStoreHeaders(res: ServerResponse) {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
}
|
||||
|
||||
function setImmutableAssetHeaders(res: ServerResponse) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
|
||||
export function handleAssetsRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (req.url && req.url.startsWith('/assets/')) {
|
||||
const pathname = req.url.split('?')[0];
|
||||
const relativePath = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
||||
const assetPath = path.resolve(process.cwd(), 'admin', relativePath);
|
||||
|
||||
console.log('[主项目] 请求 asset:', req.url, '-> 路径:', assetPath, '存在:', fs.existsSync(assetPath));
|
||||
|
||||
if (fs.existsSync(assetPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(assetPath);
|
||||
const ext = path.extname(assetPath);
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.gif': 'image/gif'
|
||||
};
|
||||
|
||||
if (hasVersionQuery(req.url)) {
|
||||
setImmutableAssetHeaders(res);
|
||||
} else {
|
||||
setNoStoreHeaders(res);
|
||||
}
|
||||
res.setHeader('Content-Type', contentTypes[ext] || 'application/octet-stream');
|
||||
res.statusCode = 200;
|
||||
res.end(content);
|
||||
console.log('[主项目] ✅ 成功返回 asset:', req.url);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[主项目] ❌ 读取 assets 文件失败:', err);
|
||||
}
|
||||
} else {
|
||||
console.log('[主项目] ❌ asset 文件不存在');
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
112
axhub-make/vite-plugins/virtualHtml/handlers/buildHandler.ts
Normal file
112
axhub-make/vite-plugins/virtualHtml/handlers/buildHandler.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execa } from 'execa';
|
||||
import { readEntriesManifest } from '../../utils/entriesManifest';
|
||||
|
||||
/**
|
||||
* 构建锁:防止同一入口并发构建,防止多个构建同时阻塞资源。
|
||||
* key = urlPath, value = Promise(当前正在构建的 Promise)
|
||||
*/
|
||||
const activeBuildMap = new Map<string, Promise<{ js: string } | null>>();
|
||||
|
||||
/** 构建超时时间(毫秒) */
|
||||
const BUILD_TIMEOUT_MS = 120_000; // 2 分钟
|
||||
|
||||
async function runBuild(urlPath: string, projectRoot: string): Promise<{ js: string } | null> {
|
||||
try {
|
||||
const buildProcess = execa('npx', ['vite', 'build'], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env, ENTRY_KEY: urlPath },
|
||||
timeout: BUILD_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await buildProcess;
|
||||
|
||||
const builtFilePath = path.resolve(projectRoot, 'dist', `${urlPath}.js`);
|
||||
if (fs.existsSync(builtFilePath)) {
|
||||
const jsContent = fs.readFileSync(builtFilePath, 'utf8');
|
||||
console.log(`✅ 构建成功: ${urlPath}`);
|
||||
return { js: jsContent };
|
||||
}
|
||||
|
||||
console.error('构建文件不存在:', builtFilePath);
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
if (error.timedOut) {
|
||||
console.error(`⏰ 构建超时 (${BUILD_TIMEOUT_MS / 1000}s): ${urlPath}`);
|
||||
} else {
|
||||
console.error(`❌ 构建失败: ${urlPath}`);
|
||||
console.error('错误信息:', error.message);
|
||||
if (error.stderr) {
|
||||
console.error('stderr:', error.stderr);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
activeBuildMap.delete(urlPath);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleBuildRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (req.url && req.url.startsWith('/build/') && req.url.endsWith('.js')) {
|
||||
const encodedUrlPath = req.url.replace('/build/', '').replace('.js', '');
|
||||
const urlPath = decodeURIComponent(encodedUrlPath);
|
||||
const projectRoot = process.cwd();
|
||||
const directEntryPath = path.resolve(projectRoot, 'src', urlPath, 'index.tsx');
|
||||
let hasEntry = fs.existsSync(directEntryPath);
|
||||
|
||||
if (!hasEntry) {
|
||||
try {
|
||||
const manifest = readEntriesManifest(projectRoot);
|
||||
const item = manifest.items?.[urlPath];
|
||||
if (item?.js) {
|
||||
const manifestEntryPath = path.resolve(projectRoot, item.js);
|
||||
hasEntry = fs.existsSync(manifestEntryPath);
|
||||
}
|
||||
} catch {
|
||||
hasEntry = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasEntry) {
|
||||
console.log(`\n🔨 开始构建: ${urlPath}`);
|
||||
|
||||
// 如果同一入口已经在构建中,复用其 Promise,不重复启动
|
||||
let buildPromise = activeBuildMap.get(urlPath);
|
||||
if (!buildPromise) {
|
||||
buildPromise = runBuild(urlPath, projectRoot);
|
||||
activeBuildMap.set(urlPath, buildPromise);
|
||||
} else {
|
||||
console.log(`⏳ 复用进行中的构建: ${urlPath}`);
|
||||
}
|
||||
|
||||
buildPromise
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
res.setHeader('Content-Type', 'application/javascript');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.statusCode = 200;
|
||||
res.end(result.js);
|
||||
} else {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end(`Build failed for ${urlPath}`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end(`Build failed for ${urlPath}\n${err.message}`);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
function tryDecodeUrlPath(input: string): string {
|
||||
try {
|
||||
return decodeURIComponent(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(baseDir: string, targetPath: string): boolean {
|
||||
const relative = path.relative(baseDir, targetPath);
|
||||
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function sendFile(res: ServerResponse, filePath: string): void {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.statusCode = 200;
|
||||
res.end(fs.readFileSync(filePath));
|
||||
}
|
||||
|
||||
function hasViteModuleQuery(url: string): boolean {
|
||||
const query = url.split('?')[1];
|
||||
if (!query) return false;
|
||||
|
||||
const params = new URLSearchParams(query);
|
||||
return (
|
||||
params.has('import') ||
|
||||
params.has('url') ||
|
||||
params.has('raw') ||
|
||||
params.has('worker') ||
|
||||
params.has('sharedworker')
|
||||
);
|
||||
}
|
||||
|
||||
export function handleDocImageAssets(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!req.url) return false;
|
||||
if (hasViteModuleQuery(req.url)) return false;
|
||||
|
||||
const rawPathname = req.url.split('?')[0];
|
||||
const pathname = tryDecodeUrlPath(rawPathname);
|
||||
const isDocsAssetRequest = pathname.startsWith('/docs/') && pathname.includes('/assets/');
|
||||
if (!pathname.includes('/assets/images/') && !isDocsAssetRequest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// /components/{name}/assets/images/{file}
|
||||
// /prototypes/{name}/assets/images/{file}
|
||||
// /themes/{name}/assets/images/{file}
|
||||
const typedMatch = pathname.match(/^\/(components|prototypes|themes)\/([^/]+)\/assets\/images\/(.+)$/);
|
||||
if (typedMatch) {
|
||||
const type = typedMatch[1];
|
||||
const entryName = typedMatch[2];
|
||||
const relativeAssetPath = typedMatch[3];
|
||||
const entryRoot = path.resolve(process.cwd(), 'src', type);
|
||||
const baseDir = path.resolve(entryRoot, entryName, 'assets', 'images');
|
||||
if (!isPathInside(entryRoot, baseDir)) {
|
||||
res.statusCode = 403;
|
||||
res.end('Forbidden');
|
||||
return true;
|
||||
}
|
||||
const targetPath = path.resolve(baseDir, relativeAssetPath);
|
||||
|
||||
if (!isPathInside(baseDir, targetPath)) {
|
||||
res.statusCode = 403;
|
||||
res.end('Forbidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isFile()) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found');
|
||||
return true;
|
||||
}
|
||||
|
||||
sendFile(res, targetPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
// /docs/assets/{file}
|
||||
// /docs/assets/images/{file}
|
||||
// /docs/{subdir}/assets/{file}
|
||||
// /docs/{subdir}/assets/images/{file}
|
||||
if (pathname.startsWith('/docs/')) {
|
||||
const afterDocs = pathname.slice('/docs/'.length);
|
||||
let docsSubDir = '';
|
||||
let relativeAssetPath = '';
|
||||
let assetDirSegments: string[] = ['assets'];
|
||||
|
||||
if (afterDocs.startsWith('assets/images/')) {
|
||||
assetDirSegments = ['assets', 'images'];
|
||||
relativeAssetPath = afterDocs.slice('assets/images/'.length);
|
||||
} else if (afterDocs.startsWith('assets/')) {
|
||||
relativeAssetPath = afterDocs.slice('assets/'.length);
|
||||
} else {
|
||||
const imageMarker = '/assets/images/';
|
||||
const imageMarkerIndex = afterDocs.indexOf(imageMarker);
|
||||
if (imageMarkerIndex > 0) {
|
||||
docsSubDir = afterDocs.slice(0, imageMarkerIndex);
|
||||
assetDirSegments = ['assets', 'images'];
|
||||
relativeAssetPath = afterDocs.slice(imageMarkerIndex + imageMarker.length);
|
||||
} else {
|
||||
const assetMarker = '/assets/';
|
||||
const assetMarkerIndex = afterDocs.indexOf(assetMarker);
|
||||
if (assetMarkerIndex > 0) {
|
||||
docsSubDir = afterDocs.slice(0, assetMarkerIndex);
|
||||
relativeAssetPath = afterDocs.slice(assetMarkerIndex + assetMarker.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (relativeAssetPath) {
|
||||
const docsRoot = path.resolve(process.cwd(), 'src', 'docs');
|
||||
const baseDir = path.resolve(docsRoot, docsSubDir, ...assetDirSegments);
|
||||
if (!isPathInside(docsRoot, baseDir)) {
|
||||
res.statusCode = 403;
|
||||
res.end('Forbidden');
|
||||
return true;
|
||||
}
|
||||
const targetPath = path.resolve(baseDir, relativeAssetPath);
|
||||
|
||||
if (!isPathInside(baseDir, targetPath)) {
|
||||
res.statusCode = 403;
|
||||
res.end('Forbidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isFile()) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found');
|
||||
return true;
|
||||
}
|
||||
|
||||
sendFile(res, targetPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logVirtualHtmlDebug } from '../logger';
|
||||
|
||||
export function handleDocsHtml(req: IncomingMessage, res: ServerResponse, specTemplate: string): boolean {
|
||||
if (!req.url?.includes('/docs.html')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const urlWithoutQuery = req.url.split('?')[0];
|
||||
const urlPath = urlWithoutQuery.replace('/docs.html', '');
|
||||
const pathParts = urlPath.split('/').filter(Boolean);
|
||||
|
||||
logVirtualHtmlDebug('Docs 请求路径:', req.url, '解析部分:', pathParts);
|
||||
|
||||
// 处理 /docs/* 的 docs.html 请求
|
||||
if (pathParts.length >= 1 && pathParts[0] === 'docs') {
|
||||
const docName = pathParts.slice(1).join('/');
|
||||
const mdPath = path.resolve(process.cwd(), 'src/docs' + (docName ? '/' + docName : '') + '.md');
|
||||
|
||||
logVirtualHtmlDebug('检查 docs markdown 文件:', mdPath, '存在:', fs.existsSync(mdPath));
|
||||
|
||||
if (fs.existsSync(mdPath)) {
|
||||
const title = `Docs: ${docName || 'Index'}`;
|
||||
const specMdUrl = `${urlPath}.md`;
|
||||
|
||||
let html = specTemplate.replace(/\{\{TITLE\}\}/g, title);
|
||||
html = html.replace(/\{\{SPEC_URL\}\}/g, specMdUrl);
|
||||
html = html.replace(/\{\{DOCS_CONFIG\}\}/g, '[]');
|
||||
html = html.replace(/\{\{MULTI_DOC\}\}/g, 'false');
|
||||
|
||||
logVirtualHtmlDebug('返回 Docs 虚拟 HTML:', req.url);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.statusCode = 200;
|
||||
res.end(html);
|
||||
return true;
|
||||
} else {
|
||||
logVirtualHtmlDebug('docs markdown 不存在:', mdPath);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logVirtualHtmlDebug, logVirtualHtmlError } from '../logger';
|
||||
|
||||
export function handleDocsMarkdown(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
// 处理 /prototypes/*.md 和 /components/*.md
|
||||
if ((req.url?.includes('/prototypes/') || req.url?.includes('/components/')) && req.url?.endsWith('.md')) {
|
||||
const urlWithoutQuery = req.url.split('?')[0];
|
||||
const decodedUrlPath = decodeURIComponent(urlWithoutQuery);
|
||||
const mdPath = path.resolve(process.cwd(), 'src' + decodedUrlPath);
|
||||
|
||||
logVirtualHtmlDebug('请求 page/element markdown:', req.url, '-> 路径:', mdPath, '存在:', fs.existsSync(mdPath));
|
||||
|
||||
if (fs.existsSync(mdPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(mdPath, 'utf8');
|
||||
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
|
||||
res.statusCode = 200;
|
||||
res.end(content);
|
||||
logVirtualHtmlDebug('返回 page/element markdown:', req.url);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logVirtualHtmlError('读取 page/element markdown 失败:', err);
|
||||
}
|
||||
} else {
|
||||
logVirtualHtmlDebug('page/element markdown 不存在:', mdPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 /docs/*.md
|
||||
if (req.url?.startsWith('/docs/') && req.url?.endsWith('.md')) {
|
||||
const urlWithoutQuery = req.url.split('?')[0];
|
||||
// 移除 /docs/ 前缀和 .md 后缀
|
||||
const docPath = urlWithoutQuery.slice(6, -3); // 移除 '/docs/' 和 '.md'
|
||||
|
||||
// 对路径进行 URL 解码
|
||||
const decodedDocPath = decodeURIComponent(docPath);
|
||||
|
||||
const mdPath = path.resolve(process.cwd(), 'src/docs', decodedDocPath + '.md');
|
||||
|
||||
logVirtualHtmlDebug('请求 docs markdown:', req.url, '-> 路径:', mdPath, '存在:', fs.existsSync(mdPath));
|
||||
|
||||
if (fs.existsSync(mdPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(mdPath, 'utf8');
|
||||
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
|
||||
res.statusCode = 200;
|
||||
res.end(content);
|
||||
logVirtualHtmlDebug('返回 docs markdown:', req.url);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logVirtualHtmlError('读取 docs markdown 失败:', err);
|
||||
}
|
||||
} else {
|
||||
logVirtualHtmlDebug('docs markdown 不存在:', mdPath);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { mergeScannedEntries, scanEntries, type ScannedEntryItem } from '../../utils/entryScanner';
|
||||
import { readEntriesManifest, writeEntriesManifestAtomic } from '../../utils/entriesManifest';
|
||||
|
||||
function serializeEntryItem(item: ScannedEntryItem) {
|
||||
return {
|
||||
name: item.name,
|
||||
displayName: item.displayName,
|
||||
demoUrl: item.demoUrl,
|
||||
specUrl: item.specUrl,
|
||||
jsUrl: item.jsUrl,
|
||||
filePath: item.filePath,
|
||||
...(item.isReference !== undefined ? { isReference: item.isReference } : {}),
|
||||
...(item.hasSubPages !== undefined ? { hasSubPages: item.hasSubPages } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function handleEntriesApi(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (req.url !== '/api/entries.json') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('\n🔍 实时扫描入口文件...');
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const scanned = scanEntries(projectRoot);
|
||||
const existingEntries = readEntriesManifest(projectRoot);
|
||||
const nextEntries = mergeScannedEntries(existingEntries, scanned.entries);
|
||||
writeEntriesManifestAtomic(projectRoot, nextEntries as any);
|
||||
|
||||
console.log(`✅ 扫描完成,发现 ${Object.keys(scanned.entries.js).length} 个入口`);
|
||||
|
||||
const result = {
|
||||
components: scanned.items.components.map(serializeEntryItem),
|
||||
prototypes: scanned.items.prototypes.map(serializeEntryItem),
|
||||
};
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify(result, null, 2));
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('生成 entries.json API 失败:', err);
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({ error: 'Internal Server Error' }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export function handleHackCssClear(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (req.method === 'POST' && req.url?.startsWith('/api/hack-css/clear')) {
|
||||
let body = '';
|
||||
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { path: rawTargetPath } = JSON.parse(body);
|
||||
const targetPath = decodeURIComponent(String(rawTargetPath || ''));
|
||||
|
||||
if (!targetPath) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'path is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const pathParts = targetPath.split('/').filter(Boolean);
|
||||
if (pathParts.length < 2 || !['components', 'prototypes'].includes(pathParts[0])) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Invalid path format. Expected: components/xxx or prototypes/xxx' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const hackCssPath = path.resolve(process.cwd(), 'src', targetPath, 'hack.css');
|
||||
|
||||
if (fs.existsSync(hackCssPath)) {
|
||||
fs.unlinkSync(hackCssPath);
|
||||
console.log('[API] ✅ 清空 hack.css:', hackCssPath);
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ success: true, path: hackCssPath }));
|
||||
} catch (err) {
|
||||
console.error('[API] ❌ 清空 hack.css 失败:', err);
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Failed to clear hack.css' }));
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export function handleHackCssRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
const requestPath = req.url ? req.url.split('?')[0] : '';
|
||||
|
||||
if (req.method === 'GET' && requestPath.endsWith('/hack.css')) {
|
||||
const decodedRequestPath = decodeURIComponent(requestPath);
|
||||
const pathParts = decodedRequestPath.split('/').filter(Boolean);
|
||||
|
||||
if (pathParts.length >= 2 && ['components', 'prototypes'].includes(pathParts[0])) {
|
||||
const hackCssPath = path.resolve(process.cwd(), 'src', decodedRequestPath.slice(1));
|
||||
|
||||
if (fs.existsSync(hackCssPath)) {
|
||||
try {
|
||||
let css = fs.readFileSync(hackCssPath, 'utf8');
|
||||
css = css.replace(/\/\*\s*@ai-agent-warning:.*?\*\/\s*/g, '');
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
||||
res.end(css);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[API] 读取 hack.css 失败:', hackCssPath, err);
|
||||
}
|
||||
} else {
|
||||
console.warn('[API] hack.css 不存在:', hackCssPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { mergeCss } from '../../utils/cssUtils';
|
||||
|
||||
export function handleHackCssSave(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (req.method === 'POST' && req.url?.startsWith('/api/hack-css/save')) {
|
||||
let body = '';
|
||||
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { path: rawTargetPath, content } = JSON.parse(body);
|
||||
const targetPath = decodeURIComponent(String(rawTargetPath || ''));
|
||||
|
||||
if (!targetPath) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'path is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const pathParts = targetPath.split('/').filter(Boolean);
|
||||
if (pathParts.length < 2 || !['components', 'prototypes'].includes(pathParts[0])) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Invalid path format. Expected: components/xxx or prototypes/xxx' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const hackCssPath = path.resolve(process.cwd(), 'src', targetPath, 'hack.css');
|
||||
|
||||
let existingCss = '';
|
||||
if (fs.existsSync(hackCssPath)) {
|
||||
existingCss = fs.readFileSync(hackCssPath, 'utf8');
|
||||
}
|
||||
|
||||
const mergedCss = mergeCss(existingCss, content || '');
|
||||
|
||||
const warningComment = '/* @ai-agent-warning: Do not modify this file unless explicitly requested by the user */\n\n';
|
||||
const finalCss = mergedCss.startsWith('/* @ai-agent-warning:')
|
||||
? mergedCss
|
||||
: warningComment + mergedCss;
|
||||
|
||||
fs.writeFileSync(hackCssPath, finalCss, 'utf8');
|
||||
console.log('[API] ✅ 增量保存 hack.css:', hackCssPath);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ success: true, path: hackCssPath, merged: mergedCss }));
|
||||
} catch (err) {
|
||||
console.error('[API] ❌ 保存 hack.css 失败:', err);
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Failed to save hack.css' }));
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
188
axhub-make/vite-plugins/virtualHtml/handlers/indexHtmlHandler.ts
Normal file
188
axhub-make/vite-plugins/virtualHtml/handlers/indexHtmlHandler.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { encodeRoutePath, normalizePath } from './pathNormalizer';
|
||||
import { logVirtualHtmlDebug, logVirtualHtmlWarn } from '../logger';
|
||||
import {
|
||||
createPreviewHostModuleCode,
|
||||
createPreviewHostOptions,
|
||||
replacePreviewLoaderScript,
|
||||
} from '../previewHost';
|
||||
|
||||
type HtmlResponder = (html: string, transformUrl?: string) => Promise<void>;
|
||||
|
||||
function replaceDevTemplateBootstrapScript(html: string, bootstrapImportPath: string): string {
|
||||
return html.replace(
|
||||
/ <script type="module" src=["']\/assets\/dev-template-bootstrap\.js(?:\?[^"']*)?["']><\/script>/,
|
||||
` <script type="module">\n import ${JSON.stringify(bootstrapImportPath)};\n </script>`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleIndexHtml(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
devTemplate: string,
|
||||
htmlTemplate: string,
|
||||
respondHtml: HtmlResponder,
|
||||
): Promise<boolean> {
|
||||
if (!req.url) return false;
|
||||
|
||||
// 先尝试标准化路径
|
||||
const normalized = normalizePath(req.url);
|
||||
|
||||
// 只处理预览请求(action === 'preview')
|
||||
if (normalized && normalized.action === 'preview') {
|
||||
const { type, name, versionId } = normalized;
|
||||
|
||||
logVirtualHtmlDebug('预览请求:', normalized.originalUrl, '→', normalized.normalizedUrl);
|
||||
|
||||
if (['components', 'prototypes', 'themes'].includes(type)) {
|
||||
const urlPath = encodeRoutePath(`/${type}/${name}`);
|
||||
const moduleImportPath = `/${type}/${name}`;
|
||||
let tsxPath: string;
|
||||
let basePath: string;
|
||||
|
||||
// 如果有版本参数,从 Git 版本目录读取
|
||||
if (versionId) {
|
||||
const gitVersionsDir = path.resolve(process.cwd(), '.git-versions', versionId);
|
||||
basePath = path.join(gitVersionsDir, 'src', type, name);
|
||||
tsxPath = path.join(basePath, 'index.tsx');
|
||||
logVirtualHtmlDebug('从 Git 版本读取:', versionId, tsxPath);
|
||||
} else {
|
||||
// 否则从当前工作目录读取
|
||||
basePath = path.resolve(process.cwd(), 'src', type, name);
|
||||
tsxPath = path.join(basePath, 'index.tsx');
|
||||
}
|
||||
|
||||
logVirtualHtmlDebug('检查 TSX 文件:', tsxPath, '存在:', fs.existsSync(tsxPath));
|
||||
|
||||
if (fs.existsSync(tsxPath)) {
|
||||
const typeLabel = type === 'components' ? 'Component' : type === 'prototypes' ? 'Prototype' : 'Theme';
|
||||
const title = versionId
|
||||
? `${typeLabel}: ${name} (版本: ${versionId}) - Dev Preview`
|
||||
: `${typeLabel}: ${name} - Dev Preview`;
|
||||
// Vite 的 html-proxy/import-analysis 在虚拟 HTML 模块里解析 import 时,
|
||||
// 对包含中文目录名的百分号编码路径兼容性不稳定。这里保留页面 URL 为编码形式,
|
||||
// 但模块 import 使用原始路由路径,让 Vite 能正确映射到 src 下的真实文件。
|
||||
const entryImportPath = versionId
|
||||
? `/@fs/${tsxPath}`
|
||||
: `${moduleImportPath}/index.tsx`;
|
||||
const hackCssPath = path.resolve(process.cwd(), 'src', type, name, 'hack.css');
|
||||
const previewHostModuleCode = createPreviewHostModuleCode(
|
||||
createPreviewHostOptions({
|
||||
type,
|
||||
name,
|
||||
entryImportPath,
|
||||
versionId,
|
||||
initialHackCssEnabled: fs.existsSync(hackCssPath),
|
||||
}),
|
||||
);
|
||||
const bootstrapModulePath = path.resolve(process.cwd(), 'admin', 'assets', 'dev-template-bootstrap.js')
|
||||
.split(path.sep)
|
||||
.join('/');
|
||||
|
||||
let html = devTemplate.replace(/\{\{TITLE\}\}/g, title);
|
||||
html = replacePreviewLoaderScript(html, previewHostModuleCode);
|
||||
html = replaceDevTemplateBootstrapScript(html, `/@fs/${bootstrapModulePath}`);
|
||||
|
||||
// 🔥 添加 <base> 标签来修正相对路径基准(重要!)
|
||||
// 新路径格式 /prototypes/ref-antd 会被浏览器当作目录,导致相对路径解析错误
|
||||
// 添加 <base href="/prototypes/ref-antd/"> 可以修正这个问题
|
||||
const baseHref = `${urlPath}/`;
|
||||
html = html.replace('</head>', ` <base href="${baseHref}">\n </head>`);
|
||||
if (fs.existsSync(hackCssPath)) {
|
||||
logVirtualHtmlDebug('注入 hack.css:', hackCssPath);
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
` <link rel="stylesheet" data-axhub-hack-css="${type}/${name}" href="./hack.css">\n </head>`,
|
||||
);
|
||||
}
|
||||
|
||||
logVirtualHtmlDebug('返回虚拟 HTML:', normalized.normalizedUrl);
|
||||
|
||||
// 交给 Vite 转换 HTML 时需要使用一个稳定的 .html 虚拟地址。
|
||||
// 当前版本与历史版本不能共用同一个 transformUrl,否则 Vite 的 html-proxy
|
||||
// 会复用当前页面的内联脚本,导致历史版本页面又加载回当前源码。
|
||||
// 这里用一个仅供 transformIndexHtml 使用的虚拟路径段隔离不同版本,
|
||||
// 避免在 URL 查询参数里追加 ver 触发双问号问题。
|
||||
const transformUrl = versionId
|
||||
? `${urlPath}/__axhub_version__/${versionId}/index.html`
|
||||
: `${urlPath}/index.html`;
|
||||
await respondHtml(html, transformUrl);
|
||||
return true;
|
||||
} else if (versionId) {
|
||||
// 版本文件不存在
|
||||
const errorHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>版本不存在</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.error-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
h1 { color: #ff4d4f; margin-top: 0; }
|
||||
p { color: #666; line-height: 1.6; }
|
||||
.version-id {
|
||||
background: #f0f0f0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<h1>❌ 版本文件不存在</h1>
|
||||
<p>版本 <span class="version-id">${versionId}</span> 的文件未找到。</p>
|
||||
<p>可能的原因:</p>
|
||||
<p>1. 版本文件尚未提取<br>2. 该版本不包含此页面<br>3. 服务器已重启,临时文件被清理</p>
|
||||
<p><strong>解决方法:</strong></p>
|
||||
<p>请先调用 <code>/api/git/build-version</code> 接口提取版本文件。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.statusCode = 404;
|
||||
res.end(errorHtml);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 兼容旧的 .html 路径检查(如果标准化失败)
|
||||
if (req.url?.includes('/index.html')) {
|
||||
const [urlWithoutQuery, queryString] = req.url.split('?');
|
||||
const urlPath = urlWithoutQuery.replace('/index.html', '');
|
||||
const pathParts = urlPath.split('/').filter(Boolean);
|
||||
|
||||
const params = new URLSearchParams(queryString || '');
|
||||
const versionId = params.get('ver');
|
||||
|
||||
logVirtualHtmlDebug('旧格式请求路径:', req.url, '解析部分:', pathParts);
|
||||
|
||||
if (pathParts.length >= 2 && ['components', 'prototypes', 'themes'].includes(pathParts[0])) {
|
||||
// 这种情况应该已经被标准化处理了,如果到这里说明有问题
|
||||
logVirtualHtmlWarn('未被标准化处理的路径:', req.url);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
433
axhub-make/vite-plugins/virtualHtml/handlers/pathNormalizer.ts
Normal file
433
axhub-make/vite-plugins/virtualHtml/handlers/pathNormalizer.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { logVirtualHtmlDebug } from '../logger';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { readEntriesManifest } from '../../utils/entriesManifest';
|
||||
|
||||
/**
|
||||
* 路径标准化器
|
||||
*
|
||||
* 新路径格式(推荐):
|
||||
* - /prototypes/{name} → 原型预览
|
||||
* - /prototypes/{name}/spec → 原型文档
|
||||
* - /components/{name} → 组件预览
|
||||
* - /components/{name}/spec → 组件文档
|
||||
* - /themes/{name} → 主题预览
|
||||
* - /themes/{name}/spec → 主题文档
|
||||
* - /docs/{name} → 系统文档
|
||||
*
|
||||
* 旧路径格式(兼容):
|
||||
* - /{name}.html → 重定向到新格式
|
||||
* - /{name}/spec.html → 重定向到新格式
|
||||
* - /prototypes/{name}/index.html → 重定向到新格式
|
||||
* - /components/{name}/index.html → 重定向到新格式
|
||||
* - /assets/docs/{name}/spec.html → 重定向到新格式
|
||||
*/
|
||||
|
||||
export interface NormalizedPath {
|
||||
type: 'prototypes' | 'components' | 'themes' | 'docs';
|
||||
name: string;
|
||||
action: 'preview' | 'spec';
|
||||
isLegacy: boolean;
|
||||
originalUrl: string;
|
||||
normalizedUrl: string;
|
||||
versionId?: string;
|
||||
subPath?: string;
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function decodePathSegments(parts: string[]): string[] {
|
||||
return parts.map((part) => safeDecodeURIComponent(part));
|
||||
}
|
||||
|
||||
function looksLikeFileRequest(subPath: string): boolean {
|
||||
if (!subPath) return false;
|
||||
const lastSegment = subPath.split('/').filter(Boolean).pop() || '';
|
||||
return /\.[a-z0-9]+$/i.test(lastSegment);
|
||||
}
|
||||
|
||||
export function encodeRoutePath(pathname: string): string {
|
||||
const hasLeadingSlash = pathname.startsWith('/');
|
||||
const hasTrailingSlash = pathname.endsWith('/') && pathname !== '/';
|
||||
const encoded = pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(safeDecodeURIComponent(segment)))
|
||||
.join('/');
|
||||
|
||||
const withLeadingSlash = hasLeadingSlash ? `/${encoded}` : encoded;
|
||||
if (hasTrailingSlash && withLeadingSlash) {
|
||||
return `${withLeadingSlash}/`;
|
||||
}
|
||||
return withLeadingSlash || (hasLeadingSlash ? '/' : '');
|
||||
}
|
||||
|
||||
function resolveEntryTypeByName(name: string): 'prototypes' | 'components' | 'themes' | null {
|
||||
const projectRoot = process.cwd();
|
||||
const normalizedName = String(name || '').trim();
|
||||
if (!normalizedName) return null;
|
||||
|
||||
const scanOrder: Array<'prototypes' | 'components' | 'themes'> = ['prototypes', 'components', 'themes'];
|
||||
for (const type of scanOrder) {
|
||||
const entryPath = path.resolve(projectRoot, 'src', type, normalizedName, 'index.tsx');
|
||||
if (fs.existsSync(entryPath)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = readEntriesManifest(projectRoot);
|
||||
for (const type of scanOrder) {
|
||||
if (manifest.items?.[`${type}/${normalizedName}`]) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore manifest read errors and keep null fallback
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveTypedEntryName(
|
||||
type: 'prototypes' | 'components' | 'themes',
|
||||
nameParts: string[],
|
||||
): { name: string; restParts: string[] } | null {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
for (let partCount = nameParts.length; partCount >= 1; partCount -= 1) {
|
||||
const candidateName = nameParts.slice(0, partCount).join('/');
|
||||
const restParts = nameParts.slice(partCount);
|
||||
const entryPath = path.resolve(projectRoot, 'src', type, candidateName, 'index.tsx');
|
||||
if (fs.existsSync(entryPath)) {
|
||||
return { name: candidateName, restParts };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = readEntriesManifest(projectRoot);
|
||||
for (let partCount = nameParts.length; partCount >= 1; partCount -= 1) {
|
||||
const candidateName = nameParts.slice(0, partCount).join('/');
|
||||
if (manifest.items?.[`${type}/${candidateName}`]) {
|
||||
return {
|
||||
name: candidateName,
|
||||
restParts: nameParts.slice(partCount),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore manifest read errors and keep null fallback
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并标准化路径
|
||||
*/
|
||||
export function normalizePath(url: string): NormalizedPath | null {
|
||||
const [urlWithoutQuery, queryString] = url.split('?');
|
||||
const params = new URLSearchParams(queryString || '');
|
||||
const versionId = params.get('ver') || undefined;
|
||||
|
||||
// Vite 内部的 html-proxy 请求需要保留原样,不能参与旧路径重定向。
|
||||
// 否则浏览器在加载 /index.html?html-proxy&index=*.js 时会被 301 到页面地址,
|
||||
// 最终表现为 script 资源加载失败。
|
||||
if (params.has('html-proxy')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 移除末尾的 .html
|
||||
const cleanUrl = urlWithoutQuery.replace(/\.html$/, '');
|
||||
|
||||
// 解析路径部分
|
||||
const pathParts = cleanUrl.split('/').filter(Boolean);
|
||||
|
||||
if (pathParts.length === 0) return null;
|
||||
|
||||
// 文档静态资源路径不参与页面路由标准化,交给资源处理器兜底。
|
||||
if (pathParts[0] === 'docs' && pathParts.includes('assets')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 情况 1: /prototypes/{name} 或 /prototypes/{name}/spec 或 /prototypes/{name}/index
|
||||
if (pathParts[0] === 'prototypes' && pathParts.length >= 2) {
|
||||
const decodedNameParts = decodePathSegments(pathParts.slice(1));
|
||||
const resolved = resolveTypedEntryName('prototypes', decodedNameParts);
|
||||
if (!resolved) return null;
|
||||
const lastPart = resolved.restParts[resolved.restParts.length - 1];
|
||||
const isSpecRoute = resolved.restParts.length === 1 && lastPart === 'spec';
|
||||
const isLegacyIndexRoute = resolved.restParts.length === 1 && lastPart === 'index';
|
||||
const subPath = !isSpecRoute && !isLegacyIndexRoute ? resolved.restParts.join('/') : '';
|
||||
|
||||
// 真实文件请求(如 index.tsx、style.css)应该交给 Vite 模块系统处理,
|
||||
// 不能被误判成页面预览路由,否则会返回 HTML 导致模块加载失败。
|
||||
if (looksLikeFileRequest(subPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resolved.restParts.length === 0 || !isSpecRoute) {
|
||||
// /prototypes/{name} 或 /prototypes/{name}/index.html
|
||||
return {
|
||||
type: 'prototypes',
|
||||
name: resolved.name,
|
||||
action: 'preview',
|
||||
isLegacy: isLegacyIndexRoute,
|
||||
originalUrl: url,
|
||||
normalizedUrl: `${encodeRoutePath(`/prototypes/${resolved.name}${subPath ? `/${subPath}` : ''}`)}${versionId ? `?ver=${versionId}` : ''}`,
|
||||
versionId,
|
||||
subPath: subPath || undefined,
|
||||
};
|
||||
} else if (isSpecRoute) {
|
||||
// /prototypes/{name}/spec 或 /prototypes/{name}/spec.html
|
||||
return {
|
||||
type: 'prototypes',
|
||||
name: resolved.name,
|
||||
action: 'spec',
|
||||
isLegacy: urlWithoutQuery.includes('.html'),
|
||||
originalUrl: url,
|
||||
normalizedUrl: `${encodeRoutePath(`/prototypes/${resolved.name}/spec`)}${versionId ? `?ver=${versionId}` : ''}`,
|
||||
versionId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 2: /components/{name} 或 /components/{name}/spec 或 /components/{name}/index
|
||||
if (pathParts[0] === 'components' && pathParts.length >= 2) {
|
||||
const decodedNameParts = decodePathSegments(pathParts.slice(1));
|
||||
const resolved = resolveTypedEntryName('components', decodedNameParts);
|
||||
if (!resolved) return null;
|
||||
const lastPart = resolved.restParts[resolved.restParts.length - 1];
|
||||
const isSpecRoute = resolved.restParts.length === 1 && lastPart === 'spec';
|
||||
const isLegacyIndexRoute = resolved.restParts.length === 1 && lastPart === 'index';
|
||||
const subPath = !isSpecRoute && !isLegacyIndexRoute ? resolved.restParts.join('/') : '';
|
||||
|
||||
// 真实文件请求(如 index.tsx、style.css)应该交给 Vite 模块系统处理,
|
||||
// 不能被误判成页面预览路由,否则会返回 HTML 导致模块加载失败。
|
||||
if (looksLikeFileRequest(subPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resolved.restParts.length === 0 || !isSpecRoute) {
|
||||
// /components/{name} 或 /components/{name}/index.html
|
||||
return {
|
||||
type: 'components',
|
||||
name: resolved.name,
|
||||
action: 'preview',
|
||||
isLegacy: isLegacyIndexRoute,
|
||||
originalUrl: url,
|
||||
normalizedUrl: `${encodeRoutePath(`/components/${resolved.name}${subPath ? `/${subPath}` : ''}`)}${versionId ? `?ver=${versionId}` : ''}`,
|
||||
versionId,
|
||||
subPath: subPath || undefined,
|
||||
};
|
||||
} else if (isSpecRoute) {
|
||||
// /components/{name}/spec 或 /components/{name}/spec.html
|
||||
return {
|
||||
type: 'components',
|
||||
name: resolved.name,
|
||||
action: 'spec',
|
||||
isLegacy: urlWithoutQuery.includes('.html'),
|
||||
originalUrl: url,
|
||||
normalizedUrl: `${encodeRoutePath(`/components/${resolved.name}/spec`)}${versionId ? `?ver=${versionId}` : ''}`,
|
||||
versionId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 3: /themes/{name} 或 /themes/{name}/spec 或 /themes/{name}/index
|
||||
if (pathParts[0] === 'themes' && pathParts.length >= 2) {
|
||||
const decodedNameParts = decodePathSegments(pathParts.slice(1));
|
||||
const resolved = resolveTypedEntryName('themes', decodedNameParts);
|
||||
if (!resolved) return null;
|
||||
const lastPart = resolved.restParts[resolved.restParts.length - 1];
|
||||
const isSpecRoute = resolved.restParts.length === 1 && lastPart === 'spec';
|
||||
const isLegacyIndexRoute = resolved.restParts.length === 1 && lastPart === 'index';
|
||||
const subPath = !isSpecRoute && !isLegacyIndexRoute ? resolved.restParts.join('/') : '';
|
||||
|
||||
// 真实文件请求(如 index.tsx、style.css)应该交给 Vite 模块系统处理,
|
||||
// 不能被误判成页面预览路由,否则会返回 HTML 导致模块加载失败。
|
||||
if (looksLikeFileRequest(subPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resolved.restParts.length === 0 || !isSpecRoute) {
|
||||
// /themes/{name} 或 /themes/{name}/index.html
|
||||
return {
|
||||
type: 'themes',
|
||||
name: resolved.name,
|
||||
action: 'preview',
|
||||
isLegacy: isLegacyIndexRoute,
|
||||
originalUrl: url,
|
||||
normalizedUrl: `${encodeRoutePath(`/themes/${resolved.name}${subPath ? `/${subPath}` : ''}`)}${versionId ? `?ver=${versionId}` : ''}`,
|
||||
versionId,
|
||||
subPath: subPath || undefined,
|
||||
};
|
||||
} else if (isSpecRoute) {
|
||||
// /themes/{name}/spec 或 /themes/{name}/spec.html
|
||||
return {
|
||||
type: 'themes',
|
||||
name: resolved.name,
|
||||
action: 'spec',
|
||||
isLegacy: urlWithoutQuery.includes('.html'),
|
||||
originalUrl: url,
|
||||
normalizedUrl: `${encodeRoutePath(`/themes/${resolved.name}/spec`)}${versionId ? `?ver=${versionId}` : ''}`,
|
||||
versionId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 4: /docs/{name} 或 /docs/{name}/spec.html
|
||||
if (pathParts[0] === 'docs' && pathParts.length >= 2) {
|
||||
const nameParts = decodePathSegments(pathParts.slice(1));
|
||||
const lastPart = nameParts[nameParts.length - 1];
|
||||
|
||||
if (lastPart === 'spec') {
|
||||
// /docs/{name}/spec.html(旧格式)→ /docs/{name}
|
||||
const name = nameParts.slice(0, -1).join('/');
|
||||
return {
|
||||
type: 'docs',
|
||||
name,
|
||||
action: 'spec',
|
||||
isLegacy: true,
|
||||
originalUrl: url,
|
||||
normalizedUrl: encodeRoutePath(`/docs/${name}`),
|
||||
versionId
|
||||
};
|
||||
}
|
||||
|
||||
// /docs/{name}
|
||||
const name = nameParts.join('/');
|
||||
return {
|
||||
type: 'docs',
|
||||
name,
|
||||
action: 'spec',
|
||||
isLegacy: false,
|
||||
originalUrl: url,
|
||||
normalizedUrl: encodeRoutePath(`/docs/${name}`),
|
||||
versionId
|
||||
};
|
||||
}
|
||||
|
||||
// 情况 5: /assets/docs/{name} 或 /assets/docs/{name}/spec.html(旧格式兼容)
|
||||
if (pathParts[0] === 'assets' && pathParts[1] === 'docs' && pathParts.length >= 3) {
|
||||
const nameParts = decodePathSegments(pathParts.slice(2));
|
||||
const lastPart = nameParts[nameParts.length - 1];
|
||||
|
||||
if (lastPart === 'spec') {
|
||||
// /assets/docs/{name}/spec.html(旧格式)→ /docs/{name}
|
||||
const name = nameParts.slice(0, -1).join('/');
|
||||
return {
|
||||
type: 'docs',
|
||||
name,
|
||||
action: 'spec',
|
||||
isLegacy: true,
|
||||
originalUrl: url,
|
||||
normalizedUrl: encodeRoutePath(`/docs/${name}`),
|
||||
versionId
|
||||
};
|
||||
}
|
||||
|
||||
// /assets/docs/{name}(旧格式)→ /docs/{name}
|
||||
const name = nameParts.join('/');
|
||||
return {
|
||||
type: 'docs',
|
||||
name,
|
||||
action: 'spec',
|
||||
isLegacy: true,
|
||||
originalUrl: url,
|
||||
normalizedUrl: encodeRoutePath(`/docs/${name}`),
|
||||
versionId
|
||||
};
|
||||
}
|
||||
|
||||
// 情况 6: /{name}.html 或 /{name}/spec.html(旧格式,需要查找是 page 还是 element)
|
||||
if (pathParts.length === 1 && urlWithoutQuery.endsWith('.html')) {
|
||||
const name = safeDecodeURIComponent(pathParts[0]);
|
||||
|
||||
const type = resolveEntryTypeByName(name);
|
||||
if (type) {
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
action: 'preview',
|
||||
isLegacy: true,
|
||||
originalUrl: url,
|
||||
normalizedUrl: `${encodeRoutePath(`/${type}/${name}`)}${versionId ? `?ver=${versionId}` : ''}`,
|
||||
versionId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 7: /{name}/spec.html(旧格式)
|
||||
if (pathParts.length === 2 && pathParts[1] === 'spec' && urlWithoutQuery.endsWith('.html')) {
|
||||
const name = safeDecodeURIComponent(pathParts[0]);
|
||||
|
||||
const type = resolveEntryTypeByName(name);
|
||||
if (type) {
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
action: 'spec',
|
||||
isLegacy: true,
|
||||
originalUrl: url,
|
||||
normalizedUrl: `${encodeRoutePath(`/${type}/${name}/spec`)}${versionId ? `?ver=${versionId}` : ''}`,
|
||||
versionId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理路径重定向(旧格式 → 新格式)
|
||||
*/
|
||||
export function handlePathRedirect(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!req.url) return false;
|
||||
|
||||
const normalized = normalizePath(req.url);
|
||||
|
||||
if (
|
||||
normalized &&
|
||||
!normalized.isLegacy &&
|
||||
normalized.action === 'preview'
|
||||
) {
|
||||
const htmlEntryPath = path.resolve(process.cwd(), 'src', normalized.type, normalized.name, 'index.html');
|
||||
if (fs.existsSync(htmlEntryPath) && !normalized.subPath) {
|
||||
const params = new URLSearchParams(req.url.split('?')[1] || '');
|
||||
const query = params.toString();
|
||||
const redirectUrl = `${encodeRoutePath(`/${normalized.type}/${normalized.name}/index.html`)}${query ? `?${query}` : ''}`;
|
||||
|
||||
res.statusCode = 302;
|
||||
res.setHeader('Location', redirectUrl);
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized && normalized.isLegacy) {
|
||||
if (normalized.action === 'preview') {
|
||||
const htmlEntryPath = path.resolve(process.cwd(), 'src', normalized.type, normalized.name, 'index.html');
|
||||
if (fs.existsSync(htmlEntryPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 旧格式,重定向到新格式
|
||||
logVirtualHtmlDebug('路径重定向:', normalized.originalUrl, '→', normalized.normalizedUrl);
|
||||
|
||||
res.statusCode = 301; // 永久重定向
|
||||
res.setHeader('Location', normalized.normalizedUrl);
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
148
axhub-make/vite-plugins/virtualHtml/handlers/specHtmlHandler.ts
Normal file
148
axhub-make/vite-plugins/virtualHtml/handlers/specHtmlHandler.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { encodeRoutePath, normalizePath } from './pathNormalizer';
|
||||
import { logVirtualHtmlDebug } from '../logger';
|
||||
import { buildDocApiPath } from '../../utils/docUtils';
|
||||
|
||||
type HtmlResponder = (html: string, transformUrl?: string) => Promise<void>;
|
||||
|
||||
export async function handleSpecHtml(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
specTemplate: string,
|
||||
respondHtml: HtmlResponder,
|
||||
): Promise<boolean> {
|
||||
if (!req.url) return false;
|
||||
|
||||
const rawPathname = req.url.split('?')[0];
|
||||
if (rawPathname.startsWith('/docs/') && rawPathname.includes('/assets/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 先尝试标准化路径
|
||||
const normalized = normalizePath(req.url);
|
||||
|
||||
// 只处理文档请求(action === 'spec')
|
||||
if (normalized && normalized.action === 'spec') {
|
||||
const { type, name, versionId } = normalized;
|
||||
|
||||
logVirtualHtmlDebug('文档请求:', normalized.originalUrl, '→', normalized.normalizedUrl);
|
||||
|
||||
// 处理 prototypes/components/themes 的 spec 请求
|
||||
if (['components', 'prototypes', 'themes'].includes(type)) {
|
||||
let basePath: string;
|
||||
let specMdPath: string;
|
||||
let prdMdPath: string;
|
||||
|
||||
// 如果有版本参数,从 Git 版本目录读取
|
||||
if (versionId) {
|
||||
const gitVersionsDir = path.resolve(process.cwd(), '.git-versions', versionId);
|
||||
basePath = path.join(gitVersionsDir, 'src', type, name);
|
||||
specMdPath = path.join(basePath, 'spec.md');
|
||||
prdMdPath = path.join(basePath, 'prd.md');
|
||||
logVirtualHtmlDebug('从 Git 版本读取:', versionId, basePath);
|
||||
} else {
|
||||
// 否则从当前工作目录读取
|
||||
basePath = path.join(process.cwd(), 'src', type, name);
|
||||
specMdPath = path.join(basePath, 'spec.md');
|
||||
prdMdPath = path.join(basePath, 'prd.md');
|
||||
}
|
||||
|
||||
const typeLabel = type === 'components' ? 'Component' : type === 'prototypes' ? 'Prototype' : 'Theme';
|
||||
|
||||
logVirtualHtmlDebug('检查文档文件:', { specMdPath, prdMdPath });
|
||||
logVirtualHtmlDebug('文件存在:', {
|
||||
spec: fs.existsSync(specMdPath),
|
||||
prd: fs.existsSync(prdMdPath)
|
||||
});
|
||||
|
||||
// 收集所有存在的文档
|
||||
const docs: Array<{ key: string; label: string; url: string }> = [];
|
||||
const urlPath = encodeRoutePath(`/${type}/${name}`);
|
||||
|
||||
if (fs.existsSync(specMdPath)) {
|
||||
const docUrl = versionId
|
||||
? `/api/git/version-file/${versionId}${urlPath}/spec.md`
|
||||
: `${urlPath}/spec.md`;
|
||||
docs.push({
|
||||
key: 'spec',
|
||||
label: 'Spec',
|
||||
url: docUrl
|
||||
});
|
||||
}
|
||||
|
||||
if (fs.existsSync(prdMdPath)) {
|
||||
const docUrl = versionId
|
||||
? `/api/git/version-file/${versionId}${urlPath}/prd.md`
|
||||
: `${urlPath}/prd.md`;
|
||||
docs.push({
|
||||
key: 'prd',
|
||||
label: 'PRD',
|
||||
url: docUrl
|
||||
});
|
||||
}
|
||||
|
||||
if (docs.length > 0) {
|
||||
const title = versionId
|
||||
? `${typeLabel}: ${name} (版本: ${versionId})`
|
||||
: `${typeLabel}: ${name}`;
|
||||
const isMultiDoc = docs.length > 1;
|
||||
const transformUrl = versionId
|
||||
? `${urlPath}/__axhub_version__/${versionId}/spec.html`
|
||||
: `${urlPath}/spec.html`;
|
||||
|
||||
// 使用 spec-template.html 模板
|
||||
let html = specTemplate.replace(/\{\{TITLE\}\}/g, title);
|
||||
|
||||
if (isMultiDoc) {
|
||||
// 多文档模式
|
||||
const docsConfig = JSON.stringify(docs);
|
||||
html = html.replace(/\{\{SPEC_URL\}\}/g, '');
|
||||
html = html.replace(/\{\{DOCS_CONFIG\}\}/g, docsConfig.replace(/"/g, '"'));
|
||||
html = html.replace(/\{\{MULTI_DOC\}\}/g, 'true');
|
||||
logVirtualHtmlDebug('返回多文档 Spec 虚拟 HTML:', normalized.normalizedUrl, '文档数:', docs.length);
|
||||
} else {
|
||||
// 单文档模式
|
||||
html = html.replace(/\{\{SPEC_URL\}\}/g, docs[0].url);
|
||||
html = html.replace(/\{\{DOCS_CONFIG\}\}/g, '[]');
|
||||
html = html.replace(/\{\{MULTI_DOC\}\}/g, 'false');
|
||||
logVirtualHtmlDebug('返回单文档 Spec 虚拟 HTML:', normalized.normalizedUrl);
|
||||
}
|
||||
|
||||
await respondHtml(html, transformUrl);
|
||||
return true;
|
||||
} else {
|
||||
logVirtualHtmlDebug('没有找到任何文档文件');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 /docs/* 的 spec 请求
|
||||
if (type === 'docs') {
|
||||
const decodedDocName = decodeURIComponent(name);
|
||||
const mdPath = path.resolve(process.cwd(), 'src/docs', decodedDocName + '.md');
|
||||
|
||||
logVirtualHtmlDebug('检查 docs markdown 文件:', mdPath, '存在:', fs.existsSync(mdPath));
|
||||
|
||||
if (fs.existsSync(mdPath)) {
|
||||
const title = `Docs: ${decodedDocName || 'Index'}`;
|
||||
const specMdUrl = buildDocApiPath(`${decodedDocName}.md`);
|
||||
|
||||
let html = specTemplate.replace(/\{\{TITLE\}\}/g, title);
|
||||
html = html.replace(/\{\{SPEC_URL\}\}/g, specMdUrl);
|
||||
html = html.replace(/\{\{DOCS_CONFIG\}\}/g, '[]');
|
||||
html = html.replace(/\{\{MULTI_DOC\}\}/g, 'false');
|
||||
|
||||
logVirtualHtmlDebug('返回 Docs 虚拟 HTML:', normalized.normalizedUrl);
|
||||
|
||||
const docsTransformUrl = `${encodeRoutePath(`/docs/${decodedDocName}`)}/spec.html`;
|
||||
await respondHtml(html, docsTransformUrl);
|
||||
return true;
|
||||
} else {
|
||||
logVirtualHtmlDebug('docs markdown 不存在:', mdPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const TARGET_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx'];
|
||||
|
||||
async function getAllFilePaths(dirPath: string): Promise<string[]> {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
const items = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name);
|
||||
if (item.isDirectory()) {
|
||||
files = files.concat(await getAllFilePaths(fullPath));
|
||||
} else if (item.isFile()) {
|
||||
const ext = path.extname(item.name).toLowerCase();
|
||||
if (TARGET_EXTENSIONS.includes(ext)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`读取目录失败: ${dirPath}`, err);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
type TextReplacement = { searchText: string };
|
||||
|
||||
async function countMatches(dirPath: string, searchText: string): Promise<number> {
|
||||
let totalCount = 0;
|
||||
const files = await getAllFilePaths(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fs.promises.readFile(file, 'utf-8');
|
||||
const count = content.split(searchText).length - 1;
|
||||
if (count > 0) {
|
||||
totalCount += count;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`无法读取文件: ${file}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
async function countMatchesBatch(dirPath: string, replacements: TextReplacement[]): Promise<Record<string, number>> {
|
||||
const counts: Record<string, number> = {};
|
||||
const files = await getAllFilePaths(dirPath);
|
||||
|
||||
const searchTexts = replacements
|
||||
.map(item => item.searchText)
|
||||
.filter(text => typeof text === 'string' && text.length > 0);
|
||||
|
||||
searchTexts.forEach(text => {
|
||||
counts[text] = 0;
|
||||
});
|
||||
|
||||
if (searchTexts.length === 0) return counts;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fs.promises.readFile(file, 'utf-8');
|
||||
for (const searchText of searchTexts) {
|
||||
const count = content.split(searchText).length - 1;
|
||||
if (count > 0) {
|
||||
counts[searchText] += count;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`无法读取文件: ${file}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function handleTextReplaceCount(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (req.method === 'POST' && req.url?.startsWith('/api/text-replace/count')) {
|
||||
let body = '';
|
||||
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { path: targetPath, searchText, searchTexts, replacements } = JSON.parse(body);
|
||||
|
||||
if (!targetPath || (!searchText && !Array.isArray(searchTexts) && !Array.isArray(replacements))) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'path and search text data are required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const pathParts = targetPath.split('/').filter(Boolean);
|
||||
if (pathParts.length < 2 || !['components', 'prototypes'].includes(pathParts[0])) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Invalid path format. Expected: components/xxx or prototypes/xxx' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.resolve(process.cwd(), 'src', targetPath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Path not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(replacements) || Array.isArray(searchTexts)) {
|
||||
const list = Array.isArray(replacements)
|
||||
? replacements
|
||||
.filter((item: any) => item && typeof item.searchText === 'string')
|
||||
.map((item: any) => ({ searchText: String(item.searchText) }))
|
||||
: (searchTexts || [])
|
||||
.filter((item: any) => typeof item === 'string')
|
||||
.map((item: any) => ({ searchText: String(item) }));
|
||||
|
||||
if (list.length === 0) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'searchTexts is empty' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const countsMap = await countMatchesBatch(fullPath, list);
|
||||
const totalCount = Object.values(countsMap).reduce((sum, value) => sum + value, 0);
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ success: true, counts: countsMap, totalCount }));
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await countMatches(fullPath, searchText);
|
||||
console.log(`[API] 统计文本 "${searchText}" 出现次数: ${count}`);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ success: true, count, totalCount: count }));
|
||||
} catch (err) {
|
||||
console.error('[API] ❌ 统计文本失败:', err);
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Failed to count text matches' }));
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const TARGET_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx'];
|
||||
|
||||
async function getAllFilePaths(dirPath: string): Promise<string[]> {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
const items = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name);
|
||||
if (item.isDirectory()) {
|
||||
files = files.concat(await getAllFilePaths(fullPath));
|
||||
} else if (item.isFile()) {
|
||||
const ext = path.extname(item.name).toLowerCase();
|
||||
if (TARGET_EXTENSIONS.includes(ext)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`读取目录失败: ${dirPath}`, err);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
type TextReplacement = { searchText: string; replaceText: string };
|
||||
|
||||
async function replaceMatches(dirPath: string, searchText: string, replaceText: string): Promise<number> {
|
||||
let changedFilesCount = 0;
|
||||
const files = await getAllFilePaths(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fs.promises.readFile(file, 'utf-8');
|
||||
if (content.includes(searchText)) {
|
||||
const newContent = content.replaceAll(searchText, replaceText);
|
||||
await fs.promises.writeFile(file, newContent, 'utf-8');
|
||||
console.log(`[API] ✅ 已修改: ${file}`);
|
||||
changedFilesCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`处理文件失败: ${file}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFilesCount;
|
||||
}
|
||||
|
||||
async function replaceMatchesBatch(dirPath: string, replacements: TextReplacement[]): Promise<number> {
|
||||
let changedFilesCount = 0;
|
||||
const files = await getAllFilePaths(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fs.promises.readFile(file, 'utf-8');
|
||||
let newContent = content;
|
||||
let changed = false;
|
||||
|
||||
for (const { searchText, replaceText } of replacements) {
|
||||
if (!searchText) continue;
|
||||
if (newContent.includes(searchText)) {
|
||||
newContent = newContent.replaceAll(searchText, replaceText);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed && newContent !== content) {
|
||||
await fs.promises.writeFile(file, newContent, 'utf-8');
|
||||
console.log(`[API] ✅ 已修改: ${file}`);
|
||||
changedFilesCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`处理文件失败: ${file}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFilesCount;
|
||||
}
|
||||
|
||||
export function handleTextReplace(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (req.method === 'POST' && req.url?.startsWith('/api/text-replace/replace')) {
|
||||
let body = '';
|
||||
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { path: targetPath, searchText, replaceText, replacements } = JSON.parse(body);
|
||||
|
||||
if (!targetPath || (!searchText && !Array.isArray(replacements))) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'path and replacement data are required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const pathParts = targetPath.split('/').filter(Boolean);
|
||||
if (pathParts.length < 2 || !['components', 'prototypes'].includes(pathParts[0])) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Invalid path format. Expected: components/xxx or prototypes/xxx' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.resolve(process.cwd(), 'src', targetPath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Path not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let changedFiles = 0;
|
||||
if (Array.isArray(replacements)) {
|
||||
const cleaned = replacements
|
||||
.filter((item: any) => item && typeof item.searchText === 'string' && item.replaceText !== undefined)
|
||||
.map((item: any) => ({
|
||||
searchText: item.searchText,
|
||||
replaceText: String(item.replaceText ?? '')
|
||||
}));
|
||||
|
||||
if (cleaned.length === 0) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'replacements is empty' }));
|
||||
return;
|
||||
}
|
||||
|
||||
changedFiles = await replaceMatchesBatch(fullPath, cleaned);
|
||||
} else {
|
||||
if (!searchText || replaceText === undefined) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'searchText and replaceText are required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
changedFiles = await replaceMatches(fullPath, searchText, replaceText);
|
||||
}
|
||||
console.log(`[API] 替换完成: 共修改了 ${changedFiles} 个文件`);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ success: true, changedFiles }));
|
||||
} catch (err) {
|
||||
console.error('[API] ❌ 替换文本失败:', err);
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Failed to replace text' }));
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
207
axhub-make/vite-plugins/virtualHtml/index.ts
Normal file
207
axhub-make/vite-plugins/virtualHtml/index.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { Plugin } from 'vite';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { handleHackCssRequest } from './handlers/hackCssHandler';
|
||||
import { handleHackCssSave } from './handlers/hackCssSaveHandler';
|
||||
import { handleHackCssClear } from './handlers/hackCssClearHandler';
|
||||
import { handleEntriesApi } from './handlers/entriesApiHandler';
|
||||
import { handleSpecHtml } from './handlers/specHtmlHandler';
|
||||
import { handleIndexHtml } from './handlers/indexHtmlHandler';
|
||||
import { handleAssetsRequest } from './handlers/assetsHandler';
|
||||
import { handleDocImageAssets } from './handlers/docImageAssetsHandler';
|
||||
import { handleBuildRequest } from './handlers/buildHandler';
|
||||
import { handleDocsMarkdown } from './handlers/docsMarkdownHandler';
|
||||
import { handleTextReplaceCount } from './handlers/textReplaceCountHandler';
|
||||
import { handleTextReplace } from './handlers/textReplaceHandler';
|
||||
import { handlePathRedirect } from './handlers/pathNormalizer';
|
||||
import {
|
||||
createDocUpdatePayload,
|
||||
createHackCssUpdatePayload,
|
||||
createPreviewHostModuleCode,
|
||||
parsePreviewHostModuleId,
|
||||
PREVIEW_HOST_MODULE_PREFIX,
|
||||
} from './previewHost';
|
||||
|
||||
/**
|
||||
* 虚拟 HTML 插件 - 在内存中生成 HTML,不写入文件系统
|
||||
*/
|
||||
export function virtualHtmlPlugin(): Plugin {
|
||||
const devTemplatePath = path.resolve(process.cwd(), 'admin/dev-template.html');
|
||||
const specTemplatePath = path.resolve(process.cwd(), 'admin/spec-template.html');
|
||||
const htmlTemplatePath = path.resolve(process.cwd(), 'admin/html-template.html');
|
||||
let devTemplate: string;
|
||||
let specTemplate: string;
|
||||
let htmlTemplate: string;
|
||||
|
||||
return {
|
||||
name: 'virtual-html',
|
||||
apply: 'serve',
|
||||
|
||||
resolveId(id) {
|
||||
if (id.startsWith(PREVIEW_HOST_MODULE_PREFIX)) {
|
||||
return `\0${id}`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (!id.startsWith(`\0${PREVIEW_HOST_MODULE_PREFIX}`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = parsePreviewHostModuleId(id.slice(1));
|
||||
if (!options) {
|
||||
throw new Error(`Invalid preview host module id: ${id}`);
|
||||
}
|
||||
|
||||
return createPreviewHostModuleCode(options);
|
||||
},
|
||||
|
||||
handleHotUpdate(ctx) {
|
||||
const payload = createDocUpdatePayload(ctx.file, 'change');
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.server.ws.send({
|
||||
type: 'custom',
|
||||
event: 'axhub:spec-doc-update',
|
||||
data: payload,
|
||||
});
|
||||
|
||||
// src 下的 markdown 文档不再走 Vite 默认的全局 full-reload,
|
||||
// 只让对应的 spec/doc 页面自行按 URL 维度刷新内容。
|
||||
return [];
|
||||
},
|
||||
|
||||
async configureServer(server) {
|
||||
try {
|
||||
devTemplate = fs.readFileSync(devTemplatePath, 'utf8');
|
||||
} catch (err) {
|
||||
console.error('无法读取 dev-template 模板文件:', devTemplatePath);
|
||||
}
|
||||
|
||||
try {
|
||||
specTemplate = fs.readFileSync(specTemplatePath, 'utf8');
|
||||
} catch (err) {
|
||||
console.error('无法读取 spec-template 模板文件:', specTemplatePath);
|
||||
}
|
||||
|
||||
try {
|
||||
htmlTemplate = fs.readFileSync(htmlTemplatePath, 'utf8');
|
||||
} catch (err) {
|
||||
console.error('无法读取 html-template 模板文件:', htmlTemplatePath);
|
||||
}
|
||||
|
||||
const broadcastHackCssUpdate = (filePath: string, changeType: 'add' | 'change' | 'unlink') => {
|
||||
const payload = createHackCssUpdatePayload(filePath, changeType);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
server.ws.send({
|
||||
type: 'custom',
|
||||
event: 'axhub:hack-css-update',
|
||||
data: payload,
|
||||
});
|
||||
};
|
||||
|
||||
server.watcher.on('add', (filePath) => broadcastHackCssUpdate(filePath, 'add'));
|
||||
server.watcher.on('change', (filePath) => broadcastHackCssUpdate(filePath, 'change'));
|
||||
server.watcher.on('unlink', (filePath) => broadcastHackCssUpdate(filePath, 'unlink'));
|
||||
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
try {
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.url) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const respondHtml = async (html: string, transformUrl?: string) => {
|
||||
const htmlUrl = transformUrl || req.url || '/index.html';
|
||||
const transformedHtml = await server.transformIndexHtml(htmlUrl, html, req.originalUrl || req.url || htmlUrl);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.statusCode = 200;
|
||||
res.end(transformedHtml);
|
||||
};
|
||||
|
||||
// 🔥 处理旧路径重定向(必须在最前面)
|
||||
if (handlePathRedirect(req, res)) return;
|
||||
|
||||
// Handle hack.css GET request
|
||||
if (handleHackCssRequest(req, res)) return;
|
||||
|
||||
// Handle hack.css save POST request
|
||||
if (handleHackCssSave(req, res)) return;
|
||||
|
||||
// Handle hack.css clear POST request
|
||||
if (handleHackCssClear(req, res)) return;
|
||||
|
||||
// Handle text replace count POST request
|
||||
if (handleTextReplaceCount(req, res)) return;
|
||||
|
||||
// Handle text replace POST request
|
||||
if (handleTextReplace(req, res)) return;
|
||||
|
||||
// Handle root path
|
||||
if (req.url === '/' || req.url === '/index.html') {
|
||||
const indexHtmlPath = path.resolve(process.cwd(), 'admin/index.html');
|
||||
if (fs.existsSync(indexHtmlPath)) {
|
||||
try {
|
||||
const html = fs.readFileSync(indexHtmlPath, 'utf8');
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.statusCode = 200;
|
||||
res.end(html);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('读取 index.html 失败:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle assets
|
||||
if (handleAssetsRequest(req, res)) return;
|
||||
|
||||
// Handle build requests
|
||||
if (handleBuildRequest(req, res)) return;
|
||||
|
||||
// Handle entries API
|
||||
if (handleEntriesApi(req, res)) return;
|
||||
|
||||
// Handle markdown-relative document images (assets/images/*)
|
||||
if (handleDocImageAssets(req, res)) return;
|
||||
|
||||
// Handle docs markdown files
|
||||
if (handleDocsMarkdown(req, res)) return;
|
||||
|
||||
// Handle spec.html
|
||||
if (await handleSpecHtml(req, res, specTemplate, respondHtml)) return;
|
||||
|
||||
// Handle index.html
|
||||
if (req.url?.includes('/themes/') && req.url?.includes('/index.html')) {
|
||||
try {
|
||||
htmlTemplate = fs.readFileSync(htmlTemplatePath, 'utf8');
|
||||
} catch (err) {
|
||||
console.error('无法读取 html-template 模板文件:', htmlTemplatePath);
|
||||
}
|
||||
}
|
||||
if (await handleIndexHtml(req, res, devTemplate, htmlTemplate, respondHtml)) return;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
22
axhub-make/vite-plugins/virtualHtml/logger.ts
Normal file
22
axhub-make/vite-plugins/virtualHtml/logger.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
const VIRTUAL_HTML_LOG_PREFIX = '[虚拟HTML]';
|
||||
const VIRTUAL_HTML_DEBUG_LOGS_ENABLED = false;
|
||||
|
||||
function formatMessage(message: string) {
|
||||
return `${VIRTUAL_HTML_LOG_PREFIX} ${message}`;
|
||||
}
|
||||
|
||||
export function logVirtualHtmlDebug(message: string, ...args: unknown[]) {
|
||||
if (!VIRTUAL_HTML_DEBUG_LOGS_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(formatMessage(message), ...args);
|
||||
}
|
||||
|
||||
export function logVirtualHtmlWarn(message: string, ...args: unknown[]) {
|
||||
console.warn(formatMessage(message), ...args);
|
||||
}
|
||||
|
||||
export function logVirtualHtmlError(message: string, ...args: unknown[]) {
|
||||
console.error(formatMessage(message), ...args);
|
||||
}
|
||||
531
axhub-make/vite-plugins/virtualHtml/previewHost.ts
Normal file
531
axhub-make/vite-plugins/virtualHtml/previewHost.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
import path from 'path';
|
||||
|
||||
import { encodeRoutePath } from './handlers/pathNormalizer';
|
||||
import { buildDocApiPath } from '../utils/docUtils';
|
||||
|
||||
export const PREVIEW_HOST_MODULE_PREFIX = 'virtual:axhub-preview-host.js?';
|
||||
|
||||
export interface PreviewHostModuleOptions {
|
||||
type: 'components' | 'prototypes' | 'themes';
|
||||
name: string;
|
||||
entryImportPath: string;
|
||||
resourcePath: string;
|
||||
resourceUrlPath: string;
|
||||
editableHackCssHref: string | null;
|
||||
initialHackCssEnabled: boolean;
|
||||
versionId?: string;
|
||||
}
|
||||
|
||||
export interface HackCssUpdatePayload {
|
||||
resourcePath: string;
|
||||
href: string;
|
||||
removed: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface DocUpdatePayload {
|
||||
docUrl: string;
|
||||
filePath: string;
|
||||
removed: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function createPreviewHostModuleId(options: PreviewHostModuleOptions): string {
|
||||
const params = new URLSearchParams({
|
||||
resourceType: options.type,
|
||||
name: options.name,
|
||||
entry: options.entryImportPath,
|
||||
resourcePath: options.resourcePath,
|
||||
resourceUrlPath: options.resourceUrlPath,
|
||||
initialHackCssEnabled: options.initialHackCssEnabled ? '1' : '0',
|
||||
});
|
||||
|
||||
if (options.editableHackCssHref) {
|
||||
params.set('editableHackCssHref', options.editableHackCssHref);
|
||||
}
|
||||
|
||||
if (options.versionId) {
|
||||
params.set('versionId', options.versionId);
|
||||
}
|
||||
|
||||
return `${PREVIEW_HOST_MODULE_PREFIX}${params.toString()}`;
|
||||
}
|
||||
|
||||
export function parsePreviewHostModuleId(id: string): PreviewHostModuleOptions | null {
|
||||
if (!id.startsWith(PREVIEW_HOST_MODULE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(id.slice(PREVIEW_HOST_MODULE_PREFIX.length));
|
||||
const type = params.get('resourceType');
|
||||
const name = params.get('name');
|
||||
const entryImportPath = params.get('entry');
|
||||
const resourcePath = params.get('resourcePath');
|
||||
const resourceUrlPath = params.get('resourceUrlPath');
|
||||
|
||||
if (
|
||||
(type !== 'components' && type !== 'prototypes' && type !== 'themes')
|
||||
|| !name
|
||||
|| !entryImportPath
|
||||
|| !resourcePath
|
||||
|| !resourceUrlPath
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
entryImportPath,
|
||||
resourcePath,
|
||||
resourceUrlPath,
|
||||
editableHackCssHref: params.get('editableHackCssHref'),
|
||||
initialHackCssEnabled: params.get('initialHackCssEnabled') === '1',
|
||||
versionId: params.get('versionId') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function replacePreviewLoaderScript(html: string, previewHostModuleCode: string): string {
|
||||
const indentedCode = previewHostModuleCode
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n');
|
||||
const loaderScript = ` <script type="module">\n${indentedCode}\n </script>`;
|
||||
const legacyLoaderPattern = /<script type="module">\s*\/\/ 等待 bootstrap 加载完成[\s\S]*?<\/script>\s*<\/body>/;
|
||||
|
||||
if (legacyLoaderPattern.test(html)) {
|
||||
return html.replace(legacyLoaderPattern, `${loaderScript}\n\n</body>`);
|
||||
}
|
||||
|
||||
return html.replace('</body>', `${loaderScript}\n</body>`);
|
||||
}
|
||||
|
||||
export function resolveEditableHackCssHref(
|
||||
type: 'components' | 'prototypes' | 'themes',
|
||||
name: string,
|
||||
versionId?: string,
|
||||
): string | null {
|
||||
if (versionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type !== 'components' && type !== 'prototypes') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return encodeRoutePath(`/${type}/${name}/hack.css`);
|
||||
}
|
||||
|
||||
export function createHackCssUpdatePayload(filePath: string, changeType: 'add' | 'change' | 'unlink'): HackCssUpdatePayload | null {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const marker = '/src/';
|
||||
const markerIndex = normalizedPath.lastIndexOf(marker);
|
||||
|
||||
if (markerIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = normalizedPath.slice(markerIndex + marker.length);
|
||||
const pathParts = relativePath.split('/');
|
||||
|
||||
if (pathParts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, ...rest] = pathParts;
|
||||
if ((type !== 'components' && type !== 'prototypes') || rest[rest.length - 1] !== 'hack.css') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = rest.slice(0, -1).join('/');
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resourcePath: `${type}/${name}`,
|
||||
href: encodeRoutePath(`/${type}/${name}/hack.css`),
|
||||
removed: changeType === 'unlink',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createDocUpdatePayload(filePath: string, changeType: 'add' | 'change' | 'unlink'): DocUpdatePayload | null {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const marker = '/src/';
|
||||
const markerIndex = normalizedPath.lastIndexOf(marker);
|
||||
|
||||
if (markerIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = normalizedPath.slice(markerIndex + marker.length);
|
||||
const pathParts = relativePath.split('/');
|
||||
|
||||
if (pathParts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pathParts[0] === 'docs' && relativePath.endsWith('.md')) {
|
||||
const docName = relativePath.slice('docs/'.length);
|
||||
if (!docName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
docUrl: buildDocApiPath(docName),
|
||||
filePath: normalizedPath,
|
||||
removed: changeType === 'unlink',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const [type, ...rest] = pathParts;
|
||||
if ((type !== 'components' && type !== 'prototypes' && type !== 'themes') || rest.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = rest[rest.length - 1];
|
||||
if (fileName !== 'spec.md' && fileName !== 'prd.md') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = rest.slice(0, -1).join('/');
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
docUrl: encodeRoutePath(`/${type}/${name}/${fileName}`),
|
||||
filePath: normalizedPath,
|
||||
removed: changeType === 'unlink',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function toJsString(value: string | null): string {
|
||||
return value === null ? 'null' : JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function createPreviewHostModuleCode(options: PreviewHostModuleOptions): string {
|
||||
const editorModeBootstrapSnippet = `
|
||||
function resolveInitialEditorMode() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const editor = params.get('editor');
|
||||
if (editor === 'inspecta' || editor === 'textEdit' || editor === 'webEditorV2') {
|
||||
return editor;
|
||||
}
|
||||
if (params.get('inspecta') === 'true') {
|
||||
return 'inspecta';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function maybeEnableInitialEditorMode(hostState) {
|
||||
if (hostState.editorModeHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrap = window.DevTemplateBootstrap;
|
||||
if (!bootstrap || !bootstrap.editors || typeof bootstrap.editors.enable !== 'function') {
|
||||
window.setTimeout(() => maybeEnableInitialEditorMode(hostState), 30);
|
||||
return;
|
||||
}
|
||||
|
||||
hostState.editorModeHandled = true;
|
||||
const initialMode = resolveInitialEditorMode();
|
||||
if (initialMode === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(bootstrap.editors.enable(initialMode)).catch((error) => {
|
||||
hostState.editorModeHandled = false;
|
||||
console.error('[Axhub Preview Host] Failed to enable initial editor mode:', error);
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
return `import React from 'react';
|
||||
import * as ReactDOMLegacy from 'react-dom';
|
||||
import { createRoot, hydrateRoot } from 'react-dom/client';
|
||||
import PreviewComponent from ${JSON.stringify(options.entryImportPath)};
|
||||
|
||||
const RESOURCE_PATH = ${JSON.stringify(options.resourcePath)};
|
||||
const RESOURCE_URL_PATH = ${JSON.stringify(options.resourceUrlPath)};
|
||||
const ENTRY_IMPORT_PATH = ${JSON.stringify(options.entryImportPath)};
|
||||
const AXHUB_RUNTIME_KEY = '__AXHUB_PREVIEW_RUNTIME__';
|
||||
const AXHUB_HOST_KEY = ${JSON.stringify(options.resourcePath)};
|
||||
let CurrentComponent = PreviewComponent;
|
||||
let currentHackCssHref = ${toJsString(options.editableHackCssHref)};
|
||||
|
||||
const LegacyReactDOM = {
|
||||
...ReactDOMLegacy,
|
||||
createRoot,
|
||||
hydrateRoot,
|
||||
};
|
||||
|
||||
function hashString(input) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function getStablePageId() {
|
||||
if (typeof window.__PAGE_FULL_PATH__ !== 'undefined') {
|
||||
const fullPath = window.__PAGE_FULL_PATH__;
|
||||
const hash = hashString(fullPath);
|
||||
const pathSegment = String(fullPath)
|
||||
.split('/')
|
||||
.slice(-2)
|
||||
.join('-')
|
||||
.replace(/\\.(tsx|jsx|ts|js)$/u, '')
|
||||
.replace(/[^a-zA-Z0-9-]/g, '-')
|
||||
.slice(0, 32);
|
||||
return \`\${pathSegment}-\${hash}\`;
|
||||
}
|
||||
|
||||
if (typeof window.__PAGE_ID__ !== 'undefined') {
|
||||
return window.__PAGE_ID__;
|
||||
}
|
||||
|
||||
const pathKey = \`\${window.location.pathname}\${window.location.search}\`;
|
||||
const hash = hashString(pathKey);
|
||||
const pathSegment = pathKey
|
||||
.replace(/[^a-zA-Z0-9]/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 32);
|
||||
return \`\${pathSegment}-\${hash}\`;
|
||||
}
|
||||
|
||||
function getRuntimeState() {
|
||||
if (!window[AXHUB_RUNTIME_KEY]) {
|
||||
window[AXHUB_RUNTIME_KEY] = { hosts: new Map() };
|
||||
}
|
||||
|
||||
const runtime = window[AXHUB_RUNTIME_KEY];
|
||||
let hostState = runtime.hosts.get(AXHUB_HOST_KEY);
|
||||
if (!hostState) {
|
||||
hostState = {
|
||||
root: null,
|
||||
rootElement: null,
|
||||
editorModeHandled: false,
|
||||
latestProps: null,
|
||||
};
|
||||
runtime.hosts.set(AXHUB_HOST_KEY, hostState);
|
||||
}
|
||||
return hostState;
|
||||
}
|
||||
|
||||
function getRootElement() {
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('[Axhub Preview Host] Missing #root container');
|
||||
}
|
||||
return rootElement;
|
||||
}
|
||||
|
||||
function ensureRoot(hostState) {
|
||||
const rootElement = getRootElement();
|
||||
if (!hostState.root || hostState.rootElement !== rootElement) {
|
||||
hostState.root = createRoot(rootElement);
|
||||
hostState.rootElement = rootElement;
|
||||
}
|
||||
return rootElement;
|
||||
}
|
||||
|
||||
function getDefaultProps(container) {
|
||||
return {
|
||||
container,
|
||||
config: {},
|
||||
data: {},
|
||||
events: {},
|
||||
};
|
||||
}
|
||||
|
||||
function getRenderProps(container, nextProps) {
|
||||
if (nextProps && typeof nextProps === 'object') {
|
||||
return {
|
||||
...getDefaultProps(container),
|
||||
...nextProps,
|
||||
container: nextProps.container || container,
|
||||
};
|
||||
}
|
||||
|
||||
return getDefaultProps(container);
|
||||
}
|
||||
|
||||
function findHackCssLink() {
|
||||
return document.head.querySelector(\`link[data-axhub-hack-css="\${RESOURCE_PATH}"]\`);
|
||||
}
|
||||
|
||||
function updateHackCssLink(payload) {
|
||||
const hostState = getRuntimeState();
|
||||
if (payload && typeof payload.href === 'string' && payload.href) {
|
||||
currentHackCssHref = payload.href;
|
||||
}
|
||||
|
||||
const nextHref = currentHackCssHref;
|
||||
const existingLink = findHackCssLink();
|
||||
if (payload && payload.removed) {
|
||||
if (existingLink) {
|
||||
existingLink.remove();
|
||||
}
|
||||
hostState.hackCssLink = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextHref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = existingLink || document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.setAttribute('data-axhub-hack-css', RESOURCE_PATH);
|
||||
link.href = \`\${nextHref}?t=\${payload && payload.timestamp ? payload.timestamp : Date.now()}\`;
|
||||
if (!link.parentNode) {
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
hostState.hackCssLink = link;
|
||||
}
|
||||
|
||||
function applyStablePageId(container) {
|
||||
const stablePageId = getStablePageId();
|
||||
if (stablePageId) {
|
||||
container.setAttribute('data-page-id', stablePageId);
|
||||
}
|
||||
}
|
||||
|
||||
function syncLegacyBootstrap(container) {
|
||||
const hostState = getRuntimeState();
|
||||
const bootstrap = window.DevTemplateBootstrap;
|
||||
|
||||
window.React = React;
|
||||
window.ReactDOM = LegacyReactDOM;
|
||||
window.AxhubDevComponent = CurrentComponent;
|
||||
|
||||
if (!bootstrap || typeof bootstrap !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
bootstrap.React = React;
|
||||
bootstrap.ReactDOM = LegacyReactDOM;
|
||||
bootstrap.renderComponent = (Component, props) => {
|
||||
if (Component) {
|
||||
CurrentComponent = Component;
|
||||
}
|
||||
hostState.latestProps = props && typeof props === 'object' ? props : null;
|
||||
renderCurrentComponent(hostState.latestProps);
|
||||
};
|
||||
|
||||
if (typeof bootstrap.inspectaMode === 'undefined') {
|
||||
bootstrap.inspectaMode = false;
|
||||
}
|
||||
}
|
||||
|
||||
${editorModeBootstrapSnippet}
|
||||
|
||||
function afterRender(container) {
|
||||
syncLegacyBootstrap(container);
|
||||
applyStablePageId(container);
|
||||
maybeEnableInitialEditorMode(getRuntimeState());
|
||||
}
|
||||
|
||||
function renderCurrentComponent(nextProps) {
|
||||
const hostState = getRuntimeState();
|
||||
const rootElement = ensureRoot(hostState);
|
||||
if (typeof nextProps !== 'undefined') {
|
||||
hostState.latestProps = nextProps && typeof nextProps === 'object' ? nextProps : null;
|
||||
}
|
||||
|
||||
const renderProps = getRenderProps(rootElement, hostState.latestProps);
|
||||
hostState.root.render(React.createElement(CurrentComponent, renderProps));
|
||||
afterRender(rootElement);
|
||||
}
|
||||
|
||||
async function resolveUpdatedPreviewModule(nextModule) {
|
||||
if (nextModule && nextModule.default) {
|
||||
return nextModule;
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshedModule = await import(/* @vite-ignore */ \`\${ENTRY_IMPORT_PATH}?t=\${Date.now()}\`);
|
||||
if (refreshedModule && refreshedModule.default) {
|
||||
return refreshedModule;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Axhub Preview Host] Fallback re-import failed:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
renderCurrentComponent();
|
||||
|
||||
if (${options.initialHackCssEnabled ? 'true' : 'false'} && currentHackCssHref) {
|
||||
updateHackCssLink({ href: currentHackCssHref, removed: false, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
if (import.meta.hot) {
|
||||
const hostState = getRuntimeState();
|
||||
if (hostState.hackCssHandler && typeof import.meta.hot.off === 'function') {
|
||||
import.meta.hot.off('axhub:hack-css-update', hostState.hackCssHandler);
|
||||
}
|
||||
|
||||
hostState.hackCssHandler = (payload) => {
|
||||
if (!payload || payload.resourcePath !== RESOURCE_PATH) {
|
||||
return;
|
||||
}
|
||||
updateHackCssLink(payload);
|
||||
};
|
||||
|
||||
import.meta.hot.on('axhub:hack-css-update', hostState.hackCssHandler);
|
||||
import.meta.hot.accept(ENTRY_IMPORT_PATH, async (module) => {
|
||||
const resolvedModule = await resolveUpdatedPreviewModule(module);
|
||||
if (!resolvedModule || !resolvedModule.default) {
|
||||
console.warn('[Axhub Preview Host] Skipped preview rerender because the updated module has no default export.');
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentComponent = resolvedModule.default;
|
||||
renderCurrentComponent();
|
||||
});
|
||||
import.meta.hot.accept();
|
||||
import.meta.hot.dispose(() => {
|
||||
if (hostState.hackCssHandler && typeof import.meta.hot.off === 'function') {
|
||||
import.meta.hot.off('axhub:hack-css-update', hostState.hackCssHandler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function __axhubRenderPreview() {
|
||||
renderCurrentComponent();
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function createPreviewHostOptions(input: {
|
||||
type: 'components' | 'prototypes' | 'themes';
|
||||
name: string;
|
||||
entryImportPath: string;
|
||||
versionId?: string;
|
||||
initialHackCssEnabled?: boolean;
|
||||
}): PreviewHostModuleOptions {
|
||||
const resourcePath = `${input.type}/${input.name}`;
|
||||
return {
|
||||
type: input.type,
|
||||
name: input.name,
|
||||
entryImportPath: input.entryImportPath,
|
||||
resourcePath,
|
||||
resourceUrlPath: encodeRoutePath(`/${resourcePath}`),
|
||||
editableHackCssHref: resolveEditableHackCssHref(input.type, input.name, input.versionId),
|
||||
initialHackCssEnabled: Boolean(input.initialHackCssEnabled),
|
||||
versionId: input.versionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPosixPath(input: string): string {
|
||||
return input.split(path.sep).join('/');
|
||||
}
|
||||
Reference in New Issue
Block a user