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:
王冕
2026-06-09 18:12:25 +08:00
parent 351688006e
commit a27e3b8e43
1510 changed files with 162044 additions and 1517 deletions

View File

@@ -0,0 +1,21 @@
import type { Plugin } from 'vite';
/**
* 添加本项目标识
* 在文件开头添加特殊标识,让第三方平台识别这是通过本项目打包的组件
*/
export function addAxhubMarker(): Plugin {
return {
name: 'add-axhub-marker',
enforce: 'post',
generateBundle(options, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type !== 'chunk') continue;
const marker = `/* @axhub-factory */\n`;
chunk.code = marker + chunk.code;
}
}
};
}

View File

@@ -0,0 +1,525 @@
import type { Plugin } from 'vite';
import { platform } from 'node:os';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { streamText } from 'ai';
import { claudeCode } from 'ai-sdk-provider-claude-code';
import { codexCli } from 'ai-sdk-provider-codex-cli';
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
type AIEngine = 'claude' | 'codex';
type ChatRequest = {
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>;
engine: AIEngine;
model?: string;
requestId: string;
options?: {
systemPrompt?: string;
systemPromptPreset?: 'claude_code';
settingSources?: Array<'user' | 'project' | 'local'>;
allowedTools?: string[];
maxBudgetUsd?: number;
reasoningEffort?: 'low' | 'medium' | 'high';
approvalMode?: 'on-failure' | 'on-request' | 'never';
sandboxMode?: 'workspace-write' | 'workspace-read' | 'read-only' | 'disabled';
};
};
type StatusResponse = {
ok: boolean;
engines: {
claude: boolean;
codex: boolean;
};
versions?: {
claude?: string | null;
codex?: string | null;
};
timestamp: string;
message?: string;
};
type ThreadStatus = 'regular' | 'archived';
type ThreadMessageItem = {
parentId: string | null;
message: any;
};
type ThreadRecord = {
id: string;
externalId?: string;
title?: string;
status: ThreadStatus;
createdAt: string;
updatedAt: string;
messages: ThreadMessageItem[];
};
type LowdbData = {
threads: Record<string, ThreadRecord>;
};
const DEFAULT_MODELS: Record<AIEngine, string> = {
claude: 'claude-sonnet-4-5',
codex: 'gpt-5.1-codex'
};
function hasCommand(cmd: string): boolean {
const checker = platform() === 'win32' ? 'where' : 'which';
const result = spawnSync(checker, [cmd], { stdio: 'ignore' });
return result.status === 0;
}
function getCommandVersion(cmd: string): string | null {
try {
const result = spawnSync(cmd, ['--version'], { encoding: 'utf8' });
if (result.status !== 0) return null;
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
return output || null;
} catch {
return null;
}
}
function readJsonBody(req: any): Promise<any> {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk: Buffer) => {
body += chunk.toString('utf8');
});
req.on('end', () => {
if (!body) {
resolve({});
return;
}
try {
resolve(JSON.parse(body));
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}
function sendJson(res: any, statusCode: number, data: any) {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
}
function sendSse(res: any, payload: any) {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}
function isDebugEnabled(req: any) {
const header = req?.headers?.['x-ai-chat-debug'];
return header === '1' || header === 'true';
}
function debugLog(enabled: boolean, ...args: any[]) {
if (!enabled) return;
console.log('[agent-chat]', ...args);
}
function isAbortError(error: any): boolean {
return Boolean(error?.name === 'AbortError' || error?.code === 'ABORT_ERR');
}
function formatErrorMessage(error: any): string {
const base = error?.message || 'Unknown error';
const stderr = typeof error?.data?.stderr === 'string' ? error.data.stderr.trim() : '';
if (!stderr) return base;
const trimmed = stderr.length > 800 ? `${stderr.slice(0, 800)}...` : stderr;
return `${base}\n${trimmed}`;
}
const THREADS_PREFIX = '/api/agent/threads';
const DB_FILE = path.resolve(process.cwd(), 'apps/axhub-make/.data/ai-chat-db.json');
let db: Low<LowdbData> | null = null;
async function getDb() {
if (!db) {
await fs.mkdir(path.dirname(DB_FILE), { recursive: true });
db = new Low<LowdbData>(new JSONFile<LowdbData>(DB_FILE), { threads: {} });
}
await db.read();
if (!db.data) {
db.data = { threads: {} };
}
return db;
}
function toThreadMeta(thread: ThreadRecord) {
return {
status: thread.status,
remoteId: thread.id,
externalId: thread.externalId,
title: thread.title
};
}
export function agentChatApiPlugin(): Plugin {
const activeRequests = new Map<string, AbortController>();
return {
name: 'agent-chat-api',
configureServer(server) {
server.middlewares.use(async (req: any, res: any, next: any) => {
if (!req.url?.startsWith(THREADS_PREFIX)) return next();
const url = new URL(req.url, 'http://localhost');
const pathname = url.pathname;
if (!pathname.startsWith(THREADS_PREFIX)) return next();
const parts = pathname.slice(THREADS_PREFIX.length).split('/').filter(Boolean);
const method = req.method?.toUpperCase();
const database = await getDb();
if (parts.length === 0) {
if (method === 'GET') {
const threads = Object.values(database.data!.threads)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(toThreadMeta);
sendJson(res, 200, { threads });
return;
}
if (method === 'POST') {
const body = await readJsonBody(req);
const externalId = typeof body?.externalId === 'string' ? body.externalId : undefined;
if (externalId) {
const existing = Object.values(database.data!.threads)
.find((thread) => thread.externalId === externalId);
if (existing) {
sendJson(res, 200, { remoteId: existing.id, externalId: existing.externalId });
return;
}
}
const id = randomUUID();
const now = new Date().toISOString();
database.data!.threads[id] = {
id,
externalId,
status: 'regular',
createdAt: now,
updatedAt: now,
messages: []
};
await database.write();
sendJson(res, 200, { remoteId: id, externalId });
return;
}
sendJson(res, 405, { error: 'Method not allowed' });
return;
}
const threadId = decodeURIComponent(parts[0]);
const thread = database.data!.threads[threadId];
if (parts.length === 1) {
if (!thread) {
sendJson(res, 404, { error: 'Thread not found' });
return;
}
if (method === 'GET') {
sendJson(res, 200, toThreadMeta(thread));
return;
}
if (method === 'PATCH') {
const body = await readJsonBody(req);
if (typeof body?.title === 'string') {
thread.title = body.title.trim() || undefined;
}
if (body?.status === 'archived' || body?.status === 'regular') {
thread.status = body.status;
}
thread.updatedAt = new Date().toISOString();
await database.write();
sendJson(res, 200, toThreadMeta(thread));
return;
}
if (method === 'DELETE') {
delete database.data!.threads[threadId];
await database.write();
sendJson(res, 200, { success: true });
return;
}
sendJson(res, 405, { error: 'Method not allowed' });
return;
}
if (parts.length === 2 && parts[1] === 'messages') {
if (!thread) {
sendJson(res, 404, { error: 'Thread not found' });
return;
}
if (method === 'GET') {
sendJson(res, 200, { messages: thread.messages ?? [] });
return;
}
if (method === 'POST') {
const body = await readJsonBody(req);
if (!body?.message?.id) {
sendJson(res, 400, { error: 'Missing message payload' });
return;
}
const existingIndex = thread.messages.findIndex(
(item) => item.message?.id === body.message.id
);
if (existingIndex >= 0) {
thread.messages[existingIndex] = body;
} else {
thread.messages.push(body);
}
thread.updatedAt = new Date().toISOString();
await database.write();
sendJson(res, 200, { success: true });
return;
}
sendJson(res, 405, { error: 'Method not allowed' });
return;
}
sendJson(res, 404, { error: 'Not found' });
});
server.middlewares.use('/api/agent/status', (req: any, res: any, next: any) => {
if (req.method !== 'GET') return next();
const debug = isDebugEnabled(req);
const claudeAvailable = hasCommand('claude');
const codexAvailable = hasCommand('codex');
const payload: StatusResponse = {
ok: claudeAvailable || codexAvailable,
engines: {
claude: claudeAvailable,
codex: codexAvailable
},
versions: {
claude: claudeAvailable ? getCommandVersion('claude') : null,
codex: codexAvailable ? getCommandVersion('codex') : null
},
timestamp: new Date().toISOString()
};
debugLog(debug, 'status', payload);
sendJson(res, 200, payload);
});
server.middlewares.use('/api/agent/chat', async (req: any, res: any, next: any) => {
if (req.method !== 'POST') return next();
const debug = isDebugEnabled(req);
let body: ChatRequest | null = null;
try {
body = await readJsonBody(req);
} catch (error: any) {
debugLog(debug, 'chat:invalid-body', error?.message);
sendJson(res, 400, { error: error?.message || 'Invalid JSON body' });
return;
}
const { messages, engine = 'claude', model, requestId, options } = body || ({} as ChatRequest);
if (!requestId) {
debugLog(debug, 'chat:missing-requestId');
sendJson(res, 400, { error: 'Missing requestId' });
return;
}
if (!Array.isArray(messages)) {
debugLog(debug, 'chat:invalid-messages');
sendJson(res, 400, { error: 'Invalid messages payload' });
return;
}
const abortController = new AbortController();
activeRequests.set(requestId, abortController);
const cleanup = () => {
activeRequests.delete(requestId);
};
req.on('close', () => {
if (!abortController.signal.aborted) {
abortController.abort();
}
cleanup();
});
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
});
res.flushHeaders?.();
try {
debugLog(debug, 'chat:start', { engine, model, requestId });
const resolvedEngine: AIEngine = engine === 'codex' ? 'codex' : 'claude';
const modelName = model || DEFAULT_MODELS[resolvedEngine];
const aiModel = resolvedEngine === 'codex'
? codexCli(modelName, {
allowNpx: true,
skipGitRepoCheck: true,
approvalMode: options?.approvalMode ?? 'on-failure',
sandboxMode: options?.sandboxMode ?? 'workspace-write',
reasoningEffort: options?.reasoningEffort
} as any)
: claudeCode(modelName, {
systemPrompt: options?.systemPrompt ?? (options?.systemPromptPreset ? { type: 'preset', preset: options.systemPromptPreset } : { type: 'preset', preset: 'claude_code' }),
settingSources: options?.settingSources ?? ['project', 'user', 'local'],
allowedTools: options?.allowedTools,
maxBudgetUsd: options?.maxBudgetUsd
} as any);
const result = streamText({
model: aiModel,
messages: messages || [],
abortSignal: abortController.signal
});
let usage: any;
let streamedText = '';
const stream: AsyncIterable<any> = (result as any).fullStream
? (result as any).fullStream
: (async function* fallback() {
for await (const delta of result.textStream) {
yield { type: 'text-delta', textDelta: delta };
}
})();
for await (const part of stream) {
if (!part) continue;
if (part.type === 'text-delta') {
const delta = part.textDelta ?? part.delta ?? '';
if (!delta) continue;
streamedText += delta;
debugLog(debug, 'chat:text-delta', { delta });
sendSse(res, { type: 'text-delta', delta });
} else if (part.type === 'reasoning-delta') {
const delta = part.textDelta ?? part.delta ?? part.reasoningDelta ?? '';
if (!delta) continue;
debugLog(debug, 'chat:reasoning-delta', { delta });
sendSse(res, { type: 'reasoning-delta', delta });
} else if (part.type === 'reasoning') {
const delta = part.text ?? '';
if (!delta) continue;
debugLog(debug, 'chat:reasoning', { delta });
sendSse(res, { type: 'reasoning-delta', delta });
} else if (part.type === 'tool-call') {
const toolCall = part.toolCall ?? part;
const toolInput =
part.args ??
toolCall?.args ??
toolCall?.input ??
toolCall?.arguments ??
part.input ??
part.argsText ??
toolCall?.argsText;
sendSse(res, {
type: 'tool-call-start',
toolName: part.toolName ?? toolCall?.name ?? 'tool',
toolId: part.toolCallId ?? toolCall?.id ?? part.toolId ?? '',
input: toolInput
});
} else if (part.type === 'tool-result') {
debugLog(debug, 'chat:tool-result', { toolId: part.toolCallId ?? part.toolCall?.id ?? part.toolId ?? '' });
sendSse(res, {
type: 'tool-call-result',
toolId: part.toolCallId ?? part.toolCall?.id ?? part.toolId ?? '',
result: part.result
});
} else if (part.type === 'finish' && part.usage) {
debugLog(debug, 'chat:finish', part.usage);
usage = part.usage;
const finishText = part.text ?? part.response?.text ?? '';
if (finishText && !streamedText) {
streamedText = finishText;
sendSse(res, { type: 'text-delta', delta: finishText });
}
} else if (part.type === 'error') {
debugLog(debug, 'chat:stream-error', part.error?.message);
sendSse(res, { type: 'error', message: part.error?.message || 'Stream error' });
}
}
if (!usage) {
try {
usage = await (result as any).usage;
} catch {
usage = undefined;
}
}
if (!streamedText) {
try {
const finalText = await (result as any).text;
if (finalText) {
sendSse(res, { type: 'text-delta', delta: finalText });
}
} catch {
// ignore missing final text
}
}
debugLog(debug, 'chat:done', usage);
sendSse(res, { type: 'done', usage });
} catch (error: any) {
console.error('[agent-chat] error:', error);
if (abortController.signal.aborted || isAbortError(error)) {
sendSse(res, { type: 'done' });
} else {
sendSse(res, { type: 'error', message: formatErrorMessage(error) });
}
} finally {
cleanup();
res.end();
}
});
server.middlewares.use('/api/agent/cancel', async (req: any, res: any, next: any) => {
if (req.method !== 'POST') return next();
let body: { requestId?: string } = {};
try {
body = await readJsonBody(req);
} catch (error: any) {
sendJson(res, 400, { error: error?.message || 'Invalid JSON body' });
return;
}
const requestId = body.requestId;
if (requestId) {
const controller = activeRequests.get(requestId);
if (controller && !controller.signal.aborted) {
controller.abort();
}
activeRequests.delete(requestId);
}
sendJson(res, 200, { success: true });
});
}
};
}

View File

@@ -0,0 +1,91 @@
# AI CLI Plugin 规格说明vite-plugins/aiCliPlugin.ts
本插件在 Vite Dev Server 上提供 `/api/ai/*` HTTP 接口,用于从浏览器侧触发本机已安装的 AI CLIClaude / Gemini / OpenCode / Cursor Agent / Codex
## 1. 端点
### 1.1 `GET /api/ai/status`
返回当前机器上相关 CLI 是否可用(通过 `which/where` 检测)。
返回示例:
```json
{
"claude": true,
"gemini": true,
"opencode": false,
"cursor": true,
"codex": true,
"runningTasks": 0,
"timestamp": "2026-02-06T00:00:00.000Z"
}
```
### 1.2 `GET /api/ai/execute`
触发执行指定 CLI。
#### Query 参数
- `cli`(必填):`claude` | `gemini` | `opencode` | `cursor` | `codex`
- `prompt`(必填):字符串
- `silent`(可选):默认 `true`
- `true`:服务端捕获 stdout/stderr 并以 JSON 返回(非交互模式推荐)
- `false`:输出直接继承到 dev server 所在终端(用于观察实时输出)
- `interactive`(可选):默认 `false`
- `false`:非交互模式,接口会等待命令结束并返回输出(内置 60s 超时)
- `true`:交互式 TUI会在 dev server 所在终端启动会话,并立即返回 `202`(不等待退出,不设置超时)
#### 返回
非交互模式(`interactive=false`)成功示例:
```json
{
"success": true,
"cli": "codex",
"output": "...",
"cached": false,
"timestamp": "2026-02-06T00:00:00.000Z"
}
```
交互模式(`interactive=true`)成功示例:
```json
{
"success": true,
"cli": "claude",
"interactive": true,
"pid": 12345,
"message": "Interactive session started in the dev server terminal.",
"timestamp": "2026-02-06T00:00:00.000Z"
}
```
当已有交互会话正在运行时会返回 `409`(避免同时抢占同一个终端 TTY
## 2. CLI 行为约定(适配器)
- `claude`
- 非交互:`claude -p <prompt>`
- 交互:`claude <prompt>`(首条消息为 prompt
- `gemini`
- 非交互:通过 stdin 写入 prompt
- 交互:`gemini -i <prompt>`
- `opencode`
- 非交互:`opencode run <prompt>`
- 交互:`opencode --prompt <prompt>`(不同版本可能不支持;不支持时会在启动时报错)
- `cursor`
- 非交互:`agent -p <prompt> --output-format text`
- 交互:`agent <prompt>`
- `codex`
- 非交互:`codex exec <prompt> --full-auto`
- 交互:`codex <prompt>`
## 3. 设计备注
- `interactive=true` 的核心目的是“提交 prompt 后进入 CLI 对话”,因此不会等待进程退出,也不会走输出缓存/防抖逻辑。
- 所有平台均使用 `shell=false`Windows 通过统一命令执行层自动包装到 `cmd.exe /d /s /c`,避免直接开启 shell 带来的注入与转义不一致问题。
- 输出解码由统一命令执行层处理UTF-8 优先Windows 下自动按活动代码页回退,避免 CLI 输出乱码影响日志和错误判定。

View File

@@ -0,0 +1,398 @@
import type { Plugin } from 'vite';
import { spawn } from 'node:child_process';
import { commandExists, decodeOutput, getSpawnCommandSpec } from '../scripts/utils/command-runtime.mjs';
type AIType = 'claude' | 'gemini' | 'opencode' | 'cursor' | 'codex';
interface RunAIOptions {
cli: AIType;
prompt: string;
silent?: boolean;
interactive?: boolean;
}
/**
* 检测 CLI 是否存在
*/
function hasCommand(cmd: string): boolean {
return commandExists(cmd);
}
type CliSpawnConfig = {
command: string;
args: string[];
useStdin: boolean;
input?: string;
};
/**
* CLI 适配器(严格对齐官方用法)
*/
const CLI_ADAPTERS: Record<AIType, (opts: RunAIOptions) => CliSpawnConfig> = {
claude: ({ prompt, interactive }) => {
if (interactive) {
// 官方claude [prompt] → 启动交互式会话,并把 prompt 作为首条消息
return { command: 'claude', args: [prompt], useStdin: false };
}
// 官方 headless 模式
return { command: 'claude', args: ['-p', prompt], useStdin: false };
},
gemini: ({ prompt, interactive }) => {
if (interactive) {
// 官方gemini -i "<prompt>" → 执行 prompt 并进入交互模式
return { command: 'gemini', args: ['-i', prompt], useStdin: false };
}
// Gemini CLI 通过 stdin 接收输入
return { command: 'gemini', args: [], useStdin: true, input: prompt };
},
opencode: ({ prompt, interactive }) => {
if (interactive) {
// 启动 OpenCode TUI并附带首条 prompt如 CLI 版本不支持该参数,会在运行时报错)
return { command: 'opencode', args: ['--prompt', prompt], useStdin: false };
}
// 非交互模式opencode run "prompt"
return { command: 'opencode', args: ['run', prompt], useStdin: false };
},
cursor: ({ prompt, interactive }) => {
if (interactive) {
// 启动 Cursor Agent 交互式会话
return { command: 'agent', args: [prompt], useStdin: false };
}
// 非交互模式打印模式agent -p "prompt" --output-format text
return { command: 'agent', args: ['-p', prompt, '--output-format', 'text'], useStdin: false };
},
codex: ({ prompt, interactive }) => {
if (interactive) {
// 启动 Codex 交互式 TUI
return { command: 'codex', args: [prompt], useStdin: false };
}
// 非交互模式codex exec "prompt" --full-auto
// --full-auto: 低摩擦自动化模式workspace-write sandbox + on-request approvals
return { command: 'codex', args: ['exec', prompt, '--full-auto'], useStdin: false };
},
};
function spawnAIProcess(options: RunAIOptions) {
const {
cli,
prompt,
silent = false,
interactive = false,
} = options;
// Cursor 使用 'agent' 命令,需要特殊检测
const commandToCheck = cli === 'cursor' ? 'agent' : cli;
if (!hasCommand(commandToCheck)) {
throw new Error(`CLI not found: ${cli} (command: ${commandToCheck})`);
}
// 交互式 → 强制非静默(否则用户看不到 TUI
const finalSilent = interactive ? false : silent;
const adapter = CLI_ADAPTERS[cli];
if (!adapter) {
throw new Error(`Unsupported CLI: ${cli}`);
}
const config = adapter({ ...options, interactive });
const { command, args, useStdin, input } = config;
console.log(
`[AI CLI] Spawning command: ${command} ${args.join(' ')}${useStdin ? ' (with stdin)' : ''}`,
);
const spawnSpec = getSpawnCommandSpec(command, args);
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: finalSilent
? ['pipe', 'pipe', 'pipe']
: (useStdin ? ['pipe', 'inherit', 'inherit'] : 'inherit'),
env: process.env,
shell: false,
windowsHide: spawnSpec.windowsHide,
});
// 如果需要通过 stdin 传递输入
if (useStdin && input && child.stdin) {
console.log(`[AI CLI] Writing to stdin: ${input.substring(0, 50)}...`);
child.stdin.write(input);
child.stdin.end();
}
return { child, finalSilent };
}
/**
* 统一执行入口
*/
function runAICommand(options: RunAIOptions): Promise<string> {
return new Promise<string>((resolve, reject) => {
const { child, finalSilent } = spawnAIProcess(options);
let output = '';
let errorOutput = '';
// 仅在非交互模式下设置超时(交互式会话不应被强制终止)
const timeoutMs = options.interactive ? 0 : 60000;
const timeout = timeoutMs
? setTimeout(() => {
console.error(`[AI CLI] Command timeout after ${Math.round(timeoutMs / 1000)}s`);
child.kill('SIGTERM');
reject(new Error(`Command execution timeout (${Math.round(timeoutMs / 1000)}s)`));
}, timeoutMs)
: null;
// 捕获输出
if (finalSilent && child.stdout && child.stderr) {
child.stdout.on('data', (data) => {
const chunk = decodeOutput(data);
output += chunk;
console.log(`[AI CLI] stdout chunk: ${chunk.substring(0, 100)}${chunk.length > 100 ? '...' : ''}`);
});
child.stderr.on('data', (data) => {
const chunk = decodeOutput(data);
errorOutput += chunk;
console.error(`[AI CLI] stderr chunk: ${chunk.substring(0, 100)}${chunk.length > 100 ? '...' : ''}`);
});
}
// 错误处理
child.on('error', (error) => {
if (timeout) clearTimeout(timeout);
console.error(`[AI CLI] Process error:`, error);
reject(error);
});
// 进程关闭 - 这是主要的完成事件
child.on('close', (code, signal) => {
if (timeout) clearTimeout(timeout);
console.log(`[AI CLI] Process closed with code ${code}, signal ${signal}`);
if (code === 0) {
const result = output.trim() || errorOutput.trim() || 'Command executed successfully';
console.log(`[AI CLI] Success, output length: ${result.length}`);
resolve(result);
} else {
const errorMsg = `CLI exited with code ${code}${signal ? ` (signal: ${signal})` : ''}\nOutput: ${output}\nError: ${errorOutput}`;
console.error(`[AI CLI] Failed:`, errorMsg);
reject(new Error(errorMsg));
}
});
});
}
/**
* AI CLI Plugin
* Provides API endpoints for executing local AI CLI commands (Claude, Gemini)
*/
export function aiCliPlugin(): Plugin {
// 防抖机制:记录正在执行的任务
const runningTasks = new Map<string, { promise: Promise<string>; timestamp: number }>();
const DEBOUNCE_TIME = 2000; // 2秒内的相同请求会被合并
let interactiveSession:
| { pid: number; cli: AIType; startedAt: number }
| null = null;
return {
name: 'ai-cli-api',
configureServer(server) {
// Helper functions
const sendJSON = (res: any, statusCode: number, data: any) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
};
const sendError = (res: any, statusCode: number, message: string) => {
sendJSON(res, statusCode, {
error: message,
timestamp: new Date().toISOString()
});
};
// 生成任务唯一标识
const getTaskKey = (cli: string, prompt: string): string => {
return `${cli}:${prompt}`;
};
// 清理过期任务
const cleanupExpiredTasks = () => {
const now = Date.now();
for (const [key, task] of runningTasks.entries()) {
if (now - task.timestamp > DEBOUNCE_TIME) {
runningTasks.delete(key);
}
}
};
// Middleware to handle API routes
server.middlewares.use(async (req, res, next) => {
const url = req.url || '';
// GET /api/ai/execute - Execute AI CLI command
if (url.startsWith('/api/ai/execute') && req.method === 'GET') {
try {
const urlObj = new URL(url, `http://${req.headers.host}`);
const cli = urlObj.searchParams.get('cli') as AIType;
const prompt = urlObj.searchParams.get('prompt');
const silent = urlObj.searchParams.get('silent') !== 'false'; // 默认 true
const interactive = urlObj.searchParams.get('interactive') === 'true'; // 默认 false
// Validate required fields
if (!cli || !['claude', 'gemini', 'opencode', 'cursor', 'codex'].includes(cli)) {
return sendError(res, 400, 'Invalid or missing "cli" parameter. Must be "claude", "gemini", "opencode", "cursor", or "codex"');
}
if (!prompt || typeof prompt !== 'string') {
return sendError(res, 400, 'Invalid or missing "prompt" parameter');
}
// Check if CLI is available
const commandToCheck = cli === 'cursor' ? 'agent' : cli;
if (!hasCommand(commandToCheck)) {
return sendError(res, 404, `CLI not found: ${cli}. Please install it first.`);
}
// 交互式会话:直接在 dev server 的终端里启动 TUI不等待结束避免 HTTP 长连接挂起)
if (interactive) {
if (interactiveSession) {
return sendError(
res,
409,
`Interactive session already running (cli: ${interactiveSession.cli}, pid: ${interactiveSession.pid}). Please exit it before starting a new one.`,
);
}
console.log(`[AI CLI] Launching interactive ${cli} session...`);
try {
const { child } = spawnAIProcess({
cli,
prompt,
silent: false,
interactive: true,
});
interactiveSession = {
pid: child.pid ?? -1,
cli,
startedAt: Date.now(),
};
child.on('close', () => {
interactiveSession = null;
});
sendJSON(res, 202, {
success: true,
cli,
interactive: true,
pid: child.pid ?? null,
message: 'Interactive session started in the dev server terminal.',
timestamp: new Date().toISOString(),
});
} catch (error: any) {
console.error('[AI CLI] Interactive launch error:', error);
interactiveSession = null;
sendError(res, 500, error.message || 'Failed to launch interactive AI CLI session');
}
return;
}
// 防抖检查
cleanupExpiredTasks();
const taskKey = getTaskKey(cli, prompt);
const existingTask = runningTasks.get(taskKey);
if (existingTask) {
console.log(`[AI CLI] Reusing existing task for ${cli}:`, prompt.substring(0, 50) + '...');
try {
const result = await existingTask.promise;
sendJSON(res, 200, {
success: true,
cli,
output: result || 'Command executed successfully',
cached: true,
timestamp: new Date().toISOString(),
});
} catch (error: any) {
console.error('[AI CLI] Cached task error:', error);
sendError(res, 500, error.message || 'Failed to execute AI CLI command');
}
return;
}
console.log(`[AI CLI] Executing ${cli} command:`, {
prompt: prompt.substring(0, 50) + '...',
silent,
interactive
});
// 创建新任务
const taskPromise = runAICommand({
cli,
prompt,
silent,
interactive,
});
// 记录任务
runningTasks.set(taskKey, {
promise: taskPromise,
timestamp: Date.now(),
});
// 执行任务
try {
const result = await taskPromise;
sendJSON(res, 200, {
success: true,
cli,
output: result || 'Command executed successfully',
cached: false,
timestamp: new Date().toISOString(),
});
} catch (error: any) {
console.error('[AI CLI] Execution error:', error);
sendError(res, 500, error.message || 'Failed to execute AI CLI command');
} finally {
// 延迟删除任务,允许短时间内的重复请求复用结果
setTimeout(() => {
runningTasks.delete(taskKey);
}, DEBOUNCE_TIME);
}
} catch (error: any) {
console.error('[AI CLI] Request handling error:', error);
sendError(res, 500, error.message || 'Internal server error');
}
return;
}
// GET /api/ai/status - Check CLI availability
if (url.startsWith('/api/ai/status') && req.method === 'GET') {
try {
const status = {
claude: hasCommand('claude'),
gemini: hasCommand('gemini'),
opencode: hasCommand('opencode'),
cursor: hasCommand('agent'), // Cursor CLI 使用 'agent' 命令
codex: hasCommand('codex'),
runningTasks: runningTasks.size,
timestamp: new Date().toISOString(),
};
sendJSON(res, 200, status);
} catch (error: any) {
console.error('[AI CLI] Status check error:', error);
sendError(res, 500, error.message || 'Failed to check CLI status');
}
return;
}
next();
});
}
};
}

View File

@@ -0,0 +1,478 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* 自动调试插件
* 提供代码检查、错误诊断、自动修复等 API
*/
export function autoDebugPlugin(): Plugin {
return {
name: 'auto-debug-plugin',
configureServer(server) {
// API 1: 代码检查
server.middlewares.use('/api/check-code', async (req: any, res: any) => {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => body += chunk);
req.on('end', async () => {
try {
const { path: targetPath, code, type } = JSON.parse(body);
if (!targetPath || !code) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing path or code parameter' }));
return;
}
const errors: any[] = [];
// 1. 检查组件导出
if (!code.includes('export default')) {
errors.push({
type: 'export',
severity: 'error',
message: 'Component must be exported with "export default"',
suggestion: 'Add "export default <ComponentName>" at the end of the file'
});
}
// 2. 检查 @name 注释
if (!code.match(/@(?:name|displayName)\s+.+/)) {
errors.push({
type: 'metadata',
severity: 'error',
message: 'Missing @name comment',
suggestion: 'Add "/**\\n * @name Component Name\\n */" at the top of the file'
});
}
// 3. 检查 React.createElement 使用
if (code.includes('React.createElement') || code.includes('<')) {
// 正常使用 React
} else {
errors.push({
type: 'react',
severity: 'warning',
message: 'Component does not seem to use React',
suggestion: 'Make sure to use React.createElement or JSX'
});
}
// 4. 检查常见错误模式
const commonErrors = [
{
pattern: /\.map\(/,
check: (code: string) => !code.includes('&&') && !code.includes('||'),
message: 'Potential null/undefined error with .map()',
suggestion: 'Add null check: data && data.map(...) or use default value: (data || []).map(...)'
},
{
pattern: /fetch\(/,
check: (code: string) => !code.includes('.catch('),
message: 'Unhandled fetch error',
suggestion: 'Add .catch() to handle fetch errors'
},
{
pattern: /JSON\.parse\(/,
check: (code: string) => !code.includes('try'),
message: 'Unhandled JSON.parse error',
suggestion: 'Wrap JSON.parse in try-catch block'
}
];
commonErrors.forEach(({ pattern, check, message, suggestion }) => {
if (pattern.test(code) && check(code)) {
errors.push({
type: 'runtime',
severity: 'warning',
message,
suggestion
});
}
});
// 5. TypeScript 类型检查(如果文件存在)
const srcDir = path.resolve(process.cwd(), 'src', targetPath);
if (fs.existsSync(srcDir)) {
try {
const { stderr } = await execAsync('npx tsc --noEmit', {
cwd: process.cwd(),
timeout: 10000
});
if (stderr) {
const typeErrors = parseTypeScriptErrors(stderr, targetPath);
errors.push(...typeErrors);
}
} catch (e: any) {
// TypeScript 错误会通过 stderr 返回
if (e.stderr) {
const typeErrors = parseTypeScriptErrors(e.stderr, targetPath);
errors.push(...typeErrors);
}
}
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
success: errors.filter(e => e.severity === 'error').length === 0,
errors
}));
} catch (e: any) {
console.error('Check code error:', e);
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }));
}
});
});
// API 2: 自动修复
server.middlewares.use('/api/auto-fix', async (req: any, res: any) => {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => body += chunk);
req.on('end', async () => {
try {
const { path: targetPath, error } = JSON.parse(body);
if (!targetPath || !error) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing path or error parameter' }));
return;
}
const srcFile = path.resolve(process.cwd(), 'src', targetPath, 'index.tsx');
if (!fs.existsSync(srcFile)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Source file not found' }));
return;
}
let code = fs.readFileSync(srcFile, 'utf8');
let fixed = false;
const changes: any[] = [];
// 修复策略 1: 缺少 export default
if (error.type === 'export' || error.message.includes('export default')) {
if (!code.includes('export default')) {
// 查找组件定义
const componentMatch = code.match(/const\s+(\w+)\s*=\s*function/);
if (componentMatch) {
const componentName = componentMatch[1];
code += `\n\nexport default ${componentName};\n`;
fixed = true;
changes.push({
file: srcFile,
change: `Added: export default ${componentName}`
});
}
}
}
// 修复策略 2: 缺少 @name 注释
if (error.type === 'metadata' || error.message.includes('@name')) {
if (!code.match(/@(?:name|displayName)/)) {
const folderName = path.basename(path.dirname(srcFile));
const displayName = folderName.split('-').map(w =>
w.charAt(0).toUpperCase() + w.slice(1)
).join(' ');
code = `/**\n * @name ${displayName}\n */\n${code}`;
fixed = true;
changes.push({
file: srcFile,
change: `Added: @name ${displayName}`
});
}
}
// 修复策略 3: 添加空值检查
if (error.message.includes('null') || error.message.includes('undefined')) {
// 查找 .map( 调用
const mapPattern = /(\w+)\.map\(/g;
let match;
while ((match = mapPattern.exec(code)) !== null) {
const varName = match[1];
// 检查是否已有空值检查
const hasCheck = new RegExp(`${varName}\\s*&&|\\|\\||\\?\\.|if\\s*\\(${varName}\\)`).test(code);
if (!hasCheck) {
code = code.replace(
new RegExp(`${varName}\\.map\\(`),
`(${varName} || []).map(`
);
fixed = true;
changes.push({
file: srcFile,
change: `Added null check for ${varName}.map()`
});
}
}
}
// 如果有修复,写入文件
if (fixed) {
fs.writeFileSync(srcFile, code, 'utf8');
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
success: true,
fixed,
changes,
message: fixed ? 'Code fixed successfully' : 'No automatic fix available'
}));
} catch (e: any) {
console.error('Auto fix error:', e);
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }));
}
});
});
// API 3: 运行时错误上报
server.middlewares.use('/api/report-error', (req: any, res: any) => {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => body += chunk);
req.on('end', () => {
try {
const { path: targetPath, error } = JSON.parse(body);
console.log(`[Auto Debug] Runtime error in ${targetPath}:`, error);
// 分析错误并提供建议
const suggestion = analyzeRuntimeError(error);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
received: true,
suggestion
}));
} catch (e: any) {
console.error('Report error failed:', e);
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }));
}
});
});
// API 4: 依赖检查
server.middlewares.use('/api/check-dependencies', (req: any, res: any) => {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => body += chunk);
req.on('end', () => {
try {
const { code } = JSON.parse(body);
if (!code) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing code parameter' }));
return;
}
// 提取 import 语句
const importPattern = /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g;
const imports: string[] = [];
let match;
while ((match = importPattern.exec(code)) !== null) {
const packageName = match[1];
// 只检查外部包(不是相对路径)
if (!packageName.startsWith('.') && !packageName.startsWith('@/')) {
// 提取包名(去掉子路径)
const pkgName = packageName.startsWith('@')
? packageName.split('/').slice(0, 2).join('/')
: packageName.split('/')[0];
if (!imports.includes(pkgName)) {
imports.push(pkgName);
}
}
}
// 检查 package.json
const pkgPath = path.resolve(process.cwd(), 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const allDeps = {
...pkg.dependencies,
...pkg.devDependencies
};
const missing: string[] = [];
const available: string[] = [];
imports.forEach(pkgName => {
if (allDeps[pkgName]) {
available.push(pkgName);
} else {
missing.push(pkgName);
}
});
const suggestions = missing.map(pkg => `npm install ${pkg}`);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
missing,
available,
suggestions
}));
} catch (e: any) {
console.error('Check dependencies error:', e);
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }));
}
});
});
// API 5: 白屏检测
server.middlewares.use('/api/check-white-screen', (req: any, res: any) => {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => body += chunk);
req.on('end', () => {
try {
const { path: targetPath } = JSON.parse(body);
const srcFile = path.resolve(process.cwd(), 'src', targetPath, 'index.tsx');
if (!fs.existsSync(srcFile)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Source file not found' }));
return;
}
const code = fs.readFileSync(srcFile, 'utf8');
const issues: any[] = [];
// 检查 1: 是否有 export default
if (!code.includes('export default')) {
issues.push({
type: 'export',
message: 'Missing export default',
suggestion: 'Add "export default <ComponentName>" at the end'
});
}
// 检查 2: 是否有 @name 注释
if (!code.match(/@(?:name|displayName)/)) {
issues.push({
type: 'metadata',
message: 'Missing @name comment',
suggestion: 'Add @name comment at the top'
});
}
// 检查 3: 是否返回 React 元素
if (!code.includes('React.createElement') && !code.includes('return')) {
issues.push({
type: 'render',
message: 'Component does not return anything',
suggestion: 'Make sure component returns a React element'
});
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
isWhiteScreen: issues.length > 0,
issues
}));
} catch (e: any) {
console.error('Check white screen error:', e);
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }));
}
});
});
}
};
}
/**
* 解析 TypeScript 错误信息
*/
function parseTypeScriptErrors(stderr: string, targetPath: string): any[] {
const errors: any[] = [];
const lines = stderr.split('\n');
lines.forEach(line => {
// 匹配格式: src/prototypes/xxx/index.tsx(10,5): error TS2322: ...
const match = line.match(/src\/(.+?)\((\d+),(\d+)\):\s*(error|warning)\s+TS\d+:\s*(.+)/);
if (match && match[1].includes(targetPath)) {
errors.push({
type: 'type',
severity: match[4] === 'error' ? 'error' : 'warning',
message: match[5],
line: parseInt(match[2]),
column: parseInt(match[3]),
suggestion: 'Check TypeScript type definitions'
});
}
});
return errors;
}
/**
* 分析运行时错误并提供建议
*/
function analyzeRuntimeError(error: any): string {
const { message, type } = error;
// 空值错误
if (message.includes('null') || message.includes('undefined')) {
if (message.includes('map')) {
return 'Add null check before using .map(): (data || []).map(...)';
}
return 'Add null/undefined check before accessing properties';
}
// Promise 拒绝
if (type === 'unhandledrejection') {
return 'Add .catch() handler to Promise or use try-catch with async/await';
}
// 模块加载错误
if (message.includes('Module') || message.includes('import')) {
return 'Check if the module is installed: npm install <package-name>';
}
// React 渲染错误
if (message.includes('React') || message.includes('render')) {
return 'Check if component returns valid React element and has no circular dependencies';
}
return 'Check error stack trace for more details';
}

View File

@@ -0,0 +1,69 @@
import type { Plugin } from 'vite';
import path from 'path';
/**
*本项目组件规范强制检查插件
* 1. 检查是否包含 default export
* 2. 在底部注入第三方平台所需的注册代码
*/
export function axhubComponentEnforcer(entryPath?: string): Plugin {
function resolveDefaultExportTarget(code: string, filePath: string) {
if (!/\bexport\s+default\b/.test(code)) {
throw new Error(`\n\n❌ 构建失败: ${filePath}\n必须包含 default export 以符合本项目组件规范。\n`);
}
const namedFunctionMatch = code.match(/export\s+default\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/);
if (namedFunctionMatch) {
return {
transformedCode: code,
target: namedFunctionMatch[1]
};
}
const namedClassMatch = code.match(/export\s+default\s+class\s+([A-Za-z_$][\w$]*)\b/);
if (namedClassMatch) {
return {
transformedCode: code,
target: namedClassMatch[1]
};
}
const identifierMatch = code.match(/export\s+default\s+(?!function\b|class\b)([A-Za-z_$][\w$]*)\s*;?/);
if (identifierMatch) {
return {
transformedCode: code,
target: identifierMatch[1]
};
}
const exportDefaultPattern = /\bexport\s+default\s+/;
const replacedCode = code.replace(exportDefaultPattern, 'const __AXHUB_DEFAULT_COMPONENT__ = ');
if (replacedCode === code) {
throw new Error(`\n\n❌ 构建失败: ${filePath}\n无法解析 default export请使用标准导出语法。\n`);
}
return {
transformedCode: `${replacedCode}\n\nexport default __AXHUB_DEFAULT_COMPONENT__;\n`,
target: '__AXHUB_DEFAULT_COMPONENT__'
};
}
return {
name: 'axhub-component-enforcer',
enforce: 'pre',
transform(code, id) {
if (!entryPath || path.resolve(id) !== path.resolve(entryPath)) {
return null;
}
const { transformedCode, target } = resolveDefaultExportTarget(code, entryPath);
const injection = `
if (typeof window !== 'undefined' && window.__AXHUB_DEFINE_COMPONENT__) {
window.__AXHUB_DEFINE_COMPONENT__(${target});
}
`;
return transformedCode + injection;
}
};
}

View File

@@ -0,0 +1,298 @@
import * as http from 'node:http';
import * as https from 'node:https';
import type { Plugin } from 'vite';
import {
getRequestPathname,
readErrorString,
readRequestBody,
serializeErrorForLog,
} from './utils/httpUtils';
import { AXURE_BRIDGE_BASE_URL } from './utils/makeConstants';
import {
buildAxureBridgeUnavailablePayload,
formatAxureProxyErrorDetails,
limitErrorText,
normalizeAxvgPayloadText,
} from './utils/proxyUtils';
type UpstreamResponse = {
status: number;
statusText: string;
headers: http.IncomingHttpHeaders;
bodyText: string;
};
const AVAILABILITY_PROBE_LOG_INTERVAL_MS = 30_000;
let lastAvailabilityProbeLogKey = '';
let lastAvailabilityProbeLogAt = 0;
function readHeaderValue(value: string | string[] | undefined): string {
if (Array.isArray(value)) {
return value.join(', ');
}
return String(value || '');
}
function logAvailabilityProbeFailure(payload: Record<string, unknown>) {
const now = Date.now();
const key = JSON.stringify(payload);
if (key === lastAvailabilityProbeLogKey && now - lastAvailabilityProbeLogAt < AVAILABILITY_PROBE_LOG_INTERVAL_MS) {
return;
}
lastAvailabilityProbeLogKey = key;
lastAvailabilityProbeLogAt = now;
console.warn('[axure-bridge-proxy] availability probe failed', payload);
}
async function requestAxureBridge(
upstreamUrl: string,
options: {
method: 'GET' | 'POST';
headers?: Record<string, string>;
body?: Buffer;
timeoutMs?: number;
},
): Promise<UpstreamResponse> {
const targetUrl = new URL(upstreamUrl);
const transport = targetUrl.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const request = transport.request(
{
protocol: targetUrl.protocol,
hostname: targetUrl.hostname,
port: targetUrl.port ? Number(targetUrl.port) : undefined,
path: `${targetUrl.pathname}${targetUrl.search}`,
method: options.method,
headers: {
Connection: 'close',
...options.headers,
},
agent: false,
},
(response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on('end', () => {
resolve({
status: response.statusCode || 502,
statusText: response.statusMessage || '',
headers: response.headers,
bodyText: Buffer.concat(chunks).toString('utf8'),
});
});
response.on('error', reject);
},
);
request.setTimeout(options.timeoutMs ?? 15_000, () => {
request.destroy(new Error(`Axure Bridge request timed out after ${options.timeoutMs ?? 15_000}ms`));
});
request.on('error', reject);
if (options.body && options.body.length > 0) {
request.write(options.body);
}
request.end();
});
}
async function requestAxureBridgeWithRetry(
upstreamUrl: string,
options: {
method: 'GET' | 'POST';
headers?: Record<string, string>;
body?: Buffer;
timeoutMs?: number;
},
): Promise<UpstreamResponse> {
try {
return await requestAxureBridge(upstreamUrl, options);
} catch (error: any) {
const code = error?.code || error?.cause?.code;
if (code !== 'ECONNRESET') {
throw error;
}
return requestAxureBridge(upstreamUrl, options);
}
}
export function axureBridgeProxyPlugin(): Plugin {
return {
name: 'axure-bridge-proxy-plugin',
configureServer(server: any) {
server.middlewares.use(async (req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
const isAvailableRoute = req.method === 'GET' && pathname === '/api/axure-bridge/available';
const isCopyRoute = req.method === 'POST' && pathname === '/api/axure-bridge/copyaxvg';
if (!isAvailableRoute && !isCopyRoute) {
return next();
}
const upstreamUrl = isAvailableRoute
? `${AXURE_BRIDGE_BASE_URL}/available`
: `${AXURE_BRIDGE_BASE_URL}/copyaxvg`;
let payloadBytes = 0;
try {
let upstreamResponse: UpstreamResponse;
if (isAvailableRoute) {
upstreamResponse = await requestAxureBridgeWithRetry(upstreamUrl, {
method: 'GET',
timeoutMs: 5_000,
});
} else {
let rawBody = '';
try {
rawBody = await readRequestBody(req);
} catch (error: any) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Invalid request body' }));
return;
}
const requestBody = normalizeAxvgPayloadText(rawBody);
const requestBuffer = Buffer.from(requestBody, 'utf8');
payloadBytes = requestBuffer.byteLength;
upstreamResponse = await requestAxureBridgeWithRetry(upstreamUrl, {
method: 'POST',
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': String(payloadBytes),
},
body: requestBuffer,
timeoutMs: 30_000,
});
}
const contentType = readHeaderValue(upstreamResponse.headers['content-type']).toLowerCase();
const responseText = upstreamResponse.bodyText;
if (upstreamResponse.status < 200 || upstreamResponse.status >= 300) {
if (isAvailableRoute) {
const unavailablePayload = buildAxureBridgeUnavailablePayload({
route: pathname,
method: req.method,
bridgeUrl: upstreamUrl,
payloadBytes: payloadBytes || undefined,
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
responseText: readErrorString(responseText) || upstreamResponse.statusText,
});
logAvailabilityProbeFailure({
route: pathname,
method: req.method,
upstreamUrl,
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
bodyPreview: limitErrorText(readErrorString(responseText), 300) || undefined,
});
res.statusCode = 200;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(unavailablePayload));
return;
}
console.warn('[axure-bridge-proxy] upstream responded with error', {
route: pathname,
method: req.method,
upstreamUrl,
payloadBytes: payloadBytes || undefined,
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
bodyPreview: limitErrorText(readErrorString(responseText), 800) || undefined,
});
}
res.statusCode = upstreamResponse.status;
res.setHeader('Cache-Control', 'no-store');
if (contentType.includes('application/json')) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(responseText || '{}');
return;
}
if (responseText) {
try {
const parsed = JSON.parse(responseText);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(parsed));
return;
} catch {
// Pass through non-JSON text responses.
}
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(responseText);
} catch (error: any) {
const errorLog = serializeErrorForLog(error);
if (isAvailableRoute) {
const unavailablePayload = buildAxureBridgeUnavailablePayload({
route: pathname,
method: req.method,
bridgeUrl: upstreamUrl,
payloadBytes: payloadBytes || undefined,
error,
});
logAvailabilityProbeFailure({
route: pathname,
method: req.method,
upstreamUrl,
payloadBytes: payloadBytes || undefined,
error: {
message: errorLog.message,
code: errorLog.code || errorLog.causeCode || undefined,
causeMessage: errorLog.causeMessage,
},
});
res.statusCode = 200;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(unavailablePayload));
return;
}
console.error('[axure-bridge-proxy] upstream request failed', {
route: pathname,
method: req.method,
upstreamUrl,
payloadBytes: payloadBytes || undefined,
error: errorLog,
});
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
error: error?.message || 'Axure Bridge unavailable',
details: formatAxureProxyErrorDetails(error),
code: errorLog.code || errorLog.causeCode || undefined,
causeMessage: errorLog.causeMessage || undefined,
route: pathname,
method: req.method,
bridgeUrl: upstreamUrl,
payloadBytes: payloadBytes || undefined,
}));
}
});
},
};
}

View File

@@ -0,0 +1,326 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { sanitizeDocBaseName } from './utils/docUtils';
import { getRequestPathname, readJsonBody } from './utils/httpUtils';
export function canvasApiPlugin(): Plugin {
const CANVAS_EXT = '.excalidraw';
const DEFAULT_CANVAS_DATA = JSON.stringify({
type: 'excalidraw',
version: 2,
source: 'axhub-make',
elements: [],
appState: { gridSize: null, viewBackgroundColor: '#ffffff' },
files: {},
}, null, 2);
return {
name: 'canvas-api-plugin',
configureServer(server: any) {
server.middlewares.use(async (req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (!pathname.startsWith('/api/canvas')) {
return next();
}
const canvasDir = path.resolve(process.cwd(), 'src/canvas');
if (
req.method === 'POST' &&
(pathname === '/api/canvas/create' || pathname === '/api/canvas/create/')
) {
try {
const body = await readJsonBody(req);
const displayName = String(body?.displayName || '').trim();
fs.mkdirSync(canvasDir, { recursive: true });
const fallbackBase = `canvas-${Date.now().toString(36)}`;
const sanitizedBase = sanitizeDocBaseName(displayName || fallbackBase) || fallbackBase;
let baseName = sanitizedBase;
let suffix = 2;
while (fs.existsSync(path.join(canvasDir, `${baseName}${CANVAS_EXT}`))) {
baseName = `${sanitizedBase}-${suffix}`;
suffix += 1;
}
const fileName = `${baseName}${CANVAS_EXT}`;
const filePath = path.join(canvasDir, fileName);
if (!filePath.startsWith(canvasDir)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
fs.writeFileSync(filePath, DEFAULT_CANVAS_DATA, 'utf8');
res.statusCode = 201;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
name: fileName,
displayName: baseName,
path: `src/canvas/${fileName}`,
}));
} catch (error: any) {
console.error('Error creating canvas:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Create canvas failed' }));
}
return;
}
if (req.method === 'POST' && pathname.startsWith('/api/canvas/') && pathname.endsWith('/copy')) {
try {
const encodedName = pathname.slice('/api/canvas/'.length, -'/copy'.length);
const canvasName = decodeURIComponent(encodedName);
if (!canvasName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing canvas name' }));
return;
}
const sourcePath = path.join(canvasDir, canvasName);
if (!sourcePath.startsWith(canvasDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(sourcePath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Canvas not found' }));
return;
}
const sourceBaseName = path.basename(sourcePath, CANVAS_EXT);
const safeBaseName = sanitizeDocBaseName(sourceBaseName) || sourceBaseName;
const candidateBase = `${safeBaseName}-copy`;
let nextBaseName = candidateBase;
let suffix = 2;
let nextName = `${nextBaseName}${CANVAS_EXT}`;
let nextPath = path.join(canvasDir, nextName);
while (fs.existsSync(nextPath)) {
nextBaseName = `${candidateBase}${suffix}`;
nextName = `${nextBaseName}${CANVAS_EXT}`;
nextPath = path.join(canvasDir, nextName);
suffix += 1;
}
if (!nextPath.startsWith(canvasDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
fs.copyFileSync(sourcePath, nextPath);
res.statusCode = 201;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
name: nextName,
displayName: nextBaseName,
path: `src/canvas/${nextName}`,
}));
} catch (error: any) {
console.error('Error copying canvas:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Copy canvas failed' }));
}
return;
}
if (req.method === 'DELETE' && pathname.startsWith('/api/canvas/')) {
try {
const encodedName = pathname.replace('/api/canvas/', '');
const canvasName = decodeURIComponent(encodedName);
if (!canvasName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing canvas name' }));
return;
}
const filePath = path.join(canvasDir, canvasName);
if (!filePath.startsWith(canvasDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(filePath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Canvas not found' }));
return;
}
fs.unlinkSync(filePath);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true }));
} catch (error: any) {
console.error('Error deleting canvas:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (req.method === 'PUT' && pathname.startsWith('/api/canvas/')) {
try {
const encodedName = pathname.replace('/api/canvas/', '');
if (!encodedName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing canvas name' }));
return;
}
const bodyData = await readJsonBody(req);
const hasContentUpdate = typeof bodyData?.content === 'string';
let newBaseName = String(bodyData?.newBaseName || '').trim();
const hasRename = Boolean(newBaseName);
if (!hasContentUpdate && !hasRename) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing content or newBaseName parameter' }));
return;
}
if (hasRename && /[/\\:*?"<>|]/.test(newBaseName)) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid newBaseName format' }));
return;
}
const canvasName = decodeURIComponent(encodedName);
const oldPath = path.join(canvasDir, canvasName);
if (!oldPath.startsWith(canvasDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(oldPath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Canvas not found' }));
return;
}
let finalPath = oldPath;
if (hasRename) {
if (newBaseName.toLowerCase().endsWith(CANVAS_EXT)) {
newBaseName = newBaseName.slice(0, -CANVAS_EXT.length).trim();
}
const safeBaseName = sanitizeDocBaseName(newBaseName);
if (!safeBaseName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid newBaseName format' }));
return;
}
const newFileName = `${safeBaseName}${CANVAS_EXT}`;
const newPath = path.join(canvasDir, newFileName);
if (!newPath.startsWith(canvasDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (newPath !== oldPath && fs.existsSync(newPath)) {
res.statusCode = 400;
res.end(JSON.stringify({ error: '目标文件已存在' }));
return;
}
if (newPath !== oldPath) {
fs.renameSync(oldPath, newPath);
}
finalPath = newPath;
}
if (hasContentUpdate) {
fs.writeFileSync(finalPath, String(bodyData.content), 'utf8');
}
const relativeName = path.basename(finalPath);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, name: relativeName }));
} catch (error: any) {
console.error('Error updating canvas:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (req.method !== 'GET') {
return next();
}
if (pathname.startsWith('/api/canvas/') && pathname !== '/api/canvas' && pathname !== '/api/canvas/') {
try {
const encodedName = pathname.replace('/api/canvas/', '');
if (!encodedName) {
return next();
}
const canvasName = decodeURIComponent(encodedName);
const filePath = path.join(canvasDir, canvasName);
if (!filePath.startsWith(canvasDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf8');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(content);
} else {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Canvas not found' }));
}
} catch (error: any) {
console.error('Error loading canvas:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (pathname === '/api/canvas' || pathname === '/api/canvas/') {
try {
const items: Array<{ name: string; displayName: string }> = [];
if (fs.existsSync(canvasDir)) {
const entries = fs.readdirSync(canvasDir, { withFileTypes: true });
entries.forEach((entry) => {
if (!entry.isFile()) return;
if (!entry.name.endsWith(CANVAS_EXT)) return;
const baseName = entry.name.slice(0, -CANVAS_EXT.length);
items.push({
name: entry.name,
displayName: baseName,
});
});
}
items.sort((a, b) => a.name.localeCompare(b.name));
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(items));
} catch (error: any) {
console.error('Error listing canvases:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
next();
});
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
const PROJECT_BLOCK_SPLIT_REGEX = /(?=\[\[projects\]\])/;
const PROJECT_NAME_REGEX = /name\s*=\s*"([^"]+)"/;
const WEIXIN_OPTIONS_SECTION_REGEX = /(\[projects\.platforms\.options\]\s*\n)([\s\S]*?)(?=\n\[\[|\n\[[^\[]|$)/;
export const BLOCKED_USER_ID = 'blocked@im.wechat';
function getProjectName(block: string): string | null {
const match = block.match(PROJECT_NAME_REGEX);
return match?.[1] || null;
}
function replaceWeixinOptionsSection(block: string, nextOptionsBody: string): string {
const nextBody = `${nextOptionsBody.trim()}\n`;
return block.replace(WEIXIN_OPTIONS_SECTION_REGEX, (_match, header) => `${header}${nextBody}`);
}
export function synchronizeAxhubWeixinCredentials(params: {
content: string;
primaryProjectName: string;
managedProjectNames: string[];
blockedUserId?: string;
}): { content: string; updated: boolean } {
const {
content,
primaryProjectName,
managedProjectNames,
blockedUserId = BLOCKED_USER_ID,
} = params;
const managedProjectNameSet = new Set(managedProjectNames);
const projectBlocks = content.split(PROJECT_BLOCK_SPLIT_REGEX);
const primaryBlock = projectBlocks.find((block) => getProjectName(block) === primaryProjectName);
const primaryOptionsMatch = primaryBlock?.match(WEIXIN_OPTIONS_SECTION_REGEX);
const primaryOptionsBody = primaryOptionsMatch?.[2]?.trim();
if (!primaryBlock || !primaryOptionsBody || !primaryOptionsBody.includes('token')) {
return { content, updated: false };
}
const tokenMatch = primaryOptionsBody.match(/token\s*=\s*"([^"]*?)"/);
if (!tokenMatch || !tokenMatch[1]) {
return { content, updated: false };
}
const blockedOptionsBody = `${primaryOptionsBody.replace(
/allow_from\s*=\s*"[^"]*"/,
`allow_from = "${blockedUserId}"`,
)}${primaryOptionsBody.includes('allow_from') ? '' : `\nallow_from = "${blockedUserId}"`}`;
const nextContent = projectBlocks
.map((block) => {
const projectName = getProjectName(block);
if (!projectName || projectName === primaryProjectName || !managedProjectNameSet.has(projectName)) {
return block;
}
if (!WEIXIN_OPTIONS_SECTION_REGEX.test(block)) {
return block;
}
return replaceWeixinOptionsSection(block, blockedOptionsBody);
})
.join('');
return {
content: nextContent,
updated: nextContent !== content,
};
}

View File

@@ -0,0 +1,213 @@
const PROJECT_BLOCK_SPLIT_REGEX = /(?=\[\[projects\]\])/;
const PROJECT_NAME_REGEX = /name\s*=\s*"([^"]+)"/;
const PROJECT_AGENT_TYPE_REGEX = /\[projects\.agent\]\s*\ntype\s*=\s*"([^"]+)"/;
const PROJECT_WORK_DIR_REGEX = /\[projects\.agent\.options\][\s\S]*?work_dir\s*=\s*"([^"]*)"/;
const WEIXIN_OPTIONS_SECTION_REGEX = /(\[\[projects\.platforms\]\]\s*\ntype\s*=\s*"weixin"\s*\n\s*\n\[projects\.platforms\.options\]\s*\n)([\s\S]*?)(?=\n\[\[projects\]\]|$)/;
const OPTION_LINE_REGEX = /^([a-zA-Z0-9_]+)\s*=\s*"((?:[^"\\]|\\.)*)"$/gm;
export interface AxhubWeixinState<TAgent extends string = string> {
version: 1;
configuredAgents: TAgent[];
activeAgent: TAgent;
workDir: string;
weixinOptions: Record<string, string>;
}
interface ParsedProjectBlock {
name: string | null;
agentType: string | null;
workDir: string | null;
weixinOptions: Record<string, string> | null;
}
function unescapeTomlString(value: string): string {
return value
.replace(/\\\\/g, '\\')
.replace(/\\"/g, '"')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t');
}
function escapeTomlString(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
}
function parseTomlOptionsBody(body: string): Record<string, string> {
const options: Record<string, string> = {};
for (const match of body.matchAll(OPTION_LINE_REGEX)) {
options[match[1]] = unescapeTomlString(match[2]);
}
return options;
}
function parseProjectBlocks(content: string): ParsedProjectBlock[] {
return content
.split(PROJECT_BLOCK_SPLIT_REGEX)
.filter((block) => block.includes('[[projects]]'))
.map((block) => {
const weixinMatch = block.match(WEIXIN_OPTIONS_SECTION_REGEX);
return {
name: block.match(PROJECT_NAME_REGEX)?.[1] || null,
agentType: block.match(PROJECT_AGENT_TYPE_REGEX)?.[1] || null,
workDir: block.match(PROJECT_WORK_DIR_REGEX)?.[1] || null,
weixinOptions: weixinMatch ? parseTomlOptionsBody(weixinMatch[2]) : null,
};
});
}
function getPreferredOptionKeys(options: Record<string, string>): string[] {
const preferred = ['token', 'base_url', 'account_id', 'allow_from', 'admin_from'];
const remaining = Object.keys(options)
.filter((key) => !preferred.includes(key))
.sort();
return [...preferred.filter((key) => key in options), ...remaining];
}
function serializeTomlOptions(options: Record<string, string>): string {
return getPreferredOptionKeys(options)
.map((key) => `${key} = "${escapeTomlString(options[key])}"`)
.join('\n');
}
function buildHeader(): string {
return `# Auto-generated by Axhub Make — cc-connect WeChat integration
# language = "zh"
[log]
level = "info"
`;
}
export function findActiveAgentFromConfig<TAgent extends string>(params: {
content: string;
supportedAgents: TAgent[];
blockedUserId: string;
}): TAgent | null {
const { content, supportedAgents, blockedUserId } = params;
const supportedAgentSet = new Set<string>(supportedAgents);
for (const project of parseProjectBlocks(content)) {
if (!project.agentType || !supportedAgentSet.has(project.agentType)) continue;
const allowFrom = project.weixinOptions?.allow_from;
if (allowFrom && allowFrom !== blockedUserId && allowFrom.includes('@im.wechat')) {
return project.agentType as TAgent;
}
}
return null;
}
export function createAxhubWeixinStateFromConfig<TAgent extends string>(params: {
content: string;
supportedAgents: TAgent[];
blockedUserId: string;
preferredActiveAgent?: TAgent | null;
recoveredAllowFrom?: string | null;
}): AxhubWeixinState<TAgent> | null {
const {
content,
supportedAgents,
blockedUserId,
preferredActiveAgent = null,
recoveredAllowFrom = null,
} = params;
const supportedAgentSet = new Set<string>(supportedAgents);
const projects = parseProjectBlocks(content).filter((project) => {
return (
!!project.agentType &&
supportedAgentSet.has(project.agentType) &&
!!project.weixinOptions?.token
);
});
if (projects.length === 0) {
return null;
}
const configuredAgents = supportedAgents.filter((agent) => {
return projects.some((project) => project.agentType === agent);
});
if (configuredAgents.length === 0) {
return null;
}
const activeAgentFromConfig = findActiveAgentFromConfig({
content,
supportedAgents: configuredAgents,
blockedUserId,
});
const activeAgent = activeAgentFromConfig
|| (preferredActiveAgent && configuredAgents.includes(preferredActiveAgent) ? preferredActiveAgent : null)
|| configuredAgents[0];
const activeProject = projects.find((project) => project.agentType === activeAgent) || projects[0];
const allowFrom = activeProject.weixinOptions?.allow_from
&& activeProject.weixinOptions.allow_from !== blockedUserId
&& activeProject.weixinOptions.allow_from.includes('@im.wechat')
? activeProject.weixinOptions.allow_from
: recoveredAllowFrom;
if (!allowFrom) {
return null;
}
return {
version: 1,
configuredAgents,
activeAgent,
workDir: activeProject.workDir || '',
weixinOptions: {
...(activeProject.weixinOptions || {}),
allow_from: allowFrom,
},
};
}
export function generateSingleAgentConfigToml<TAgent extends string>(params: {
agent: TAgent;
workDir: string;
weixinOptions: Record<string, string>;
}): string {
const { agent, workDir, weixinOptions } = params;
return `${buildHeader()}[[projects]]
name = "axhub-${escapeTomlString(agent)}"
[projects.agent]
type = "${escapeTomlString(agent)}"
[projects.agent.options]
work_dir = "${escapeTomlString(workDir)}"
mode = "yolo"
[[projects.platforms]]
type = "weixin"
[projects.platforms.options]
${serializeTomlOptions(weixinOptions)}
`;
}
export function generateWeixinSetupConfigToml<TAgent extends string>(params: {
agent: TAgent;
workDir: string;
}): string {
const { agent, workDir } = params;
return generateSingleAgentConfigToml({
agent,
workDir,
weixinOptions: {
token: '',
},
});
}

View File

@@ -0,0 +1,858 @@
/**
* 代码检查插件
*
* 提供 /api/code-review 端点,用于检查代码是否符合开发规范
*
* 检查项目:
* 1. 导出规范:必须存在 `export default`
* - 仅在 Axure 导出检查模式下,默认导出名必须是 `Component`
* 2. Tailwind CSS如果使用了 Tailwind 类名,必须在 style.css 中添加 `@import "tailwindcss"`
* 3. Axure API如果使用了 Axure API必须符合 axure-types.ts 的类型定义
*/
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import ts from 'typescript';
// 检查结果类型
export interface ReviewIssue {
type: 'error' | 'warning';
rule: string;
message: string;
line?: number;
suggestion?: string;
}
export interface ReviewResult {
file: string;
passed: boolean;
issues: ReviewIssue[];
}
interface ReviewOptions {
enforceComponentExportName?: boolean;
}
export type AxureApiListKey = 'eventList' | 'actionList' | 'varList' | 'configList' | 'dataList';
export interface AxureApiListPreview {
sourceKey: string | null;
raw: string | null;
items: Array<Record<string, unknown>>;
parseStatus: 'parsed' | 'raw' | 'missing';
warnings: string[];
}
export interface AxureApiPreviewResult {
file: string;
passedSourceCheck: boolean;
hasAxureHandle: boolean;
lists: Record<AxureApiListKey, AxureApiListPreview>;
}
const AXURE_LIST_KEYS: AxureApiListKey[] = ['eventList', 'actionList', 'varList', 'configList', 'dataList'];
function extractDefaultExportName(content: string): string | null {
const namedFunctionMatch = content.match(/export\s+default\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/);
if (namedFunctionMatch) {
return namedFunctionMatch[1];
}
const namedClassMatch = content.match(/export\s+default\s+class\s+([A-Za-z_$][\w$]*)\b/);
if (namedClassMatch) {
return namedClassMatch[1];
}
const identifierMatch = content.match(/export\s+default\s+(?!function\b|class\b)([A-Za-z_$][\w$]*)\s*;?/);
if (identifierMatch) {
return identifierMatch[1];
}
return null;
}
/**
* 检查文件是否包含 default export
*/
function checkExportDefault(content: string, filePath: string, options: ReviewOptions = {}): ReviewIssue[] {
const issues: ReviewIssue[] = [];
// 检查是否有 default export不限制导出名称
const hasExportDefault = /\bexport\s+default\b/.test(content);
if (!hasExportDefault) {
issues.push({
type: 'error',
rule: 'export-default',
message: '缺少 export default 导出',
suggestion: '请添加 default 导出例如export default MyComponent'
});
return issues;
}
if (options.enforceComponentExportName) {
const exportedName = extractDefaultExportName(content);
if (exportedName !== 'Component') {
issues.push({
type: 'error',
rule: 'export-default-name',
message: exportedName
? `导出名称错误:使用了 "${exportedName}"Axure 导出检查要求使用 "Component"`
: 'Axure 导出检查要求默认导出为命名变量 "Component"',
suggestion: '请使用 `const Component = ...` 并导出 `export default Component`'
});
}
}
return issues;
}
/**
* 检查 Tailwind CSS 配置
*/
function checkTailwindCSS(content: string, filePath: string): ReviewIssue[] {
const issues: ReviewIssue[] = [];
// 检查是否使用了 Tailwind 类名(常见的 Tailwind 类名模式)
const tailwindPatterns = [
/className=["'][^"']*\b(flex|grid|block|inline|hidden|relative|absolute|fixed|sticky)\b/,
/className=["'][^"']*\b(w-|h-|m-|p-|text-|bg-|border-|rounded-)/,
/className=["'][^"']*\b(hover:|focus:|active:|disabled:)/,
/className=["'][^"']*\b(sm:|md:|lg:|xl:|2xl:)/
];
const usesTailwind = tailwindPatterns.some(pattern => pattern.test(content));
if (usesTailwind) {
// 检查是否导入了 CSS 文件(支持 style.css, styles.css 等)
const hasStyleImport = /import\s+['"]\.\/[^'"]*\.css['"]/.test(content);
if (!hasStyleImport) {
issues.push({
type: 'error',
rule: 'tailwind-style-import',
message: '使用了 Tailwind CSS 类名,但未导入 CSS 文件',
suggestion: '在文件顶部添加import \'./style.css\' 或 import \'./styles.css\''
});
} else {
// 提取导入的 CSS 文件名
const styleImportMatch = content.match(/import\s+['"]\.\/([^'"]+\.css)['"]/);
if (styleImportMatch) {
const cssFileName = styleImportMatch[1];
const dir = path.dirname(filePath);
const stylePath = path.join(dir, cssFileName);
if (fs.existsSync(stylePath)) {
const styleContent = fs.readFileSync(stylePath, 'utf8');
const hasTailwindImport = /@import\s+["']tailwindcss["']/.test(styleContent);
if (!hasTailwindImport) {
issues.push({
type: 'error',
rule: 'tailwind-css-import',
message: `${cssFileName} 中缺少 @import "tailwindcss"`,
suggestion: `${cssFileName} 文件中添加:@import "tailwindcss";`
});
}
} else {
issues.push({
type: 'error',
rule: 'tailwind-style-file',
message: `导入了 ${cssFileName} 但文件不存在`,
suggestion: `创建 ${cssFileName} 文件并添加:@import "tailwindcss";`
});
}
}
}
}
return issues;
}
/**
* 检查 Axure API 使用规范
*/
function checkAxureAPI(content: string, filePath: string): ReviewIssue[] {
const issues: ReviewIssue[] = [];
// 检查是否使用了 forwardRef
const usesForwardRef = /forwardRef\s*</.test(content);
if (!usesForwardRef) {
// 可能不是 Axure 组件,跳过检查
return issues;
}
// 检查是否导入了 AxureProps 和 AxureHandle支持多行导入
const hasAxurePropsImport = /import\s+type\s*\{[^}]*\bAxureProps\b[^}]*\}\s*from\s+['"].*axure-types/.test(content.replace(/\n/g, ' '));
const hasAxureHandleImport = /import\s+type\s*\{[^}]*\bAxureHandle\b[^}]*\}\s*from\s+['"].*axure-types/.test(content.replace(/\n/g, ' '));
if (!hasAxurePropsImport) {
issues.push({
type: 'error',
rule: 'axure-api-props',
message: '使用了 forwardRef 但未导入 AxureProps 类型',
suggestion: '从 axure-types 导入import type { AxureProps, AxureHandle } from \'../../common/axure-types\''
});
}
if (!hasAxureHandleImport) {
issues.push({
type: 'error',
rule: 'axure-api-handle',
message: '使用了 forwardRef 但未导入 AxureHandle 类型',
suggestion: '从 axure-types 导入import type { AxureProps, AxureHandle } from \'../../common/axure-types\''
});
}
// 检查 forwardRef 类型标注
const forwardRefMatch = content.match(/forwardRef\s*<\s*([^,>]+)\s*,\s*([^>]+)\s*>/);
if (forwardRefMatch) {
const handleType = forwardRefMatch[1].trim();
const propsType = forwardRefMatch[2].trim();
if (handleType !== 'AxureHandle') {
issues.push({
type: 'error',
rule: 'axure-api-handle-type',
message: `forwardRef 第一个类型参数错误:使用了 "${handleType}",应该使用 "AxureHandle"`,
suggestion: '使用正确的类型forwardRef<AxureHandle, AxureProps>'
});
}
if (propsType !== 'AxureProps') {
issues.push({
type: 'error',
rule: 'axure-api-props-type',
message: `forwardRef 第二个类型参数错误:使用了 "${propsType}",应该使用 "AxureProps"`,
suggestion: '使用正确的类型forwardRef<AxureHandle, AxureProps>'
});
}
}
// 检查是否有 useImperativeHandle
const hasUseImperativeHandle = /useImperativeHandle\s*\(/.test(content);
if (usesForwardRef && !hasUseImperativeHandle) {
issues.push({
type: 'warning',
rule: 'axure-api-imperative-handle',
message: '使用了 forwardRef 但未使用 useImperativeHandle',
suggestion: '使用 useImperativeHandle 暴露 AxureHandle 接口'
});
}
// 检查 onEvent 参数类型payload 必须是 string
const onEventCalls = content.match(/onEvent(?:Handler)?\s*\(\s*['"][^'"]+['"]\s*,\s*([^)]+)\)/g);
if (onEventCalls) {
onEventCalls.forEach(call => {
// 检查是否传递了对象字面量作为 payload
if (/\{[^}]+\}/.test(call)) {
issues.push({
type: 'error',
rule: 'axure-api-event-payload',
message: 'onEvent 的 payload 参数必须是字符串类型,不能传递对象',
suggestion: '将对象转换为 JSON 字符串JSON.stringify(payload)'
});
}
});
}
return issues;
}
/**
* 其他推荐检查项(需要用户确认)
*/
function checkRecommended(content: string, filePath: string): ReviewIssue[] {
const issues: ReviewIssue[] = [];
// 检查是否有 @name 注释
const hasNameComment = /@name\s+.+/.test(content);
if (!hasNameComment) {
issues.push({
type: 'warning',
rule: 'file-header-name',
message: '缺少 @name 注释',
suggestion: '在文件头部添加:/**\\n * @name 组件名称\\n */'
});
}
// 检查是否在 JSX 中直接定义函数(性能问题)
const hasInlineFunction = /onClick=\{function\s*\(/.test(content) || /onClick=\{\(\s*\)\s*=>/.test(content);
if (hasInlineFunction) {
issues.push({
type: 'warning',
rule: 'jsx-inline-function',
message: '在 JSX 中直接定义了函数,可能影响性能',
suggestion: '使用 useCallback 预定义函数'
});
}
// 检查是否使用了 ES6 解构 state不推荐
const hasStateDestructure = /const\s*\[\s*\w+\s*,\s*\w+\s*\]\s*=\s*useState/.test(content);
if (hasStateDestructure) {
issues.push({
type: 'warning',
rule: 'state-destructure',
message: '使用了 ES6 解构 state不符合项目规范',
suggestion: '使用数组索引访问const countState = useState(0); const count = countState[0];'
});
}
return issues;
}
/**
* 检查单个文件
*/
function reviewFile(filePath: string, options: ReviewOptions = {}): ReviewResult {
const issues: ReviewIssue[] = [];
try {
if (!fs.existsSync(filePath)) {
return {
file: filePath,
passed: false,
issues: [{
type: 'error',
rule: 'file-not-found',
message: '文件不存在'
}]
};
}
const content = fs.readFileSync(filePath, 'utf8');
console.log('[Code Review] Starting checks for:', filePath);
// 执行各项检查
const exportIssues = checkExportDefault(content, filePath, options);
console.log('[Code Review] Export issues:', exportIssues.length);
issues.push(...exportIssues);
const tailwindIssues = checkTailwindCSS(content, filePath);
console.log('[Code Review] Tailwind issues:', tailwindIssues.length);
issues.push(...tailwindIssues);
const axureIssues = checkAxureAPI(content, filePath);
console.log('[Code Review] Axure issues:', axureIssues.length);
issues.push(...axureIssues);
console.log('[Code Review] Total issues:', issues.length);
} catch (error: any) {
issues.push({
type: 'error',
rule: 'file-read-error',
message: `读取文件失败: ${error.message}`
});
}
// 只有 error 类型的问题才算不通过
const hasErrors = issues.some(issue => issue.type === 'error');
return {
file: filePath,
passed: !hasErrors,
issues
};
}
function createMissingListPreview(): AxureApiListPreview {
return {
sourceKey: null,
raw: null,
items: [],
parseStatus: 'missing',
warnings: [],
};
}
function createEmptyAxureApiPreview(filePath: string, passedSourceCheck: boolean): AxureApiPreviewResult {
return {
file: filePath,
passedSourceCheck,
hasAxureHandle: false,
lists: {
eventList: createMissingListPreview(),
actionList: createMissingListPreview(),
varList: createMissingListPreview(),
configList: createMissingListPreview(),
dataList: createMissingListPreview(),
},
};
}
function isUseImperativeHandleCallee(expression: ts.Expression): boolean {
if (ts.isIdentifier(expression)) {
return expression.text === 'useImperativeHandle';
}
if (ts.isPropertyAccessExpression(expression)) {
return expression.name.text === 'useImperativeHandle';
}
return false;
}
function collectTopLevelConstInitializers(sourceFile: ts.SourceFile): Map<string, ts.Expression> {
const map = new Map<string, ts.Expression>();
sourceFile.statements.forEach((statement) => {
if (!ts.isVariableStatement(statement)) {
return;
}
const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0;
if (!isConst) {
return;
}
statement.declarationList.declarations.forEach((declaration) => {
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) {
return;
}
map.set(declaration.name.text, declaration.initializer);
});
});
return map;
}
function getObjectPropertyName(name: ts.PropertyName): string | null {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return null;
}
function findReturnedObjectFromFunction(fn: ts.Expression | undefined): ts.ObjectLiteralExpression | null {
if (!fn) {
return null;
}
if (ts.isArrowFunction(fn)) {
if (ts.isObjectLiteralExpression(fn.body)) {
return fn.body;
}
if (ts.isParenthesizedExpression(fn.body) && ts.isObjectLiteralExpression(fn.body.expression)) {
return fn.body.expression;
}
if (ts.isBlock(fn.body)) {
for (const statement of fn.body.statements) {
if (ts.isReturnStatement(statement) && statement.expression && ts.isObjectLiteralExpression(statement.expression)) {
return statement.expression;
}
}
}
}
if (ts.isFunctionExpression(fn) || ts.isFunctionDeclaration(fn)) {
if (!fn.body) {
return null;
}
for (const statement of fn.body.statements) {
if (ts.isReturnStatement(statement) && statement.expression && ts.isObjectLiteralExpression(statement.expression)) {
return statement.expression;
}
}
}
return null;
}
interface HandleReturnObjectSearchResult {
hasAxureHandle: boolean;
handleObject: ts.ObjectLiteralExpression | null;
}
function findHandleReturnObject(sourceFile: ts.SourceFile): HandleReturnObjectSearchResult {
const matches: ts.ObjectLiteralExpression[] = [];
let hasCall = false;
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node) && isUseImperativeHandleCallee(node.expression)) {
hasCall = true;
const handleObject = findReturnedObjectFromFunction(node.arguments[1]);
if (handleObject) {
matches.push(handleObject);
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sourceFile, visit);
if (!hasCall) {
return { hasAxureHandle: false, handleObject: null };
}
const withTargetKeys = matches.find((objectLiteral) => objectLiteral.properties.some((property) => {
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
return false;
}
const propertyName = getObjectPropertyName(property.name);
return propertyName != null && AXURE_LIST_KEYS.includes(propertyName as AxureApiListKey);
}));
return {
hasAxureHandle: true,
handleObject: withTargetKeys ?? matches[0] ?? null,
};
}
interface ResolvedExpressionResult {
expression: ts.Expression;
warnings: string[];
}
function resolveExpressionFromConstMap(
expression: ts.Expression,
constants: Map<string, ts.Expression>,
): ResolvedExpressionResult {
const warnings: string[] = [];
const visited = new Set<string>();
let current = expression;
while (ts.isIdentifier(current)) {
const key = current.text;
const next = constants.get(key);
if (!next) {
break;
}
if (visited.has(key)) {
warnings.push(`检测到循环引用: ${key}`);
break;
}
visited.add(key);
current = next;
}
return { expression: current, warnings };
}
interface ExpressionToJsonResult {
value: unknown;
unresolved: boolean;
warnings: string[];
}
function expressionToJson(
expression: ts.Expression,
sourceFile: ts.SourceFile,
constants: Map<string, ts.Expression>,
visitedIdentifiers: Set<string> = new Set<string>(),
): ExpressionToJsonResult {
if (ts.isParenthesizedExpression(expression)) {
return expressionToJson(expression.expression, sourceFile, constants, visitedIdentifiers);
}
if (ts.isAsExpression(expression) || ts.isTypeAssertionExpression(expression) || ts.isNonNullExpression(expression) || ts.isSatisfiesExpression(expression)) {
return expressionToJson(expression.expression, sourceFile, constants, visitedIdentifiers);
}
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
return { value: expression.text, unresolved: false, warnings: [] };
}
if (ts.isNumericLiteral(expression)) {
return { value: Number(expression.text), unresolved: false, warnings: [] };
}
if (expression.kind === ts.SyntaxKind.TrueKeyword) {
return { value: true, unresolved: false, warnings: [] };
}
if (expression.kind === ts.SyntaxKind.FalseKeyword) {
return { value: false, unresolved: false, warnings: [] };
}
if (expression.kind === ts.SyntaxKind.NullKeyword) {
return { value: null, unresolved: false, warnings: [] };
}
if (ts.isPrefixUnaryExpression(expression)) {
const inner = expressionToJson(expression.operand, sourceFile, constants, visitedIdentifiers);
if (typeof inner.value === 'number') {
if (expression.operator === ts.SyntaxKind.MinusToken) {
return { value: -inner.value, unresolved: inner.unresolved, warnings: inner.warnings };
}
if (expression.operator === ts.SyntaxKind.PlusToken) {
return { value: Number(inner.value), unresolved: inner.unresolved, warnings: inner.warnings };
}
if (expression.operator === ts.SyntaxKind.ExclamationToken) {
return { value: !inner.value, unresolved: inner.unresolved, warnings: inner.warnings };
}
}
const warning = `无法静态解析前缀表达式: ${expression.getText(sourceFile)}`;
return {
value: { __expr: expression.getText(sourceFile) },
unresolved: true,
warnings: [...inner.warnings, warning],
};
}
if (ts.isArrayLiteralExpression(expression)) {
const warnings: string[] = [];
let unresolved = false;
const value = expression.elements.map((element) => {
if (ts.isSpreadElement(element)) {
unresolved = true;
warnings.push(`数组项包含扩展语法: ${element.getText(sourceFile)}`);
return { __expr: element.getText(sourceFile) };
}
const parsed = expressionToJson(element, sourceFile, constants, new Set(visitedIdentifiers));
unresolved = unresolved || parsed.unresolved;
warnings.push(...parsed.warnings);
return parsed.value;
});
return { value, unresolved, warnings };
}
if (ts.isObjectLiteralExpression(expression)) {
const warnings: string[] = [];
let unresolved = false;
const value: Record<string, unknown> = {};
expression.properties.forEach((property, index) => {
if (ts.isPropertyAssignment(property)) {
const propertyName = getObjectPropertyName(property.name);
if (!propertyName) {
unresolved = true;
warnings.push(`对象属性名无法静态解析: ${property.getText(sourceFile)}`);
value[`__expr_${index}`] = property.getText(sourceFile);
return;
}
const parsed = expressionToJson(property.initializer, sourceFile, constants, new Set(visitedIdentifiers));
unresolved = unresolved || parsed.unresolved;
warnings.push(...parsed.warnings);
value[propertyName] = parsed.value;
return;
}
if (ts.isShorthandPropertyAssignment(property)) {
const propertyName = property.name.text;
if (visitedIdentifiers.has(propertyName)) {
unresolved = true;
warnings.push(`检测到循环引用: ${propertyName}`);
value[propertyName] = { __expr: property.getText(sourceFile) };
return;
}
const initializer = constants.get(propertyName);
if (!initializer) {
unresolved = true;
warnings.push(`未找到简写属性引用: ${propertyName}`);
value[propertyName] = { __expr: property.getText(sourceFile) };
return;
}
const nextVisited = new Set(visitedIdentifiers);
nextVisited.add(propertyName);
const parsed = expressionToJson(initializer, sourceFile, constants, nextVisited);
unresolved = unresolved || parsed.unresolved;
warnings.push(...parsed.warnings);
value[propertyName] = parsed.value;
return;
}
unresolved = true;
warnings.push(`对象包含无法静态解析的属性: ${property.getText(sourceFile)}`);
value[`__expr_${index}`] = property.getText(sourceFile);
});
return { value, unresolved, warnings };
}
if (ts.isIdentifier(expression)) {
const identifier = expression.text;
if (visitedIdentifiers.has(identifier)) {
return {
value: { __expr: expression.getText(sourceFile) },
unresolved: true,
warnings: [`检测到循环引用: ${identifier}`],
};
}
const next = constants.get(identifier);
if (!next) {
return {
value: { __expr: expression.getText(sourceFile) },
unresolved: true,
warnings: [`无法静态解析标识符: ${identifier}`],
};
}
const nextVisited = new Set(visitedIdentifiers);
nextVisited.add(identifier);
return expressionToJson(next, sourceFile, constants, nextVisited);
}
return {
value: { __expr: expression.getText(sourceFile) },
unresolved: true,
warnings: [`无法静态解析表达式: ${expression.getText(sourceFile)}`],
};
}
function toSerializableRecordArray(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
return item as Record<string, unknown>;
}
return { value: item };
});
}
function normalizeWarnings(warnings: string[]): string[] {
const seen = new Set<string>();
const deduped: string[] = [];
for (const warning of warnings) {
const normalized = String(warning || '').trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
deduped.push(normalized);
}
return deduped;
}
function buildListPreview(
key: AxureApiListKey,
handleObject: ts.ObjectLiteralExpression | null,
sourceFile: ts.SourceFile,
constants: Map<string, ts.Expression>,
): AxureApiListPreview {
if (!handleObject) {
return createMissingListPreview();
}
const property = handleObject.properties.find((node) => {
if (!ts.isPropertyAssignment(node) && !ts.isShorthandPropertyAssignment(node)) {
return false;
}
const propertyName = getObjectPropertyName(node.name);
return propertyName === key;
});
if (!property) {
return createMissingListPreview();
}
const rawExpression = ts.isPropertyAssignment(property)
? property.initializer
: property.name;
const sourceKey = ts.isIdentifier(rawExpression) ? rawExpression.text : null;
const resolved = resolveExpressionFromConstMap(rawExpression, constants);
const parsed = expressionToJson(resolved.expression, sourceFile, constants);
const warnings = normalizeWarnings([...resolved.warnings, ...parsed.warnings]);
if (!Array.isArray(parsed.value)) {
return {
sourceKey,
raw: resolved.expression.getText(sourceFile),
items: [],
parseStatus: 'raw',
warnings: normalizeWarnings([...warnings, `字段 ${key} 不是数组字面量`]),
};
}
const parseStatus: AxureApiListPreview['parseStatus'] = parsed.unresolved || warnings.length > 0 ? 'raw' : 'parsed';
return {
sourceKey,
raw: resolved.expression.getText(sourceFile),
items: toSerializableRecordArray(parsed.value),
parseStatus,
warnings,
};
}
export function extractAxureApiPreviewFromContent(content: string, filePath: string): AxureApiPreviewResult {
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const constants = collectTopLevelConstInitializers(sourceFile);
const handleResult = findHandleReturnObject(sourceFile);
const result = createEmptyAxureApiPreview(filePath, true);
result.hasAxureHandle = handleResult.hasAxureHandle;
AXURE_LIST_KEYS.forEach((key) => {
result.lists[key] = buildListPreview(key, handleResult.handleObject, sourceFile, constants);
});
return result;
}
export function getAxureApiPreviewFromFile(filePath: string): AxureApiPreviewResult {
if (!fs.existsSync(filePath)) {
return createEmptyAxureApiPreview(filePath, false);
}
try {
const content = fs.readFileSync(filePath, 'utf8');
return extractAxureApiPreviewFromContent(content, filePath);
} catch {
return createEmptyAxureApiPreview(filePath, false);
}
}
function isSafeRelativeTargetPath(targetPath: string): boolean {
const normalized = String(targetPath || '');
if (!normalized) return false;
if (normalized.includes('..')) return false;
if (normalized.startsWith('/')) return false;
if (normalized.startsWith('\\')) return false;
if (path.isAbsolute(normalized)) return false;
return true;
}
/**
* 代码检查插件
*/
export function codeReviewPlugin(): Plugin {
return {
name: 'code-review-plugin',
configureServer(server: any) {
const parseBody = (req: any): Promise<any> => new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
req.on('end', () => {
try {
const bodyText = Buffer.concat(chunks).toString('utf8').trim();
resolve(bodyText ? JSON.parse(bodyText) : {});
} catch (error: any) {
reject(new Error(error?.message || 'Invalid JSON body'));
}
});
req.on('error', reject);
});
const sendJson = (res: any, statusCode: number, data: any) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
};
server.middlewares.use(async (req: any, res: any, next: any) => {
const url = req.url || '';
const isCodeReviewRoute = req.method === 'POST' && url === '/api/code-review';
const isApiPreviewRoute = req.method === 'POST' && url === '/api/axure-api-preview';
if (!isCodeReviewRoute && !isApiPreviewRoute) {
return next();
}
try {
const body = await parseBody(req);
const targetPath = String(body.path || '').trim();
if (!targetPath) {
sendJson(res, 400, { error: 'Missing path parameter' });
return;
}
if (!isSafeRelativeTargetPath(targetPath)) {
sendJson(res, 403, { error: 'Invalid path' });
return;
}
const filePath = path.resolve(process.cwd(), 'src', targetPath, 'index.tsx');
if (isCodeReviewRoute) {
const enforceComponentExportName = body.enforceComponentExportName === true;
const result = reviewFile(filePath, { enforceComponentExportName });
sendJson(res, 200, result);
return;
}
const result = getAxureApiPreviewFromFile(filePath);
sendJson(res, 200, result);
} catch (error: any) {
console.error('Code review error:', error);
sendJson(res, 500, { error: error?.message || 'Server error' });
}
});
}
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,439 @@
import type { Plugin } from 'vite';
import { lowdbService } from './lowdbService';
import {
ValidationError,
validateRequired,
validateFileName,
validateRecordForInsert,
validateRecordForUpdate
} from './validation';
import Papa from 'papaparse';
import { buildAttachmentContentDisposition } from './utils/contentDisposition';
/**
* Error response interface
*/
interface ErrorResponse {
error: string;
code: string;
details?: any;
timestamp: string;
}
/**
* Custom error class for API errors
*/
class APIError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: any
) {
super(message);
this.name = 'APIError';
}
}
/**
* Data Management API Plugin
* Provides RESTful API endpoints for managing data tables
*/
export function dataManagementApiPlugin(): Plugin {
return {
name: 'data-management-api',
async configureServer(server) {
// Initialize the database
await lowdbService.initialize();
// Helper function to parse JSON body
const parseBody = (req: any): Promise<any> => {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk: any) => body += chunk);
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (e) {
reject(new APIError(400, 'INVALID_JSON', 'Invalid JSON in request body'));
}
});
req.on('error', reject);
});
};
// Helper function to send JSON response
const sendJSON = (res: any, statusCode: number, data: any) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
};
// Helper function to send error response
const sendError = (res: any, statusCode: number, message: string, code?: string, details?: any) => {
const errorResponse: ErrorResponse = {
error: message,
code: code || 'ERROR',
timestamp: new Date().toISOString()
};
if (details) {
errorResponse.details = details;
}
sendJSON(res, statusCode, errorResponse);
};
// Error logging function
const logError = (error: Error | APIError, context: string) => {
const timestamp = new Date().toISOString();
if (error instanceof APIError) {
console.error(`[${timestamp}] [Data Management API] ${context}:`, {
statusCode: error.statusCode,
code: error.code,
message: error.message,
details: error.details
});
} else {
console.error(`[${timestamp}] [Data Management API] ${context}:`, {
name: error.name,
message: error.message,
stack: error.stack
});
}
};
// Validation helper functions
const validateRequiredField = (value: any, fieldName: string) => {
try {
validateRequired(value, fieldName);
} catch (e: any) {
throw new APIError(400, 'VALIDATION_ERROR', e.message);
}
};
const validateFileNameParam = (fileName: string) => {
try {
validateFileName(fileName);
} catch (e: any) {
throw new APIError(400, 'VALIDATION_ERROR', e.message);
}
};
const validateRecordDataForInsert = (data: any) => {
try {
validateRecordForInsert(data);
} catch (e: any) {
if (e instanceof ValidationError) {
throw new APIError(400, 'VALIDATION_ERROR', e.message, e.details);
}
throw new APIError(400, 'VALIDATION_ERROR', e.message);
}
};
const validateRecordDataForUpdate = (data: any) => {
try {
validateRecordForUpdate(data);
} catch (e: any) {
if (e instanceof ValidationError) {
throw new APIError(400, 'VALIDATION_ERROR', e.message, e.details);
}
throw new APIError(400, 'VALIDATION_ERROR', e.message);
}
};
// Main middleware for data management API
server.middlewares.use(async (req: any, res: any, next: any) => {
// Only handle /api/data/* routes
if (!req.url.startsWith('/api/data')) {
return next();
}
try {
// Parse URL
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
// Route: GET /api/data/tables - Get all tables
if (pathname === '/api/data/tables' && req.method === 'GET') {
const tables = await lowdbService.getAllTables();
sendJSON(res, 200, tables);
return;
}
// Route: POST /api/data/tables - Create new table
if (pathname === '/api/data/tables' && req.method === 'POST') {
const body = await parseBody(req);
const { tableName } = body;
validateRequiredField(tableName, 'tableName');
// Generate fileName from tableName if not provided
// Use tableName directly as fileName (supports Chinese)
const fileName = tableName.trim();
validateFileNameParam(fileName);
// Check if table already exists
const exists = await lowdbService.tableExists(fileName);
if (exists) {
throw new APIError(400, 'TABLE_EXISTS', `Table '${tableName}' already exists`);
}
await lowdbService.createTable(fileName, tableName);
sendJSON(res, 201, { success: true, fileName, tableName });
return;
}
// Route: PUT /api/data/tables/:fileName - Update table info (e.g., tableName)
const updateTableMatch = pathname.match(/^\/api\/data\/tables\/([^/]+)$/);
if (updateTableMatch && req.method === 'PUT') {
const fileName = decodeURIComponent(updateTableMatch[1]);
validateFileNameParam(fileName);
const exists = await lowdbService.tableExists(fileName);
if (!exists) {
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
}
const body = await parseBody(req);
const { tableName } = body;
if (tableName !== undefined) {
await lowdbService.updateTableName(fileName, tableName);
}
sendJSON(res, 200, { success: true, fileName, tableName });
return;
}
// Route: DELETE /api/data/tables/:fileName - Delete table
const deleteTableMatch = pathname.match(/^\/api\/data\/tables\/([^/]+)$/);
if (deleteTableMatch && req.method === 'DELETE') {
const fileName = decodeURIComponent(deleteTableMatch[1]);
validateFileNameParam(fileName);
const exists = await lowdbService.tableExists(fileName);
if (!exists) {
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
}
await lowdbService.deleteTable(fileName);
sendJSON(res, 200, { success: true });
return;
}
// Route: GET /api/data/:fileName/export - CSV Export (must be before :id route)
const exportMatch = pathname.match(/^\/api\/data\/([^/]+)\/export$/);
if (exportMatch && req.method === 'GET') {
const fileName = decodeURIComponent(exportMatch[1]);
validateFileNameParam(fileName);
const exists = await lowdbService.tableExists(fileName);
if (!exists) {
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
}
// Get all records
const records = await lowdbService.getTable(fileName);
// Convert to CSV
const csv = Papa.unparse(records, {
quotes: true,
header: true
});
// Send CSV response
res.statusCode = 200;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', buildAttachmentContentDisposition(`${fileName}.csv`));
res.end(csv);
return;
}
// Route: GET /api/data/:fileName - Get all data from table
const getTableMatch = pathname.match(/^\/api\/data\/([^/]+)$/);
if (getTableMatch && req.method === 'GET') {
const fileName = decodeURIComponent(getTableMatch[1]);
validateFileNameParam(fileName);
const exists = await lowdbService.tableExists(fileName);
if (!exists) {
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
}
const records = await lowdbService.getTable(fileName);
sendJSON(res, 200, records);
return;
}
// Route: GET /api/data/:fileName/:id - Get single record
const getRecordMatch = pathname.match(/^\/api\/data\/([^/]+)\/([^/]+)$/);
if (getRecordMatch && req.method === 'GET') {
const fileName = decodeURIComponent(getRecordMatch[1]);
const id = decodeURIComponent(getRecordMatch[2]);
validateFileNameParam(fileName);
const exists = await lowdbService.tableExists(fileName);
if (!exists) {
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
}
const record = await lowdbService.getRecord(fileName, id);
if (!record) {
throw new APIError(404, 'NOT_FOUND', `Record with id '${id}' not found`);
}
sendJSON(res, 200, record);
return;
}
// Route: POST /api/data/:fileName - Insert new record
const insertMatch = pathname.match(/^\/api\/data\/([^/]+)$/);
if (insertMatch && req.method === 'POST') {
const fileName = decodeURIComponent(insertMatch[1]);
validateFileNameParam(fileName);
const exists = await lowdbService.tableExists(fileName);
if (!exists) {
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
}
const body = await parseBody(req);
validateRecordDataForInsert(body);
const newRecord = await lowdbService.insertData(fileName, body);
sendJSON(res, 201, newRecord);
return;
}
// Route: PUT /api/data/:fileName/:id - Update record
const updateMatch = pathname.match(/^\/api\/data\/([^/]+)\/([^/]+)$/);
if (updateMatch && req.method === 'PUT') {
const fileName = decodeURIComponent(updateMatch[1]);
const id = decodeURIComponent(updateMatch[2]);
validateFileNameParam(fileName);
const exists = await lowdbService.tableExists(fileName);
if (!exists) {
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
}
const body = await parseBody(req);
validateRecordDataForUpdate(body);
try {
const updatedRecord = await lowdbService.updateData(fileName, id, body);
sendJSON(res, 200, updatedRecord);
} catch (e: any) {
if (e.message.includes('not found')) {
throw new APIError(404, 'NOT_FOUND', `Record with id '${id}' not found`);
}
throw e;
}
return;
}
// Route: DELETE /api/data/:fileName/:id - Delete record
const deleteMatch = pathname.match(/^\/api\/data\/([^/]+)\/([^/]+)$/);
if (deleteMatch && req.method === 'DELETE') {
const fileName = decodeURIComponent(deleteMatch[1]);
const id = decodeURIComponent(deleteMatch[2]);
validateFileNameParam(fileName);
const exists = await lowdbService.tableExists(fileName);
if (!exists) {
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
}
try {
await lowdbService.deleteData(fileName, id);
sendJSON(res, 200, { success: true });
} catch (e: any) {
if (e.message.includes('not found')) {
throw new APIError(404, 'NOT_FOUND', `Record with id '${id}' not found`);
}
throw e;
}
return;
}
// Route: POST /api/data/:fileName/import - CSV Import
const importMatch = pathname.match(/^\/api\/data\/([^/]+)\/import$/);
if (importMatch && req.method === 'POST') {
const fileName = decodeURIComponent(importMatch[1]);
validateFileNameParam(fileName);
const exists = await lowdbService.tableExists(fileName);
if (!exists) {
throw new APIError(404, 'NOT_FOUND', `Table '${fileName}' not found`);
}
const body = await parseBody(req);
const { csvData } = body;
if (!csvData || typeof csvData !== 'string') {
throw new APIError(400, 'VALIDATION_ERROR', 'Missing or invalid csvData parameter');
}
// Parse CSV
const parseResult = Papa.parse(csvData, {
header: true,
dynamicTyping: true,
skipEmptyLines: true,
transformHeader: (header) => header.trim()
});
if (parseResult.errors.length > 0) {
throw new APIError(
400,
'CSV_PARSE_ERROR',
'Failed to parse CSV file',
{ errors: parseResult.errors }
);
}
// Import data with upsert logic
const importedRecords = await lowdbService.importCSV(fileName, parseResult.data);
sendJSON(res, 200, {
success: true,
recordCount: importedRecords.length,
records: importedRecords
});
return;
}
// No route matched
throw new APIError(404, 'NOT_FOUND', 'API endpoint not found');
} catch (e: any) {
// Error handling middleware
if (e instanceof APIError) {
// Known API error
logError(e, `${req.method} ${req.url}`);
sendError(res, e.statusCode, e.message, e.code, e.details);
} else if (e instanceof ValidationError) {
// Validation error from lowdbService
logError(e, `${req.method} ${req.url}`);
sendError(res, 400, e.message, 'VALIDATION_ERROR', e.details);
} else if (e.code === 'ENOENT') {
// File not found error
logError(e, `${req.method} ${req.url}`);
sendError(res, 404, 'Resource not found', 'NOT_FOUND');
} else if (e.code === 'EACCES') {
// Permission error
logError(e, `${req.method} ${req.url}`);
sendError(res, 500, 'Permission denied', 'PERMISSION_ERROR');
} else {
// Unknown error
logError(e, `${req.method} ${req.url}`);
sendError(res, 500, 'Internal server error', 'SERVER_ERROR', {
message: e.message
});
}
}
});
}
};
}

View File

@@ -0,0 +1,609 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import {
createManualDocTemplate,
getDocsDir,
getTemplatesDir,
isTemplateDocName,
isProtectedDocName,
safeDecodeURIComponent,
sanitizeDocBaseName,
scanDocReferences,
} from './utils/docUtils';
import { getRequestPathname, readJsonBody } from './utils/httpUtils';
type DocAction = 'rename' | 'delete';
function getProtectedDocPayload(action: DocAction) {
return {
error: action === 'rename'
? '项目总览入口文档禁止改名,可继续编辑内容'
: '项目总览入口文档禁止删除',
code: 'PROTECTED_DOC',
protected: true,
references: [],
hasReferences: false,
};
}
function getReferencedDocPayload(action: DocAction, references: string[]) {
return {
error: action === 'rename'
? '文档存在项目内引用,请先处理引用后再改名'
: '文档存在项目内引用,请先处理引用后再删除',
code: 'DOC_REFERENCED',
protected: false,
references,
hasReferences: references.length > 0,
};
}
function normalizeRenameBaseNameForPath(docPath: string, nextBaseName: string) {
const ext = path.extname(docPath);
let normalizedBaseName = String(nextBaseName || '').trim();
if (ext && normalizedBaseName.toLowerCase().endsWith(ext.toLowerCase())) {
normalizedBaseName = normalizedBaseName.slice(0, -ext.length).trim();
}
return {
ext,
safeBaseName: sanitizeDocBaseName(normalizedBaseName),
};
}
export function docsApiPlugin(): Plugin {
return {
name: 'docs-api-plugin',
configureServer(server: any) {
server.middlewares.use(async (req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (!pathname.startsWith('/api/docs')) {
return next();
}
if (pathname === '/api/docs/templates' || pathname.startsWith('/api/docs/templates/')) {
return next();
}
const docsDir = getDocsDir(process.cwd());
const encodedDocName = pathname.startsWith('/api/docs/')
? pathname.slice('/api/docs/'.length)
: '';
const decodedDocName = safeDecodeURIComponent(encodedDocName);
const isCompatTemplateRequest = Boolean(encodedDocName) && isTemplateDocName(decodedDocName);
if (isCompatTemplateRequest && (req.method === 'GET' || req.method === 'PUT')) {
try {
const templateName = decodedDocName.slice('templates/'.length);
if (!templateName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing template name' }));
return;
}
const templatesDir = getTemplatesDir(process.cwd());
const templatePath = path.join(templatesDir, templateName);
if (!templatePath.startsWith(templatesDir)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(templatePath)) {
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Template not found' }));
return;
}
if (req.method === 'GET') {
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.end(fs.readFileSync(templatePath, 'utf8'));
return;
}
const bodyData = await readJsonBody(req);
if (typeof bodyData?.content !== 'string') {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing content parameter' }));
return;
}
fs.writeFileSync(templatePath, String(bodyData.content), 'utf8');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ success: true, name: templateName }));
return;
} catch (error: any) {
console.error('Error handling encoded template doc path:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Template request failed' }));
return;
}
}
if (req.method === 'POST' && (pathname === '/api/docs/check-references' || pathname === '/api/docs/check-references/')) {
try {
const body = await readJsonBody(req);
const docName = String(body?.docName || '').trim();
const action = body?.action === 'rename' ? 'rename' : body?.action === 'delete' ? 'delete' : '';
const nextBaseName = String(body?.nextBaseName || '').trim();
if (!docName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing docName parameter' }));
return;
}
if (isTemplateDocName(docName)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Templates must be managed via /api/docs/templates' }));
return;
}
if (action !== 'rename' && action !== 'delete') {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Invalid action parameter' }));
return;
}
const docPath = path.join(docsDir, docName);
if (!docPath.startsWith(docsDir)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(docPath)) {
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Document not found' }));
return;
}
let hasActualRename = action === 'delete';
if (action === 'rename') {
if (!nextBaseName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing nextBaseName parameter' }));
return;
}
if (/[/\\:*?"<>|]/.test(nextBaseName)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Invalid nextBaseName format' }));
return;
}
const { ext, safeBaseName } = normalizeRenameBaseNameForPath(docPath, nextBaseName);
if (!safeBaseName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Invalid nextBaseName format' }));
return;
}
const nextPath = path.join(path.dirname(docPath), `${safeBaseName}${ext}`);
hasActualRename = nextPath !== docPath;
}
if (hasActualRename && isProtectedDocName(docName)) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
docName,
...getProtectedDocPayload(action),
}));
return;
}
const references = hasActualRename ? scanDocReferences(docName) : [];
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
docName,
references,
hasReferences: references.length > 0,
protected: false,
...(references.length > 0 ? { code: 'DOC_REFERENCED' } : {}),
}));
} catch (error: any) {
console.error('Error checking doc references:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Check doc references failed' }));
}
return;
}
if (
req.method === 'POST' &&
(pathname === '/api/docs/manual-create' || pathname === '/api/docs/manual-create/' || pathname === '/manual-create')
) {
try {
const body = await readJsonBody(req);
const displayName = String(body?.displayName || '').trim();
const fileNameInput = String(body?.fileName || body?.displayName || '').trim();
if (!displayName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing displayName' }));
return;
}
fs.mkdirSync(docsDir, { recursive: true });
const fallbackBase = `doc-${Date.now().toString(36)}`;
const sanitizedBase = sanitizeDocBaseName(fileNameInput || displayName) || fallbackBase;
let baseName = sanitizedBase;
let suffix = 2;
while (fs.existsSync(path.join(docsDir, `${baseName}.md`))) {
baseName = `${sanitizedBase}-${suffix}`;
suffix += 1;
}
const docFileName = `${baseName}.md`;
const docPath = path.join(docsDir, docFileName);
if (!docPath.startsWith(docsDir)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
fs.writeFileSync(docPath, createManualDocTemplate(displayName), 'utf8');
res.statusCode = 201;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
name: docFileName,
displayName,
path: `src/docs/${docFileName}`,
}));
} catch (error: any) {
console.error('Error manual creating doc:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Create doc failed' }));
}
return;
}
if (req.method === 'POST' && pathname.startsWith('/api/docs/') && pathname.endsWith('/copy')) {
try {
const encodedDocName = pathname.slice('/api/docs/'.length, -'/copy'.length);
const docName = decodeURIComponent(encodedDocName);
if (!docName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing document name' }));
return;
}
if (isTemplateDocName(docName)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Templates must be managed via /api/docs/templates' }));
return;
}
const sourcePath = path.join(docsDir, docName);
if (!sourcePath.startsWith(docsDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(sourcePath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Document not found' }));
return;
}
const sourceDir = path.dirname(sourcePath);
const ext = path.extname(sourcePath);
const sourceBaseName = path.basename(sourcePath, ext);
const safeBaseName = sanitizeDocBaseName(sourceBaseName) || sourceBaseName;
const candidateBase = `${safeBaseName}-copy`;
let nextBaseName = candidateBase;
let suffix = 2;
let nextName = `${nextBaseName}${ext}`;
let nextPath = path.join(sourceDir, nextName);
while (fs.existsSync(nextPath)) {
nextBaseName = `${candidateBase}${suffix}`;
nextName = `${nextBaseName}${ext}`;
nextPath = path.join(sourceDir, nextName);
suffix += 1;
}
if (!nextPath.startsWith(docsDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
fs.copyFileSync(sourcePath, nextPath);
const relativeName = path.relative(docsDir, nextPath).split(path.sep).join('/');
const relativeDisplayName = relativeName.replace(/\.[^./\\]+$/u, '');
res.statusCode = 201;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
name: relativeName,
displayName: relativeDisplayName,
path: `src/docs/${relativeName}`,
}));
} catch (error: any) {
console.error('Error copying doc:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Copy doc failed' }));
}
return;
}
if (req.method === 'DELETE' && pathname.startsWith('/api/docs/')) {
try {
const encodedDocName = pathname.replace('/api/docs/', '');
const docName = decodeURIComponent(encodedDocName);
if (!docName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing document name' }));
return;
}
if (isTemplateDocName(docName)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Templates must be managed via /api/docs/templates' }));
return;
}
const docPath = path.join(docsDir, docName);
if (!docPath.startsWith(docsDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(docPath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Document not found' }));
return;
}
if (isProtectedDocName(docName)) {
res.statusCode = 409;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(getProtectedDocPayload('delete')));
return;
}
const references = scanDocReferences(docName);
if (references.length > 0) {
res.statusCode = 409;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(getReferencedDocPayload('delete', references)));
return;
}
fs.unlinkSync(docPath);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true }));
} catch (error: any) {
console.error('Error deleting doc:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (req.method === 'PUT' && pathname.startsWith('/api/docs/')) {
try {
const encodedDocName = pathname.replace('/api/docs/', '');
if (!encodedDocName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing document name' }));
return;
}
const bodyData = await readJsonBody(req);
const hasContentUpdate = typeof bodyData?.content === 'string';
let newBaseName = String(bodyData?.newBaseName || '').trim();
const hasRename = Boolean(newBaseName);
if (!hasContentUpdate && !hasRename) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing content or newBaseName parameter' }));
return;
}
if (hasRename && /[/\\:*?"<>|]/.test(newBaseName)) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid newBaseName format' }));
return;
}
const docName = decodeURIComponent(encodedDocName);
if (isTemplateDocName(docName)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Templates must be managed via /api/docs/templates' }));
return;
}
const oldPath = path.join(docsDir, docName);
if (!oldPath.startsWith(docsDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(oldPath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Document not found' }));
return;
}
let finalPath = oldPath;
if (hasRename) {
const { ext, safeBaseName } = normalizeRenameBaseNameForPath(oldPath, newBaseName);
if (!safeBaseName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid newBaseName format' }));
return;
}
const oldDir = path.dirname(oldPath);
const newFileName = `${safeBaseName}${ext}`;
const newPath = path.join(oldDir, newFileName);
if (!newPath.startsWith(docsDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (newPath !== oldPath && fs.existsSync(newPath)) {
res.statusCode = 400;
res.end(JSON.stringify({ error: '目标文件已存在' }));
return;
}
if (newPath !== oldPath) {
if (isProtectedDocName(docName)) {
res.statusCode = 409;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(getProtectedDocPayload('rename')));
return;
}
const references = scanDocReferences(docName);
if (references.length > 0) {
res.statusCode = 409;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(getReferencedDocPayload('rename', references)));
return;
}
fs.renameSync(oldPath, newPath);
}
finalPath = newPath;
}
if (hasContentUpdate) {
fs.writeFileSync(finalPath, String(bodyData.content), 'utf8');
}
const relativeName = path.relative(docsDir, finalPath).split(path.sep).join('/');
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, name: relativeName }));
} catch (error: any) {
console.error('Error updating doc:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (req.method !== 'GET') {
return next();
}
if (pathname.startsWith('/api/docs/') && pathname !== '/api/docs' && pathname !== '/api/docs/') {
try {
const encodedDocName = pathname.replace('/api/docs/', '');
if (!encodedDocName) {
return next();
}
const docName = decodeURIComponent(encodedDocName);
if (isTemplateDocName(docName)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Templates must be managed via /api/docs/templates' }));
return;
}
const docPath = path.join(docsDir, docName);
if (!docPath.startsWith(docsDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (fs.existsSync(docPath)) {
const content = fs.readFileSync(docPath, 'utf8');
const ext = path.extname(docPath);
const contentTypeMap: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.yaml': 'text/yaml; charset=utf-8',
'.yml': 'text/yaml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
};
res.setHeader('Content-Type', contentTypeMap[ext] || 'text/plain; charset=utf-8');
res.end(content);
} else {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Document not found' }));
}
} catch (error: any) {
console.error('Error loading doc:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (pathname === '/api/docs' || pathname === '/api/docs/') {
try {
const docs: any[] = [];
const supportedExtensions = ['.md', '.csv', '.json', '.yaml', '.yml', '.txt'];
if (fs.existsSync(docsDir)) {
const walkDocsDir = (dirPath: string) => {
const items = fs.readdirSync(dirPath, { withFileTypes: true });
items.forEach((item) => {
const fullPath = path.join(dirPath, item.name);
if (item.isDirectory()) {
walkDocsDir(fullPath);
return;
}
if (!item.isFile()) {
return;
}
const ext = path.extname(item.name).toLowerCase();
if (!supportedExtensions.includes(ext)) {
return;
}
const relativePath = path.relative(docsDir, fullPath).split(path.sep).join('/');
if (isTemplateDocName(relativePath)) {
return;
}
docs.push({
name: relativePath,
displayName: relativePath,
});
});
};
walkDocsDir(docsDir);
}
docs.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')));
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(docs));
} catch (error: any) {
console.error('Error loading docs:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
next();
});
},
};
}

View File

@@ -0,0 +1,284 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import formidable from 'formidable';
import { getRequestPathname } from './utils/httpUtils';
import {
resolveUniqueMarkdownPath,
sanitizeImportFileBaseName,
} from './utils/docUtils';
import {
convertFileToMarkdownWithMarkitdown,
DOC_IMPORT_MAX_FILE_COUNT,
DOC_IMPORT_MAX_FILE_SIZE,
DOC_IMPORT_MAX_TOTAL_SIZE,
DOC_IMPORT_SUPPORTED_EXTENSIONS,
resolveMarkitdownCommand,
} from './utils/markitdown';
export function docsImportApiPlugin(): Plugin {
return {
name: 'docs-import-api-plugin',
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
const projectRoot = process.cwd();
const docsDir = path.resolve(projectRoot, 'src/docs');
if (pathname === '/api/docs/import/markitdown-status') {
if (req.method !== 'GET') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
const resolved = resolveMarkitdownCommand();
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
installed: resolved.installed,
commandSource: resolved.commandSource,
version: resolved.version,
installHints: resolved.installHints,
error: resolved.error,
}));
return;
}
if (pathname !== '/api/docs/import' && pathname !== '/api/docs/import/') {
return next();
}
if (req.method !== 'POST') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
const uploadDir = path.resolve(projectRoot, 'temp', 'docs-import');
fs.mkdirSync(uploadDir, { recursive: true });
fs.mkdirSync(docsDir, { recursive: true });
const form = formidable({
uploadDir,
keepExtensions: true,
multiples: true,
maxFileSize: DOC_IMPORT_MAX_FILE_SIZE,
});
form.parse(req, async (error: any, _fields: any, files: any) => {
if (error) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Failed to parse upload payload' }));
return;
}
const normalizeFiles = (value: any): any[] => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
};
const uploadedFiles = normalizeFiles(files?.files).length > 0
? normalizeFiles(files?.files)
: normalizeFiles(files?.file);
if (uploadedFiles.length === 0) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing files' }));
return;
}
if (uploadedFiles.length > DOC_IMPORT_MAX_FILE_COUNT) {
res.statusCode = 413;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
error: `Too many files. Maximum ${DOC_IMPORT_MAX_FILE_COUNT} files per import.`,
}));
return;
}
const totalSize = uploadedFiles.reduce(
(sum, file) => sum + Number(file?.size || 0),
0,
);
if (totalSize > DOC_IMPORT_MAX_TOTAL_SIZE) {
res.statusCode = 413;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
error: `Total payload too large. Maximum ${Math.floor(DOC_IMPORT_MAX_TOTAL_SIZE / 1024 / 1024)}MB.`,
}));
return;
}
const markitdown = resolveMarkitdownCommand();
const results: Array<{
originalName: string;
extension: string;
success: boolean;
mode: 'direct-md' | 'markitdown';
savedName?: string;
savedPath?: string;
error?: string;
}> = [];
for (const file of uploadedFiles) {
const tempPath = String(file?.filepath || file?.path || '').trim();
const originalName = String(
file?.originalFilename || file?.name || file?.newFilename || 'unnamed-file',
).trim();
const extension = path.extname(originalName || tempPath).toLowerCase();
const safeOriginalName = originalName || path.basename(tempPath) || 'unnamed-file';
const cleanupTempFile = () => {
if (!tempPath) return;
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch {
// Ignore cleanup errors.
}
};
if (!tempPath || !fs.existsSync(tempPath)) {
results.push({
originalName: safeOriginalName,
extension,
success: false,
mode: extension === '.md' ? 'direct-md' : 'markitdown',
error: 'Uploaded file is missing from temporary storage',
});
cleanupTempFile();
continue;
}
if (!DOC_IMPORT_SUPPORTED_EXTENSIONS.has(extension)) {
results.push({
originalName: safeOriginalName,
extension,
success: false,
mode: extension === '.md' ? 'direct-md' : 'markitdown',
error: `Unsupported file extension: ${extension || 'unknown'}`,
});
cleanupTempFile();
continue;
}
const baseName = sanitizeImportFileBaseName(safeOriginalName)
|| `doc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
const target = resolveUniqueMarkdownPath(docsDir, baseName);
if (!target.absolutePath.startsWith(docsDir)) {
results.push({
originalName: safeOriginalName,
extension,
success: false,
mode: extension === '.md' ? 'direct-md' : 'markitdown',
error: 'Forbidden target path',
});
cleanupTempFile();
continue;
}
if (extension === '.md') {
try {
fs.copyFileSync(tempPath, target.absolutePath);
results.push({
originalName: safeOriginalName,
extension,
success: true,
mode: 'direct-md',
savedName: target.fileName,
savedPath: `src/docs/${target.fileName}`,
});
} catch (copyError: any) {
results.push({
originalName: safeOriginalName,
extension,
success: false,
mode: 'direct-md',
error: copyError?.message || 'Failed to save markdown file',
});
} finally {
cleanupTempFile();
}
continue;
}
if (!markitdown.installed || !markitdown.command || !markitdown.args) {
results.push({
originalName: safeOriginalName,
extension,
success: false,
mode: 'markitdown',
error: markitdown.error || 'markitdown is not installed. Only .md files can be imported now.',
});
cleanupTempFile();
continue;
}
const conversion = convertFileToMarkdownWithMarkitdown({
command: markitdown.command,
args: markitdown.args,
sourcePath: tempPath,
});
if (!conversion.success) {
results.push({
originalName: safeOriginalName,
extension,
success: false,
mode: 'markitdown',
error: conversion.error,
});
cleanupTempFile();
continue;
}
try {
fs.writeFileSync(target.absolutePath, conversion.content, 'utf8');
results.push({
originalName: safeOriginalName,
extension,
success: true,
mode: 'markitdown',
savedName: target.fileName,
savedPath: `src/docs/${target.fileName}`,
});
} catch (writeError: any) {
results.push({
originalName: safeOriginalName,
extension,
success: false,
mode: 'markitdown',
error: writeError?.message || 'Failed to write converted markdown',
});
} finally {
cleanupTempFile();
}
}
const successCount = results.filter((item) => item.success).length;
const failedCount = results.length - successCount;
const hasSuccess = successCount > 0;
res.statusCode = hasSuccess ? 200 : 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: failedCount === 0,
successCount,
failedCount,
commandSource: markitdown.commandSource,
markitdownInstalled: markitdown.installed,
markitdownError: markitdown.error,
results,
}));
});
});
},
};
}

View File

@@ -0,0 +1,49 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { getRequestPathname, streamDirectoryAsZip } from './utils/httpUtils';
export function downloadDistPlugin(): Plugin {
return {
name: 'download-dist-plugin',
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (req.method !== 'GET' || pathname !== '/api/download-dist') {
return next();
}
try {
const projectRoot = process.cwd();
const distDir = path.resolve(projectRoot, 'dist');
if (!fs.existsSync(distDir)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Dist directory not found' }));
return;
}
let projectName = 'project';
try {
const pkgPath = path.resolve(projectRoot, 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
projectName = pkg.name || 'project';
}
} catch (error) {
console.warn('Failed to read project name from package.json:', error);
}
streamDirectoryAsZip(res, distDir, `${projectName}-dist.zip`);
} catch (error: any) {
console.error('Download dist error:', error);
if (!res.headersSent) {
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
}
});
},
};
}

View File

@@ -0,0 +1,559 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import archiver from 'archiver';
import { getRequestPathname } from './utils/httpUtils';
import { buildAttachmentContentDisposition } from './utils/contentDisposition';
import { scanProjectEntries, writeEntriesManifestAtomic, readEntriesManifest } from './utils/entriesManifest';
interface ExportEntry {
key: string; // e.g. "prototypes/my-page"
group: string; // "components" | "prototypes"
name: string; // "my-page"
displayName: string; // "我的页面"
jsPath: string; // relative path to built JS in dist/
}
interface PageHtmlOptions {
includeBackLink?: boolean;
assetPrefix?: string;
}
/**
* 从入口文件中提取 @name 注释作为显示名称
*/
function getDisplayName(filePath: string): string | null {
try {
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/@name\s+([^\n]+)/);
return match ? match[1].trim() : null;
} catch {
return null;
}
}
/**
* 扫描 dist 目录获取构建产物
*/
function scanDistEntries(projectRoot: string): ExportEntry[] {
const distDir = path.join(projectRoot, 'dist');
if (!fs.existsSync(distDir)) {
return [];
}
const entries: ExportEntry[] = [];
const groups = ['components', 'prototypes'];
for (const group of groups) {
const groupDir = path.join(distDir, group);
if (!fs.existsSync(groupDir)) continue;
// 扫描子目录
const dirs = fs.readdirSync(groupDir, { withFileTypes: true });
for (const dir of dirs) {
if (!dir.isDirectory()) continue;
if (dir.name.startsWith('.') || dir.name.startsWith('ref-')) continue;
const jsFile = path.join(groupDir, dir.name + '.js');
const jsFileInDir = path.join(groupDir, dir.name, 'index.js');
// 构建产物格式: dist/prototypes/my-page.js
let jsPath: string | null = null;
if (fs.existsSync(jsFile)) {
jsPath = `${group}/${dir.name}.js`;
}
if (!jsPath) continue;
// 获取显示名称
const srcIndexPath = path.join(projectRoot, 'src', group, dir.name, 'index.tsx');
const displayName = getDisplayName(srcIndexPath) || dir.name;
entries.push({
key: `${group}/${dir.name}`,
group,
name: dir.name,
displayName,
jsPath,
});
}
}
// 也处理直接位于 dist/ 下的 JS 文件(如 dist/prototypes/xxx.js
for (const group of groups) {
const distEntries = fs.readdirSync(distDir, { withFileTypes: true });
// 构建产物可能直接放在 dist/ 下以 group/name.js 的形式
}
return entries;
}
/**
* 重新扫描 dist 目录,识别构建好的 JS 入口文件。
* 构建系统产出 dist/{group}/{name}.js 格式的 IIFE bundle。
*/
function scanBuiltEntries(projectRoot: string, options: { includeRef?: boolean } = {}): ExportEntry[] {
const distDir = path.join(projectRoot, 'dist');
if (!fs.existsSync(distDir)) return [];
const entries: ExportEntry[] = [];
const includeRef = options.includeRef === true;
// 扫描 dist 下所有 .js 文件
const files = fs.readdirSync(distDir, { withFileTypes: true });
for (const file of files) {
if (!file.isFile() || !file.name.endsWith('.js')) continue;
// 文件名格式: prototypes/my-page.js → key = "prototypes/my-page"
// 但构建系统实际上把入口 key 中的 / 变成了文件名的一部分
// 实际文件名: e.g. "prototypesmy-page.js" — 需要查看实际产物
}
// 根据 entries.json 的 key 查找对应的构建产物
const manifest = readEntriesManifest(projectRoot);
const jsEntries = manifest.js as Record<string, string>;
const items = manifest.items as Record<string, { group: string; name: string; js: string }>;
for (const [key, item] of Object.entries(items)) {
const group = item.group;
if (group !== 'components' && group !== 'prototypes') continue;
// 项目级导出默认跳过 ref- 前缀的参考组件/页面;单条目导出允许显式包含
if (!includeRef && item.name.startsWith('ref-')) continue;
// 构建产物路径: dist/{key}.js (key 中的 / 被 rollup 保留)
const builtJsPath = path.join(distDir, `${key}.js`);
if (!fs.existsSync(builtJsPath)) continue;
const srcIndexPath = path.join(projectRoot, 'src', key, 'index.tsx');
const displayName = getDisplayName(srcIndexPath) || item.name;
entries.push({
key,
group,
name: item.name,
displayName,
jsPath: `${key}.js`,
});
}
return entries;
}
/**
* 生成单个页面的查看 HTML
*/
function generatePageHtml(entry: ExportEntry, options: PageHtmlOptions = {}): string {
const { displayName, jsPath, group } = entry;
const isComponent = group === 'components';
const includeBackLink = options.includeBackLink !== false;
const assetPrefix = options.assetPrefix ?? '../';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${escapeHtml(displayName)}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html, body {
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 0;
height: 100%;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
}
#root {
width: 100%;
margin-left: auto;
margin-right: auto;
height: 100%;
min-height: 100vh;
overflow: visible;
}
${isComponent ? `
body.is-element-page #root {
width: 100vw;
height: 100vh;
}` : ''}
.back-link {
position: fixed;
top: 12px;
left: 12px;
z-index: 99999;
background: rgba(0,0,0,0.6);
color: #fff;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
text-decoration: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: background 0.2s;
}
.back-link:hover {
background: rgba(0,0,0,0.8);
}
</style>
</head>
<body${isComponent ? ' class="is-element-page"' : ''}>
${includeBackLink ? '<a href="../index.html" class="back-link">← 返回列表</a>' : ''}
<div id="root"></div>
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"><\/script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"><\/script>
<script>
(function() {
window.__AXHUB_EXPORTED_COMPONENT__ = null;
window.__AXHUB_DEFINE_COMPONENT__ = function(component) {
window.__AXHUB_EXPORTED_COMPONENT__ = component;
window.UserComponent = component;
};
})();
<\/script>
<script src="${assetPrefix}${jsPath}"><\/script>
<script>
(function() {
var root = document.getElementById('root');
var registered = window.__AXHUB_EXPORTED_COMPONENT__ || window.UserComponent;
if (!registered) {
root.innerHTML = '<div style="padding:40px;text-align:center;color:#999;font-family:sans-serif;">' +
'<h2>组件加载失败</h2><p>请确保构建产物正确</p></div>';
return;
}
var Component = registered.Component || registered.default || registered;
var reactRoot = ReactDOM.createRoot(root);
reactRoot.render(React.createElement(Component, {
container: root,
config: {},
data: {},
events: {}
}));
})();
<\/script>
</body>
</html>`;
}
/**
* 生成首页 HTML页面列表
*/
function generateIndexHtml(entries: ExportEntry[], projectName: string): string {
const prototypes = entries.filter(e => e.group === 'prototypes');
const components = entries.filter(e => e.group === 'components');
const renderList = (items: ExportEntry[], group: string) => {
if (items.length === 0) return '';
return items.map(item => {
const href = `pages/${item.group}--${item.name}.html`;
return ` <a href="${href}" class="item-card">
<div class="item-name">${escapeHtml(item.displayName)}</div>
<div class="item-path">${escapeHtml(item.key)}</div>
</a>`;
}).join('\n');
};
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${escapeHtml(projectName)} - 原型预览</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
background: #f5f5f5;
color: #333;
min-height: 100vh;
}
.header {
background: #fff;
border-bottom: 1px solid #e8e8e8;
padding: 24px 32px;
}
.header h1 {
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
}
.header h1 span { color: #1677ff; }
.header p {
margin-top: 8px;
font-size: 14px;
color: #999;
}
.content {
max-width: 960px;
margin: 0 auto;
padding: 32px 24px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #666;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #e8e8e8;
}
.section { margin-bottom: 32px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.item-card {
display: block;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 20px;
text-decoration: none;
color: inherit;
transition: all 0.2s;
}
.item-card:hover {
border-color: #1677ff;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.1);
transform: translateY(-2px);
}
.item-name {
font-size: 15px;
font-weight: 500;
color: #1a1a1a;
margin-bottom: 6px;
}
.item-path {
font-size: 12px;
color: #999;
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
}
.empty {
color: #ccc;
font-size: 14px;
padding: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>${escapeHtml(projectName)} <span>原型预览</span></h1>
<p>共 ${entries.length} 个页面 · 由 Axhub Make 导出</p>
</div>
<div class="content">
${prototypes.length > 0 ? ` <div class="section">
<div class="section-title">页面(${prototypes.length}</div>
<div class="grid">
${renderList(prototypes, 'prototypes')}
</div>
</div>` : ''}
${components.length > 0 ? ` <div class="section">
<div class="section-title">组件(${components.length}</div>
<div class="grid">
${renderList(components, 'components')}
</div>
</div>` : ''}
${entries.length === 0 ? ' <div class="empty">没有可预览的页面或组件</div>' : ''}
</div>
</body>
</html>`;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function sendJSON(res: any, status: number, data: any) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
}
function buildSingleEntry(projectRoot: string, entryKey: string) {
const buildResult = spawnSync('npx', ['vite', 'build'], {
cwd: projectRoot,
env: { ...process.env, ENTRY_KEY: entryKey },
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5 * 60 * 1000,
shell: true,
});
if (buildResult.status !== 0) {
const stderr = buildResult.stderr?.toString() || '';
const stdout = buildResult.stdout?.toString() || '';
throw new Error(stderr || stdout || `exit code ${buildResult.status}`);
}
}
function buildAllEntries(projectRoot: string) {
const buildScript = path.join(projectRoot, 'scripts', 'build-all.js');
const nodeCommand = process.platform === 'win32' ? 'node.exe' : 'node';
const buildResult = spawnSync(nodeCommand, [buildScript], {
cwd: projectRoot,
env: { ...process.env },
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5 * 60 * 1000,
});
if (buildResult.status !== 0) {
const stderr = buildResult.stderr?.toString() || '';
const stdout = buildResult.stdout?.toString() || '';
throw new Error(stderr || stdout || `exit code ${buildResult.status}`);
}
}
function sanitizeZipName(name: string) {
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
}
export function exportHtmlApiPlugin(): Plugin {
return {
name: 'export-html-api-plugin',
configureServer(server: any) {
server.middlewares.use(async (req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (req.method !== 'GET' || pathname !== '/api/export-html') {
return next();
}
const projectRoot = process.cwd();
try {
const requestUrl = new URL(req.url, 'http://127.0.0.1');
const targetPath = requestUrl.searchParams.get('path')?.trim() || '';
console.log('\n📦 [导出 HTML] 开始构建...');
// 1. 扫描并更新 entries
const scanned = scanProjectEntries(projectRoot, ['components', 'prototypes', 'themes']);
const manifest = writeEntriesManifestAtomic(projectRoot, scanned);
let entries: ExportEntry[] = [];
let singleEntry: ExportEntry | null = null;
if (targetPath) {
const item = manifest.items?.[targetPath];
if (!item || (item.group !== 'components' && item.group !== 'prototypes')) {
return sendJSON(res, 404, { error: '未找到可导出的原型或组件' });
}
console.log(`[导出 HTML] 构建单个入口: ${targetPath}`);
try {
buildSingleEntry(projectRoot, targetPath);
} catch (error: any) {
console.error('[导出 HTML] 单入口构建失败:', error);
return sendJSON(res, 500, { error: `构建失败: ${error.message || 'unknown error'}` });
}
entries = scanBuiltEntries(projectRoot, { includeRef: true });
singleEntry = entries.find((entry) => entry.key === targetPath) || null;
if (!singleEntry) {
return sendJSON(res, 500, { error: '构建完成但没有找到当前条目的 HTML 产物' });
}
console.log(`[导出 HTML] 单条目导出就绪: ${singleEntry.key}`);
} else {
console.log('[导出 HTML] 运行全量构建脚本...');
try {
buildAllEntries(projectRoot);
} catch (error: any) {
console.error('[导出 HTML] 全量构建失败:', error);
return sendJSON(res, 500, { error: `构建失败: ${error.message || 'unknown error'}` });
}
entries = scanBuiltEntries(projectRoot);
if (entries.length === 0) {
return sendJSON(res, 500, { error: '构建完成但没有找到可导出的页面' });
}
console.log(`[导出 HTML] 找到 ${entries.length} 个可导出入口`);
}
// 4. 获取项目名称
let projectName = 'Axhub Project';
try {
const pkgPath = path.join(projectRoot, 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
projectName = pkg.name || projectName;
}
} catch { /* ignore */ }
// 5. 创建 ZIP 流
const zipFileName = singleEntry
? `${sanitizeZipName(singleEntry.name)}-html.zip`
: `${sanitizeZipName(projectName)}-html.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', buildAttachmentContentDisposition(zipFileName));
const archive = archiver('zip', { zlib: { level: 6 } });
archive.on('warning', (warning: any) => {
console.warn('[导出 HTML] ZIP warning:', warning);
});
archive.on('error', (error: any) => {
console.error('[导出 HTML] ZIP error:', error);
if (!res.headersSent) {
sendJSON(res, 500, { error: `ZIP 创建失败: ${error.message}` });
} else {
res.end();
}
});
archive.pipe(res);
// 6. 添加构建产物 JS 文件
const distDir = path.join(projectRoot, 'dist');
const archiveEntries = singleEntry ? [singleEntry] : entries;
for (const entry of archiveEntries) {
const builtJsPath = path.join(distDir, entry.jsPath);
if (fs.existsSync(builtJsPath)) {
archive.file(builtJsPath, { name: entry.jsPath });
}
}
// 7. 添加 HTML 入口
if (singleEntry) {
const pageHtml = generatePageHtml(singleEntry, { includeBackLink: false, assetPrefix: '' });
archive.append(pageHtml, { name: 'index.html' });
} else {
const indexHtml = generateIndexHtml(entries, projectName);
archive.append(indexHtml, { name: 'index.html' });
for (const entry of entries) {
const pageHtml = generatePageHtml(entry);
const pageFileName = `pages/${entry.group}--${entry.name}.html`;
archive.append(pageHtml, { name: pageFileName });
}
}
// 8. 添加媒体资源(如果有)
const mediaDir = path.join(projectRoot, 'src', 'media');
if (fs.existsSync(mediaDir)) {
archive.directory(mediaDir, 'media');
}
await archive.finalize();
console.log('[导出 HTML] ✅ ZIP 导出完成');
} catch (error: any) {
console.error('[导出 HTML] 导出失败:', error);
if (!res.headersSent) {
sendJSON(res, 500, { error: error.message || '导出失败' });
}
}
});
},
};
}

View File

@@ -0,0 +1,99 @@
import type { Plugin } from 'vite';
import { getRequestPathname, serializeErrorForLog } from './utils/httpUtils';
import { isAllowedProxyImageUrl } from './utils/proxyUtils';
export function exportImageProxyPlugin(): Plugin {
return {
name: 'export-image-proxy-plugin',
configureServer(server: any) {
server.middlewares.use(async (req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (req.method !== 'GET' || pathname !== '/api/export/image-proxy') {
return next();
}
const requestUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const targetUrl = String(requestUrl.searchParams.get('url') || '').trim();
if (!targetUrl) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing url query parameter' }));
return;
}
if (!isAllowedProxyImageUrl(targetUrl)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Unsupported proxy target url' }));
return;
}
try {
const upstreamResponse = await fetch(targetUrl, {
method: 'GET',
redirect: 'follow',
headers: {
Accept: 'image/*,*/*;q=0.8',
'User-Agent': 'AxhubMakeExportProxy/1.0',
},
});
if (!upstreamResponse.ok) {
res.statusCode = upstreamResponse.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
error: `Upstream responded with ${upstreamResponse.status}`,
targetUrl,
}));
return;
}
const contentType = String(upstreamResponse.headers.get('content-type') || '').toLowerCase();
if (contentType && !contentType.startsWith('image/')) {
res.statusCode = 415;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
error: `Unsupported upstream content-type: ${contentType}`,
targetUrl,
}));
return;
}
const body = Buffer.from(await upstreamResponse.arrayBuffer());
res.statusCode = 200;
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', upstreamResponse.headers.get('cache-control') || 'public, max-age=600');
res.setHeader('Content-Type', contentType || 'application/octet-stream');
res.setHeader('Content-Length', String(body.byteLength));
const etag = upstreamResponse.headers.get('etag');
if (etag) {
res.setHeader('ETag', etag);
}
const lastModified = upstreamResponse.headers.get('last-modified');
if (lastModified) {
res.setHeader('Last-Modified', lastModified);
}
res.end(body);
} catch (error: any) {
console.error('[export-image-proxy] request failed', {
targetUrl,
error: serializeErrorForLog(error),
});
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
error: error?.message || 'Failed to fetch target image',
targetUrl,
}));
}
});
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
import type { Plugin } from 'vite';
export function forceInlineDynamicImportsOff(enable: boolean): Plugin {
return {
name: 'force-inline-dynamic-imports-off',
configResolved(config) {
if (!enable) {
return;
}
const output = config.build.rollupOptions.output;
const outputs = Array.isArray(output) ? output : output ? [output] : [];
outputs.forEach((item) => {
if (item) {
item.inlineDynamicImports = false;
}
});
}
};
}

View File

@@ -0,0 +1,635 @@
import type { Plugin } from 'vite';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs';
const execAsync = promisify(exec);
/**
* Git 版本管理 API 插件
* 提供基于 Git 的版本控制功能,用于页面和元素的文件夹级别版本管理
*/
export function gitVersionApiPlugin(): Plugin {
let gitAvailable = false;
let gitCheckError: string | null = null;
const resolveModuleFile = (basePath: string): string | null => {
const fileCandidates = [basePath, `${basePath}.ts`, `${basePath}.tsx`, `${basePath}.js`, `${basePath}.jsx`];
for (const candidate of fileCandidates) {
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
} catch {
// Ignore fs race/errors and continue probing other candidates.
}
}
const indexCandidates = [
path.join(basePath, 'index.ts'),
path.join(basePath, 'index.tsx'),
path.join(basePath, 'index.js'),
path.join(basePath, 'index.jsx'),
];
for (const candidate of indexCandidates) {
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
} catch {
// Ignore fs race/errors and continue probing other candidates.
}
}
return null;
};
return {
name: 'git-version-api',
/**
* 解决跨文件夹导入问题:
* 当从 .git-versions/{id}/src/... 中的文件发起相对导入时,如果目标文件
* 不在已提取的版本目录中(如 ../../components/side-menu则回退到
* 当前工作目录的 src/ 下去解析。
*/
resolveId(source, importer) {
if (!importer) return null;
const projectRoot = process.cwd();
const gitVersionsDir = path.join(projectRoot, '.git-versions');
const normalizedImporter = path.normalize(importer);
// 只处理来自 .git-versions 目录中的导入
if (!normalizedImporter.startsWith(gitVersionsDir)) return null;
// 解析相对路径导入
const resolved = path.resolve(path.dirname(normalizedImporter), source);
// 如果解析后的路径仍在 .git-versions 内且文件不存在,则回退到真实 src/
if (!resolved.startsWith(gitVersionsDir)) return null;
const versionResolvedPath = resolveModuleFile(resolved);
if (versionResolvedPath) {
return versionResolvedPath;
}
// 文件不存在 → 提取 .git-versions/{id}/ 之后的相对路径,映射到真实 src/
const relativeTail = path.relative(gitVersionsDir, resolved);
// relativeTail 形如 "67c09dc5/src/components/side-menu"
const slashIdx = relativeTail.indexOf(path.sep);
if (slashIdx < 0) return null;
const pathAfterVersionId = relativeTail.substring(slashIdx + 1);
// pathAfterVersionId 形如 "src/components/side-menu"
const realPath = path.join(projectRoot, pathAfterVersionId);
return resolveModuleFile(realPath);
},
configureServer(server) {
const projectRoot = process.cwd();
// 检查 Git 是否可用
(async () => {
try {
await execAsync('git --version', { cwd: projectRoot });
gitAvailable = true;
console.log('[Git 版本管理] ✅ Git 已就绪');
} catch (error: any) {
gitAvailable = false;
gitCheckError = error.message;
console.warn('[Git 版本管理] ⚠️ Git 未安装或不可用');
console.warn('[Git 版本管理] 💡 请安装 Git 以使用版本管理功能: https://git-scm.com/downloads');
}
})();
// 服务器启动时清理临时版本文件
const gitVersionsDir = path.join(projectRoot, '.git-versions');
if (fs.existsSync(gitVersionsDir)) {
try {
console.log('[Git 版本管理] 清理临时版本文件...');
fs.rmSync(gitVersionsDir, { recursive: true, force: true });
console.log('[Git 版本管理] ✅ 临时版本文件已清理');
} catch (error) {
console.error('[Git 版本管理] ⚠️ 清理临时文件失败:', error);
}
}
// Helper function to parse JSON body
const parseBody = (req: any): Promise<any> => {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk: any) => body += chunk);
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (e) {
reject(new Error('Invalid JSON in request body'));
}
});
req.on('error', reject);
});
};
// Helper function to send JSON response
const sendJSON = (res: any, statusCode: number, data: any) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
};
// Helper function to check git availability and return error response if not available
const checkGitAvailable = (res: any): boolean => {
if (!gitAvailable) {
sendJSON(res, 503, {
error: 'Git 未安装或不可用',
message: '版本管理功能需要 Git 支持。请先安装 Git 后重启开发服务器。',
details: gitCheckError || undefined
});
return false;
}
return true;
};
// Helper function to execute git command
const execGit = async (command: string, cwd: string) => {
try {
const { stdout, stderr } = await execAsync(command, { cwd, maxBuffer: 1024 * 1024 * 10 });
return { stdout: stdout.trim(), stderr: stderr.trim() };
} catch (error: any) {
// 提供更友好的错误信息
if (error.message.includes('not a git repository')) {
throw new Error('当前项目不是 Git 仓库。请先运行 "git init" 初始化仓库。');
}
throw new Error(`Git 命令执行失败: ${error.message}`);
}
};
const normalizeTargetPath = (rawTargetPath: string, projectRoot: string) => {
const sourceRoot = path.resolve(projectRoot, 'src');
const trimmedPath = String(rawTargetPath || '').trim();
if (!trimmedPath) {
throw new Error('Missing path parameter');
}
let normalizedPath = trimmedPath.replace(/\\/g, '/');
const normalizedProjectRoot = projectRoot.replace(/\\/g, '/').replace(/\/+$/, '');
const normalizedSourceRoot = sourceRoot.replace(/\\/g, '/').replace(/\/+$/, '');
if (normalizedPath.startsWith(normalizedSourceRoot + '/')) {
normalizedPath = normalizedPath.slice(normalizedSourceRoot.length + 1);
} else if (normalizedPath.startsWith(normalizedProjectRoot + '/src/')) {
normalizedPath = normalizedPath.slice(normalizedProjectRoot.length + '/src/'.length);
} else {
const srcMarkerIndex = normalizedPath.lastIndexOf('/src/');
if (srcMarkerIndex >= 0) {
normalizedPath = normalizedPath.slice(srcMarkerIndex + '/src/'.length);
} else if (normalizedPath.startsWith('src/')) {
normalizedPath = normalizedPath.slice('src/'.length);
}
}
normalizedPath = normalizedPath
.replace(/^\/+/, '')
.replace(/\/index\.(t|j)sx?$/i, '')
.replace(/\/+$/, '');
if (!normalizedPath) {
throw new Error('Invalid path');
}
const segments = normalizedPath.split('/').filter(Boolean);
if (segments.length === 0 || segments.some((segment) => segment === '.' || segment === '..')) {
throw new Error('Invalid path');
}
const resolvedFolderPath = path.resolve(sourceRoot, normalizedPath);
const relativeToSourceRoot = path.relative(sourceRoot, resolvedFolderPath);
if (!relativeToSourceRoot || relativeToSourceRoot.startsWith('..') || path.isAbsolute(relativeToSourceRoot)) {
throw new Error('Invalid path');
}
return {
sourceRoot,
targetPath: segments.join('/'),
folderPath: resolvedFolderPath,
};
};
// Main middleware for git version API
server.middlewares.use(async (req: any, res: any, next: any) => {
// Only handle /api/git/* routes
if (!req.url.startsWith('/api/git')) {
return next();
}
try {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
// Route: GET /api/git/history - Get git history for a folder
if (pathname === '/api/git/history' && req.method === 'GET') {
if (!checkGitAvailable(res)) return;
const rawTargetPath = url.searchParams.get('path'); // e.g., 'prototypes/home' or 'components/button'
if (!rawTargetPath) {
sendJSON(res, 400, { error: 'Missing path parameter' });
return;
}
const projectRoot = process.cwd();
let targetPath = '';
let folderPath = '';
try {
({ targetPath, folderPath } = normalizeTargetPath(rawTargetPath, projectRoot));
} catch (error: any) {
sendJSON(res, 403, { error: 'Invalid path' });
return;
}
if (!fs.existsSync(folderPath)) {
sendJSON(res, 404, { error: 'Folder not found' });
return;
}
// Get git log for the folder (last 20 commits)
const gitCommand = `git log -20 --pretty=format:'%H|%an|%ae|%at|%s' -- src/${targetPath}`;
const { stdout } = await execGit(gitCommand, projectRoot);
if (!stdout) {
sendJSON(res, 200, { commits: [], hasUncommitted: false });
return;
}
const commits = stdout.split('\n').map(line => {
const [hash, author, email, timestamp, message] = line.split('|');
return {
hash,
author,
email,
timestamp: parseInt(timestamp) * 1000, // Convert to milliseconds
message,
date: new Date(parseInt(timestamp) * 1000).toISOString()
};
});
// Check for uncommitted changes in the folder
const statusCommand = `git status --porcelain -- src/${targetPath}`;
const { stdout: statusOutput } = await execGit(statusCommand, projectRoot);
const hasUncommitted = statusOutput.length > 0;
sendJSON(res, 200, { commits, hasUncommitted, uncommittedFiles: statusOutput });
return;
}
// Route: POST /api/git/restore - Restore folder to a specific commit
if (pathname === '/api/git/restore' && req.method === 'POST') {
if (!checkGitAvailable(res)) return;
const body = await parseBody(req);
const { path: rawTargetPath, commitHash } = body;
if (!rawTargetPath || !commitHash) {
sendJSON(res, 400, { error: 'Missing path or commitHash parameter' });
return;
}
const projectRoot = process.cwd();
let targetPath = '';
let folderPath = '';
try {
({ targetPath, folderPath } = normalizeTargetPath(rawTargetPath, projectRoot));
} catch (error: any) {
sendJSON(res, 403, { error: 'Invalid path' });
return;
}
if (!fs.existsSync(folderPath)) {
sendJSON(res, 404, { error: 'Folder not found' });
return;
}
// Verify commit exists
const verifyCommand = `git cat-file -t ${commitHash}`;
try {
await execGit(verifyCommand, projectRoot);
} catch (error) {
sendJSON(res, 400, { error: 'Invalid commit hash' });
return;
}
// Restore folder to specific commit
const restoreCommand = `git checkout ${commitHash} -- src/${targetPath}`;
await execGit(restoreCommand, projectRoot);
sendJSON(res, 200, { success: true, message: 'Folder restored successfully' });
return;
}
// Route: POST /api/git/commit - Commit changes for a folder
if (pathname === '/api/git/commit' && req.method === 'POST') {
if (!checkGitAvailable(res)) return;
const body = await parseBody(req);
const { path: rawTargetPath, message } = body;
if (!rawTargetPath || !message) {
sendJSON(res, 400, { error: 'Missing path or message parameter' });
return;
}
const projectRoot = process.cwd();
let targetPath = '';
let folderPath = '';
try {
({ targetPath, folderPath } = normalizeTargetPath(rawTargetPath, projectRoot));
} catch (error: any) {
sendJSON(res, 403, { error: 'Invalid path' });
return;
}
if (!fs.existsSync(folderPath)) {
sendJSON(res, 404, { error: 'Folder not found' });
return;
}
// Check if there are changes to commit
const statusCommand = `git status --porcelain -- src/${targetPath}`;
const { stdout: statusOutput } = await execGit(statusCommand, projectRoot);
if (!statusOutput) {
sendJSON(res, 400, { error: 'No changes to commit' });
return;
}
// Add and commit changes
const addCommand = `git add src/${targetPath}`;
await execGit(addCommand, projectRoot);
const commitCommand = `git commit -m "${message.replace(/"/g, '\\"')}"`;
const { stdout: commitOutput } = await execGit(commitCommand, projectRoot);
sendJSON(res, 200, { success: true, message: 'Changes committed successfully', output: commitOutput });
return;
}
// Route: GET /api/git/diff - Get diff for uncommitted changes
if (pathname === '/api/git/diff' && req.method === 'GET') {
if (!checkGitAvailable(res)) return;
const rawTargetPath = url.searchParams.get('path');
if (!rawTargetPath) {
sendJSON(res, 400, { error: 'Missing path parameter' });
return;
}
const projectRoot = process.cwd();
let targetPath = '';
let folderPath = '';
try {
({ targetPath, folderPath } = normalizeTargetPath(rawTargetPath, projectRoot));
} catch (error: any) {
sendJSON(res, 403, { error: 'Invalid path' });
return;
}
if (!fs.existsSync(folderPath)) {
sendJSON(res, 404, { error: 'Folder not found' });
return;
}
// Get diff for uncommitted changes
const diffCommand = `git diff -- src/${targetPath}`;
const { stdout: diffOutput } = await execGit(diffCommand, projectRoot);
// Get list of changed files
const statusCommand = `git status --porcelain -- src/${targetPath}`;
const { stdout: statusOutput } = await execGit(statusCommand, projectRoot);
const changedFiles = statusOutput.split('\n').filter(line => line.trim()).map(line => {
const status = line.substring(0, 2).trim();
const file = line.substring(3);
return { status, file };
});
sendJSON(res, 200, { diff: diffOutput, changedFiles });
return;
}
// Route: POST /api/git/build-version - Extract version files (no build)
if (pathname === '/api/git/build-version' && req.method === 'POST') {
if (!checkGitAvailable(res)) return;
const body = await parseBody(req);
const { path: rawTargetPath, commitHash } = body;
if (!rawTargetPath || !commitHash) {
sendJSON(res, 400, { error: 'Missing path or commitHash parameter' });
return;
}
const projectRoot = process.cwd();
let targetPath = '';
let folderPath = '';
try {
({ targetPath, folderPath } = normalizeTargetPath(rawTargetPath, projectRoot));
} catch (error: any) {
sendJSON(res, 403, { error: 'Invalid path' });
return;
}
if (!fs.existsSync(folderPath)) {
sendJSON(res, 404, { error: 'Folder not found' });
return;
}
try {
// Create temporary directory for version files
const versionId = commitHash.substring(0, 8);
const tempDir = path.join(projectRoot, '.git-versions', versionId);
const srcPath = path.join(tempDir, 'src', targetPath);
// Check if already extracted
if (fs.existsSync(srcPath)) {
console.log('[Git 版本管理] 使用已提取的版本:', versionId);
const specPath = path.join(srcPath, 'spec.md');
const indexPath = path.join(srcPath, 'index.tsx');
const hasSpec = fs.existsSync(specPath);
const hasPrototype = fs.existsSync(indexPath);
sendJSON(res, 200, {
success: true,
versionId,
hasSpec,
hasPrototype,
specUrl: hasSpec ? `/${targetPath}/spec.html?ver=${versionId}` : null,
prototypeUrl: hasPrototype ? `/${targetPath}/index.html?ver=${versionId}` : null,
cached: true
});
return;
}
fs.mkdirSync(srcPath, { recursive: true });
console.log('[Git 版本管理] 开始提取版本文件:', versionId, targetPath);
// Get list of files in the commit
const listCommand = `git ls-tree -r --name-only ${commitHash} src/${targetPath}`;
const { stdout: fileList } = await execGit(listCommand, projectRoot);
if (!fileList.trim()) {
sendJSON(res, 404, { error: 'No files found in this version' });
return;
}
// Extract each file
const files = fileList.trim().split('\n');
for (const file of files) {
const targetFile = path.join(tempDir, file);
const targetDir = path.dirname(targetFile);
// Create directory if needed
fs.mkdirSync(targetDir, { recursive: true });
// Get file content from git
const showCommand = `git show ${commitHash}:${file}`;
const { stdout: content } = await execGit(showCommand, projectRoot);
fs.writeFileSync(targetFile, content);
}
console.log('[Git 版本管理] ✅ 文件提取完成');
// Check what files exist in the version
const specPath = path.join(srcPath, 'spec.md');
const indexPath = path.join(srcPath, 'index.tsx');
const hasSpec = fs.existsSync(specPath);
const hasPrototype = fs.existsSync(indexPath);
// Generate URLs for accessing the version
const specUrl = hasSpec ? `/${targetPath}/spec.html?ver=${versionId}` : null;
const prototypeUrl = hasPrototype ? `/${targetPath}/index.html?ver=${versionId}` : null;
sendJSON(res, 200, {
success: true,
versionId,
hasSpec,
hasPrototype,
specUrl,
prototypeUrl
});
} catch (error: any) {
console.error('Extract version error:', error);
sendJSON(res, 500, { error: error.message || 'Failed to extract version' });
}
return;
}
// Route: GET /api/git/version-file - Get file from specific version
if (pathname.startsWith('/api/git/version-file/') && req.method === 'GET') {
try {
// Parse URL: /api/git/version-file/{versionId}/{path}/spec.md
const parts = pathname.replace('/api/git/version-file/', '').split('/');
if (parts.length < 3) {
sendJSON(res, 400, { error: 'Invalid URL format' });
return;
}
const versionId = parts[0];
const fileName = parts[parts.length - 1];
const targetPath = parts.slice(1, -1).join('/');
// Validate
if (targetPath.includes('..') || targetPath.startsWith('/')) {
sendJSON(res, 403, { error: 'Invalid path' });
return;
}
const projectRoot = process.cwd();
const tempDir = path.join(projectRoot, '.git-versions', versionId);
const filePath = path.join(tempDir, 'src', targetPath, fileName);
if (!fs.existsSync(filePath)) {
sendJSON(res, 404, { error: 'File not found in version' });
return;
}
const content = fs.readFileSync(filePath, 'utf8');
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.end(content);
} catch (error: any) {
console.error('Get version file error:', error);
sendJSON(res, 500, { error: error.message });
}
return;
}
// Route: GET /api/git/status - Check git repository status
if (pathname === '/api/git/status' && req.method === 'GET') {
// 对于 status 接口,即使 Git 不可用也要返回状态信息
if (!gitAvailable) {
sendJSON(res, 200, {
initialized: false,
isGitRepo: false,
gitAvailable: false,
error: 'Git 未安装或不可用',
message: '版本管理功能需要 Git 支持。请先安装 Git 后重启开发服务器。'
});
return;
}
const projectRoot = process.cwd();
try {
// Check if git is initialized
const { stdout: branchOutput } = await execGit('git branch --show-current', projectRoot);
const currentBranch = branchOutput || 'main';
// Get repository status
const { stdout: statusOutput } = await execGit('git status --porcelain', projectRoot);
const hasChanges = statusOutput.length > 0;
sendJSON(res, 200, {
initialized: true,
currentBranch,
hasChanges,
isGitRepo: true,
gitAvailable: true
});
} catch (error: any) {
sendJSON(res, 200, {
initialized: false,
isGitRepo: false,
gitAvailable: true,
error: 'Git 仓库未初始化',
message: error.message.includes('not a git repository')
? '当前项目不是 Git 仓库。请先运行 "git init" 初始化仓库。'
: error.message
});
}
return;
}
// No route matched
sendJSON(res, 404, { error: 'API endpoint not found' });
} catch (error: any) {
console.error('Git version API error:', error);
sendJSON(res, 500, { error: error.message || 'Internal server error' });
}
});
}
};
}

View File

@@ -0,0 +1,113 @@
import type { Plugin } from 'vite';
import { createHash } from 'crypto';
import path from 'path';
/**
* 为每个页面/组件文件注入稳定的唯一 ID
* 基于文件相对路径生成,保证构建间稳定不变
*
* 生成的 ID 格式:{项目名}-{目录类型}-{项目名称}-{16位哈希}
* 例如axhub-make-prototypes-demo-antd-a1b2c3d4e5f6g7h8
*
* 规则:
* - 忽略 index.tsx 中的 index 部分(因为都是同一个,没有意义)
* - 使用项目目录名(如 axhub-make
* - 根据 components 和 prototypes 目录区分
* - 加上文件项目名称
*/
export function injectStablePageIds(): Plugin {
const cwd = process.cwd();
// 从 cwd 获取项目目录名(如 axhub-make
const projectName = path.basename(cwd);
return {
name: 'inject-stable-page-ids',
enforce: 'pre',
transform(code, id) {
// 只处理 tsx/jsx 文件
if (!/\.(tsx|jsx)$/.test(id)) return null;
// 获取相对路径
const relativePath = path.relative(cwd, id).replace(/\\/g, '/');
// 生成长哈希16位 SHA-256极低重复风险
const longHash = createHash('sha256')
.update(relativePath)
.digest('hex')
.slice(0, 16);
// 解析路径src/prototypes/demo-antd/index.tsx 或 src/components/demo-button/index.tsx
// 目标格式axhub-make-prototypes-demo-antd 或 axhub-make-components-demo-button
// 规则:忽略 index.tsx 中的 index 部分,使用目录名作为项目名称
const pathParts = relativePath
.replace(/^src\//, '') // 移除 src/ 前缀
.replace(/\.(tsx|jsx)$/, '') // 移除文件扩展名
.split('/');
// 查找 prototypes 或 components 目录
const categoryIndex = pathParts.findIndex(part => part === 'prototypes' || part === 'components');
let readableId: string;
if (categoryIndex >= 0 && categoryIndex < pathParts.length - 1) {
// 找到目录类型prototypes 或 components
const category = pathParts[categoryIndex];
// 获取项目名称category 后面的第一个非 index 部分)
// 例如prototypes/demo-antd/index -> demo-antd
// prototypes/demo-antd/some-file -> demo-antd
// prototypes/index -> '' (空,使用降级方案)
let itemName = '';
// 从 category 后面开始查找项目名称
for (let i = categoryIndex + 1; i < pathParts.length; i++) {
const part = pathParts[i];
// 如果遇到 index跳过它继续查找下一级
if (part === 'index') {
continue;
}
// 找到第一个非 index 的部分,作为项目名称
itemName = part;
break;
}
// 组合:项目名-目录类型-项目名称
readableId = `${projectName}-${category}${itemName ? '-' + itemName : ''}`;
} else {
// 降级方案:如果路径不符合预期,使用原来的逻辑但移除 index
readableId = relativePath
.replace(/^src\//, '')
.replace(/\.(tsx|jsx)$/, '')
.replace(/\/index$/, '') // 移除末尾的 /index
.replace(/[\/\.]/g, '-')
.replace(/^-+|-+$/g, '') // 移除首尾的连字符
.slice(0, 48);
// 如果 readableId 不以项目名开头,则添加
if (!readableId.startsWith(projectName)) {
readableId = `${projectName}-${readableId}`;
}
}
// 组合 ID可读路径 + 哈希
const stableId = `${readableId}-${longHash}`;
// 注入全局常量,组件中可直接使用
const injectedCode = `
// Auto-injected by vite-plugin-inject-stable-page-ids
const __PAGE_ID__ = '${stableId}';
const __PAGE_PATH__ = '${readableId}';
const __PAGE_FULL_PATH__ = '${relativePath}';
const __PAGE_HASH__ = '${longHash}';
${code}
`.trim();
return {
code: injectedCode,
map: null
};
}
};
}

View File

@@ -0,0 +1,104 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { MAKE_CONFIG_RELATIVE_PATH } from './utils/makeConstants';
export function lanAccessControlPlugin(): Plugin {
let allowLAN = true;
return {
name: 'lan-access-control',
configResolved() {
const configPath = path.resolve(process.cwd(), MAKE_CONFIG_RELATIVE_PATH);
if (fs.existsSync(configPath)) {
try {
const axhubConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
allowLAN = axhubConfig.server?.allowLAN !== false;
console.log(`🔒 局域网访问控制: ${allowLAN ? '允许' : '禁止'}`);
} catch {
// Ignore config parse errors and keep default.
}
}
},
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
if (allowLAN) {
return next();
}
const clientIP = req.socket.remoteAddress || req.connection.remoteAddress;
const localIPs = [
'127.0.0.1',
'::1',
'::ffff:127.0.0.1',
'localhost',
];
const isLocalAccess = localIPs.some((ip) => clientIP?.includes(ip));
if (!isLocalAccess) {
res.statusCode = 403;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>访问被拒绝</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
text-align: center;
max-width: 500px;
}
h1 {
color: #e74c3c;
margin: 0 0 20px 0;
}
p {
color: #666;
line-height: 1.6;
}
.ip {
background: #f5f5f5;
padding: 10px;
border-radius: 5px;
font-family: monospace;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🚫 访问被拒绝</h1>
<p>此服务器已禁用局域网访问。</p>
<p>只允许本地访问localhost/127.0.0.1)。</p>
<div class="ip">您的 IP: ${clientIP}</div>
<p style="font-size: 12px; color: #999;">
如需允许局域网访问,请在配置文件中设置 allowLAN: true 并重启服务器
</p>
</div>
</body>
</html>
`);
return;
}
next();
});
},
};
}

View File

@@ -0,0 +1,419 @@
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs/promises';
import * as path from 'path';
import {
validateIdField,
validateDataTypes,
checkDuplicateId,
validateRecordForInsert,
validateRecordForUpdate,
validateCSVData
} from './validation';
/**
* Data record interface - all records must have an id field
* ID can be either string or number to support both UUID and integer IDs
*/
export interface DataRecord {
id: string | number;
[key: string]: any;
}
/**
* Table metadata structure stored in JSON files
*/
export interface TableMetadata {
tableName: string;
records: DataRecord[];
}
/**
* LowDB Service for managing data tables
*/
export class LowDBService {
private dbPath: string;
private dbCache: Map<string, Low<TableMetadata>> = new Map();
private locks: Map<string, Promise<void>> = new Map();
constructor(dbPath: string = 'src/database') {
this.dbPath = dbPath;
}
/**
* Acquire a lock for a specific file to ensure atomic operations
* This prevents concurrent write conflicts by serializing operations on the same file
*/
private async acquireLock(fileName: string): Promise<() => void> {
// Wait for any existing lock to be released
while (this.locks.has(fileName)) {
await this.locks.get(fileName);
}
// Create a new lock
let releaseLock: () => void;
const lockPromise = new Promise<void>((resolve) => {
releaseLock = resolve;
});
this.locks.set(fileName, lockPromise);
// Return the release function
return () => {
this.locks.delete(fileName);
releaseLock!();
};
}
/**
* Execute an operation with file locking to ensure concurrent safety
*/
private async withLock<T>(fileName: string, operation: () => Promise<T>): Promise<T> {
const release = await this.acquireLock(fileName);
try {
return await operation();
} finally {
release();
}
}
/**
* Initialize the database directory
*/
async initialize(): Promise<void> {
try {
await fs.access(this.dbPath);
} catch {
// Directory doesn't exist, create it
await fs.mkdir(this.dbPath, { recursive: true });
}
}
/**
* Get database instance for a table
*/
private async getDB(fileName: string): Promise<Low<TableMetadata>> {
if (this.dbCache.has(fileName)) {
const db = this.dbCache.get(fileName)!;
await db.read();
return db;
}
const filePath = path.join(this.dbPath, `${fileName}.json`);
const adapter = new JSONFile<TableMetadata>(filePath);
const defaultData: TableMetadata = {
tableName: fileName,
records: []
};
const db = new Low(adapter, defaultData);
await db.read();
this.dbCache.set(fileName, db);
return db;
}
/**
* Get all table names (file names without .json extension)
*/
async getAllTables(): Promise<Array<{ fileName: string; tableName: string }>> {
await this.initialize();
const files = await fs.readdir(this.dbPath);
const jsonFiles = files.filter(file => file.endsWith('.json'));
const tables = await Promise.all(
jsonFiles.map(async (file) => {
const fileName = file.replace('.json', '');
const db = await this.getDB(fileName);
return {
fileName,
tableName: db.data.tableName
};
})
);
return tables;
}
/**
* Get all records from a table
*/
async getTable(fileName: string): Promise<DataRecord[]> {
const db = await this.getDB(fileName);
return db.data.records;
}
/**
* Get a single record by id
*/
async getRecord(fileName: string, id: string | number): Promise<DataRecord | undefined> {
const db = await this.getDB(fileName);
// Support both string and number comparison
return db.data.records.find(record => record.id == id);
}
/**
* Create a new table with a sample record
*/
async createTable(fileName: string, tableName?: string): Promise<void> {
return this.withLock(fileName, async () => {
const db = await this.getDB(fileName);
// Create a sample record to demonstrate the format
const sampleRecord: DataRecord = {
id: uuidv4(),
name: '示例数据',
description: '这是一条示例数据,用于演示数据格式',
createdAt: new Date().toISOString()
};
db.data = {
tableName: tableName || fileName,
records: [sampleRecord]
};
await db.write();
});
}
/**
* Update table name
*/
async updateTableName(fileName: string, newTableName: string): Promise<void> {
return this.withLock(fileName, async () => {
const db = await this.getDB(fileName);
db.data.tableName = newTableName;
await db.write();
});
}
/**
* Delete a table
*/
async deleteTable(fileName: string): Promise<void> {
return this.withLock(fileName, async () => {
const filePath = path.join(this.dbPath, `${fileName}.json`);
await fs.unlink(filePath);
this.dbCache.delete(fileName);
});
}
/**
* Insert a new record
*/
async insertData(fileName: string, data: Omit<DataRecord, 'id'>): Promise<DataRecord> {
return this.withLock(fileName, async () => {
const db = await this.getDB(fileName);
// Validate data before insertion
validateRecordForInsert(data);
// Generate ID if not provided, or validate if provided
let recordId: string;
if ('id' in data && data.id) {
// ID provided - validate and check for duplicates
validateIdField(data.id);
checkDuplicateId(data.id, db.data.records);
recordId = data.id;
} else {
// No ID - generate new UUID
recordId = uuidv4();
}
const newRecord: DataRecord = {
...data,
id: recordId
};
db.data.records.push(newRecord);
await db.write();
return newRecord;
});
}
/**
* Update an existing record
*/
async updateData(fileName: string, id: string | number, data: Partial<DataRecord>): Promise<DataRecord> {
return this.withLock(fileName, async () => {
const db = await this.getDB(fileName);
// Validate the update data
validateRecordForUpdate(data);
// If trying to change ID, validate and check for duplicates
if (data.id && data.id != id) {
validateIdField(data.id);
checkDuplicateId(data.id, db.data.records, id);
}
const index = db.data.records.findIndex(record => record.id == id);
if (index === -1) {
throw new Error(`Record with id ${id} not found`);
}
// Merge the update data with existing record
db.data.records[index] = {
...db.data.records[index],
...data,
id // Ensure id cannot be changed (keep original)
};
await db.write();
return db.data.records[index];
});
}
/**
* Delete a record
*/
async deleteData(fileName: string, id: string | number): Promise<void> {
return this.withLock(fileName, async () => {
const db = await this.getDB(fileName);
const index = db.data.records.findIndex(record => record.id == id);
if (index === -1) {
throw new Error(`Record with id ${id} not found`);
}
db.data.records.splice(index, 1);
await db.write();
});
}
/**
* Batch insert records
*/
async batchInsert(fileName: string, dataList: Array<Omit<DataRecord, 'id'>>): Promise<DataRecord[]> {
return this.withLock(fileName, async () => {
const db = await this.getDB(fileName);
// Validate all records first
dataList.forEach((data, index) => {
try {
validateRecordForInsert(data);
} catch (e: any) {
throw new Error(`Validation failed for record ${index}: ${e.message}`);
}
});
const newRecords: DataRecord[] = dataList.map((data, index) => {
let recordId: string;
if ('id' in data && data.id) {
// ID provided - validate and check for duplicates
validateIdField(data.id);
// Check against existing records and previously processed records
const allRecords = [...db.data.records, ...newRecords.slice(0, index)];
checkDuplicateId(data.id, allRecords);
recordId = data.id;
} else {
// No ID - generate new UUID
recordId = uuidv4();
}
return {
...data,
id: recordId
};
});
db.data.records.push(...newRecords);
await db.write();
return newRecords;
});
}
/**
* Replace all table data (used for CSV import)
*/
async replaceTableData(fileName: string, records: DataRecord[]): Promise<void> {
return this.withLock(fileName, async () => {
const db = await this.getDB(fileName);
db.data.records = records;
await db.write();
});
}
/**
* Import CSV data with upsert logic
* - Records with id: update if exists, create if not
* - Records without id: generate new UUID and create
* - Replace mode: completely replace all table data with imported data
*/
async importCSV(fileName: string, csvData: any[]): Promise<DataRecord[]> {
return this.withLock(fileName, async () => {
const db = await this.getDB(fileName);
// Validate CSV data
validateCSVData(csvData);
// Track IDs to detect duplicates within the CSV
const seenIds = new Set<string>();
// Process each row from CSV
const records: DataRecord[] = csvData.map((row, index) => {
// Check if row has a valid ID (string or number)
const hasValidId = row.id !== undefined &&
row.id !== null &&
row.id !== '' &&
(typeof row.id === 'string' || typeof row.id === 'number');
if (hasValidId) {
// Has ID - validate it
validateIdField(row.id);
// Convert ID to string for comparison to avoid type issues
const idStr = String(row.id);
// Check for duplicate within CSV
if (seenIds.has(idStr)) {
throw new Error(`Duplicate id '${row.id}' found in CSV at row ${index + 1}`);
}
seenIds.add(idStr);
return {
...row,
id: row.id // Keep original type (string or number)
};
} else {
// No ID or empty ID - generate new UUID
const { id, ...rest } = row; // Remove empty id field if exists
const newId = uuidv4();
seenIds.add(newId);
return {
id: newId,
...rest
};
}
});
// Replace entire table with imported data (覆盖模式)
db.data.records = records;
await db.write();
return records;
});
}
/**
* Check if a table exists
*/
async tableExists(fileName: string): Promise<boolean> {
const filePath = path.join(this.dbPath, `${fileName}.json`);
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
}
// Export a singleton instance
export const lowdbService = new LowDBService();

View File

@@ -0,0 +1,280 @@
import type { Plugin } from 'vite';
import * as fs from 'fs';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import formidable from 'formidable';
interface MediaAsset {
id: string;
name: string;
path: string;
type: 'image' | 'audio' | 'animation' | 'folder';
size?: number;
mimeType?: string;
createdAt: string;
parentPath?: string;
}
/**
* Media Management API Plugin
* Provides API endpoints for managing media assets (images, audio, animations, folders)
*/
export function mediaManagementApiPlugin(): Plugin {
let mediaDir: string;
return {
name: 'media-management-api',
configureServer(server) {
// Set media directory path
mediaDir = path.join(process.cwd(), 'assets', 'media');
// Ensure media directory exists
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
// Helper functions
const sendJSON = (res: any, statusCode: number, data: any) => {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
};
const sendError = (res: any, statusCode: number, message: string) => {
sendJSON(res, statusCode, { error: message, timestamp: new Date().toISOString() });
};
const getAssetType = (filePath: string, isDirectory: boolean): MediaAsset['type'] => {
if (isDirectory) return 'folder';
const ext = path.extname(filePath).toLowerCase();
const mimeTypes: Record<string, MediaAsset['type']> = {
'.jpg': 'image',
'.jpeg': 'image',
'.png': 'image',
'.gif': 'image',
'.webp': 'image',
'.svg': 'image',
'.mp3': 'audio',
'.wav': 'audio',
'.ogg': 'audio',
'.m4a': 'audio',
'.json': 'animation',
};
return mimeTypes[ext] || 'image';
};
const scanDirectory = (dirPath: string, relativePath: string = ''): MediaAsset[] => {
const assets: MediaAsset[] = [];
try {
const items = fs.readdirSync(dirPath);
for (const item of items) {
// Skip hidden files
if (item.startsWith('.')) continue;
const fullPath = path.join(dirPath, item);
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
const stats = fs.statSync(fullPath);
const asset: MediaAsset = {
id: uuidv4(),
name: item,
path: itemRelativePath,
type: getAssetType(fullPath, stats.isDirectory()),
createdAt: stats.birthtime.toISOString(),
parentPath: relativePath || undefined,
};
if (!stats.isDirectory()) {
asset.size = stats.size;
asset.mimeType = getMimeType(fullPath);
}
assets.push(asset);
}
} catch (error: any) {
console.error('Error scanning directory:', error);
}
return assets;
};
const getMimeType = (filePath: string): string => {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.m4a': 'audio/mp4',
'.json': 'application/json',
};
return mimeTypes[ext] || 'application/octet-stream';
};
// Middleware to handle API routes
server.middlewares.use(async (req, res, next) => {
const url = req.url || '';
// GET /api/media - List media assets
if (url.startsWith('/api/media') && req.method === 'GET') {
try {
const urlObj = new URL(url, `http://${req.headers.host}`);
const requestedPath = urlObj.searchParams.get('path') || '';
const targetDir = requestedPath
? path.join(mediaDir, requestedPath)
: mediaDir;
if (!fs.existsSync(targetDir)) {
return sendError(res, 404, 'Directory not found');
}
const assets = scanDirectory(targetDir, requestedPath);
sendJSON(res, 200, assets);
} catch (error: any) {
console.error('Error listing media:', error);
sendError(res, 500, error.message || 'Failed to list media assets');
}
return;
}
// POST /api/media/upload - Upload file
if (url === '/api/media/upload' && req.method === 'POST') {
try {
const form = formidable({
uploadDir: mediaDir,
keepExtensions: true,
maxFileSize: 50 * 1024 * 1024, // 50MB
});
form.parse(req, (err, fields, files) => {
if (err) {
return sendError(res, 400, 'Upload failed: ' + err.message);
}
try {
const file = Array.isArray(files.file) ? files.file[0] : files.file;
if (!file) {
return sendError(res, 400, 'No file uploaded');
}
const targetPath = Array.isArray(fields.path) ? fields.path[0] : fields.path;
const targetDir = targetPath ? path.join(mediaDir, targetPath) : mediaDir;
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const finalPath = path.join(targetDir, file.originalFilename || file.newFilename);
fs.renameSync(file.filepath, finalPath);
sendJSON(res, 200, {
message: 'File uploaded successfully',
path: path.relative(mediaDir, finalPath)
});
} catch (error: any) {
sendError(res, 500, 'Failed to save file: ' + error.message);
}
});
} catch (error: any) {
sendError(res, 500, error.message || 'Upload failed');
}
return;
}
// POST /api/media/folder - Create folder
if (url === '/api/media/folder' && req.method === 'POST') {
try {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const { name, parentPath } = JSON.parse(body);
if (!name || !name.trim()) {
return sendError(res, 400, 'Folder name is required');
}
const targetDir = parentPath
? path.join(mediaDir, parentPath, name)
: path.join(mediaDir, name);
if (fs.existsSync(targetDir)) {
return sendError(res, 400, 'Folder already exists');
}
fs.mkdirSync(targetDir, { recursive: true });
sendJSON(res, 200, {
message: 'Folder created successfully',
path: path.relative(mediaDir, targetDir)
});
} catch (error: any) {
sendError(res, 400, 'Invalid request: ' + error.message);
}
});
} catch (error: any) {
sendError(res, 500, error.message || 'Failed to create folder');
}
return;
}
// DELETE /api/media/:path - Delete file or folder
if (url.startsWith('/api/media/') && req.method === 'DELETE') {
try {
const assetPath = decodeURIComponent(url.replace('/api/media/', ''));
const fullPath = path.join(mediaDir, assetPath);
if (!fs.existsSync(fullPath)) {
return sendError(res, 404, 'Asset not found');
}
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
fs.rmSync(fullPath, { recursive: true, force: true });
} else {
fs.unlinkSync(fullPath);
}
sendJSON(res, 200, { message: 'Asset deleted successfully' });
} catch (error: any) {
console.error('Error deleting asset:', error);
sendError(res, 500, error.message || 'Failed to delete asset');
}
return;
}
// GET /api/media/file/:path - Serve file
if (url.startsWith('/api/media/file/') && req.method === 'GET') {
try {
const assetPath = decodeURIComponent(url.replace('/api/media/file/', ''));
const fullPath = path.join(mediaDir, assetPath);
if (!fs.existsSync(fullPath) || fs.statSync(fullPath).isDirectory()) {
return sendError(res, 404, 'File not found');
}
const mimeType = getMimeType(fullPath);
res.setHeader('Content-Type', mimeType);
fs.createReadStream(fullPath).pipe(res);
} catch (error: any) {
console.error('Error serving file:', error);
sendError(res, 500, error.message || 'Failed to serve file');
}
return;
}
next();
});
}
};
}

View File

@@ -0,0 +1,270 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { getLocalIP, getRequestPathname } from './utils/httpUtils';
import { MAKE_ENTRIES_RELATIVE_PATH } from './utils/makeConstants';
import { buildDocApiPath } from './utils/docUtils';
function readInjectedHtml(htmlPath: string, injectScript: string) {
let html = fs.readFileSync(htmlPath, 'utf8');
html = html.replace('</head>', `${injectScript}\n</head>`);
return html;
}
function setNoStoreHeaders(res: any) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
function setImmutableAssetHeaders(res: any) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
function setImageCacheHeaders(res: any) {
res.setHeader('Cache-Control', 'public, max-age=86400');
}
function hasVersionQuery(requestUrl: string) {
return /[?&]v=/.test(requestUrl);
}
function setAdminStaticCacheHeaders(res: any, pathname: string, requestUrl: string) {
if (!hasVersionQuery(requestUrl)) {
setNoStoreHeaders(res);
return;
}
if (pathname.startsWith('/images/')) {
setImageCacheHeaders(res);
return;
}
if (pathname.startsWith('/assets/')) {
setImmutableAssetHeaders(res);
return;
}
setNoStoreHeaders(res);
}
function escapeHtmlAttribute(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function getCanvasDisplayName(canvasName: string) {
return path.basename(canvasName, path.extname(canvasName)) || canvasName;
}
function injectCanvasTemplateHtml(templateHtml: string, canvasName: string, injectScript: string) {
const displayName = getCanvasDisplayName(canvasName);
const pageTitle = `${displayName} - Canvas`;
return templateHtml
.replace(/\{\{CANVAS_NAME\}\}/g, escapeHtmlAttribute(canvasName))
.replace(/\{\{CANVAS_TITLE\}\}/g, escapeHtmlAttribute(pageTitle))
.replace('<title>Canvas</title>', `<title>${escapeHtmlAttribute(pageTitle)}</title>`)
.replace('</head>', `${injectScript}\n</head>`);
}
export function serveAdminPlugin(): Plugin {
const projectRoot = process.cwd();
const appsMatch = projectRoot.match(/[\/\\]apps[\/\\]([^\/\\]+)/);
let projectPrefix = '';
if (appsMatch) {
const rootDir = projectRoot.split(/[\/\\]apps[\/\\]/)[0];
const appsDir = path.join(rootDir, 'apps');
if (fs.existsSync(appsDir)) {
const appFolders = fs.readdirSync(appsDir);
for (const folder of appFolders) {
const folderPath = path.join(appsDir, folder);
const entriesPath = path.join(folderPath, MAKE_ENTRIES_RELATIVE_PATH);
if (fs.existsSync(entriesPath)) {
projectPrefix = `apps/${folder}/`;
break;
}
}
}
}
const isMixedProject = Boolean(projectPrefix);
return {
name: 'serve-admin-plugin',
configureServer(server: any) {
server.middlewares.use(async (req: any, res: any, next: any) => {
try {
const adminDir = path.resolve(projectRoot, 'admin');
const pathname = getRequestPathname(req);
const requestUrl = String(req.url || pathname || '/');
const localIP = getLocalIP();
const actualPort = server.httpServer?.address()?.port || server.config.server?.port || 5173;
const injectScript = `
<script>
window.__PROJECT_PREFIX__ = '${projectPrefix}';
window.__IS_MIXED_PROJECT__ = ${isMixedProject};
window.__LOCAL_IP__ = '${localIP}';
window.__LOCAL_PORT__ = ${actualPort};
</script>`;
const sendHtml = async (html: string, options?: { transform?: boolean }) => {
let responseHtml = html;
if (options?.transform) {
const htmlUrl = requestUrl === '/' ? '/index.html' : requestUrl;
responseHtml = await server.transformIndexHtml(htmlUrl, html, requestUrl);
}
setNoStoreHeaders(res);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(responseHtml);
};
if (pathname === '/' || pathname === '/index.html') {
const indexPath = path.join(adminDir, 'index.html');
if (fs.existsSync(indexPath)) {
// 首页 admin 壳不参与 src HMR避免外层页面被 Vite client 带着刷新。
await sendHtml(readInjectedHtml(indexPath, injectScript), { transform: false });
return;
}
}
if (pathname && pathname.match(/^\/[^/]+\.html$/)) {
const htmlPath = path.join(adminDir, pathname);
if (fs.existsSync(htmlPath)) {
// 其他 admin 静态壳页面同样不接入 HMR只保留 iframe 内 src 页面自己的热更。
await sendHtml(readInjectedHtml(htmlPath, injectScript), { transform: false });
return;
}
}
if (pathname && pathname.startsWith('/assets/')) {
const assetPath = path.join(adminDir, pathname);
if (fs.existsSync(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',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
};
setAdminStaticCacheHeaders(res, pathname, requestUrl);
res.setHeader('Content-Type', contentTypes[ext] || 'application/octet-stream');
res.end(fs.readFileSync(assetPath));
return;
}
}
if (pathname && pathname.startsWith('/images/')) {
const imagePath = path.join(adminDir, pathname);
if (fs.existsSync(imagePath)) {
const ext = path.extname(imagePath);
const contentTypes: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
setAdminStaticCacheHeaders(res, pathname, requestUrl);
res.setHeader('Content-Type', contentTypes[ext] || 'image/png');
res.end(fs.readFileSync(imagePath));
return;
}
}
if (pathname && pathname.startsWith('/admin/')) {
const adminFilePath = path.join(adminDir, pathname.replace('/admin/', ''));
if (fs.existsSync(adminFilePath)) {
const ext = path.extname(adminFilePath);
const contentTypes: Record<string, string> = {
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
};
const adminPathname = pathname.replace('/admin', '') || '/';
setAdminStaticCacheHeaders(res, adminPathname, requestUrl);
res.setHeader('Content-Type', contentTypes[ext] || 'application/octet-stream');
res.end(fs.readFileSync(adminFilePath));
return;
}
}
if (pathname && pathname.match(/^\/[^/]+\.js$/)) {
const jsPath = path.join(adminDir, pathname);
if (fs.existsSync(jsPath)) {
setNoStoreHeaders(res);
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.end(fs.readFileSync(jsPath));
return;
}
}
const isDocsAssetRequest = pathname?.startsWith('/docs/') && pathname.includes('/assets/');
const encodedDocName = isDocsAssetRequest
? undefined
: pathname?.match(/^\/docs\/(.+?)(?:\/spec\.html)?$/)?.[1];
if (encodedDocName) {
const specTemplatePath = path.join(adminDir, 'spec-template.html');
if (fs.existsSync(specTemplatePath)) {
let html = fs.readFileSync(specTemplatePath, 'utf8');
const docName = decodeURIComponent(encodedDocName);
const docFileName = docName.endsWith('.md') ? docName : `${docName}.md`;
const specUrl = buildDocApiPath(docFileName);
html = html.replace(/\{\{SPEC_URL\}\}/g, specUrl);
html = html.replace(/\{\{TITLE\}\}/g, docName);
html = html.replace(/\{\{MULTI_DOC\}\}/g, 'false');
html = html.replace(/\{\{DOCS_CONFIG\}\}/g, '[]');
html = html.replace('</head>', `${injectScript}\n</head>`);
// 文档页内容源现在也在 src 下,保留它自己的 Vite 转换与更新能力。
await sendHtml(html, { transform: true });
return;
}
}
const encodedCanvasName = pathname?.match(/^\/canvas\/(.+?)\/?$/)?.[1];
if (encodedCanvasName) {
const canvasTemplatePath = path.join(adminDir, 'canvas-template.html');
if (fs.existsSync(canvasTemplatePath)) {
const canvasName = decodeURIComponent(encodedCanvasName);
const html = injectCanvasTemplateHtml(
fs.readFileSync(canvasTemplatePath, 'utf8'),
canvasName,
injectScript,
);
await sendHtml(html, { transform: true });
return;
}
}
next();
} catch (error) {
next(error);
}
});
},
};
}

View File

@@ -0,0 +1,49 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
export function sourceApiPlugin(): Plugin {
return {
name: 'source-api-plugin',
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
if (req.method !== 'GET' || !req.url.startsWith('/api/source')) {
return next();
}
try {
const url = new URL(req.url, `http://${req.headers.host}`);
const targetPath = url.searchParams.get('path');
if (!targetPath) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing path parameter' }));
return;
}
if (targetPath.includes('..') || targetPath.startsWith('/')) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Invalid path' }));
return;
}
const sourceFile = path.resolve(process.cwd(), 'src', targetPath, 'index.tsx');
if (!fs.existsSync(sourceFile)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Source file not found' }));
return;
}
const sourceCode = fs.readFileSync(sourceFile, 'utf8');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(sourceCode);
} catch (error: any) {
console.error('Source file error:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
});
},
};
}

View File

@@ -0,0 +1,227 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import formidable from 'formidable';
import {
isPathInside,
resolveDocumentPathFromDocUrl,
resolveUniqueFilePath,
sanitizeImageUploadFileName,
SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS,
SPEC_DOC_IMAGE_MAX_FILE_SIZE,
SPEC_DOC_IMAGE_MIME_TO_EXTENSION,
} from './utils/docUtils';
import { getRequestPathname } from './utils/httpUtils';
export function specDocApiPlugin(): Plugin {
return {
name: 'spec-doc-api-plugin',
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
const projectRoot = process.cwd();
if (req.method === 'POST' && pathname === '/api/spec-doc/upload-image') {
const uploadDir = path.resolve(projectRoot, 'temp', 'spec-doc-images');
fs.mkdirSync(uploadDir, { recursive: true });
const form = formidable({
uploadDir,
keepExtensions: true,
multiples: false,
maxFileSize: SPEC_DOC_IMAGE_MAX_FILE_SIZE,
});
form.parse(req, (parseError: any, fields: any, files: any) => {
const removeTempFile = (tempPath: string) => {
if (!tempPath) return;
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch {
// Ignore cleanup errors.
}
};
if (parseError) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: parseError?.message || 'Failed to parse multipart payload' }));
return;
}
const docUrlField = Array.isArray(fields?.docUrl) ? fields.docUrl[0] : fields?.docUrl;
const docUrl = String(docUrlField || '').trim();
if (!docUrl) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing docUrl' }));
return;
}
const uploadedFileValue = files?.file ?? files?.files;
const uploadedFile = Array.isArray(uploadedFileValue) ? uploadedFileValue[0] : uploadedFileValue;
if (!uploadedFile) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing file' }));
return;
}
const tempPath = String(uploadedFile?.filepath || uploadedFile?.path || '').trim();
if (!tempPath || !fs.existsSync(tempPath)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Uploaded file is missing from temporary storage' }));
return;
}
try {
const resolved = resolveDocumentPathFromDocUrl(docUrl, req.headers.host, projectRoot);
if ('status' in resolved) {
res.statusCode = resolved.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: resolved.error }));
return;
}
if (!fs.existsSync(resolved.docPath)) {
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Document not found' }));
return;
}
const originalName = String(
uploadedFile?.originalFilename || uploadedFile?.newFilename || uploadedFile?.name || path.basename(tempPath),
).trim();
const mimeType = String(uploadedFile?.mimetype || uploadedFile?.type || '').toLowerCase();
const originalExt = path.extname(originalName).toLowerCase();
const extByMime = SPEC_DOC_IMAGE_MIME_TO_EXTENSION[mimeType] || '';
const normalizedExt = SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS.has(originalExt)
? originalExt
: extByMime;
if (!mimeType.startsWith('image/') && !SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS.has(originalExt)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Only image files are allowed' }));
return;
}
if (!normalizedExt || !SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS.has(normalizedExt)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Unsupported image type' }));
return;
}
const docDir = path.dirname(resolved.docPath);
const docsRoot = path.resolve(projectRoot, 'src', 'docs');
const isDocsEntry = isPathInside(docsRoot, resolved.docPath);
const assetsDir = isDocsEntry
? path.join(docDir, 'assets')
: path.join(docDir, 'assets', 'images');
fs.mkdirSync(assetsDir, { recursive: true });
const safeFileName = sanitizeImageUploadFileName(
originalName || `image${normalizedExt}`,
mimeType,
);
const finalPath = resolveUniqueFilePath(assetsDir, safeFileName);
if (!isPathInside(assetsDir, finalPath)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Forbidden path' }));
return;
}
fs.copyFileSync(tempPath, finalPath);
const relativePath = path.relative(projectRoot, finalPath).split(path.sep).join('/');
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
url: isDocsEntry
? `assets/${path.basename(finalPath)}`
: `assets/images/${path.basename(finalPath)}`,
path: relativePath,
}));
} catch (error: any) {
console.error('Error uploading spec doc image:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Image upload failed' }));
} finally {
removeTempFile(tempPath);
}
});
return;
}
if (req.method !== 'POST' || pathname !== '/api/spec-doc/save') {
return next();
}
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
try {
const bodyText = Buffer.concat(chunks).toString('utf8');
const body = bodyText ? JSON.parse(bodyText) : {};
const docUrl = typeof body?.docUrl === 'string' ? body.docUrl : '';
const content = typeof body?.content === 'string' ? body.content : '';
if (!docUrl) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing docUrl' }));
return;
}
const resolved = resolveDocumentPathFromDocUrl(docUrl, req.headers.host, projectRoot);
if ('status' in resolved) {
res.statusCode = resolved.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: resolved.error }));
return;
}
const srcRoot = path.resolve(projectRoot, 'src');
const relativeToSrc = path.relative(srcRoot, resolved.docPath).split(path.sep).join('/');
const [entryType] = relativeToSrc.split('/');
const fileName = path.basename(resolved.docPath).toLowerCase();
const isAllowedEntryType = ['components', 'prototypes', 'themes'].includes(entryType || '');
const isSpecFile = fileName === 'spec.md' || fileName === 'prd.md';
if (!isAllowedEntryType || !isSpecFile) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Only spec.md/prd.md under components/prototypes/themes can be saved' }));
return;
}
if (!fs.existsSync(resolved.docPath)) {
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Document not found' }));
return;
}
fs.writeFileSync(resolved.docPath, content, 'utf8');
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ success: true, path: `/${relativeToSrc}` }));
} catch (error: any) {
console.error('Error saving spec doc:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Save failed' }));
}
});
});
},
};
}

View File

@@ -0,0 +1,159 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { getRequestPathname, readJsonBody } from './utils/httpUtils';
export interface SubPageItem {
name: string;
path: string;
}
interface PagesJson {
pages: SubPageItem[];
}
function getPagesJsonPath(prototypeName: string): string {
const projectRoot = process.cwd();
return path.resolve(projectRoot, 'src', 'prototypes', prototypeName, 'pages.json');
}
function normalizeSubPageName(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeSubPagePath(value: unknown): string {
if (typeof value !== 'string') {
return '';
}
return value
.trim()
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.replace(/\/{2,}/g, '/');
}
function readPagesJson(prototypeName: string): PagesJson {
const filePath = getPagesJsonPath(prototypeName);
if (!fs.existsSync(filePath)) {
return { pages: [] };
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
const data = JSON.parse(raw);
if (Array.isArray(data.pages)) {
const seenPaths = new Set<string>();
const pages = data.pages
.map((page) => ({
name: normalizeSubPageName(page?.name),
path: normalizeSubPagePath(page?.path),
}))
.filter((page) => {
if (!page.name || !page.path || seenPaths.has(page.path)) {
return false;
}
seenPaths.add(page.path);
return true;
});
return { pages };
}
return { pages: [] };
} catch {
return { pages: [] };
}
}
function writePagesJson(prototypeName: string, data: PagesJson): void {
const filePath = getPagesJsonPath(prototypeName);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
throw new Error(`原型目录不存在: ${prototypeName}`);
}
if (!data.pages.length) {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return;
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}
export function subPagesApiPlugin(): Plugin {
return {
name: 'sub-pages-api-plugin',
configureServer(server: any) {
server.middlewares.use(async (req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (pathname !== '/api/sub-pages') {
return next();
}
const url = new URL(req.url || '/', `http://${req.headers.host}`);
const prototypeName = url.searchParams.get('prototype');
if (!prototypeName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: '缺少 prototype 参数' }));
return;
}
// Validate prototype name to prevent path traversal
if (/[/\\]|\.\./.test(prototypeName)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: '无效的原型名称' }));
return;
}
try {
if (req.method === 'GET') {
const data = readPagesJson(prototypeName);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(data));
return;
}
if (req.method === 'PUT') {
const body = await readJsonBody(req);
if (!body || !Array.isArray(body.pages)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: '请求体必须包含 pages 数组' }));
return;
}
const seenPaths = new Set<string>();
const pages: SubPageItem[] = body.pages
.map((page: any) => ({
name: normalizeSubPageName(page?.name),
path: normalizeSubPagePath(page?.path),
}))
.filter((page: SubPageItem) => {
if (!page.name || !page.path || seenPaths.has(page.path)) {
return false;
}
seenPaths.add(page.path);
return true;
});
writePagesJson(prototypeName, { pages });
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ success: true, pages }));
return;
}
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
} catch (error: any) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || '未知错误' }));
}
});
},
};
}

View File

@@ -0,0 +1,467 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import {
createManualDocTemplate,
ensureTemplatesDirMigrated,
getTemplatesDir,
isProtectedTemplateName,
listTemplateAssets,
sanitizeDocBaseName,
scanTemplateReferences,
toTemplateProjectPath,
} from './utils/docUtils';
import { getRequestPathname, readJsonBody } from './utils/httpUtils';
type TemplateAction = 'rename' | 'delete';
function getProtectedTemplatePayload(action: TemplateAction) {
return {
error: action === 'rename' ? '系统模板不支持重命名' : '系统模板不支持删除',
code: 'PROTECTED_TEMPLATE',
protected: true,
references: [],
hasReferences: false,
};
}
function getReferencedTemplatePayload(action: TemplateAction, references: string[]) {
return {
error: action === 'rename'
? '模板存在项目内引用,请先处理引用后再改名'
: '模板存在项目内引用,请先处理引用后再删除',
code: 'TEMPLATE_REFERENCED',
protected: false,
references,
hasReferences: references.length > 0,
};
}
function normalizeRenameBaseNameForPath(templatePath: string, nextBaseName: string) {
const ext = path.extname(templatePath);
let normalizedBaseName = String(nextBaseName || '').trim();
if (ext && normalizedBaseName.toLowerCase().endsWith(ext.toLowerCase())) {
normalizedBaseName = normalizedBaseName.slice(0, -ext.length).trim();
}
return {
ext,
safeBaseName: sanitizeDocBaseName(normalizedBaseName),
};
}
export function templatesApiPlugin(): Plugin {
return {
name: 'templates-api-plugin',
configureServer(server: any) {
const templatesDir = getTemplatesDir(process.cwd());
const migrationResult = ensureTemplatesDirMigrated(process.cwd());
if (migrationResult.conflicts.length > 0) {
console.error(
'[templates-api-plugin] Template migration conflicts detected:\n' +
migrationResult.conflicts
.map((conflict) => `- ${conflict.relativePath}\n legacy: ${conflict.legacyPath}\n target: ${conflict.targetPath}`)
.join('\n'),
);
}
server.middlewares.use(async (req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (!pathname.startsWith('/api/docs/templates')) {
return next();
}
if (req.method === 'POST' && (pathname === '/api/docs/templates/check-references' || pathname === '/api/docs/templates/check-references/')) {
try {
const body = await readJsonBody(req);
const templateName = String(body?.templateName || '').trim();
const action = body?.action === 'rename' ? 'rename' : body?.action === 'delete' ? 'delete' : '';
const nextBaseName = String(body?.nextBaseName || '').trim();
if (!templateName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing templateName parameter' }));
return;
}
if (action !== 'rename' && action !== 'delete') {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Invalid action parameter' }));
return;
}
const templatePath = path.join(templatesDir, templateName);
if (!templatePath.startsWith(templatesDir)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(templatePath)) {
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Template not found' }));
return;
}
let hasActualRename = action === 'delete';
if (action === 'rename') {
if (!nextBaseName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing nextBaseName parameter' }));
return;
}
if (/[/\\:*?"<>|]/.test(nextBaseName)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Invalid nextBaseName format' }));
return;
}
const { ext, safeBaseName } = normalizeRenameBaseNameForPath(templatePath, nextBaseName);
if (!safeBaseName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Invalid nextBaseName format' }));
return;
}
const nextPath = path.join(path.dirname(templatePath), `${safeBaseName}${ext}`);
hasActualRename = nextPath !== templatePath;
}
if (hasActualRename && isProtectedTemplateName(templateName)) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
templateName,
...getProtectedTemplatePayload(action),
}));
return;
}
const references = hasActualRename ? scanTemplateReferences(templateName) : [];
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
templateName,
references,
hasReferences: references.length > 0,
protected: false,
...(references.length > 0 ? { code: 'TEMPLATE_REFERENCED' } : {}),
}));
} catch (error: any) {
console.error('Error checking template references:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Check template references failed' }));
}
return;
}
if (req.method === 'POST' && (pathname === '/api/docs/templates' || pathname === '/api/docs/templates/')) {
try {
const body = await readJsonBody(req);
const displayName = String(body?.displayName || '').trim();
const fileNameInput = String(body?.fileName || body?.displayName || '').trim();
if (!displayName) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing displayName' }));
return;
}
fs.mkdirSync(templatesDir, { recursive: true });
const fallbackBase = `template-${Date.now().toString(36)}`;
const sanitizedBase = sanitizeDocBaseName(fileNameInput || displayName) || fallbackBase;
let baseName = sanitizedBase;
let suffix = 2;
while (fs.existsSync(path.join(templatesDir, `${baseName}.md`))) {
baseName = `${sanitizedBase}-${suffix}`;
suffix += 1;
}
const templateFileName = `${baseName}.md`;
const templatePath = path.join(templatesDir, templateFileName);
if (!templatePath.startsWith(templatesDir)) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
fs.writeFileSync(templatePath, createManualDocTemplate(displayName), 'utf8');
res.statusCode = 201;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
name: templateFileName,
displayName: templateFileName.replace(/\.[^./\\]+$/u, ''),
path: toTemplateProjectPath(templateFileName),
}));
} catch (error: any) {
console.error('Error creating template:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Create template failed' }));
}
return;
}
if (req.method === 'POST' && pathname.startsWith('/api/docs/templates/') && pathname.endsWith('/copy')) {
try {
const encodedTemplateName = pathname.slice('/api/docs/templates/'.length, -'/copy'.length);
const templateName = decodeURIComponent(encodedTemplateName);
if (!templateName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing template name' }));
return;
}
const sourcePath = path.join(templatesDir, templateName);
if (!sourcePath.startsWith(templatesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(sourcePath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Template not found' }));
return;
}
const sourceDir = path.dirname(sourcePath);
const ext = path.extname(sourcePath);
const sourceBaseName = path.basename(sourcePath, ext);
const safeBaseName = sanitizeDocBaseName(sourceBaseName) || sourceBaseName;
const candidateBase = `${safeBaseName}-copy`;
let nextBaseName = candidateBase;
let suffix = 2;
let nextName = `${nextBaseName}${ext}`;
let nextPath = path.join(sourceDir, nextName);
while (fs.existsSync(nextPath)) {
nextBaseName = `${candidateBase}${suffix}`;
nextName = `${nextBaseName}${ext}`;
nextPath = path.join(sourceDir, nextName);
suffix += 1;
}
if (!nextPath.startsWith(templatesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
fs.copyFileSync(sourcePath, nextPath);
const relativeName = path.relative(templatesDir, nextPath).split(path.sep).join('/');
res.statusCode = 201;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
success: true,
name: relativeName,
displayName: relativeName.replace(/\.[^./\\]+$/u, ''),
path: toTemplateProjectPath(relativeName),
}));
} catch (error: any) {
console.error('Error copying template:', error);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Copy template failed' }));
}
return;
}
if (req.method === 'DELETE' && pathname.startsWith('/api/docs/templates/') && pathname !== '/api/docs/templates/' && pathname !== '/api/docs/templates') {
try {
const encodedTemplateName = pathname.replace('/api/docs/templates/', '');
const templateName = decodeURIComponent(encodedTemplateName);
const templatePath = path.join(templatesDir, templateName);
if (!templatePath.startsWith(templatesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(templatePath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Template not found' }));
return;
}
if (isProtectedTemplateName(templateName)) {
res.statusCode = 409;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(getProtectedTemplatePayload('delete')));
return;
}
const references = scanTemplateReferences(templateName);
if (references.length > 0) {
res.statusCode = 409;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(getReferencedTemplatePayload('delete', references)));
return;
}
fs.unlinkSync(templatePath);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ success: true }));
} catch (error: any) {
console.error('Error deleting template:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error?.message || 'Delete template failed' }));
}
return;
}
if (req.method === 'PUT' && pathname.startsWith('/api/docs/templates/') && pathname !== '/api/docs/templates/' && pathname !== '/api/docs/templates') {
try {
const encodedTemplateName = pathname.replace('/api/docs/templates/', '');
if (!encodedTemplateName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing template name' }));
return;
}
const bodyData = await readJsonBody(req);
const hasContentUpdate = typeof bodyData?.content === 'string';
let newBaseName = String(bodyData?.newBaseName || '').trim();
const hasRename = Boolean(newBaseName);
if (!hasContentUpdate && !hasRename) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing content or newBaseName parameter' }));
return;
}
if (hasRename && /[/\\:*?"<>|]/.test(newBaseName)) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid newBaseName format' }));
return;
}
const templateName = decodeURIComponent(encodedTemplateName);
const oldPath = path.join(templatesDir, templateName);
if (!oldPath.startsWith(templatesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(oldPath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Template not found' }));
return;
}
let finalPath = oldPath;
if (hasRename) {
const { ext, safeBaseName } = normalizeRenameBaseNameForPath(oldPath, newBaseName);
if (!safeBaseName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Invalid newBaseName format' }));
return;
}
const oldDir = path.dirname(oldPath);
const newFileName = `${safeBaseName}${ext}`;
const newPath = path.join(oldDir, newFileName);
if (!newPath.startsWith(templatesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (newPath !== oldPath && fs.existsSync(newPath)) {
res.statusCode = 400;
res.end(JSON.stringify({ error: '目标文件已存在' }));
return;
}
if (newPath !== oldPath) {
if (isProtectedTemplateName(templateName)) {
res.statusCode = 409;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(getProtectedTemplatePayload('rename')));
return;
}
const references = scanTemplateReferences(templateName);
if (references.length > 0) {
res.statusCode = 409;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(getReferencedTemplatePayload('rename', references)));
return;
}
fs.renameSync(oldPath, newPath);
}
finalPath = newPath;
}
if (hasContentUpdate) {
fs.writeFileSync(finalPath, String(bodyData.content), 'utf8');
}
const relativeName = path.relative(templatesDir, finalPath).split(path.sep).join('/');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ success: true, name: relativeName }));
} catch (error: any) {
console.error('Error updating template:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error?.message || 'Update template failed' }));
}
return;
}
if (req.method !== 'GET') {
return next();
}
if (pathname.startsWith('/api/docs/templates/') && pathname !== '/api/docs/templates/' && pathname !== '/api/docs/templates') {
try {
const encodedTemplateName = pathname.replace('/api/docs/templates/', '');
const templateName = decodeURIComponent(encodedTemplateName);
const templatePath = path.join(templatesDir, templateName);
if (!templatePath.startsWith(templatesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(templatePath)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Template not found' }));
return;
}
const content = fs.readFileSync(templatePath, 'utf8');
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.end(content);
} catch (error: any) {
console.error('Error loading template:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error?.message || 'Load template failed' }));
}
return;
}
if (pathname !== '/api/docs/templates' && pathname !== '/api/docs/templates/') {
return next();
}
try {
const templates = listTemplateAssets(templatesDir);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(templates));
} catch (error: any) {
console.error('Error loading templates:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
});
},
};
}

View File

@@ -0,0 +1,300 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { getRequestPathname, readJsonBody } from './utils/httpUtils';
export function themesApiPlugin(): Plugin {
return {
name: 'themes-api-plugin',
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (!pathname.startsWith('/api/themes')) {
return next();
}
// Sync a theme's DESIGN.md to the project root
if (req.method === 'POST' && pathname === '/api/themes/sync-design') {
(async () => {
try {
const body = await readJsonBody(req);
const themeName = (body?.themeName || '').trim();
const rootDesignPath = path.resolve(process.cwd(), 'DESIGN.md');
if (!themeName) {
// Clear: remove root DESIGN.md if it exists
if (fs.existsSync(rootDesignPath)) {
fs.unlinkSync(rootDesignPath);
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, removed: true }));
return;
}
const themesDir = path.resolve(process.cwd(), 'src/themes');
const themeDesignPath = path.join(themesDir, themeName, 'DESIGN.md');
if (!themeDesignPath.startsWith(themesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(themeDesignPath)) {
// Theme has no DESIGN.md — remove root copy if present
if (fs.existsSync(rootDesignPath)) {
fs.unlinkSync(rootDesignPath);
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, skipped: true }));
return;
}
fs.copyFileSync(themeDesignPath, rootDesignPath);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true }));
} catch (error: any) {
console.error('Error syncing DESIGN.md:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
})();
return;
}
if (req.method === 'DELETE' && pathname !== '/api/themes' && pathname !== '/api/themes/') {
try {
const themeName = pathname.replace('/api/themes/', '');
if (!themeName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing theme name' }));
return;
}
const themesDir = path.resolve(process.cwd(), 'src/themes');
const themeDir = path.join(themesDir, themeName);
if (!themeDir.startsWith(themesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(themeDir)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Theme not found' }));
return;
}
fs.rmSync(themeDir, { recursive: true, force: true });
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true }));
} catch (error: any) {
console.error('Error deleting theme:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (req.method === 'PUT' && pathname !== '/api/themes' && pathname !== '/api/themes/') {
(async () => {
try {
const themeName = pathname.replace('/api/themes/', '');
if (!themeName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing theme name' }));
return;
}
const body = await readJsonBody(req);
const displayName = (body?.displayName || '').trim();
if (!displayName) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing displayName' }));
return;
}
const themesDir = path.resolve(process.cwd(), 'src/themes');
const themeDir = path.join(themesDir, themeName);
if (!themeDir.startsWith(themesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (!fs.existsSync(themeDir)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Theme not found' }));
return;
}
const designTokenPath = path.join(themeDir, 'designToken.json');
let designToken: any = {};
if (fs.existsSync(designTokenPath)) {
try {
designToken = JSON.parse(fs.readFileSync(designTokenPath, 'utf8'));
} catch (error: any) {
res.statusCode = 400;
res.end(JSON.stringify({ error: error.message || 'Invalid designToken.json' }));
return;
}
}
designToken.name = displayName;
fs.writeFileSync(designTokenPath, JSON.stringify(designToken, null, 2));
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true }));
} catch (error: any) {
console.error('Error updating theme name:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
})();
return;
}
if (req.method !== 'GET') {
return next();
}
if (pathname !== '/api/themes' && pathname !== '/api/themes/') {
try {
const themeName = pathname.replace('/api/themes/', '');
if (!themeName) {
return next();
}
const themesDir = path.resolve(process.cwd(), 'src/themes');
const themeDir = path.join(themesDir, themeName);
if (!themeDir.startsWith(themesDir)) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Forbidden' }));
return;
}
if (fs.existsSync(themeDir) && fs.statSync(themeDir).isDirectory()) {
const designTokenPath = path.join(themeDir, 'designToken.json');
const indexHtmlPath = path.join(themeDir, 'index.html');
const themeData: any = { name: themeName, displayName: themeName };
if (fs.existsSync(designTokenPath)) {
try {
const designToken = JSON.parse(fs.readFileSync(designTokenPath, 'utf8'));
themeData.designToken = designToken;
if (designToken && designToken.name) {
themeData.displayName = designToken.name;
}
} catch (error) {
console.error('Error parsing designToken.json:', error);
}
}
if (fs.existsSync(indexHtmlPath)) {
themeData.indexHtml = fs.readFileSync(indexHtmlPath, 'utf8');
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(themeData));
} else {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Theme not found' }));
}
} catch (error: any) {
console.error('Error loading theme:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
return;
}
try {
const themesDir = path.resolve(process.cwd(), 'src/themes');
const themes: any[] = [];
if (fs.existsSync(themesDir)) {
const items = fs.readdirSync(themesDir, { withFileTypes: true });
items.forEach((item) => {
if (item.isDirectory()) {
const themeDir = path.join(themesDir, item.name);
const designTokenPath = path.join(themeDir, 'designToken.json');
const globalsPath = path.join(themeDir, 'globals.css');
const designSpecPath = path.join(themeDir, 'DESIGN.md');
const indexTsxPath = path.join(themeDir, 'index.tsx');
let displayName = item.name;
const hasDesignToken = fs.existsSync(designTokenPath);
const hasGlobals = fs.existsSync(globalsPath);
const hasDesignSpec = fs.existsSync(designSpecPath);
const hasIndexTsx = fs.existsSync(indexTsxPath);
if (hasDesignToken) {
try {
const designToken = JSON.parse(fs.readFileSync(designTokenPath, 'utf8'));
if (designToken && designToken.name) {
displayName = designToken.name;
}
} catch (error) {
console.error(`Error loading theme ${item.name} designToken:`, error);
}
}
let description = '';
let hasDoc = false;
const readmePath = path.join(themeDir, 'README.md');
if (fs.existsSync(readmePath)) {
try {
const content = fs.readFileSync(readmePath, 'utf8');
const firstLine = content.split('\n')[0];
description = firstLine.replace(/^#\s*/, '').trim();
hasDoc = true;
} catch (error) {
console.warn(`Failed to read README.md for ${item.name}:`, error);
}
} else if (fs.existsSync(designSpecPath)) {
try {
const content = fs.readFileSync(designSpecPath, 'utf8');
const firstLine = content.split('\n')[0];
description = firstLine.replace(/^#\s*/, '').trim();
hasDoc = true;
} catch (error) {
console.warn(`Failed to read DESIGN.md for ${item.name}:`, error);
}
}
themes.push({
name: item.name,
displayName,
description,
hasDoc,
hasDesignToken,
hasGlobals,
hasDesignSpec,
hasIndexTsx,
});
}
});
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(themes));
} catch (error: any) {
console.error('Error loading themes:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
});
},
};
}

View File

@@ -0,0 +1,74 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
export function unsetReferenceApiPlugin(): Plugin {
return {
name: 'unset-reference-api-plugin',
configureServer(server: any) {
server.middlewares.use('/api/unset-reference', (req: any, res: any) => {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
let body = '';
req.on('data', (chunk: any) => {
body += chunk;
});
req.on('end', () => {
try {
const { path: targetPath } = JSON.parse(body);
if (!targetPath) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing path parameter' }));
return;
}
if (targetPath.includes('..') || targetPath.startsWith('/')) {
res.statusCode = 403;
res.end(JSON.stringify({ error: 'Invalid path' }));
return;
}
const srcDir = path.resolve(process.cwd(), 'src', targetPath);
if (!fs.existsSync(srcDir)) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Directory not found' }));
return;
}
const folderName = path.basename(srcDir);
if (!folderName.startsWith('ref-')) {
res.statusCode = 400;
res.end(JSON.stringify({ error: '该项目不是参考项目' }));
return;
}
const newFolderName = folderName.substring(4);
const parentDir = path.dirname(srcDir);
const newSrcDir = path.join(parentDir, newFolderName);
if (fs.existsSync(newSrcDir)) {
res.statusCode = 409;
res.end(JSON.stringify({ error: '同名项目已存在' }));
return;
}
fs.renameSync(srcDir, newSrcDir);
res.statusCode = 200;
res.end(JSON.stringify({ success: true }));
} catch (error: any) {
console.error('Unset reference error:', error);
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
});
});
},
};
}

View File

@@ -0,0 +1,87 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { getRequestPathname } from './utils/httpUtils';
export function uploadDocsApiPlugin(): Plugin {
return {
name: 'upload-docs-api-plugin',
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (req.method !== 'POST' || pathname !== '/api/upload-docs') {
return next();
}
const chunks: Buffer[] = [];
let totalLength = 0;
req.on('data', (chunk: Buffer) => {
totalLength += chunk.length;
if (totalLength > 1024 * 1024 * 20) {
res.statusCode = 413;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Payload too large' }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf8');
const body = raw ? JSON.parse(raw) : null;
const files = body?.files;
if (!Array.isArray(files) || files.length === 0) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Missing files' }));
return;
}
const docsDir = path.resolve(process.cwd(), 'src/docs');
fs.mkdirSync(docsDir, { recursive: true });
const saved: string[] = [];
files.forEach((f: any) => {
const rawName = typeof f?.name === 'string' ? f.name : '';
const content = typeof f?.content === 'string' ? f.content : '';
if (!rawName) {
throw new Error('Invalid file name');
}
let safeName = path.basename(rawName).trim();
safeName = safeName.replace(/[^\w.\- ]+/g, '-').replace(/\s+/g, '-');
const lowerName = safeName.toLowerCase();
if (!lowerName.endsWith('.md') && !lowerName.endsWith('.csv') && !lowerName.endsWith('.json')) {
throw new Error('Only .md, .csv, and .json files are allowed');
}
const targetPath = path.join(docsDir, safeName);
if (!targetPath.startsWith(docsDir)) {
throw new Error('Forbidden');
}
fs.writeFileSync(targetPath, content, 'utf8');
saved.push(safeName.replace(/\.(md|csv|json)$/i, ''));
});
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ success: true, files: saved }));
} catch (error: any) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Upload failed' }));
}
});
});
},
};
}

View File

@@ -0,0 +1,22 @@
function encodeRFC5987Value(value: string): string {
return encodeURIComponent(value).replace(/['()*]/g, (char) =>
`%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
function createAsciiFallback(fileName: string): string {
const normalized = fileName
.normalize('NFKD')
.replace(/[^\x20-\x7E]+/g, '_')
.replace(/["\\;%]/g, '_')
.replace(/\s+/g, ' ')
.trim();
return normalized || 'download';
}
export function buildAttachmentContentDisposition(fileName: string): string {
const fallback = createAsciiFallback(fileName);
const encoded = encodeRFC5987Value(fileName);
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}

View File

@@ -0,0 +1,62 @@
/**
* CSS 增量合并函数
* 相同选择器:增加新属性,更新已有属性
*/
export function mergeCss(existingCss: string, newCss: string): string {
const parseCss = (css: string): Map<string, Map<string, string>> => {
const rules = new Map<string, Map<string, string>>();
const ruleRegex = /([^{]+)\{([^}]+)\}/g;
let match;
while ((match = ruleRegex.exec(css)) !== null) {
const selector = match[1].trim();
const declarations = match[2].trim();
if (!rules.has(selector)) {
rules.set(selector, new Map());
}
const props = rules.get(selector)!;
const propRegex = /([^:;]+):([^;]+)/g;
let propMatch;
while ((propMatch = propRegex.exec(declarations)) !== null) {
const property = propMatch[1].trim();
const value = propMatch[2].trim();
props.set(property, value);
}
}
return rules;
};
const serializeCss = (rules: Map<string, Map<string, string>>): string => {
const lines: string[] = [];
for (const [selector, props] of rules) {
lines.push(`${selector} {`);
for (const [property, value] of props) {
lines.push(` ${property}: ${value};`);
}
lines.push('}\n');
}
return lines.join('\n');
};
const existingRules = parseCss(existingCss);
const newRules = parseCss(newCss);
for (const [selector, newProps] of newRules) {
if (!existingRules.has(selector)) {
existingRules.set(selector, newProps);
} else {
const existingProps = existingRules.get(selector)!;
for (const [property, value] of newProps) {
existingProps.set(property, value);
}
}
}
return serializeCss(existingRules);
}

View File

@@ -0,0 +1,608 @@
import fs from 'fs';
import path from 'path';
const PROTECTED_TEMPLATE_BASENAMES = new Set([
'spec-template',
]);
const PROTECTED_DOC_BASENAMES = new Set([
'project-overview',
]);
const DOC_REFERENCE_SCAN_DIRECTORIES = [
'src/docs',
'rules',
'skills',
];
const DOC_REFERENCE_ALLOWED_EXTENSIONS = new Set([
'.md',
'.txt',
'.json',
'.yaml',
'.yml',
'.csv',
'.ts',
'.tsx',
'.js',
'.jsx',
'.css',
'.html',
]);
const TEMPLATE_REFERENCE_SCAN_DIRECTORIES = [
'src/docs',
'rules',
'skills',
];
const DOCS_ROOT_RELATIVE_PATH = 'src/docs';
const TEMPLATES_ROOT_RELATIVE_PATH = 'src/docs/templates';
const LEGACY_TEMPLATES_ROOT_RELATIVE_PATH = 'assets/templates';
export const SPEC_DOC_IMAGE_MAX_FILE_SIZE = 5 * 1024 * 1024;
export const SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.svg',
]);
export const SPEC_DOC_IMAGE_MIME_TO_EXTENSION: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
};
export function sanitizeDocBaseName(input: string) {
return input
.trim()
.replace(/\.md$/i, '')
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function normalizePathSegments(input: string) {
return String(input || '')
.trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/\/+/g, '/');
}
function getDocBaseName(docName: string) {
const normalized = normalizePathSegments(docName);
const ext = path.extname(normalized);
return path.basename(normalized, ext);
}
function escapeRegExp(input: string) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function normalizeRelativeProjectPath(projectRoot: string, filePath: string) {
return path.relative(projectRoot, filePath).split(path.sep).join('/');
}
export function getDocsDir(projectRoot: string = process.cwd()) {
return path.resolve(projectRoot, DOCS_ROOT_RELATIVE_PATH);
}
export function getTemplatesDir(projectRoot: string = process.cwd()) {
return path.resolve(projectRoot, TEMPLATES_ROOT_RELATIVE_PATH);
}
export function getLegacyTemplatesDir(projectRoot: string = process.cwd()) {
return path.resolve(projectRoot, LEGACY_TEMPLATES_ROOT_RELATIVE_PATH);
}
export function toTemplateProjectPath(templateName: string) {
const normalizedName = normalizePathSegments(templateName);
return normalizedName ? `${TEMPLATES_ROOT_RELATIVE_PATH}/${normalizedName}` : TEMPLATES_ROOT_RELATIVE_PATH;
}
export function isTemplateDocName(docName: string) {
const normalizedName = normalizePathSegments(docName);
return normalizedName === 'templates' || normalizedName.startsWith('templates/');
}
export function buildDocApiPath(docName: string) {
const normalizedName = normalizePathSegments(docName);
if (!normalizedName) {
return '/api/docs';
}
if (isTemplateDocName(normalizedName)) {
const templateName = normalizedName === 'templates'
? ''
: normalizedName.slice('templates/'.length);
return templateName
? `/api/docs/templates/${encodeURIComponent(templateName)}`
: '/api/docs/templates';
}
return `/api/docs/${encodeURIComponent(normalizedName)}`;
}
function buildDocReferencePatterns(docName: string): RegExp[] {
const normalizedDocName = normalizePathSegments(docName);
if (!normalizedDocName) {
return [];
}
const ext = path.extname(normalizedDocName).toLowerCase();
const normalizedBaseName = ext ? normalizedDocName.slice(0, -ext.length) : normalizedDocName;
const candidates = [
`src/docs/${normalizedDocName}`,
`/docs/${normalizedDocName}`,
];
if (ext === '.md') {
candidates.push(`src/docs/${normalizedBaseName}`, `/docs/${normalizedBaseName}`);
}
return Array.from(new Set(candidates))
.filter(Boolean)
.map((candidate) => new RegExp(`${escapeRegExp(candidate)}(?=$|[^A-Za-z0-9_-])`));
}
function buildTemplateReferencePatterns(templateName: string): RegExp[] {
const normalizedTemplateName = normalizePathSegments(templateName);
if (!normalizedTemplateName) {
return [];
}
const ext = path.extname(normalizedTemplateName).toLowerCase();
const normalizedBaseName = ext ? normalizedTemplateName.slice(0, -ext.length) : normalizedTemplateName;
const candidates = [
toTemplateProjectPath(normalizedTemplateName),
`/docs/templates/${normalizedTemplateName}`,
];
if (ext === '.md') {
candidates.push(
toTemplateProjectPath(normalizedBaseName),
`/docs/templates/${normalizedBaseName}`,
);
}
return Array.from(new Set(candidates))
.filter(Boolean)
.map((candidate) => new RegExp(`${escapeRegExp(candidate)}(?=$|[^A-Za-z0-9_-])`));
}
export function isProtectedTemplateName(templateName: string) {
const normalizedName = String(templateName || '').trim();
if (!normalizedName) return false;
const baseName = path.basename(normalizedName, path.extname(normalizedName));
return PROTECTED_TEMPLATE_BASENAMES.has(baseName);
}
export function isProtectedDocName(docName: string) {
return PROTECTED_DOC_BASENAMES.has(getDocBaseName(docName));
}
export function safeDecodeURIComponent(input: string): string {
try {
return decodeURIComponent(input);
} catch {
return input;
}
}
export function isPathInside(baseDir: string, targetPath: string): boolean {
const relative = path.relative(baseDir, targetPath);
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
}
type TemplateMigrationConflict = {
relativePath: string;
legacyPath: string;
targetPath: string;
};
type TemplateMigrationResult = {
moved: string[];
deduped: string[];
conflicts: TemplateMigrationConflict[];
removedLegacyDir: boolean;
};
function areFilesIdentical(leftPath: string, rightPath: string) {
const leftStat = fs.statSync(leftPath);
const rightStat = fs.statSync(rightPath);
if (leftStat.size !== rightStat.size) {
return false;
}
return fs.readFileSync(leftPath).equals(fs.readFileSync(rightPath));
}
function removeEmptyDirectories(directoryPath: string, stopAt: string) {
let currentPath = directoryPath;
const normalizedStopAt = path.resolve(stopAt);
while (isPathInside(normalizedStopAt, currentPath) || currentPath === normalizedStopAt) {
if (!fs.existsSync(currentPath)) {
break;
}
const entries = fs.readdirSync(currentPath);
if (entries.length > 0) {
break;
}
fs.rmdirSync(currentPath);
if (currentPath === normalizedStopAt) {
break;
}
currentPath = path.dirname(currentPath);
}
}
export function ensureTemplatesDirMigrated(projectRoot: string = process.cwd()): TemplateMigrationResult {
const templatesDir = getTemplatesDir(projectRoot);
const legacyTemplatesDir = getLegacyTemplatesDir(projectRoot);
const result: TemplateMigrationResult = {
moved: [],
deduped: [],
conflicts: [],
removedLegacyDir: false,
};
if (!fs.existsSync(legacyTemplatesDir)) {
return result;
}
fs.mkdirSync(templatesDir, { recursive: true });
const walkLegacyDir = (directoryPath: string) => {
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry) {
continue;
}
if (entry.name.startsWith('.')) {
const hiddenPath = path.join(directoryPath, entry.name);
if (entry.isFile()) {
fs.unlinkSync(hiddenPath);
}
continue;
}
const legacyPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
walkLegacyDir(legacyPath);
continue;
}
if (!entry.isFile()) {
continue;
}
const relativePath = path.relative(legacyTemplatesDir, legacyPath).split(path.sep).join('/');
if (!relativePath) {
continue;
}
const targetPath = path.join(templatesDir, relativePath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
if (!fs.existsSync(targetPath)) {
fs.renameSync(legacyPath, targetPath);
result.moved.push(relativePath);
continue;
}
if (areFilesIdentical(legacyPath, targetPath)) {
fs.unlinkSync(legacyPath);
result.deduped.push(relativePath);
continue;
}
result.conflicts.push({
relativePath,
legacyPath,
targetPath,
});
}
};
walkLegacyDir(legacyTemplatesDir);
removeEmptyDirectories(legacyTemplatesDir, legacyTemplatesDir);
result.removedLegacyDir = !fs.existsSync(legacyTemplatesDir);
return result;
}
export function resolveDocumentPathFromDocUrl(
docUrl: string,
host?: string,
projectRoot: string = process.cwd(),
): { docPath: string } | { status: number; error: string } {
let pathname = '';
try {
pathname = new URL(docUrl, `http://${host || 'localhost'}`).pathname;
} catch {
return { status: 400, error: 'Invalid docUrl' };
}
const srcRoot = path.resolve(projectRoot, 'src');
const docsRoot = getDocsDir(projectRoot);
if (pathname.startsWith('/api/docs/')) {
const encodedDocName = pathname.slice('/api/docs/'.length);
if (!encodedDocName) {
return { status: 400, error: 'Missing document name in docUrl' };
}
const decodedDocName = safeDecodeURIComponent(encodedDocName);
const docPath = path.resolve(docsRoot, decodedDocName);
if (!isPathInside(docsRoot, docPath)) {
return { status: 403, error: 'Forbidden path' };
}
return { docPath };
}
if (pathname.startsWith('/docs/')) {
const rawDocPath = pathname.slice('/docs/'.length);
if (!rawDocPath) {
return { status: 400, error: 'Missing document name in docUrl' };
}
const decodedDocPath = safeDecodeURIComponent(rawDocPath);
const normalizedDocPath = decodedDocPath.toLowerCase().endsWith('.md')
? decodedDocPath
: `${decodedDocPath}.md`;
const docPath = path.resolve(docsRoot, normalizedDocPath);
if (!isPathInside(docsRoot, docPath)) {
return { status: 403, error: 'Forbidden path' };
}
return { docPath };
}
const specMatch = pathname.match(/^\/(components|prototypes|themes)\/([^/]+)\/(spec|prd)\.md$/i);
if (specMatch) {
const entryType = specMatch[1].toLowerCase();
const entryName = safeDecodeURIComponent(specMatch[2]);
const docName = `${specMatch[3].toLowerCase()}.md`;
const entryRoot = path.resolve(srcRoot, entryType);
const docPath = path.resolve(entryRoot, entryName, docName);
if (!isPathInside(entryRoot, docPath)) {
return { status: 403, error: 'Forbidden path' };
}
return { docPath };
}
return { status: 400, error: 'Unsupported docUrl path' };
}
export function sanitizeImageUploadFileName(originalName: string, mimeType?: string): string {
const normalizedMimeType = String(mimeType || '').toLowerCase();
const extensionByMime = SPEC_DOC_IMAGE_MIME_TO_EXTENSION[normalizedMimeType] || '';
const rawExt = path.extname(originalName || '').toLowerCase();
const extension = SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS.has(rawExt)
? rawExt
: (SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS.has(extensionByMime) ? extensionByMime : '.png');
const rawBaseName = path.basename(originalName || '', path.extname(originalName || '')).trim();
const safeBaseName = (rawBaseName || `image-${Date.now().toString(36)}`)
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || `image-${Date.now().toString(36)}`;
return `${safeBaseName}${extension}`;
}
export function resolveUniqueFilePath(directoryPath: string, fileName: string): string {
const ext = path.extname(fileName);
const baseName = path.basename(fileName, ext);
let index = 1;
let candidateName = fileName;
let candidatePath = path.join(directoryPath, candidateName);
while (fs.existsSync(candidatePath)) {
index += 1;
candidateName = `${baseName}-${index}${ext}`;
candidatePath = path.join(directoryPath, candidateName);
}
return candidatePath;
}
export function scanDocReferences(docName: string, projectRoot: string = process.cwd()) {
const normalizedDocName = normalizePathSegments(docName);
if (!normalizedDocName) {
return [];
}
const docsRoot = getDocsDir(projectRoot);
const currentDocPath = path.resolve(docsRoot, normalizedDocName);
const referencePatterns = buildDocReferencePatterns(normalizedDocName);
if (referencePatterns.length === 0) {
return [];
}
const references = new Set<string>();
const walkDir = (directoryPath: string) => {
if (!fs.existsSync(directoryPath)) {
return;
}
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
walkDir(entryPath);
continue;
}
if (!entry.isFile()) {
continue;
}
if (path.resolve(entryPath) === currentDocPath) {
continue;
}
const ext = path.extname(entry.name).toLowerCase();
if (!DOC_REFERENCE_ALLOWED_EXTENSIONS.has(ext)) {
continue;
}
const content = fs.readFileSync(entryPath, 'utf8');
if (referencePatterns.some((pattern) => pattern.test(content))) {
references.add(normalizeRelativeProjectPath(projectRoot, entryPath));
}
}
};
DOC_REFERENCE_SCAN_DIRECTORIES
.map((relativePath) => path.resolve(projectRoot, relativePath))
.forEach(walkDir);
return Array.from(references).sort();
}
export function scanTemplateReferences(templateName: string, projectRoot: string = process.cwd()) {
const normalizedTemplateName = normalizePathSegments(templateName);
if (!normalizedTemplateName) {
return [];
}
const templatesRoot = getTemplatesDir(projectRoot);
const currentTemplatePath = path.resolve(templatesRoot, normalizedTemplateName);
const referencePatterns = buildTemplateReferencePatterns(normalizedTemplateName);
if (referencePatterns.length === 0) {
return [];
}
const references = new Set<string>();
const walkDir = (directoryPath: string) => {
if (!fs.existsSync(directoryPath)) {
return;
}
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
walkDir(entryPath);
continue;
}
if (!entry.isFile()) {
continue;
}
if (path.resolve(entryPath) === currentTemplatePath) {
continue;
}
const ext = path.extname(entry.name).toLowerCase();
if (!DOC_REFERENCE_ALLOWED_EXTENSIONS.has(ext)) {
continue;
}
const content = fs.readFileSync(entryPath, 'utf8');
if (referencePatterns.some((pattern) => pattern.test(content))) {
references.add(normalizeRelativeProjectPath(projectRoot, entryPath));
}
}
};
TEMPLATE_REFERENCE_SCAN_DIRECTORIES
.map((relativePath) => path.resolve(projectRoot, relativePath))
.forEach(walkDir);
return Array.from(references).sort();
}
export function createManualDocTemplate(displayName: string) {
return `# ${displayName}
## 概述
请在此补充文档目标、范围与背景信息。
## 详细内容
请在此继续编写正文。
`;
}
export function extractMarkdownDisplayName(content: string, fallbackName: string) {
const titleMatch = content.match(/^#\s+(.+)$/m);
return titleMatch?.[1]?.trim() || fallbackName;
}
export function extractMarkdownDescription(content: string) {
const lines = content.split('\n');
for (const rawLine of lines) {
const line = rawLine.trim();
if (line && !line.startsWith('#')) {
return line;
}
}
return '';
}
export function listTemplateAssets(templatesDir: string) {
const templates: Array<{ name: string; displayName: string; description: string }> = [];
if (!fs.existsSync(templatesDir)) {
return templates;
}
const walkTemplatesDir = (dirPath: string) => {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
entries.forEach((entry) => {
if (!entry || entry.name.startsWith('.')) {
return;
}
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
walkTemplatesDir(fullPath);
return;
}
if (!entry.isFile()) {
return;
}
const relativePath = path.relative(templatesDir, fullPath).split(path.sep).join('/');
const content = fs.readFileSync(fullPath, 'utf8');
templates.push({
name: relativePath,
displayName: relativePath.replace(/\.[^./\\]+$/u, ''),
description: extractMarkdownDescription(content),
});
});
};
walkTemplatesDir(templatesDir);
templates.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')));
return templates;
}
export function sanitizeImportFileBaseName(fileName: string): string {
return String(fileName || '')
.trim()
.replace(/\.[^/.]+$/, '')
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function resolveUniqueMarkdownPath(docsDir: string, baseName: string) {
const safeBaseName = sanitizeImportFileBaseName(baseName) || `doc-${Date.now().toString(36)}`;
let fileName = `${safeBaseName}.md`;
let nextPath = path.join(docsDir, fileName);
let suffix = 1;
while (fs.existsSync(nextPath)) {
fileName = `${safeBaseName}-${suffix}.md`;
nextPath = path.join(docsDir, fileName);
suffix += 1;
}
return { fileName, absolutePath: nextPath };
}

View File

@@ -0,0 +1,55 @@
import {
getEntriesPath as getEntriesPathCore,
migrateLegacyEntries as migrateLegacyEntriesCore,
readEntriesManifest as readEntriesManifestCore,
scanProjectEntries as scanProjectEntriesCore,
toCompatMaps as toCompatMapsCore,
writeEntriesManifestAtomic as writeEntriesManifestAtomicCore,
} from './entriesManifestCore.js';
export type EntryGroup = 'components' | 'prototypes' | 'themes';
export interface EntriesManifestItem {
group: string;
name: string;
js: string;
html: string;
}
export interface EntriesManifestV2 {
schemaVersion: 2;
generatedAt: string;
items: Record<string, EntriesManifestItem>;
js: Record<string, string>;
html: Record<string, string>;
}
export function toCompatMaps(items: Record<string, EntriesManifestItem>): {
js: Record<string, string>;
html: Record<string, string>;
} {
return toCompatMapsCore(items);
}
export function scanProjectEntries(
projectRoot: string,
groups: EntryGroup[] = ['components', 'prototypes', 'themes'],
): EntriesManifestV2 {
return scanProjectEntriesCore(projectRoot, groups);
}
export function migrateLegacyEntries(raw: unknown, projectRoot: string): EntriesManifestV2 {
return migrateLegacyEntriesCore(raw, projectRoot);
}
export function writeEntriesManifestAtomic(projectRoot: string, manifest: EntriesManifestV2): EntriesManifestV2 {
return writeEntriesManifestAtomicCore(projectRoot, manifest);
}
export function readEntriesManifest(projectRoot: string): EntriesManifestV2 {
return readEntriesManifestCore(projectRoot);
}
export function getEntriesPath(projectRoot: string): string {
return getEntriesPathCore(projectRoot);
}

View File

@@ -0,0 +1,247 @@
import fs from 'fs';
import path from 'path';
const DEFAULT_GROUPS = ['components', 'prototypes', 'themes'];
const SCHEMA_VERSION = 2;
const ENTRIES_RELATIVE_PATH = path.join('.axhub', 'make', 'entries.json');
function toPosixPath(input) {
return String(input || '').split(path.sep).join('/');
}
function normalizeRelativePath(projectRoot, filePath) {
if (!filePath || typeof filePath !== 'string') {
return '';
}
const absoluteCandidate = path.isAbsolute(filePath)
? filePath
: path.resolve(projectRoot, filePath);
const relative = path.relative(projectRoot, absoluteCandidate);
if (!relative || relative.startsWith('..')) {
return toPosixPath(filePath).replace(/^\.?\//, '');
}
return toPosixPath(relative).replace(/^\.?\//, '');
}
function sortRecordByKey(record) {
const next = {};
Object.keys(record || {})
.sort((a, b) => a.localeCompare(b))
.forEach((key) => {
next[key] = record[key];
});
return next;
}
function normalizeItemKey(key) {
const normalized = String(key || '').trim().replace(/\\/g, '/');
if (!normalized || !normalized.includes('/')) return '';
return normalized.replace(/^\/+/, '');
}
function parseKey(key) {
const normalized = normalizeItemKey(key);
if (!normalized) return null;
const [group, ...nameParts] = normalized.split('/');
const name = nameParts.join('/');
if (!group || !name) return null;
return { group, name };
}
function sanitizeItem(item, projectRoot, fallbackKey) {
const keyInfo = parseKey(fallbackKey);
if (!keyInfo) return null;
const group = String(item?.group || keyInfo.group).trim();
const name = String(item?.name || keyInfo.name).trim();
if (!group || !name) return null;
const key = `${group}/${name}`;
const js = normalizeRelativePath(
projectRoot,
item?.js || `src/${group}/${name}/index.tsx`,
);
const html = normalizeRelativePath(
projectRoot,
item?.html || `src/${group}/${name}/index.html`,
);
return {
key,
item: {
group,
name,
js,
html,
},
};
}
function buildManifestFromItems(items, generatedAt) {
const compat = toCompatMaps(items);
return {
schemaVersion: SCHEMA_VERSION,
generatedAt: generatedAt || new Date().toISOString(),
items,
js: compat.js,
html: compat.html,
};
}
function normalizeManifest(raw, projectRoot, generatedAt) {
const nextItems = {};
const sourceItems =
raw && typeof raw === 'object' && raw.items && typeof raw.items === 'object'
? raw.items
: {};
Object.keys(sourceItems)
.sort((a, b) => a.localeCompare(b))
.forEach((key) => {
const sanitized = sanitizeItem(sourceItems[key], projectRoot, key);
if (sanitized) {
nextItems[sanitized.key] = sanitized.item;
}
});
return buildManifestFromItems(nextItems, generatedAt);
}
export function toCompatMaps(items) {
const js = {};
const html = {};
Object.keys(items || {})
.sort((a, b) => a.localeCompare(b))
.forEach((key) => {
const item = items[key];
if (!item || typeof item !== 'object') return;
const jsPath = typeof item.js === 'string' ? item.js.trim() : '';
const htmlPath = typeof item.html === 'string' ? item.html.trim() : '';
if (jsPath) {
js[key] = jsPath;
}
if (htmlPath) {
html[key] = htmlPath;
}
});
return {
js: sortRecordByKey(js),
html: sortRecordByKey(html),
};
}
export function scanProjectEntries(projectRoot, groups = DEFAULT_GROUPS) {
const root = path.resolve(projectRoot, 'src');
const items = {};
for (const group of groups) {
const groupDir = path.join(root, group);
if (!fs.existsSync(groupDir)) continue;
const names = fs
.readdirSync(groupDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
for (const name of names) {
const jsEntry = path.join(groupDir, name, 'index.tsx');
if (!fs.existsSync(jsEntry)) continue;
const key = `${group}/${name}`;
items[key] = {
group,
name,
js: toPosixPath(path.relative(projectRoot, jsEntry)),
html: toPosixPath(path.relative(projectRoot, path.join(groupDir, name, 'index.html'))),
};
}
}
return buildManifestFromItems(sortRecordByKey(items));
}
export function migrateLegacyEntries(raw, projectRoot) {
if (raw && typeof raw === 'object' && raw.schemaVersion === SCHEMA_VERSION && raw.items) {
return normalizeManifest(raw, projectRoot, raw.generatedAt);
}
const legacyJs =
raw && typeof raw === 'object' && raw.js && typeof raw.js === 'object'
? raw.js
: {};
const legacyHtml =
raw && typeof raw === 'object' && raw.html && typeof raw.html === 'object'
? raw.html
: {};
const keys = new Set([
...Object.keys(legacyJs),
...Object.keys(legacyHtml),
]);
const items = {};
Array.from(keys)
.sort((a, b) => a.localeCompare(b))
.forEach((key) => {
const parsed = parseKey(key);
if (!parsed) return;
const js = normalizeRelativePath(
projectRoot,
legacyJs[key] || `src/${parsed.group}/${parsed.name}/index.tsx`,
);
const html = normalizeRelativePath(
projectRoot,
legacyHtml[key] || `src/${parsed.group}/${parsed.name}/index.html`,
);
items[key] = {
group: parsed.group,
name: parsed.name,
js,
html,
};
});
return buildManifestFromItems(items);
}
export function writeEntriesManifestAtomic(projectRoot, manifest) {
const entriesPath = getEntriesPath(projectRoot);
const normalized = normalizeManifest(manifest, projectRoot, manifest?.generatedAt);
const tempPath = `${entriesPath}.tmp-${process.pid}-${Date.now()}`;
fs.mkdirSync(path.dirname(entriesPath), { recursive: true });
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, entriesPath);
return normalized;
}
export function readEntriesManifest(projectRoot) {
const entriesPath = getEntriesPath(projectRoot);
if (!fs.existsSync(entriesPath)) {
const scanned = scanProjectEntries(projectRoot, DEFAULT_GROUPS);
return writeEntriesManifestAtomic(projectRoot, scanned);
}
let raw;
try {
raw = JSON.parse(fs.readFileSync(entriesPath, 'utf8'));
} catch {
raw = {};
}
const migrated = migrateLegacyEntries(raw, projectRoot);
const rawString = JSON.stringify(raw);
const nextString = JSON.stringify(migrated);
if (rawString !== nextString) {
return writeEntriesManifestAtomic(projectRoot, migrated);
}
return migrated;
}
export function getEntriesPath(projectRoot) {
return path.resolve(projectRoot, ENTRIES_RELATIVE_PATH);
}

View File

@@ -0,0 +1,186 @@
import fs from 'fs';
import path from 'path';
import { getDisplayName } from './fileUtils';
import { migrateLegacyEntries, toCompatMaps } from './entriesManifest';
export type SidebarTreeTab = 'prototypes' | 'components' | 'docs' | 'canvas';
type ScannableGroup = 'components' | 'prototypes' | 'themes';
export interface ScannedEntryItem {
name: string;
displayName: string;
demoUrl: string;
specUrl: string;
jsUrl: string;
filePath: string;
isReference?: boolean;
hasSubPages?: boolean;
}
export interface EntriesFileData extends Record<string, unknown> {
schemaVersion?: number;
generatedAt?: string;
items?: Record<string, {
group?: string;
name?: string;
js?: string;
html?: string;
}>;
js?: Record<string, string>;
html?: Record<string, string>;
}
export interface EntryScanResult {
entries: {
js: Record<string, string>;
html: Record<string, string>;
};
items: Record<SidebarTreeTab, ScannedEntryItem[]>;
}
const MANIFEST_SCANNED_GROUPS: ScannableGroup[] = ['components', 'prototypes', 'themes'];
function encodeUrlPathSegments(value: string): string {
return value
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
}
function isScannedGroupKey(key: string): boolean {
return MANIFEST_SCANNED_GROUPS.some((group) => key.startsWith(`${group}/`));
}
function scanGroup(projectRoot: string, group: ScannableGroup): {
entries: { js: Record<string, string>; html: Record<string, string> };
items: ScannedEntryItem[];
} {
const groupDir = path.resolve(projectRoot, 'src', group);
const entries = { js: {} as Record<string, string>, html: {} as Record<string, string> };
const items: ScannedEntryItem[] = [];
if (!fs.existsSync(groupDir)) {
return { entries, items };
}
const names = fs
.readdirSync(groupDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
for (const name of names) {
const folderPath = path.join(groupDir, name);
const jsEntry = path.join(folderPath, 'index.tsx');
if (!fs.existsSync(jsEntry)) {
continue;
}
const key = `${group}/${name}`;
entries.js[key] = jsEntry;
entries.html[key] = path.join(folderPath, 'index.html');
let displayName = getDisplayName(jsEntry) || name;
const encodedKey = encodeUrlPathSegments(key);
items.push({
name,
displayName,
demoUrl: `/${encodedKey}`,
specUrl: `/${encodedKey}/spec`,
jsUrl: `/build/${encodedKey}.js`,
filePath: jsEntry,
isReference: name.startsWith('ref-'),
hasSubPages: group === 'prototypes' && fs.existsSync(path.join(folderPath, 'pages.json')),
});
}
items.sort((a, b) => a.name.localeCompare(b.name));
return {
entries,
items,
};
}
export function scanEntries(projectRoot: string): EntryScanResult {
const result: EntryScanResult = {
entries: { js: {}, html: {} },
items: {
components: [],
prototypes: [],
docs: [],
canvas: [],
},
};
for (const group of MANIFEST_SCANNED_GROUPS) {
const scanned = scanGroup(projectRoot, group);
Object.assign(result.entries.js, scanned.entries.js);
Object.assign(result.entries.html, scanned.entries.html);
if (group === 'components' || group === 'prototypes') {
result.items[group] = scanned.items;
}
}
return result;
}
export function allowedItemKeysByTab(
scannedEntries: Record<string, string>,
tab: SidebarTreeTab,
): Set<string> {
return new Set(
Object.keys(scannedEntries)
.filter((key) => key.startsWith(`${tab}/`))
.sort((a, b) => a.localeCompare(b)),
);
}
export function mergeScannedEntries(existing: EntriesFileData, scanned: EntryScanResult['entries']): EntriesFileData {
const migrated = migrateLegacyEntries(existing, process.cwd());
const nextItems = { ...(migrated.items || {}) };
for (const key of Object.keys(nextItems)) {
if (isScannedGroupKey(key) && !Object.prototype.hasOwnProperty.call(scanned.js, key)) {
delete nextItems[key];
}
}
for (const [key, jsPath] of Object.entries(scanned.js || {})) {
const [group, ...nameParts] = key.split('/');
const name = nameParts.join('/');
if (!group || !name) continue;
nextItems[key] = {
group,
name,
js: jsPath,
html: scanned.html?.[key] || `src/${group}/${name}/index.html`,
};
}
const sortedItems = Object.keys(nextItems)
.sort((a, b) => a.localeCompare(b))
.reduce<Record<string, {
group?: string;
name?: string;
js?: string;
html?: string;
}>>((acc, key) => {
acc[key] = nextItems[key];
return acc;
}, {});
const compat = toCompatMaps(sortedItems);
const nextEntries: EntriesFileData = {
...existing,
schemaVersion: 2,
generatedAt: new Date().toISOString(),
items: sortedItems,
js: compat.js,
html: compat.html,
};
delete (nextEntries as { sidebarTree?: unknown }).sidebarTree;
return nextEntries;
}

View File

@@ -0,0 +1,17 @@
import fs from 'fs';
/**
* 从文件注释中读取显示名称
*/
export function getDisplayName(filePath: string): string | null {
try {
const content = fs.readFileSync(filePath, 'utf8');
const nameMatch = content.match(/@(?:name|displayName)\s+(.+)/);
if (nameMatch && nameMatch[1]) {
return nameMatch[1].trim();
}
} catch (err) {
// 忽略读取错误
}
return null;
}

View File

@@ -0,0 +1,125 @@
import { networkInterfaces } from 'os';
import archiver from 'archiver';
import { buildAttachmentContentDisposition } from './contentDisposition';
export function getLocalIP(): string {
const interfaces = networkInterfaces();
for (const name of Object.keys(interfaces)) {
const nets = interfaces[name];
if (!nets) continue;
for (const net of nets) {
if (net.family === 'IPv4' && !net.internal) {
return net.address;
}
}
}
return 'localhost';
}
export function getRequestPathname(req: any): string {
try {
return new URL(req.url || '/', `http://${req.headers.host}`).pathname;
} catch {
return (req.url || '/').split('?')[0];
}
}
export function readJsonBody(req: any): Promise<any> {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk: Buffer) => {
body += chunk.toString('utf8');
});
req.on('end', () => {
if (!body) {
resolve({});
return;
}
try {
resolve(JSON.parse(body));
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}
export function readRequestBody(req: any): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => {
resolve(Buffer.concat(chunks).toString('utf8'));
});
req.on('error', reject);
});
}
export function readErrorString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
export function limitErrorText(value: string, maxLength: number = 500): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength)}...`;
}
export function serializeErrorForLog(error: any) {
const cause = error?.cause;
const stack = readErrorString(error?.stack);
const causeStack = readErrorString(cause?.stack);
return {
name: readErrorString(error?.name) || undefined,
message: readErrorString(error?.message) || undefined,
code: readErrorString(error?.code) || undefined,
errno: readErrorString(error?.errno) || undefined,
syscall: readErrorString(error?.syscall) || undefined,
address: readErrorString(error?.address) || undefined,
port: typeof error?.port === 'number' ? error.port : undefined,
causeName: readErrorString(cause?.name) || undefined,
causeMessage: readErrorString(cause?.message) || undefined,
causeCode: readErrorString(cause?.code) || undefined,
causeErrno: readErrorString(cause?.errno) || undefined,
causeSyscall: readErrorString(cause?.syscall) || undefined,
causeAddress: readErrorString(cause?.address) || undefined,
causePort: typeof cause?.port === 'number' ? cause.port : undefined,
stack: stack ? limitErrorText(stack, 1200) : undefined,
causeStack: causeStack ? limitErrorText(causeStack, 1200) : undefined,
};
}
export function streamDirectoryAsZip(res: any, sourceDir: string, fileName: string) {
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', buildAttachmentContentDisposition(fileName));
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('warning', (warning: any) => {
console.warn('[download-dist-plugin] ZIP warning:', warning);
});
archive.on('error', (error: any) => {
console.error('[download-dist-plugin] ZIP error:', error);
if (!res.headersSent) {
res.statusCode = 500;
res.end(JSON.stringify({ error: `Failed to create zip: ${error.message}` }));
return;
}
res.destroy(error);
});
archive.pipe(res);
archive.directory(sourceDir, false);
void archive.finalize();
}

View File

@@ -0,0 +1,20 @@
export const INSTALL_SKILL_TARGET_DIRS: Record<string, string | null> = {
// IDE editors
cursor: '.cursor/skills',
windsurf: '.windsurf/skills',
qoder: 'skills',
trae: '.trae/skills',
trae_cn: '.trae/skills',
vscode: '.github/skills',
kiro: '.kiro/skills',
antigravity: '.agent/skills',
// CLI / Genie tools
'claude-code': '.claude/skills',
// Codex project-level skills follow the official recommended location.
codex: '.agents/skills',
opencode: '.opencode/skills',
};
export function getInstallSkillTargetDir(client: string): string | null {
return INSTALL_SKILL_TARGET_DIRS[client] ?? null;
}

View File

@@ -0,0 +1,7 @@
import path from 'path';
export const MAKE_STATE_DIR = path.join('.axhub', 'make');
export const MAKE_CONFIG_RELATIVE_PATH = path.join(MAKE_STATE_DIR, 'axhub.config.json');
export const MAKE_DEV_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.dev-server-info.json');
export const MAKE_ENTRIES_RELATIVE_PATH = path.join(MAKE_STATE_DIR, 'entries.json');
export const AXURE_BRIDGE_BASE_URL = 'http://localhost:32767';

View File

@@ -0,0 +1,446 @@
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import { tmpdir } from 'os';
export const DOC_IMPORT_MAX_FILE_SIZE = 30 * 1024 * 1024;
export const DOC_IMPORT_MAX_FILE_COUNT = 30;
export const DOC_IMPORT_MAX_TOTAL_SIZE = 150 * 1024 * 1024;
export const DOC_IMPORT_SUPPORTED_EXTENSIONS = new Set([
'.md',
'.txt',
'.pdf',
'.docx',
'.pptx',
'.xlsx',
'.csv',
'.json',
'.html',
'.xml',
]);
type MarkitdownCommandCandidate = {
command: string;
args: string[];
commandSource: string;
};
type PythonRuntimeProbe = {
command: string;
available: boolean;
versionText: string;
major: number;
minor: number;
meetsRequirement: boolean;
};
export type MarkitdownResolvedCommand = {
installed: boolean;
command?: string;
args?: string[];
pythonCommand?: string;
commandSource: string;
version: string;
installHints: string[];
error: string;
};
type MarkitdownOptionalFeature = {
extension: string;
feature: string;
modules: string[];
};
const MARKITDOWN_MIN_PYTHON_MAJOR = 3;
const MARKITDOWN_MIN_PYTHON_MINOR = 10;
const MARKITDOWN_DIRECT_COMMAND_CANDIDATE: MarkitdownCommandCandidate = {
command: 'markitdown',
args: [],
commandSource: 'markitdown',
};
const MARKITDOWN_PYTHON_COMMAND_CANDIDATES = [
'python3.12',
'python3.11',
'python3.10',
'python3',
'python',
];
const MARKITDOWN_REQUIRED_OPTIONAL_FEATURES: MarkitdownOptionalFeature[] = [
{ extension: '.pdf', feature: 'pdf', modules: ['pdfminer', 'pdfplumber'] },
{ extension: '.docx', feature: 'docx', modules: ['mammoth'] },
{ extension: '.pptx', feature: 'pptx', modules: ['pptx'] },
{ extension: '.xlsx', feature: 'xlsx', modules: ['pandas', 'openpyxl'] },
];
function parsePythonVersionText(versionText: string): { major: number; minor: number } | null {
const match = versionText.match(/Python\s+(\d+)\.(\d+)/i);
if (!match) return null;
return {
major: Number(match[1] || 0),
minor: Number(match[2] || 0),
};
}
function isPythonVersionSupported(major: number, minor: number): boolean {
if (major > MARKITDOWN_MIN_PYTHON_MAJOR) return true;
if (major < MARKITDOWN_MIN_PYTHON_MAJOR) return false;
return minor >= MARKITDOWN_MIN_PYTHON_MINOR;
}
function probePythonRuntime(command: string): PythonRuntimeProbe {
const versionAttempt = spawnSync(command, ['--version'], {
encoding: 'utf8',
timeout: 8000,
maxBuffer: 1024 * 1024,
});
if (versionAttempt.error || versionAttempt.status !== 0) {
return {
command,
available: false,
versionText: '',
major: 0,
minor: 0,
meetsRequirement: false,
};
}
const versionText = String(versionAttempt.stdout || versionAttempt.stderr || '').trim();
const parsedVersion = parsePythonVersionText(versionText);
const major = parsedVersion?.major || 0;
const minor = parsedVersion?.minor || 0;
return {
command,
available: true,
versionText,
major,
minor,
meetsRequirement: isPythonVersionSupported(major, minor),
};
}
function buildMarkitdownInstallHints(preferredPythonCommand?: string): string[] {
const installCommand = `${preferredPythonCommand || 'python3.11'} -m pip install -U 'markitdown[pdf,docx,pptx,xlsx]'`;
if (preferredPythonCommand) {
return [installCommand];
}
return [
'brew install python@3.11',
installCommand,
];
}
function isCommandWorking(candidate: MarkitdownCommandCandidate): {
success: boolean;
version: string;
details: string;
} {
const versionAttempt = spawnSync(
candidate.command,
[...candidate.args, '--version'],
{ encoding: 'utf8', timeout: 8000, maxBuffer: 1024 * 1024 },
);
const versionOutput = String(versionAttempt.stdout || versionAttempt.stderr || '').trim();
if (!versionAttempt.error && versionAttempt.status === 0) {
return {
success: true,
version: versionOutput || 'unknown',
details: '',
};
}
const helpAttempt = spawnSync(
candidate.command,
[...candidate.args, '--help'],
{ encoding: 'utf8', timeout: 8000, maxBuffer: 1024 * 1024 },
);
const helpOutput = String(helpAttempt.stdout || helpAttempt.stderr || '').trim();
if (!helpAttempt.error && helpAttempt.status === 0) {
return {
success: true,
version: versionOutput || 'available',
details: '',
};
}
return {
success: false,
version: '',
details: [versionOutput, helpOutput].filter(Boolean).join('\n'),
};
}
function resolveMarkitdownPythonCommand(candidate: MarkitdownCommandCandidate): string | undefined {
if (candidate.args[0] === '-m' && candidate.args[1] === 'markitdown') {
return candidate.command;
}
const whichAttempt = spawnSync('which', [candidate.command], {
encoding: 'utf8',
timeout: 8000,
maxBuffer: 1024 * 1024,
});
if (whichAttempt.error || whichAttempt.status !== 0) {
return undefined;
}
const executablePath = String(whichAttempt.stdout || '').trim().split(/\r?\n/)[0]?.trim();
if (!executablePath || !fs.existsSync(executablePath)) {
return undefined;
}
try {
const firstLine = fs.readFileSync(executablePath, 'utf8').split(/\r?\n/, 1)[0]?.trim() || '';
if (!firstLine.startsWith('#!')) {
return undefined;
}
const shebang = firstLine.slice(2).trim();
if (!shebang) {
return undefined;
}
const shebangParts = shebang.split(/\s+/).filter(Boolean);
if (shebangParts[0] === '/usr/bin/env') {
return shebangParts[1];
}
return shebangParts[0];
} catch {
return undefined;
}
}
function probeMarkitdownOptionalDependencies(pythonCommand?: string): {
ready: boolean;
missingExtensions: string[];
error: string;
installHints: string[];
} {
if (!pythonCommand) {
return {
ready: true,
missingExtensions: [],
error: '',
installHints: [],
};
}
const checksJson = JSON.stringify(MARKITDOWN_REQUIRED_OPTIONAL_FEATURES);
const probeScript = [
'import importlib.util, json',
`checks = json.loads(${JSON.stringify(checksJson)})`,
'missing = []',
'for item in checks:',
" missing_modules = [name for name in item['modules'] if importlib.util.find_spec(name) is None]",
' if missing_modules:',
" missing.append({'extension': item['extension'], 'feature': item['feature'], 'missingModules': missing_modules})",
"print(json.dumps({'missing': missing}, ensure_ascii=False))",
].join('\n');
const probeAttempt = spawnSync(pythonCommand, ['-c', probeScript], {
encoding: 'utf8',
timeout: 8000,
maxBuffer: 1024 * 1024,
});
if (probeAttempt.error || probeAttempt.status !== 0) {
return {
ready: false,
missingExtensions: [],
error: `markitdown 依赖检测失败,请执行 ${buildMarkitdownInstallHints(pythonCommand)[0]} 后重试。`,
installHints: buildMarkitdownInstallHints(pythonCommand),
};
}
try {
const payload = JSON.parse(String(probeAttempt.stdout || '{}')) as {
missing?: Array<{ extension?: string; missingModules?: string[] }>;
};
const missing = Array.isArray(payload?.missing) ? payload.missing : [];
if (missing.length === 0) {
return {
ready: true,
missingExtensions: [],
error: '',
installHints: [],
};
}
const missingDescriptions = missing.map((item) => {
const extension = String(item?.extension || '').trim() || 'unknown';
const missingModules = Array.isArray(item?.missingModules) ? item.missingModules.filter(Boolean) : [];
return `${extension}${missingModules.length > 0 ? `(缺少:${missingModules.join(', ')}` : ''}`;
});
return {
ready: false,
missingExtensions: missing.map((item) => String(item?.extension || '').trim()).filter(Boolean),
error: `markitdown 已安装,但以下格式依赖不完整:${missingDescriptions.join('、')}。请先安装完整依赖后再导入非 .md 文档。`,
installHints: buildMarkitdownInstallHints(pythonCommand),
};
} catch {
return {
ready: false,
missingExtensions: [],
error: `markitdown 依赖检测结果无法解析,请执行 ${buildMarkitdownInstallHints(pythonCommand)[0]} 后重试。`,
installHints: buildMarkitdownInstallHints(pythonCommand),
};
}
}
export function resolveMarkitdownCommand(): MarkitdownResolvedCommand {
const directCommandResult = isCommandWorking(MARKITDOWN_DIRECT_COMMAND_CANDIDATE);
if (directCommandResult.success) {
const pythonCommand = resolveMarkitdownPythonCommand(MARKITDOWN_DIRECT_COMMAND_CANDIDATE);
const dependencyProbe = probeMarkitdownOptionalDependencies(pythonCommand);
return {
installed: dependencyProbe.ready,
command: MARKITDOWN_DIRECT_COMMAND_CANDIDATE.command,
args: MARKITDOWN_DIRECT_COMMAND_CANDIDATE.args,
pythonCommand,
commandSource: MARKITDOWN_DIRECT_COMMAND_CANDIDATE.commandSource,
version: directCommandResult.version,
installHints: dependencyProbe.installHints,
error: dependencyProbe.error,
};
}
const pythonRuntimeProbes = MARKITDOWN_PYTHON_COMMAND_CANDIDATES
.map((command) => probePythonRuntime(command))
.filter((probe, index, allProbes) => allProbes.findIndex((item) => item.command === probe.command) === index);
const supportedPythonRuntimes = pythonRuntimeProbes.filter((probe) => probe.available && probe.meetsRequirement);
const preferredPythonCommand = supportedPythonRuntimes[0]?.command;
let sawLegacyPackage = false;
let sawModuleMissing = false;
for (const pythonRuntime of supportedPythonRuntimes) {
const pythonCandidate: MarkitdownCommandCandidate = {
command: pythonRuntime.command,
args: ['-m', 'markitdown'],
commandSource: `${pythonRuntime.command} -m markitdown`,
};
const candidateResult = isCommandWorking(pythonCandidate);
if (candidateResult.success) {
const dependencyProbe = probeMarkitdownOptionalDependencies(pythonRuntime.command);
return {
installed: dependencyProbe.ready,
command: pythonCandidate.command,
args: pythonCandidate.args,
pythonCommand: pythonRuntime.command,
commandSource: pythonCandidate.commandSource,
version: candidateResult.version,
installHints: dependencyProbe.installHints,
error: dependencyProbe.error,
};
}
const details = candidateResult.details || '';
if (/markitdown\.__main__|cannot be directly executed/i.test(details)) {
sawLegacyPackage = true;
} else if (/No module named markitdown/i.test(details)) {
sawModuleMissing = true;
}
}
if (supportedPythonRuntimes.length === 0) {
const availableVersions = pythonRuntimeProbes
.filter((probe) => probe.available)
.map((probe) => `${probe.command} (${probe.versionText || 'unknown'})`)
.join(', ');
return {
installed: false,
commandSource: 'unavailable',
version: '',
installHints: buildMarkitdownInstallHints(),
error: availableVersions
? `markitdown 需要 Python 3.10+。当前仅检测到:${availableVersions}`
: 'markitdown 需要 Python 3.10+。当前未检测到可用的 Python 运行时。',
};
}
if (sawLegacyPackage) {
return {
installed: false,
commandSource: 'unavailable',
version: '',
installHints: buildMarkitdownInstallHints(preferredPythonCommand),
error: '检测到旧版 markitdown例如 0.0.1a1),该版本没有 CLI 入口。请在 Python 3.10+ 环境重新安装最新版。',
};
}
if (sawModuleMissing) {
return {
installed: false,
commandSource: 'unavailable',
version: '',
installHints: buildMarkitdownInstallHints(preferredPythonCommand),
error: `未在 ${preferredPythonCommand || 'Python 3.10+'} 环境中检测到 markitdown请先安装后重试。`,
};
}
return {
installed: false,
commandSource: 'unavailable',
version: '',
installHints: buildMarkitdownInstallHints(preferredPythonCommand),
error: 'markitdown 不可用,请安装后重试。',
};
}
export function convertFileToMarkdownWithMarkitdown(params: {
command: string;
args: string[];
sourcePath: string;
}): { success: true; content: string } | { success: false; error: string } {
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'axhub-doc-import-'));
const outputPath = path.join(tempDir, 'output.md');
try {
const result = spawnSync(
params.command,
[...params.args, '--keep-data-uris', params.sourcePath, '-o', outputPath],
{
encoding: 'utf8',
timeout: 120000,
maxBuffer: 1024 * 1024 * 20,
},
);
if (result.error) {
return {
success: false,
error: result.error.message || 'markitdown execution failed',
};
}
if (result.status !== 0) {
const stderr = String(result.stderr || '').trim();
const stdout = String(result.stdout || '').trim();
const details = stderr || stdout || `exit code ${result.status}`;
return {
success: false,
error: `markitdown convert failed: ${details}`,
};
}
if (!fs.existsSync(outputPath)) {
return {
success: false,
error: 'markitdown did not produce output file',
};
}
const content = fs.readFileSync(outputPath, 'utf8');
return {
success: true,
content,
};
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}

View File

@@ -0,0 +1,157 @@
import { limitErrorText, readErrorString } from './httpUtils';
export function formatAxureProxyErrorDetails(error: any): string {
const parts: string[] = [];
const message = readErrorString(error?.message);
const causeMessage = readErrorString(error?.cause?.message);
const code = readErrorString(error?.code) || readErrorString(error?.cause?.code);
const errno = readErrorString(error?.errno) || readErrorString(error?.cause?.errno);
const syscall = readErrorString(error?.syscall) || readErrorString(error?.cause?.syscall);
const address = readErrorString(error?.address) || readErrorString(error?.cause?.address);
const port =
typeof error?.port === 'number'
? String(error.port)
: typeof error?.cause?.port === 'number'
? String(error.cause.port)
: '';
if (message) {
parts.push(message);
}
if (causeMessage && causeMessage !== message) {
parts.push(`cause=${causeMessage}`);
}
if (code) {
parts.push(`code=${code}`);
}
if (errno && errno !== code) {
parts.push(`errno=${errno}`);
}
if (syscall) {
parts.push(`syscall=${syscall}`);
}
if (address) {
parts.push(`address=${address}`);
}
if (port) {
parts.push(`port=${port}`);
}
return parts.join('; ') || 'Unknown upstream error';
}
export function normalizeAxvgPayloadText(rawBody: string): string {
const source = rawBody.trim();
if (!source) {
return '// axvg\n{}';
}
if (source.startsWith('// axvg')) {
return source;
}
return `// axvg\n${source}`;
}
export function buildAxureBridgeUnavailablePayload(params: {
route: string;
method: string;
bridgeUrl: string;
payloadBytes?: number;
error?: any;
status?: number;
statusText?: string;
responseText?: string;
}) {
const errorCode =
readErrorString(params.error?.code)
|| readErrorString(params.error?.cause?.code)
|| undefined;
const errorMessage =
readErrorString(params.error?.message)
|| readErrorString(params.responseText)
|| (typeof params.status === 'number' ? `Axure Bridge unavailable (HTTP ${params.status})` : 'Axure Bridge unavailable');
const details =
params.error
? formatAxureProxyErrorDetails(params.error)
: limitErrorText(readErrorString(params.responseText), 800) || undefined;
return {
available: false,
running: false,
success: false,
error: errorMessage,
details,
code: errorCode,
route: params.route,
method: params.method,
bridgeUrl: params.bridgeUrl,
payloadBytes: params.payloadBytes || undefined,
status: typeof params.status === 'number' ? params.status : undefined,
statusText: readErrorString(params.statusText) || undefined,
};
}
export function isLoopbackOrPrivateHostname(hostname: string): boolean {
const normalized = String(hostname || '').trim().toLowerCase();
if (!normalized) {
return true;
}
if (
normalized === 'localhost' ||
normalized === '127.0.0.1' ||
normalized === '0.0.0.0' ||
normalized === '::1' ||
normalized === '[::1]'
) {
return true;
}
if (/^127\./.test(normalized)) {
return true;
}
if (/^10\./.test(normalized)) {
return true;
}
if (/^192\.168\./.test(normalized)) {
return true;
}
if (/^169\.254\./.test(normalized)) {
return true;
}
const match172 = normalized.match(/^172\.(\d{1,3})\./);
if (match172) {
const secondOctet = Number(match172[1]);
if (secondOctet >= 16 && secondOctet <= 31) {
return true;
}
}
return false;
}
export function isAllowedProxyImageUrl(rawUrl: string): boolean {
let parsedUrl: URL;
try {
parsedUrl = new URL(rawUrl);
} catch {
return false;
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return false;
}
if (isLoopbackOrPrivateHostname(parsedUrl.hostname)) {
return false;
}
return true;
}
export { limitErrorText };

View File

@@ -0,0 +1,237 @@
import fs from 'fs';
import path from 'path';
import type { SidebarTreeTab } from './entryScanner';
export type ResourceOrderType = 'themes' | 'data' | 'templates';
export type SidebarTreeNodeKind = 'folder' | 'item';
export interface SidebarTreeNode {
id: string;
kind: SidebarTreeNodeKind;
title: string;
itemKey?: string;
children?: SidebarTreeNode[];
}
export interface SidebarTreeStore {
version: number;
updatedAt: string;
prototypes: SidebarTreeNode[];
components: SidebarTreeNode[];
docs: SidebarTreeNode[];
canvas: SidebarTreeNode[];
themes: string[];
data: string[];
templates: string[];
}
interface EntriesWithLegacySidebarTree {
sidebarTree?: {
version?: number;
prototypes?: SidebarTreeNode[];
components?: SidebarTreeNode[];
docs?: SidebarTreeNode[];
canvas?: SidebarTreeNode[];
themes?: string[];
data?: string[];
templates?: string[];
};
}
interface SidebarTreeStoreOptions {
version: number;
legacyEntriesPath: string;
storePath: string;
}
const ENTRIES_MANIFEST_RELATIVE_PATH = path.join('.axhub', 'make', 'entries.json');
export const SIDEBAR_TREE_STORE_RELATIVE_PATH = path.join('.axhub', 'make', 'sidebar-tree.json');
function createDefaultStore(version: number): SidebarTreeStore {
return {
version,
updatedAt: new Date().toISOString(),
prototypes: [],
components: [],
docs: [],
canvas: [],
themes: [],
data: [],
templates: [],
};
}
function readJsonFile(filePath: string): unknown {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
function cloneTree(nodes: SidebarTreeNode[]): SidebarTreeNode[] {
return nodes.map((node) => ({
...node,
children: Array.isArray(node.children) ? cloneTree(node.children) : undefined,
}));
}
function normalizeStore(data: unknown, version: number): SidebarTreeStore | null {
if (!data || typeof data !== 'object') {
return null;
}
const parsed = data as Partial<SidebarTreeStore>;
const prototypes = Array.isArray(parsed.prototypes) ? cloneTree(parsed.prototypes) : [];
const components = Array.isArray(parsed.components) ? cloneTree(parsed.components) : [];
const docs = Array.isArray(parsed.docs) ? cloneTree(parsed.docs) : [];
const canvas = Array.isArray(parsed.canvas) ? cloneTree(parsed.canvas) : [];
const themes = Array.isArray(parsed.themes)
? parsed.themes.filter((key): key is string => typeof key === 'string')
: [];
const dataOrder = Array.isArray(parsed.data)
? parsed.data.filter((key): key is string => typeof key === 'string')
: [];
const updatedAt = typeof parsed.updatedAt === 'string' && parsed.updatedAt.trim()
? parsed.updatedAt
: new Date().toISOString();
const templates = Array.isArray(parsed.templates)
? parsed.templates.filter((key): key is string => typeof key === 'string')
: [];
return {
version,
updatedAt,
prototypes,
components,
docs,
canvas,
themes,
data: dataOrder,
templates,
};
}
function readLegacySidebarTree(legacyEntriesPath: string, version: number): SidebarTreeStore | null {
const data = readJsonFile(legacyEntriesPath);
if (!data || typeof data !== 'object') {
return null;
}
const entries = data as EntriesWithLegacySidebarTree;
const legacy = entries.sidebarTree;
if (!legacy || typeof legacy !== 'object') {
return null;
}
return {
version,
updatedAt: new Date().toISOString(),
prototypes: Array.isArray(legacy.prototypes) ? cloneTree(legacy.prototypes) : [],
components: Array.isArray(legacy.components) ? cloneTree(legacy.components) : [],
docs: Array.isArray((legacy as any).docs) ? cloneTree((legacy as any).docs) : [],
canvas: Array.isArray((legacy as any).canvas) ? cloneTree((legacy as any).canvas) : [],
themes: Array.isArray((legacy as any).themes)
? (legacy as any).themes.filter((key: unknown): key is string => typeof key === 'string')
: [],
data: Array.isArray((legacy as any).data)
? (legacy as any).data.filter((key: unknown): key is string => typeof key === 'string')
: [],
templates: Array.isArray((legacy as any).templates)
? (legacy as any).templates.filter((key: unknown): key is string => typeof key === 'string')
: [],
};
}
function writeStoreAtomic(storePath: string, store: SidebarTreeStore): void {
const dir = path.dirname(storePath);
fs.mkdirSync(dir, { recursive: true });
const tempPath = `${storePath}.tmp-${process.pid}-${Date.now()}`;
try {
fs.writeFileSync(tempPath, JSON.stringify(store, null, 2), 'utf8');
fs.renameSync(tempPath, storePath);
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
function resolveOptions(projectRoot: string, options?: Partial<SidebarTreeStoreOptions>): SidebarTreeStoreOptions {
return {
version: options?.version ?? 1,
legacyEntriesPath: options?.legacyEntriesPath ?? path.join(projectRoot, ENTRIES_MANIFEST_RELATIVE_PATH),
storePath: options?.storePath ?? path.join(projectRoot, SIDEBAR_TREE_STORE_RELATIVE_PATH),
};
}
export function createSidebarTreeStore(projectRoot: string, options?: Partial<SidebarTreeStoreOptions>) {
const resolved = resolveOptions(projectRoot, options);
const ensureStore = (): SidebarTreeStore => {
const loaded = normalizeStore(readJsonFile(resolved.storePath), resolved.version);
if (loaded) {
return loaded;
}
const migrated = readLegacySidebarTree(resolved.legacyEntriesPath, resolved.version);
const nextStore = migrated || createDefaultStore(resolved.version);
writeStoreAtomic(resolved.storePath, nextStore);
return nextStore;
};
const saveStore = (store: SidebarTreeStore) => {
const nextStore: SidebarTreeStore = {
version: resolved.version,
updatedAt: new Date().toISOString(),
prototypes: cloneTree(store.prototypes),
components: cloneTree(store.components),
docs: cloneTree(store.docs),
canvas: cloneTree(store.canvas),
themes: Array.isArray(store.themes) ? [...store.themes] : [],
data: Array.isArray(store.data) ? [...store.data] : [],
templates: Array.isArray(store.templates) ? [...store.templates] : [],
};
writeStoreAtomic(resolved.storePath, nextStore);
return nextStore;
};
return {
getStorePath() {
return resolved.storePath;
},
getStore() {
return ensureStore();
},
getTree(tab: SidebarTreeTab) {
const store = ensureStore();
return cloneTree(store[tab]);
},
setTree(tab: SidebarTreeTab, tree: SidebarTreeNode[]) {
const store = ensureStore();
const nextStore: SidebarTreeStore = {
...store,
[tab]: cloneTree(tree),
};
return saveStore(nextStore);
},
getResourceOrder(type: ResourceOrderType) {
const store = ensureStore();
return Array.isArray(store[type]) ? [...store[type]] : [];
},
setResourceOrder(type: ResourceOrderType, order: string[]) {
const store = ensureStore();
const nextStore: SidebarTreeStore = {
...store,
[type]: Array.isArray(order) ? [...order] : [],
};
return saveStore(nextStore);
},
};
}

View File

@@ -0,0 +1,224 @@
/**
* Validation service for data management
* Handles validation of data records, IDs, and data types
*/
import { DataRecord } from './lowdbService';
/**
* Custom validation error class
*/
export class ValidationError extends Error {
constructor(
message: string,
public field?: string,
public details?: any
) {
super(message);
this.name = 'ValidationError';
}
}
/**
* Validate that a value is not empty
*/
export function validateRequired(value: any, fieldName: string): void {
if (value === undefined || value === null || value === '') {
throw new ValidationError(`Missing required field: ${fieldName}`, fieldName);
}
}
/**
* Validate file name format
* File names can contain letters, numbers, underscores, hyphens, and Chinese characters
*/
export function validateFileName(fileName: string): void {
if (!fileName || typeof fileName !== 'string') {
throw new ValidationError('Invalid fileName parameter', 'fileName');
}
// Trim whitespace
const trimmed = fileName.trim();
if (trimmed === '') {
throw new ValidationError('fileName cannot be empty', 'fileName');
}
// Check for invalid characters in file name (allow Chinese characters)
// Disallow: / \ : * ? " < > |
if (/[/\\:*?"<>|]/.test(trimmed)) {
throw new ValidationError(
'fileName cannot contain the following characters: / \\ : * ? " < > |',
'fileName'
);
}
}
/**
* Validate that ID field exists and is a valid string or number
* Requirement 3.2: Validate the required id field before persisting
* Updated to support both string and number IDs
*/
export function validateIdField(id: any): void {
if (id === undefined || id === null) {
throw new ValidationError('Missing required field: id', 'id');
}
// Accept both string and number types
if (typeof id !== 'string' && typeof id !== 'number') {
throw new ValidationError('Invalid id: must be a string or number', 'id');
}
// For string IDs, check if empty
if (typeof id === 'string' && id.trim() === '') {
throw new ValidationError('Invalid id: cannot be empty', 'id');
}
// For number IDs, check if valid (not NaN, not Infinity)
if (typeof id === 'number' && (!Number.isFinite(id))) {
throw new ValidationError('Invalid id: must be a finite number', 'id');
}
}
/**
* Validate data types for record fields
* Requirement 3.4: Validate data types for all fields
*/
export function validateDataTypes(data: any): void {
if (!data || typeof data !== 'object') {
throw new ValidationError('Invalid data: must be an object');
}
// Check if data is an array (not allowed for single record)
if (Array.isArray(data)) {
throw new ValidationError('Invalid data: expected object, got array');
}
// Validate that all values are of acceptable types
for (const [key, value] of Object.entries(data)) {
// Skip id field as it's validated separately
if (key === 'id') continue;
// Check for undefined values (null is allowed)
if (value === undefined) {
throw new ValidationError(
`Invalid data type for field '${key}': undefined is not allowed`,
key
);
}
// Check for functions (not allowed in JSON)
if (typeof value === 'function') {
throw new ValidationError(
`Invalid data type for field '${key}': functions are not allowed`,
key
);
}
// Check for symbols (not allowed in JSON)
if (typeof value === 'symbol') {
throw new ValidationError(
`Invalid data type for field '${key}': symbols are not allowed`,
key
);
}
// Nested objects are allowed, but check for circular references
if (typeof value === 'object' && value !== null) {
try {
JSON.stringify(value);
} catch (e) {
throw new ValidationError(
`Invalid data type for field '${key}': circular reference or non-serializable value`,
key
);
}
}
}
}
/**
* Check for duplicate ID in existing records
* Requirement 3.3: Detect duplicate IDs
* Updated to support both string and number IDs using loose equality
*/
export function checkDuplicateId(id: string | number, existingRecords: DataRecord[], excludeId?: string | number): void {
const duplicate = existingRecords.find(record => {
// If excludeId is provided (for updates), skip that record
if (excludeId !== undefined && record.id == excludeId) {
return false;
}
// Use loose equality to match both string and number IDs
return record.id == id;
});
if (duplicate) {
throw new ValidationError(
`Duplicate id: a record with id '${id}' already exists`,
'id',
{ duplicateId: id }
);
}
}
/**
* Validate a complete record for insertion
* Combines all validation checks
*/
export function validateRecordForInsert(data: any): void {
// Validate data structure
validateDataTypes(data);
// If id is provided, validate it
if (data.id !== undefined) {
validateIdField(data.id);
}
}
/**
* Validate a record for update
* Similar to insert but allows partial data
*/
export function validateRecordForUpdate(data: any): void {
// Validate data structure
validateDataTypes(data);
// If id is provided in update data, validate it
if (data.id !== undefined) {
validateIdField(data.id);
}
}
/**
* Validate CSV import data
* Checks each row for valid structure
*/
export function validateCSVData(csvData: any[]): void {
if (!Array.isArray(csvData)) {
throw new ValidationError('Invalid CSV data: must be an array');
}
if (csvData.length === 0) {
throw new ValidationError('Invalid CSV data: cannot be empty');
}
// Validate each row
csvData.forEach((row, index) => {
try {
validateDataTypes(row);
// If row has id, validate it (support both string and number)
if (row.id !== undefined && row.id !== null && row.id !== '') {
validateIdField(row.id);
}
} catch (e) {
if (e instanceof ValidationError) {
throw new ValidationError(
`Invalid data in CSV row ${index + 1}: ${e.message}`,
e.field,
{ row: index + 1, ...e.details }
);
}
throw e;
}
});
}

View File

@@ -0,0 +1,33 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { getRequestPathname } from './utils/httpUtils';
export function versionApiPlugin(): Plugin {
return {
name: 'version-api-plugin',
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
const pathname = getRequestPathname(req);
if (req.method !== 'GET' || pathname !== '/api/version') {
return next();
}
try {
const pkgPath = path.resolve(process.cwd(), 'package.json');
const pkg = fs.existsSync(pkgPath) ? JSON.parse(fs.readFileSync(pkgPath, 'utf8')) : null;
const version = pkg?.version ?? null;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ version }));
} catch (error: any) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: error?.message || 'Unknown error' }));
}
});
},
};
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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;
}

View 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;
}

View 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, '&quot;'));
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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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);
}
});
}
};
}

View 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);
}

View 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('/');
}

View File

@@ -0,0 +1,863 @@
import type { Plugin, ViteDevServer } from 'vite';
import { WebSocketServer, WebSocket } from 'ws';
import type { IncomingMessage } from 'http';
import fs from 'fs';
import path from 'path';
import extractZip from 'extract-zip';
import { runCommand } from '../scripts/utils/command-runtime.mjs';
export interface WebSocketMessage {
type: string;
data?: any;
payload?: any;
client?: string;
version?: string;
}
export interface ClientMeta {
id: number;
type: string; // 'figma' | 'vscode' | 'browser' | 'unknown'
version?: string;
address?: string;
connectedAt: number;
}
interface UploadSession {
transferId: string;
pageName: string;
displayName?: string;
outputRelativeDir: string;
fileName: string;
mode: 'zip' | 'files';
totalChunks: number;
totalBytes?: number;
receivedChunks: number;
receivedBytes: number;
chunks: Map<number, Buffer>;
filesRoot?: string;
filesReceived: number;
startedAt: number;
}
interface HandleMessageContext {
clientMeta: Map<WebSocket, ClientMeta>;
uploadSessions: Map<string, UploadSession>;
projectRoot: string;
}
const IGNORED_EXTRACT_ENTRIES = new Set(['__MACOSX', '.DS_Store']);
const nodeCommand = process.execPath;
function ensureDir(dirPath: string) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function inferExtractedRootFolder(extractDir: string) {
if (!fs.existsSync(extractDir)) {
return { entryCount: 0, hasRootFolder: false, rootFolderName: '' };
}
const entries = fs
.readdirSync(extractDir, { withFileTypes: true })
.filter(entry => !IGNORED_EXTRACT_ENTRIES.has(entry.name));
if (entries.length === 1 && entries[0].isDirectory()) {
return { entryCount: entries.length, hasRootFolder: true, rootFolderName: entries[0].name };
}
return { entryCount: entries.length, hasRootFolder: false, rootFolderName: '' };
}
function isSafeName(value: string) {
return Boolean(value && value.trim() && !value.includes('..') && !/[\\/]/.test(value));
}
function isSafeRelativePath(value: string) {
if (!value || typeof value !== 'string') return false;
const normalized = value.replace(/\\/g, '/');
if (normalized.startsWith('/') || normalized.startsWith('~')) return false;
if (normalized.split('/').some(part => part === '..')) return false;
return true;
}
function isValidDisplayName(value?: string) {
if (value === undefined) return true;
const text = String(value).trim();
return text.length > 0 && text.length <= 200;
}
function normalizeRelativeDir(value: string) {
return value
.replace(/\\/g, '/')
.split('/')
.filter(Boolean)
.join('/');
}
function resolveOutputRelativeDir(data: any, fallbackName: string) {
const candidates = [
data?.outputRelativeDir,
data?.outputPath,
data?.targetPath,
data?.targetDir,
data?.folderPath,
data?.relativePath,
data?.pagePath,
];
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
const normalized = normalizeRelativeDir(candidate.trim());
if (!normalized) continue;
if (!isSafeRelativePath(normalized)) return null;
const segments = normalized.split('/');
if (segments.length === 0 || segments.some(segment => !isSafeName(segment))) {
return null;
}
return normalized;
}
return fallbackName;
}
function resolvePrototypeOutputDir(projectRoot: string, outputRelativeDir: string) {
return path.join(projectRoot, 'src', 'prototypes', ...outputRelativeDir.split('/'));
}
function sendWsMessage(ws: WebSocket, payload: any) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
/**
* WebSocket 插件 - 在 Vite 开发服务器上添加 WebSocket 支持
*/
export function websocketPlugin(): Plugin {
let wss: WebSocketServer | null = null;
const clients = new Set<WebSocket>();
const clientMeta = new Map<WebSocket, ClientMeta>();
const uploadSessions = new Map<string, UploadSession>();
let nextClientId = 1;
const WS_PATH = '/ws';
const projectRoot = process.cwd();
return {
name: 'vite-websocket',
apply: 'serve',
configureServer(server: ViteDevServer) {
// 使用独立的 noServer 模式,避免抢占 Vite 自带的 HMR WebSocket 升级请求
// (此前共享同一个 httpServer 且指定 path 会拦截非 /ws 升级,导致 HMR 报 Invalid frame header
wss = new WebSocketServer({ noServer: true });
const handleUpgrade = (req: IncomingMessage, socket: any, head: Buffer) => {
// 只处理 /ws 的升级请求,其余交给 Vite 自己的 HMR 逻辑
const pathname = req.url ? new URL(req.url, 'http://localhost').pathname : '';
if (pathname !== WS_PATH) {
return;
}
wss?.handleUpgrade(req, socket, head, (ws) => {
wss?.emit('connection', ws, req);
});
};
server.httpServer?.on('upgrade', handleUpgrade);
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
// 从 URL 查询参数中获取客户端类型
const url = new URL(req.url || '', 'http://localhost');
const clientType = url.searchParams.get('client') || 'unknown';
const clientVersion = url.searchParams.get('version') || undefined;
console.log('[WebSocket] 新客户端连接:', {
type: clientType,
version: clientVersion,
address: req.socket.remoteAddress
});
clients.add(ws);
const meta: ClientMeta = {
id: nextClientId++,
type: clientType,
version: clientVersion,
address: req.socket.remoteAddress,
connectedAt: Date.now()
};
clientMeta.set(ws, meta);
// 发送欢迎消息
ws.send(JSON.stringify({
type: 'connected',
message: 'WebSocket 连接成功'
}));
// 处理客户端消息
ws.on('message', (rawData) => {
try {
const payloadText = typeof rawData === 'string'
? rawData
: Buffer.isBuffer(rawData)
? rawData.toString()
: Array.isArray(rawData)
? Buffer.concat(rawData).toString()
: Buffer.from(rawData as ArrayBuffer).toString();
const message: WebSocketMessage = JSON.parse(payloadText);
const client = clientMeta.get(ws);
const bodyKeys = message && typeof message === 'object' ? Object.keys(message as any) : [];
const dataKeys = message?.data && typeof message.data === 'object'
? Object.keys(message.data)
: [];
console.log('[WebSocket] 收到 WS 消息:', {
clientId: client?.id,
address: client?.address,
type: message?.type,
hasData: message?.data !== undefined,
bodyKeys,
dataKeys,
bytes: Buffer.byteLength(payloadText, 'utf8')
});
// 处理不同类型的消息
handleMessage(ws, message, clients, { clientMeta, uploadSessions, projectRoot });
} catch (err) {
console.error('[WebSocket] 解析消息失败:', err);
ws.send(JSON.stringify({
type: 'error',
message: '消息格式错误'
}));
}
});
// 处理连接关闭
ws.on('close', () => {
console.log('[WebSocket] 客户端断开连接');
clients.delete(ws);
clientMeta.delete(ws);
});
// 处理错误
ws.on('error', (err) => {
console.error('[WebSocket] 连接错误:', err);
clients.delete(ws);
clientMeta.delete(ws);
});
});
// HTTP API: 获取当前 WS 客户端信息
server.middlewares.use('/api/ws/clients', (req, res) => {
if (req.method !== 'GET') {
res.statusCode = 405;
res.end('Method Not Allowed');
return;
}
const list = Array.from(clientMeta.values()).map((item) => ({
id: item.id,
type: item.type,
version: item.version,
address: item.address,
connectedAt: item.connectedAt
}));
// 统计各类型客户端数量
const stats = Array.from(clientMeta.values()).reduce((acc, item) => {
acc[item.type] = (acc[item.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
clients: list,
stats,
total: list.length
}));
});
// HTTP API: 发送消息给全部客户端
server.middlewares.use('/api/ws/send', (req, res) => {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end('Method Not Allowed');
return;
}
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
try {
const parsedBody = JSON.parse(body || '{}');
const { type } = parsedBody;
const payload = (parsedBody as any).payload;
const targetClientType = (parsedBody as any).targetClientType;
const targetClientTypes = (parsedBody as any).targetClientTypes;
// 兼容历史字段payload / data
const data = payload !== undefined
? payload
: (parsedBody as any).data;
// 验证 type
if (!type || typeof type !== 'string') {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'type is required' }));
return;
}
// 验证 data
if (data === undefined || data === null) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'data is required' }));
return;
}
// 针对 sync-widget-content 的特殊验证
if (type === 'sync-widget-content') {
// 验证 widgetId必需字段
if (!parsedBody.widgetId || typeof parsedBody.widgetId !== 'string') {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'widgetId is required for sync-widget-content' }));
return;
}
// 验证 data.layers 不为空
if (!data || typeof data !== 'object' || !data.layers) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'data.layers is required' }));
return;
}
if (!Array.isArray(data.layers) || data.layers.length === 0) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'data.layers array is empty' }));
return;
}
}
// 针对 sync-page-content 的验证
if (type === 'sync-page-content') {
// 验证 data.layers 不为空
if (!data || typeof data !== 'object' || !data.layers) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'data.layers is required' }));
return;
}
if (!Array.isArray(data.layers) || data.layers.length === 0) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'data.layers array is empty' }));
return;
}
}
const allClientCount = clients.size;
const normalizedTargetClientTypes = Array.isArray(targetClientTypes)
? targetClientTypes.filter((v) => typeof v === 'string' && v)
: typeof targetClientType === 'string' && targetClientType
? [targetClientType]
: [];
// 这里的 targetClientTypes 是插件层的“按客户端类型定向”机制。
// 当前前端仅对 vscode/cursor 做 open-file 的稳定投递;其他 IDE 本轮通过系统命令打开,不走 WS 定向。
const targetClients = normalizedTargetClientTypes.length === 0
? clients
: new Set(
Array.from(clients).filter((ws) => {
const meta = clientMeta.get(ws);
return meta?.type && normalizedTargetClientTypes.includes(meta.type);
})
);
const targetClientCount = targetClients.size;
// 如果没有客户端连接
if (allClientCount === 0) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ ok: true, sent: 0, warning: 'no clients connected' }));
return;
}
console.log('[WebSocket] /api/ws/send 请求:', {
type,
hasPayload: data !== undefined,
clientCount: allClientCount,
targetClientCount,
targetClientTypes: normalizedTargetClientTypes,
bodyKeys: parsedBody && typeof parsedBody === 'object' ? Object.keys(parsedBody) : []
});
// 立即返回成功状态(不等待渲染完成)
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
ok: true,
sent: targetClientCount,
...(normalizedTargetClientTypes.length > 0 && targetClientCount === 0
? { warning: 'no target clients connected' }
: {})
}));
// 异步广播消息(不阻塞响应)
setImmediate(() => {
try {
// 构建完整的消息对象包含所有字段type, widgetId/pageId, data, blurImages, metadata 等)
const message: any = {
type,
...(parsedBody.widgetId ? { widgetId: parsedBody.widgetId } : {}),
...(parsedBody.pageId ? { pageId: parsedBody.pageId } : {}),
...(payload !== undefined ? { payload } : {}),
data,
...(parsedBody.blurImages !== undefined ? { blurImages: parsedBody.blurImages } : {}),
...(parsedBody.metadata ? { metadata: parsedBody.metadata } : {})
};
const sentCount = broadcast(targetClients, message);
console.log('[WebSocket] 消息已广播:', { type, sentCount });
} catch (err) {
console.error('[WebSocket] 广播消息失败:', err);
}
});
} catch (err) {
console.error('[WebSocket] /api/ws/send 解析失败:', err);
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'invalid json body' }));
}
});
});
// 服务器关闭时清理
server.httpServer?.on('close', () => {
if (wss) {
wss.close();
clients.clear();
console.log('[WebSocket] 服务器已关闭');
}
server.httpServer?.off?.('upgrade', handleUpgrade);
});
}
};
}
/**
* 处理 WebSocket 消息
*/
function handleMessage(
ws: WebSocket,
message: WebSocketMessage,
clients: Set<WebSocket>,
context: HandleMessageContext
) {
switch (message.type) {
case 'identify':
// 客户端身份识别(支持连接后再发送身份信息)
{
const meta = Array.from(clients).find(c => c === ws);
if (meta) {
const clientInfo = context.clientMeta.get(ws);
if (clientInfo) {
clientInfo.type = message.client || clientInfo.type;
clientInfo.version = message.version || clientInfo.version;
context.clientMeta.set(ws, clientInfo);
console.log('[WebSocket] 客户端已识别:', {
id: clientInfo.id,
type: clientInfo.type,
version: clientInfo.version
});
}
}
ws.send(JSON.stringify({
type: 'identified',
message: '身份识别成功'
}));
}
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
case 'broadcast':
// 广播消息给所有客户端
broadcast(clients, {
type: 'broadcast',
data: message.data
});
break;
case 'echo':
// 回显消息
ws.send(JSON.stringify({
type: 'echo',
data: message.data
}));
break;
case 'chrome-export:init':
{
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
const pageName = String(data.pageName || '').trim();
const displayName = data.displayName !== undefined ? String(data.displayName).trim() : undefined;
const mode = data.mode === 'files' ? 'files' : 'zip';
const totalChunks = Number(data.totalChunks);
const totalBytes = typeof data.totalBytes === 'number' ? data.totalBytes : undefined;
const fileNameRaw = String(data.fileName || 'chrome-export.zip');
const fileName = path.basename(fileNameRaw || 'chrome-export.zip');
const outputRelativeDir = resolveOutputRelativeDir(data, pageName);
if (!transferId || !isSafeName(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', message: 'transferId is invalid' });
}
if (!pageName || !isSafeName(pageName)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'pageName is invalid' });
}
if (!isValidDisplayName(displayName)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'displayName is invalid' });
}
if (!outputRelativeDir) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'output path is invalid' });
}
if (mode === 'zip' && (!Number.isFinite(totalChunks) || totalChunks <= 0)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'totalChunks is invalid' });
}
if (context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'transferId already exists' });
}
const transferDir = path.join(context.projectRoot, 'temp', 'chrome-export', transferId);
const filesRoot = mode === 'files' ? path.join(transferDir, 'files') : undefined;
if (filesRoot) {
ensureDir(filesRoot);
}
const session: UploadSession = {
transferId,
pageName,
displayName,
outputRelativeDir,
fileName,
mode,
totalChunks,
totalBytes,
receivedChunks: 0,
receivedBytes: 0,
chunks: new Map(),
filesRoot,
filesReceived: 0,
startedAt: Date.now()
};
context.uploadSessions.set(transferId, session);
sendWsMessage(ws, { type: 'chrome-export:ack', transferId });
}
break;
case 'chrome-export:chunk':
{
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
const chunkIndex = Number(data.index);
const chunkData = data.data;
if (!transferId || !context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' });
}
const session = context.uploadSessions.get(transferId)!;
if (session.mode !== 'zip') {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'chunk not allowed for files mode' });
}
if (!Number.isFinite(chunkIndex) || chunkIndex < 0) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid chunk index' });
}
if (typeof chunkData !== 'string' || !chunkData) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid chunk data' });
}
if (chunkIndex >= session.totalChunks) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'chunk index out of range' });
}
if (!session.chunks.has(chunkIndex)) {
const buffer = Buffer.from(chunkData, 'base64');
session.chunks.set(chunkIndex, buffer);
session.receivedChunks = session.chunks.size;
session.receivedBytes += buffer.byteLength;
}
sendWsMessage(ws, {
type: 'chrome-export:progress',
transferId,
receivedChunks: session.receivedChunks,
totalChunks: session.totalChunks,
receivedBytes: session.receivedBytes,
totalBytes: session.totalBytes
});
}
break;
case 'chrome-export:file':
{
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
const relativePath = String(data.path || data.relativePath || '').trim();
const fileData = data.data;
if (!transferId || !context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' });
}
const session = context.uploadSessions.get(transferId)!;
if (session.mode !== 'files') {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'file not allowed for zip mode' });
}
if (!isSafeRelativePath(relativePath)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid file path' });
}
if (typeof fileData !== 'string' || !fileData) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid file data' });
}
if (!session.filesRoot) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'files root not ready' });
}
const targetPath = path.join(session.filesRoot, relativePath);
ensureDir(path.dirname(targetPath));
const buffer = Buffer.from(fileData, 'base64');
fs.writeFileSync(targetPath, buffer);
session.filesReceived += 1;
sendWsMessage(ws, {
type: 'chrome-export:progress',
transferId,
filesReceived: session.filesReceived
});
}
break;
case 'chrome-export:complete':
{
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
if (!transferId || !context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' });
}
const session = context.uploadSessions.get(transferId)!;
const inboxRoot = path.join(context.projectRoot, 'temp', 'chrome-export');
const transferDir = path.join(inboxRoot, transferId);
const extractDir = path.join(transferDir, 'extract');
if (session.mode === 'zip') {
const missing: number[] = [];
for (let i = 0; i < session.totalChunks; i += 1) {
if (!session.chunks.has(i)) {
missing.push(i);
}
}
if (missing.length > 0) {
return sendWsMessage(ws, {
type: 'chrome-export:error',
transferId,
message: 'missing chunks',
missing
});
}
const orderedBuffers: Buffer[] = new Array(session.totalChunks);
for (let i = 0; i < session.totalChunks; i += 1) {
orderedBuffers[i] = session.chunks.get(i)!;
}
const zipBuffer = Buffer.concat(orderedBuffers);
const zipPath = path.join(transferDir, session.fileName);
ensureDir(transferDir);
fs.writeFileSync(zipPath, zipBuffer);
if (fs.existsSync(extractDir)) {
fs.rmSync(extractDir, { recursive: true, force: true });
}
ensureDir(extractDir);
sendWsMessage(ws, { type: 'chrome-export:status', transferId, stage: 'extracting' });
extractZip(zipPath, { dir: extractDir })
.then(() => {
const inferred = inferExtractedRootFolder(extractDir);
if (inferred.entryCount === 0) {
throw new Error('empty zip');
}
const sourceDir = inferred.hasRootFolder
? path.join(extractDir, inferred.rootFolderName)
: extractDir;
const outputName = session.pageName;
if (!isSafeName(outputName)) {
throw new Error('invalid pageName');
}
const scriptPath = path.join(context.projectRoot, 'scripts', 'chrome-export-converter.mjs');
const commandArgs = [scriptPath, sourceDir, outputName];
if (session.displayName) {
commandArgs.push('--display-name', session.displayName);
}
commandArgs.push('--target-dir', session.outputRelativeDir);
sendWsMessage(ws, { type: 'chrome-export:status', transferId, stage: 'importing' });
void runCommand({
command: nodeCommand,
args: commandArgs,
cwd: context.projectRoot,
capture: true,
}).then((result) => {
if (result.code !== 0) {
sendWsMessage(ws, {
type: 'chrome-export:error',
transferId,
message: result.stderr || result.stdout || 'import failed'
});
} else {
const outputDir = resolvePrototypeOutputDir(context.projectRoot, session.outputRelativeDir);
sendWsMessage(ws, {
type: 'chrome-export:done',
transferId,
pageName: outputName,
displayName: session.displayName,
outputRelativeDir: session.outputRelativeDir,
sourceDir,
outputDir,
stdout: result.stdout ? String(result.stdout).trim() : undefined,
stderr: result.stderr ? String(result.stderr).trim() : undefined
});
if (fs.existsSync(transferDir)) {
fs.rmSync(transferDir, { recursive: true, force: true });
}
context.uploadSessions.delete(transferId);
}
}).catch((error: any) => {
sendWsMessage(ws, {
type: 'chrome-export:error',
transferId,
message: error?.message || 'import failed'
});
});
})
.catch((error: any) => {
sendWsMessage(ws, {
type: 'chrome-export:error',
transferId,
message: error?.message || 'extract failed'
});
});
} else {
if (!session.filesRoot) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'files root not ready' });
}
const sourceDir = session.filesRoot;
const outputName = session.pageName;
if (!isSafeName(outputName)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid pageName' });
}
const scriptPath = path.join(context.projectRoot, 'scripts', 'chrome-export-converter.mjs');
const commandArgs = [scriptPath, sourceDir, outputName];
if (session.displayName) {
commandArgs.push('--display-name', session.displayName);
}
commandArgs.push('--target-dir', session.outputRelativeDir);
sendWsMessage(ws, { type: 'chrome-export:status', transferId, stage: 'importing' });
void runCommand({
command: nodeCommand,
args: commandArgs,
cwd: context.projectRoot,
capture: true,
}).then((result) => {
if (result.code !== 0) {
sendWsMessage(ws, {
type: 'chrome-export:error',
transferId,
message: result.stderr || result.stdout || 'import failed'
});
} else {
const outputDir = resolvePrototypeOutputDir(context.projectRoot, session.outputRelativeDir);
sendWsMessage(ws, {
type: 'chrome-export:done',
transferId,
pageName: outputName,
displayName: session.displayName,
outputRelativeDir: session.outputRelativeDir,
sourceDir,
outputDir,
stdout: result.stdout ? String(result.stdout).trim() : undefined,
stderr: result.stderr ? String(result.stderr).trim() : undefined
});
if (fs.existsSync(transferDir)) {
fs.rmSync(transferDir, { recursive: true, force: true });
}
context.uploadSessions.delete(transferId);
}
}).catch((error: any) => {
sendWsMessage(ws, {
type: 'chrome-export:error',
transferId,
message: error?.message || 'import failed'
});
});
}
}
break;
case 'chrome-export:abort':
{
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
if (!transferId || !context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' });
}
context.uploadSessions.delete(transferId);
sendWsMessage(ws, { type: 'chrome-export:aborted', transferId });
}
break;
default:
// 未知消息类型,可以在这里添加自定义处理逻辑
console.log('[WebSocket] 未处理的消息类型:', message.type);
ws.send(JSON.stringify({
type: 'unknown',
message: `未知的消息类型: ${message.type}`
}));
}
}
/**
* 广播消息给所有连接的客户端
*/
function broadcast(clients: Set<WebSocket>, message: WebSocketMessage) {
const data = JSON.stringify(message);
let count = 0;
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
count += 1;
}
});
return count;
}

View File

@@ -0,0 +1,51 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { getLocalIP } from './utils/httpUtils';
import {
MAKE_CONFIG_RELATIVE_PATH,
MAKE_DEV_SERVER_INFO_RELATIVE_PATH,
} from './utils/makeConstants';
export function writeDevServerInfoPlugin(): Plugin {
return {
name: 'write-dev-server-info',
configureServer(server: any) {
server.httpServer?.once('listening', () => {
try {
const localIP = getLocalIP();
const actualPort = server.httpServer?.address()?.port || server.config.server?.port || 5173;
const configPath = path.resolve(process.cwd(), MAKE_CONFIG_RELATIVE_PATH);
let displayHost = 'localhost';
if (fs.existsSync(configPath)) {
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
displayHost = config.server?.host || 'localhost';
} catch {
// Ignore config parse errors and keep default.
}
}
const devServerInfo = {
port: actualPort,
host: displayHost,
localIP,
timestamp: new Date().toISOString(),
};
const infoPath = path.resolve(process.cwd(), MAKE_DEV_SERVER_INFO_RELATIVE_PATH);
fs.mkdirSync(path.dirname(infoPath), { recursive: true });
fs.writeFileSync(infoPath, JSON.stringify(devServerInfo, null, 2), 'utf8');
console.log(`\n✅ Dev server info written to ${MAKE_DEV_SERVER_INFO_RELATIVE_PATH}`);
console.log(` Local: http://${displayHost}:${actualPort}`);
console.log(` Network: http://${localIP}:${actualPort}\n`);
} catch (error) {
console.error('Failed to write dev server info:', error);
}
});
},
};
}